「部署复盘」二、my-resume 上线实录:从踩坑到方法论

3892 字
19 分钟
「部署复盘」二、my-resume 上线实录:从踩坑到方法论

📌 系列简介:《my-resume 部署实战三部曲》 本篇:真实踩坑复盘 + 上线之后还有什么 上一篇:Docker + CI/CD 20% 核心认知 / 下一篇:AI 实战 前端转 JS 全栈,正在学部署,理解难免有偏差,欢迎批评指正 ~


写在前面#

今天早上发版,又卡住了。

Terminal window
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 timeout

docker 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. 统一日志格式

Terminal window
log() {
printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"
}

所有脚本的输出都带时间戳,排障时能看出哪一步卡了多久。

2. dry-run 支持

Terminal window
run_cmd() {
if [[ "$DRY_RUN" == '1' ]]; then
log "[dry-run] $*"
return 0
fi
"$@"
}

--dry-run 参数,只打印命令不执行。 每次改了脚本逻辑,先 dry-run 确认参数对不对,再真正跑。

3. 模板渲染

Terminal window
render_template() {
local template_path="$1"
local output_path="$2"
# 用 python3 做变量替换,__VAR_NAME__ 格式
# 缺变量直接报错,不会生成残缺配置
}

templates/ 里的 .tpl 文件用 __VAR_NAME__ 占位,渲染时从环境变量里取值。 缺了变量不会静默跳过,直接报错——这个设计救过我几次。

4. 健康检查

Terminal window
curl_check() {
local url="$1"
local label="$2"
local max_seconds="${3:-120}"
# 循环重试,超时才报错
}

发布完不是立刻验收,而是等服务真正可用再验收。 重试窗口默认 120 秒,2核2G 启动慢,这个时间是跑出来的。

5. 环境变量加载与校验

Terminal window
load_stack_env() # 按优先级查找并加载 stack env 文件
require_vars() # 检查必填变量,缺了直接 die
resolve_deploy_mode() # 自动判断 build 模式还是 image 模式

这几个函数加起来,解决了”脚本跑到一半才发现变量没配”的问题。


三、六个真实的坑#

坑 1:buildx + auth.docker.io 超时#

现象:

Terminal window
ERROR: failed to solve: DeadlineExceeded:
failed to fetch oauth token:
Post "https://auth.docker.io/token": i/o timeout

docker pull node:22-slim 成功了,但 buildx 还是超时。

根因: docker pull 用的是宿主机 Docker daemon 的缓存。 但 buildxdocker-container builder 是一个独立容器, 不共享宿主机的镜像缓存,每次构建都要重新去 auth.docker.io 拿 token。 国内网络对 auth.docker.io 不稳定,一旦超时,整个构建就挂了。

解法一(应急):--engine-build,绕开 buildx 容器,用宿主机 daemon 直接构建:

Terminal window
./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:

Terminal window
# 1. 拉官方镜像
docker pull node:22-slim
# 2. 打 tag 推到自己的 ACR
BASE_REPO=crpi-xxxxxx.cn-chengdu.personal.cr.aliyuncs.com/fridolph-space/my-resume-base-node
docker tag node:22-slim $BASE_REPO:22-slim
docker tag node:22-slim $BASE_REPO:22-slim-20260421 # 带日期的不可变备份
docker push $BASE_REPO:22-slim
docker 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 pullmanifest unknown,但本地明明 push 成功了。

根因: 两种情况:

  1. push 没真正成功(网络中断,没有报错但也没完成)
  2. release.sh 里用的 tag 和实际 push 的 tag 对不上

解法: 发布前做三服务 manifest 验证:

Terminal window
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 一直报连接失败, 看日志发现 webserver 还没准备好时就开始请求了。

根因: 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:上线成功 ≠ 业务可用#

现象:

Terminal window
GET /api/auth/me 200
GET /api/resume/published 500: no such table: resume_publication_snapshots

基础接口通了,以为上线成功了。但业务接口报 no such table

根因: 本地开发时跑过数据库迁移,线上没跑。 表结构业务数据是两件事,我之前把它们混在一起验收了。

解法: 上线验收拆两步:

Terminal window
# 第一步:表结构存不存在
python3 - <<'PY'
import sqlite3
conn = 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 多行,支持十几个参数。

但它最开始只是一条命令:

Terminal window
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_DOMAINADMIN_DOMAIN,但还不完整。

AI 接口会花钱,目前没有用量上限#

AI 接口是按 token 计费的。如果没有用量限制,任何人调用都会产生费用。

我现在的处理方式:

  • 所有调用 AI 的接口都做了记录(调用时间、用量、来源)
  • 后续会加用量上限,超出限制直接拒绝
  • 对外开放的功能会做登录限制,不登录不能调用

这不是完整的方案,但是现阶段能做的最小防护。

admin 后台:主动关掉了公开体验#

admin 后台能直接操作数据库、管理内容、调用所有 API。 如果开放公开体验,等于把数据库管理权交给了所有访客。

我的判断是:这个风险不值得冒。

所以我直接关掉了 admin 的公开体验入口。 感兴趣的朋友可以 fork 仓库,自己部署,自己玩。

这不是功能不完善,是主动的风险控制。


七、沉淀成什么#

现在每次发版的完整流程:

Terminal window
# 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

Terminal window
./deploy/ecs/release-from-local.sh \
--version 2.2.13 \
--stack-env ./.env.stack.local \
--ecs-host 8.137.34.240 \
--services server

出了问题,回滚:

Terminal window
./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 实战的开始—— 不是部署,是真正让简历”活起来”的那部分。

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

「部署复盘」二、my-resume 上线实录:从踩坑到方法论
https://blog.fridolph.top/posts/2026-04-14__pre-prj-2/
作者
Fridolph
发布于
2026-04-14
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
Fridolph
热爱 Coding、音乐和羽毛球的 90 后全栈工程师
公告
欢迎访问我的小站 ^_^ 我是昇哥,热爱Coding,喜爱音乐、羽毛球和摄影的 90后全栈工程师
分类
标签

文章目录