「部署复盘」二、my-resume 上线实录:从踩坑到方法论
📌 系列简介:《my-resume 部署实战三部曲》 本篇:真实踩坑复盘 + 上线之后还有什么 上一篇:Docker + CI/CD 20% 核心认知 / 下一篇:AI 实战 前端转 JS 全栈,正在学部署,理解难免有偏差,欢迎批评指正 ~
写在前面
今天早上发版,又卡住了。
ERROR: failed to build: failed to solve: DeadlineExceeded:failed to fetch oauth token:Post "https://auth.docker.io/token": dial tcp 118.107.180.216:443: i/o timeoutdocker pull node:22-slim 明明成功了,buildx 还是超时。
这不是第一次了。
从 my-resume 第一次上线到现在稳定在 v2.2.13,
我遇到的问题大多数不是”代码写错了”,
而是环境、工具链、资源限制这些前端平时碰不到的东西。
这篇想把这个过程完整写下来—— 不只是”怎么发版”,还有”发版稳定之后发现了什么”: 业务验收的漏洞、已知的安全风险、以及我打算怎么走下一步。
还有一件事值得单独说: 这套运维脚本不是我一个人写的,是我和 AI 一起迭代出来的。 哪里麻烦,哪里加——这是整套脚本的生长方式。
一、运维目录是怎么长出来的
先看现在的目录结构:
deploy/├── ecs/│ ├── bootstrap.sh # 第一次初始化 ECS 环境│ ├── build-and-push-images.sh # 本地构建镜像并推送│ ├── release-from-local.sh # 本地构建 + 推送 + 远程发布,一键│ ├── release.sh # ECS 侧发布(只拉镜像启动)│ ├── rollback.sh # 出问题回滚到上一个 tag│ ├── pre-release-port-cleanup.sh # 发布前清理端口和磁盘│ ├── render-config.sh # 渲染模板配置文件│ ├── lib.sh # 公共函数库│ └── stack-env-checklist.md # 环境变量说明文档└── templates/ ├── compose.prod.image.yml.tpl # image 模式 compose 模板 ├── compose.prod.yml.tpl # build 模式 compose 模板 ├── nginx.conf.tpl # nginx 配置模板(HTTPS) ├── nginx.http.conf.tpl # nginx 配置模板(HTTP) └── stack.env.example # 环境变量示例这些脚本不是一开始设计好的。
最开始只有一个 release.sh,就是 SSH 上 ECS 跑 docker compose up。
后来发现 ECS 2核2G 跑 next build 会卡死,加了 build-and-push-images.sh。
后来发现每次要分两步执行很麻烦,加了 release-from-local.sh 把两步合一。
后来发现发布前端口经常被占,加了 pre-release-port-cleanup.sh。
后来发现配置文件里有重复逻辑,抽出了 lib.sh。
每一个脚本,都是被一个具体的麻烦逼出来的。
二、lib.sh:公共函数库的设计思路
lib.sh 是整套脚本的地基,其他脚本都 source 它。
里面解决的是几类重复问题:
1. 统一日志格式
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"}所有脚本的输出都带时间戳,排障时能看出哪一步卡了多久。
2. dry-run 支持
run_cmd() { if [[ "$DRY_RUN" == '1' ]]; then log "[dry-run] $*" return 0 fi "$@"}加 --dry-run 参数,只打印命令不执行。
每次改了脚本逻辑,先 dry-run 确认参数对不对,再真正跑。
3. 模板渲染
render_template() { local template_path="$1" local output_path="$2" # 用 python3 做变量替换,__VAR_NAME__ 格式 # 缺变量直接报错,不会生成残缺配置}templates/ 里的 .tpl 文件用 __VAR_NAME__ 占位,渲染时从环境变量里取值。
缺了变量不会静默跳过,直接报错——这个设计救过我几次。
4. 健康检查
curl_check() { local url="$1" local label="$2" local max_seconds="${3:-120}" # 循环重试,超时才报错}发布完不是立刻验收,而是等服务真正可用再验收。 重试窗口默认 120 秒,2核2G 启动慢,这个时间是跑出来的。
5. 环境变量加载与校验
load_stack_env() # 按优先级查找并加载 stack env 文件require_vars() # 检查必填变量,缺了直接 dieresolve_deploy_mode() # 自动判断 build 模式还是 image 模式这几个函数加起来,解决了”脚本跑到一半才发现变量没配”的问题。
三、六个真实的坑
坑 1:buildx + auth.docker.io 超时
现象:
ERROR: failed to solve: DeadlineExceeded:failed to fetch oauth token:Post "https://auth.docker.io/token": i/o timeoutdocker pull node:22-slim 成功了,但 buildx 还是超时。
根因:
docker pull 用的是宿主机 Docker daemon 的缓存。
但 buildx 的 docker-container builder 是一个独立容器,
不共享宿主机的镜像缓存,每次构建都要重新去 auth.docker.io 拿 token。
国内网络对 auth.docker.io 不稳定,一旦超时,整个构建就挂了。
解法一(应急): 加 --engine-build,绕开 buildx 容器,用宿主机 daemon 直接构建:
./deploy/ecs/release-from-local.sh \ --version 2.2.13 \ --stack-env ./.env.stack.local \ --ecs-host 8.137.34.240 \ --engine-build解法二(根治): 把基础镜像私有化到自己的 ACR,彻底不依赖 DockerHub token:
# 1. 拉官方镜像docker pull node:22-slim
# 2. 打 tag 推到自己的 ACRBASE_REPO=crpi-xxxxxx.cn-chengdu.personal.cr.aliyuncs.com/fridolph-space/my-resume-base-nodedocker tag node:22-slim $BASE_REPO:22-slimdocker tag node:22-slim $BASE_REPO:22-slim-20260421 # 带日期的不可变备份docker push $BASE_REPO:22-slimdocker push $BASE_REPO:22-slim-20260421
# 3. 验证docker manifest inspect $BASE_REPO:22-slim >/dev/null && echo "ACR base image OK"沉淀: 写进 .env.stack.local,后续不用每次手传参数:
DEPLOY_BASE_IMAGE=crpi-xxxxxx.cn-chengdu.personal.cr.aliyuncs.com/fridolph-space/my-resume-base-node:22-slim坑 2:no space left on device
现象: 发版时 ECS 报磁盘满,三个服务全部拉取失败。
根因: 2核2G / 40G 的 ECS,三个镜像并发拉取,加上历史版本没清理,磁盘就满了。 前端平时不会遇到这个报错——但在小规格服务器上,这是真实的约束。
解法:
- 串行 pull,不并发:
docker compose pull默认并发,改为逐个 pull - 发布前检查磁盘:
pre-release-port-cleanup.sh里加磁盘可用空间检查 - 定期清理旧镜像:
docker image prune -f - 只保留最近一个版本:ECS 磁盘不够挥霍,历史镜像只留一个,够回滚就行
沉淀: 发布脚本里加了发布前体检——端口、磁盘、旧栈状态,三连检查通过才继续。
坑 3:manifest unknown
现象:
ECS 执行 docker compose pull 报 manifest unknown,但本地明明 push 成功了。
根因: 两种情况:
- push 没真正成功(网络中断,没有报错但也没完成)
release.sh里用的 tag 和实际 push 的 tag 对不上
解法: 发布前做三服务 manifest 验证:
docker manifest inspect $SERVER_IMAGE:$TAG >/dev/null || die "server manifest missing"docker manifest inspect $WEB_IMAGE:$TAG >/dev/null || die "web manifest missing"docker manifest inspect $ADMIN_IMAGE:$TAG >/dev/null || die "admin manifest missing"沉淀: 现在的判断是:Git tag 存在不等于可部署,镜像 manifest 齐全才算版本。
坑 4:depends_on 骗了我
现象:
server 容器显示 Up,但 web 一直报连接失败,
看日志发现 web 在 server 还没准备好时就开始请求了。
根因:
depends_on 只保证容器启动顺序,不保证服务可用。
server 容器起来了,不代表 API 已经在响应请求。
解法: healthcheck + 重试窗口,固化进 compose 模板:
server: healthcheck: test: ["CMD", "curl", "-f", "http://localhost:5577/api"] interval: 10s timeout: 5s retries: 12 # 最多等 120 秒 start_period: 30s # 启动缓冲期沉淀: compose.prod.image.yml.tpl 里固化了这套 healthcheck 配置。
curl_check 函数的默认重试窗口 120 秒,也是从这个坑里定出来的。
坑 5:浏览器请求到了 http://server:5577
现象: 页面白屏,浏览器控制台:
Mixed Content: The page at 'https://resume.fridolph.top' was loaded over HTTPS,but requested an insecure resource 'http://server:5577/api/...'根因:
前端构建时 NEXT_PUBLIC_API_BASE_URL 没有正确注入公网地址,
用了容器内网地址 http://server:5577。
这是前端转全栈最容易踩的坑之一:
http://server:5577是容器内网地址,只有容器之间通信才用- 浏览器发出的请求必须用公网地址,而且必须是 HTTPS
- 这个变量是构建时注入的,运行时改环境变量没用,镜像里已经固化了
两者在 Next.js 里是两个不同的变量:
NEXT_PUBLIC_API_BASE_URL=https://api-resume.fridolph.top # 浏览器用,构建时注入RESUME_API_BASE_URL=http://server:5577 # 服务端 SSR 用,运行时注入沉淀: release-from-local.sh 里加了 --public-api-base-url 参数,
构建时强制传入,不依赖默认值。
坑 6:上线成功 ≠ 业务可用
现象:
GET /api/auth/me → 200 ✅GET /api/resume/published → 500: no such table: resume_publication_snapshots基础接口通了,以为上线成功了。但业务接口报 no such table。
根因: 本地开发时跑过数据库迁移,线上没跑。 表结构和业务数据是两件事,我之前把它们混在一起验收了。
解法: 上线验收拆两步:
# 第一步:表结构存不存在python3 - <<'PY'import sqlite3conn = sqlite3.connect('/root/my-resume/.deploy-runtime/shared/data/my-resume.db')tables = [r[0] for r in conn.execute("select name from sqlite_master where type='table'")]print("resume_publication_snapshots:", "resume_publication_snapshots" in tables)PY
# 第二步:业务数据存不存在# 接口返回 200 不等于业务可用,要看到数据才算curl -s https://api-resume.fridolph.top/api/resume/published | python3 -m json.tool沉淀: 发布验收 checklist 里加了”关键表校验”这一项, 不再只看接口状态码。
四、AI 辅助写脚本:我的工作方式
release-from-local.sh 现在有 300 多行,支持十几个参数。
但它最开始只是一条命令:
ssh root@8.137.34.240 "cd /opt/my-resume && ./deploy/ecs/release.sh v2.2.0"是怎么长成现在这样的?不是一次设计好的,是一次次被问题推着加的。
我的工作方式:
1. 遇到麻烦,描述给 AI ↓2. AI 给初版方案或修改建议 ↓3. 跑一遍,把报错贴给 AI ↓4. AI 解释根因 + 给修改 ↓5. 验证通过,沉淀进脚本或文档比如 --engine-build,是因为 buildx 超时加的。
比如 --reuse-from-tag,是因为只改了 server 但不想重新构建 web/admin 加的。
比如 --skip-public-check,是因为有时候只想验证 ECS 侧,不想等公网检查加的。
每一个参数背后,都有一个具体的麻烦。
AI 在这个过程里做什么?
- 生成初版脚本框架
- 解释报错的根因(buildx 不共享宿主机缓存这件事,我自己不一定能想到)
- 提供备选方案(
--engine-build还是私有化基础镜像,两个方案都是 AI 给的) - 帮我把”我的需求”翻译成”bash 的正确写法”
我做什么?
- 提需求,描述麻烦
- 验证,看日志,判断对不对
- 决定用哪个方案
- 把结果沉淀进文档
脚本是 AI 写的,但判断是我做的。
五、2核2G ECS 的运维原则
跑了这么多版本,总结下来就这几条:
构建不上 ECS。
本地或 CI 构建镜像,ECS 只拉取启动。
2核2G 跑 next build 会把内存打满,直接卡死。
串行不并发。 拉三个镜像串行,不并发。磁盘 40G,并发拉容易满。
只保留最近一个版本。
历史镜像只留一个,够回滚就行。docker image prune -f 定期跑。
tag 有纪律。
用 v2.2.13,不用 latest。
出了问题,回滚就是改 tag 重启,不需要重新排查。
发布前体检。 端口可用、磁盘可用、manifest 齐全,三连通过才发布。 这三个检查加起来不到 10 秒,但能拦住大多数”发到一半失败”的情况。
六、已知的安全风险(诚实说)
部署稳定之后,我做了一轮联调,发现了另一类问题—— 不是”发不出去”,而是”发出去了但不安全”。
目前这套上线的东西,安全层面是不完整的。 这不是谦虚,是事实。作为学习项目公开出来,有必要把风险写清楚。
API 没有完整的输入校验
目前 API 接口对输入数据的校验是不完整的:
- 部分接口缺少参数类型校验
- 没有统一的正则过滤(XSS、SQL 注入等)
- 没有请求体大小限制
正确的做法是在 API 层统一做输入校验——
比如用 zod 做 schema 验证,在进入业务逻辑之前就拦截非法输入。
这是后续要补的,现在还没做。
API 没有完整的白名单限制
理论上任何人都可以直接调用 API 接口,不经过前端页面。
正确的做法包括:
- CORS 配置只允许指定域名(部分已做)
- 敏感接口加 rate limiting(限流)
- 需要鉴权的接口强制校验 JWT
CORS 已经配置了 RESUME_DOMAIN 和 ADMIN_DOMAIN,但还不完整。
AI 接口会花钱,目前没有用量上限
AI 接口是按 token 计费的。如果没有用量限制,任何人调用都会产生费用。
我现在的处理方式:
- 所有调用 AI 的接口都做了记录(调用时间、用量、来源)
- 后续会加用量上限,超出限制直接拒绝
- 对外开放的功能会做登录限制,不登录不能调用
这不是完整的方案,但是现阶段能做的最小防护。
admin 后台:主动关掉了公开体验
admin 后台能直接操作数据库、管理内容、调用所有 API。 如果开放公开体验,等于把数据库管理权交给了所有访客。
我的判断是:这个风险不值得冒。
所以我直接关掉了 admin 的公开体验入口。 感兴趣的朋友可以 fork 仓库,自己部署,自己玩。
这不是功能不完善,是主动的风险控制。
七、沉淀成什么
现在每次发版的完整流程:
# 1. 保持代码和 tag 最新git checkout main && git pull origin main && git fetch --tags --force
# 2. 一键构建 + 推送 + 远程发布./deploy/ecs/release-from-local.sh \ --version 2.2.13 \ --stack-env ./.env.stack.local \ --ecs-host 8.137.34.240 \ --ecs-user root \ --ecs-port 22
# 脚本自动完成:# - 检查 git tag,不存在自动创建并推送# - 三服务 manifest 验证# - 本地构建镜像(用 ACR 基础镜像,不依赖 DockerHub)# - 推送到 ACR# - SSH 到 ECS 执行 release.sh# - 健康检查三个公网域名只改后端时,只构建 server:
./deploy/ecs/release-from-local.sh \ --version 2.2.13 \ --stack-env ./.env.stack.local \ --ecs-host 8.137.34.240 \ --services server出了问题,回滚:
./deploy/ecs/rollback.sh --tag v2.2.12 --ecs-host 8.137.34.240八、现在在哪里,下一步去哪里
诚实地说,现在的状态是:
✅ L1 基础可用 镜像化发布、ECS pull + up、健康检查、回滚
✅ L2 联调稳定 DB 同步、API 基址一致性、环境变量分层
🔄 L2.5 安全加固(进行中,还不完整) 输入校验、限流、AI 用量控制
⬜ L3 研发效率(还没开始) 按变更范围构建、构建缓存优化
⬜ L4 生产可靠性(还没开始) 蓝绿发布、自动回滚、告警体系
⬜ L5 企业治理(暂不考虑) 镜像安全扫描、多环境权限分级L3 以上,我现在没有足够的实际经历,不打算在这里展开。 等真正做了,再写。
接下来的优先级只有一个: 把部署这件事放下,去做 AI 实战。 L2.5 的安全加固会随着功能开发一起补,不单独开一个阶段做。
写在最后
三篇写下来,我想说两件事。
第一件:这套东西现在是学习项目,不是生产系统。
API 校验不完整,安全加固没做完,admin 直接关掉了—— 这些我都知道,也都写出来了。 不是因为不重要,是因为我现在的阶段, 比起”做完整”,更重要的是”做清楚”: 清楚自己在哪里,清楚风险在哪里,清楚下一步去哪里。
第二件:工具可以借,经验得自己攒。
这套脚本是 AI 帮我写的,但每一行背后的判断是我做的。 每一个坑,是我踩的。每一个 SOP,是我验证过的。
如果你也在学全栈,希望这篇能让你少踩几个坑。 如果你发现了我写错的地方,欢迎告诉我—— 我们一起学,一起改。
参考
昇哥 · 2026年4月 my-resume 从 v2.2.0 跑到 v2.2.13 途中,把踩过的坑写下来
下一篇,会是 AI 实战的开始—— 不是部署,是真正让简历”活起来”的那部分。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!