系列帖子
上一篇记录了 A Tour of Go 多语言翻译项目首页手机端顶栏标题换行的问题。
那个问题本身最终只修改了几行 CSS,但真正发布到生产环境时,却意外暴露出了另一个更值得认真处理的问题:
新 release 切换以后,
go-tour.service因目录权限异常出现status=203/EXEC,systemd 不断尝试重启,生产站点一度返回 502。
问题最终顺利恢复,但这次经历让我重新审视了 production 发布流程。
如果以后每次上线都还需要人工完成上传、调整权限、切换 current、重启服务、检查端口、判断是否回滚,那么发布本身就很容易成为新的故障来源。
于是,这次手机端 CSS 修复之后,我又继续完成了一件事情:
为 A Tour of Go 多语言翻译项目整理出第一版 production 自动部署脚本,并完成了第一次真实生产验证。
不过这次自动化过程本身也经历了一次很重要的“减法”:第一版脚本一度发展到 722 行,开始出现复杂状态机、部署锁所有权、多个退出码和 SSH 中断后的自动推断。
这让我想起了之前处理历史文章翻译项目时遇到的问题——自动化流程本身过于复杂,最后大量时间反而花在维护自动化工具,而不是完成核心工作。
所以这次最终目标变得非常明确:
自动部署脚本应该让发布更简单、更可靠,而不能变成另一个需要长期维护的大项目。
本文记录这整个过程。
一、一个很小的 CSS 发布,触发了 203/EXEC
前一篇完成手机端顶栏 CSS 修复以后,我生成了新的 production release,并准备切换生产版本。
新的 release 上传到了:
/data/go-tour/releases/20260813-zh-CN-a4d4dca
随后切换:
/data/go-tour/current
并重启:
go-tour.service
但服务没有正常起来。
systemd 开始不断自动重启,并反复出现:
status=203/EXEC

从日志中甚至可以看到 restart counter 连续增长:
47
48
49
50
51
52
这说明 systemd 自身其实一直在按照配置尝试恢复服务,但可执行文件根本没有正常启动条件。
二、真正的问题不是二进制,而是 release 根目录权限
继续检查以后,原因很快找到了。
新的 release 上传完成以后,目录权限继承成了类似:
drwx------ www:www
也就是:
0700
www:www
而 go-tour.service 并不是以 www 用户运行。
服务配置使用:
User=go-tour
Group=go-tour
因此即使:
bin/tour
本身具有执行权限,只要上层 release 目录不能被 go-tour 用户进入,systemd 仍然无法执行最终二进制。
这就是:
status=203/EXEC
的直接原因。
也就是说,问题并不是:
Go binary 编译坏了。
也不是:
systemd 配置坏了。
而是:
go-tour用户根本无法穿过 release 根目录访问二进制。
三、先回滚,再修复权限
当时没有继续在故障版本上冒险修改,而是先把:
/data/go-tour/current
切回之前已经确认正常的 release。
旧版本重新启动以后:
go-tour.service
恢复 active,127.0.0.1:3999 重新返回 HTTP 200,生产站点也随之恢复。
然后再处理新的 release。
最终统一成:
- owner/group:
root:root - 所有目录:
0755 - 普通文件:
0644 bin/tour:0755
也就是 release 不依赖上传端原来的 owner、group 和权限,而是在生产服务器上统一归一化。
调整完成以后再次切换,新版本成功启动。
这个问题虽然很快解决,却让我意识到:
只要 production 发布仍然依赖一连串人工操作,就总会存在某一步遗漏的可能。
今天是权限。
以后也可能是:
- 上传不完整;
- 校验和没有检查;
current指错目录;- 服务刚显示
active就误判成功; - localhost 实际还没准备好;
- 公网 CDN 仍然返回旧资源;
- 新版本失败以后忘记及时回滚。
于是开始考虑把已经验证过的人工流程固定下来。
四、最开始想把发布过程尽可能“保护完整”
第一版自动部署设计比较谨慎。
当时希望脚本能够处理尽可能多的边界情况,包括:
- 本地 release 严格校验;
- staging 上传;
- deployment lock;
- lock token;
- 锁所有权判断;
- 陈旧锁处理;
- 多种本地退出码;
- 多层
EXITtrap; - SSH 中断自动恢复;
- 激活阶段状态机;
- staging 自动清理;
- current 切换临界窗口判断;
- 自动回滚;
- 回滚失败分类;
- 公网异常分类。
结果脚本逐渐增长到:
722 行
从单个机制来看,每一个似乎都有理由。
但把它们组合起来以后,一个新的问题开始出现:
部署脚本本身变得越来越难判断。
例如一次 SSH 中断究竟属于:
- 上传阶段失败;
- staging 已完成;
- final 已创建;
- current 尚未切换;
- current 可能已经切换;
- restart 可能已经执行;
- rollback 是否需要执行;
- lock 是否应该删除;
为了覆盖这些情况,又需要继续增加状态变量和退出状态。
这时我开始觉得方向不对了。
五、历史文章翻译项目已经给过一次教训
之前在处理 WordPress 历史文章自动翻译项目时,我就遇到过类似问题。
为了让批量翻译流程足够可靠,逐渐增加:
- 状态文件;
- 恢复流程;
- candidate;
- execution evidence;
- retry;
- resume;
- restart from current;
- 各种错误分类和恢复入口。
这些能力本身都有用途。
但流程复杂到一定程度以后,经常出现一种情况:
本来应该排查文章为什么翻译失败,最后却变成排查“翻译自动化系统为什么认为这篇文章失败”。
也就是说,自动化工具开始抢走核心任务本身的时间。
生产部署脚本如果继续按 722 行这个方向发展,很可能以后也会变成:
本来只是想发布一个新版,却要先分析部署状态机为什么进入某个分支。
这显然违背了自动化的初衷。
所以这次决定主动做减法。
六、重新确定部署脚本的目标
新的目标不再是:
建设一套尽可能完备的 deployment framework。
而是:
把已经真实验证过的 production 发布步骤自动执行,并在真正危险的位置保留必要保护。
最终确定的优先级是:
可靠 > 简单 > 容易排错 > 极端场景全自动恢复
服务器本身也只有一个维护者,不存在复杂团队并发部署需求。
因此没有必要为了未来可能出现的多维护者场景增加大量逻辑。
deployment lock 仍然保留,但语义非常简单:
如果不存在:
/data/go-tour/.deploy.lock
就原子创建。
如果已经存在:
停止部署,提示可能存在未完成的上一次部署,人工检查。
不分析:
- 这个锁是谁创建的;
- token 是否匹配;
- 是否已经陈旧;
- 能不能自动接管。
七、722 行做了一次减法式重构
经过重新整理以后,脚本从:
722 行
降到了:
478 行
后来根据最终生产语义审计补了几个必要边界,最终定稿为:
494 行
删除的主要复杂机制包括:
- 10/20/21/22/23/30 多层本地退出码协议;
- deployment lock token/所有权文件;
- 陈旧锁判断;
- “另一个维护者的锁”判断;
- 多层
EXITtrap; - 复杂远端自动清理状态机;
- 多组交叉布尔状态;
- 激活 SSH 异常后的自动猜测和恢复;
- 重复的完整远端 manifest Python 校验;
- 每个微秒级切换窗口的自动恢复尝试。
最终只保留真正和生产安全直接相关的部分。
八、最终保留了哪些保护
生产部署脚本最终保留的核心流程包括:
- 本地 production bundle 检查;
- release 名称安全检查;
- deployment lock;
- staging 上传;
- 不继承本地 owner/group/perms;
- 生产服务器统一权限归一化;
- 远端 SHA-256 校验;
go-tour用户访问权限检查;- 同名 final release 禁止覆盖;
- 保存旧
current; - staging 同文件系统重命名为 final;
- 临时 symlink +
mv -Tf原子替换current; - restart
go-tour.service; - 连续 3 次 systemd + localhost HTTP 健康检查;
- 新版本明确失败时自动回滚旧 release;
- localhost 健康以后才检查公网;
- 公网/CDN 异常不回滚已经健康的源站。
最终正式提交:
3852bc1
feat: 添加生产发布自动部署脚本

九、一个很重要的设计:生产已经坏了,也应该允许部署
最终安全审计时还发现了一个很关键的问题。
脚本曾经要求旧 release 对:
go-tour
用户仍然可执行。
看起来很合理:
回滚目标最好应该是可用的。
但仔细想以后就会发现,这个要求存在严重问题。
如果 production 当前恰好就是因为:
203/EXEC
而故障,那么:
旧 release 不可执行。
按照这个前置条件,脚本反而会拒绝部署新的修复版本。
这就出现了悖论:
生产坏了,所以需要部署修复版本;
但因为生产坏了,所以部署脚本不允许部署。
最终把这个硬门槛删除。
现在旧 current 在部署开始时只要求:
- 是 symlink;
readlink -f能解析;- 对应目录真实存在;
- 位于
/data/go-tour/releases/下面。
不要求:
- 旧 binary 可执行;
- 旧 release 当前健康;
- systemd active;
- localhost HTTP 200。
这样即使 production 当前已经坏了,仍然可以使用自动部署脚本发布修复版本。
十、旧版本不健康,也不能谎报“回滚成功”
放宽部署前条件以后,还必须解决另一个语义问题。
考虑这种情况:
当前 production 本来就是坏的 → 部署一个修复版本 → 修复版本也失败 → 脚本把
current切回 old。
这时只能说明:
symlink 已经切回旧 release。
不能说明:
生产已经恢复。
因此自动回滚以后,脚本仍然会:
- restart
go-tour.service; - 检查 systemd;
- 检查 localhost HTTP;
- 连续 3 次成功以后才认为旧版本恢复健康。
只有真正通过,才输出类似:
rollback completed; old release is healthy
否则:
- 保留 deployment lock;
- 保留现场;
- 停止继续自动处理;
- 提示人工检查。
这个边界最终也通过了审计。
十一、第一次真实使用自动部署脚本
脚本提交并推送以后,我没有直接把它当作“已经完成”。
还需要一次真正的 production 验证。
于是基于当时仓库生成新的 release:
/tmp/go-tour-release-20260813-zh-CN-3852bc1
publish 状态为:
locale=zh-CN
ready=103
pending=0
blocked=0
pages=103
articles=7
然后第一次真正执行:
scripts/deploy-production.sh \
/tmp/go-tour-release-20260813-zh-CN-3852bc1
整个流程自动完成:
- 本地 release preflight;
- 远端 deployment lock;
- staging;
- rsync;
- 权限归一化;
- SHA-256;
go-tour用户权限检查;- final release;
current原子切换;- restart;
- localhost 健康检查;
- 公网验收。
而这次第一次真实执行,还验证了一个之前非常值得保留的设计。
十二、systemd 已经 active,应用却还没有真正准备好
restart 之后第一次健康检查出现:
service=active HTTP=000
也就是说:
systemd 已经认为进程是 active。
但是:
localhost 还没有得到正常 HTTP 响应。
随后才出现:
active + HTTP 200 (consecutive 1/3)
active + HTTP 200 (consecutive 2/3)
active + HTTP 200 (consecutive 3/3)
最终脚本才判定部署成功。

这一幕非常有价值。
因为此前人工部署时,很容易使用:
systemctl is-active go-tour.service
看到:
active
以后就认为:
服务已经好了。
但第一次真实自动部署已经证明:
进程已经运行,并不等于应用已经可以正常服务请求。
这也是为什么最终没有把健康检查简化成一次 systemctl is-active。
十三、为什么要求连续 3 次 HTTP 200
最终健康标准是:
每一次都必须同时满足:
systemctl is-active == active
以及:
http://127.0.0.1:3999/ == HTTP 200
而且必须:
连续 3 次
只要其中一次失败:
连续计数重新归零。
最多检查:
12 轮
间隔:
3 秒
第一次真实运行中的:
active + HTTP 000
恰好证明了这个机制不是多余防御。
十四、公网/CDN 验收必须和源站分开
localhost 连续健康以后,脚本还会检查:
https://go-dev.shuijingwanwq.com/
第一次真实部署中最终得到:
public acceptance passed: HTTP 200
不过这里还有一个非常重要的设计原则:
公网失败不会自动回滚已经健康的源站。
原因是前一篇手机端 CSS 修复已经遇到了 EdgeOne 缓存问题。
可能出现:
localhost 正常
Nginx 正常
production release 正常
EdgeOne 仍缓存旧 CSS
如果脚本看到公网内容异常以后就自动回滚,那么:
一个健康的新版本会因为 CDN 缓存问题被错误撤回。
因此最终把两层明确分开:
源站健康
决定:
新 release 是否能够继续运行。
公网/CDN 验收
决定:
是否还需要继续排查 EdgeOne、HTTPS、Nginx 或缓存。
公网检查可以返回非零,但不会自动回滚一个已经连续健康的源站 release。
十五、第一次真实自动部署最终闭环
脚本执行完成以后,又单独做了一次最终现场检查。
结果为:
CURRENT:
/data/go-tour/releases/20260813-zh-CN-3852bc1
DEPLOY LOCK:
NO LOCK
SERVICE:
active
LOCAL HTTP:
200

这说明第一次真实运行已经完整验证:
- release 正确上传;
- 权限正确;
- SHA-256 正确;
current正确切换;- 服务正确启动;
- localhost 正常;
- 公网正常;
- deployment lock 正确释放;
- 没有 staging 残留;
- 没有触发人工恢复;
- 没有发生错误回滚。
十六、现在的正常 production 发布流程
完成这次自动化以后,以后正常生产发布流程已经明显简单很多。
首先生成 release:
GOOS=linux GOARCH=amd64 go run -mod=readonly ./cmd/tour-i18n publish \
--locale zh-CN \
--published-at "$published_at" \
--output /tmp/go-tour-release-...
然后执行:
scripts/deploy-production.sh \
/tmp/go-tour-release-...
剩余过程由脚本负责:
- 上传;
- 权限归一化;
- SHA-256;
- staging;
- final;
current;- restart;
- 连续健康检查;
- 必要回滚;
- 公网验收。
从日常维护角度看,这才是这份脚本真正的价值。
不是因为它有多少功能。
而是:
发布时需要人工记住的步骤明显减少了。
十七、哪些事情故意没有自动化
这次最终还专门保留了一些“不自动处理”的边界。
1. deployment lock 已经存在
直接停止。
不自动判断:
是不是陈旧锁。
也不自动删除。
因为只有一个维护者,如果看到:
/data/go-tour/.deploy.lock
存在,更合理的行为就是:
先确认上一次部署发生了什么。
2. SSH 恰好在 current 切换期间断开
不尝试自动猜:
到底切了还是没切。
脚本会保留:
- lock;
- release;
- 现场。
然后提示人工检查:
ssh aliyun 'readlink -f /data/go-tour/current'
ssh aliyun 'systemctl status go-tour.service --no-pager -l'
ssh aliyun 'curl -sS -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3999/'
ssh aliyun 'journalctl -u go-tour.service -n 80 --no-pager'
这种极少发生的临界情况,人工判断比继续构建几十种自动恢复分支更可靠。
3. EdgeOne 缓存
脚本不调用 EdgeOne API。
如果:
源站已经正确,但公网仍显示旧 CSS。
就单独人工检查 CDN。
这和 production release 是否健康属于不同问题。
十八、这次最大的收获不是脚本本身
这次最终确实得到了一份:
494 行
的 production 自动部署脚本。
但我觉得真正重要的并不是“终于写了一个部署脚本”。
而是重新确认了一个对长期项目非常重要的原则:
自动化应该消灭重复劳动,而不是制造新的维护对象。
最初的 722 行版本,从“覆盖更多异常”的角度看其实更完整。
但长期维护真正需要考虑的是:
当半年后部署失败时,我能不能快速看懂它在做什么?
如果答案是:
需要重新研究一套复杂退出状态协议和状态机。
那么它就已经开始偏离最初目的。
最终这版选择:
- 正常路径自动完成;
- 明确失败自动回滚;
- 不确定状态停止;
- 极端边界人工确认。
对当前:
单维护者 + 单服务器 + 单 production 服务
的场景已经足够。
十九、从手工故障到自动部署,形成了一个完整闭环
回头看,这次事情最初只是:
手机首页标题最后一个“目”换行了。
但后续链路变成:
修 CSS → 发布 production → release 权限异常 →
203/EXEC→ 回滚 → 修复权限 → 重新上线 → EdgeOne 缓存 → 真机验证 → 重新审视手工部署 → 自动部署脚本 → 脚本过度复杂 → 减法式重构 → 最终语义审计 → 第一次真实自动部署。
这也是长期维护项目中很常见的一种演进方式。
真正推动基础设施完善的,往往不是:
我今天决定设计一套发布系统。
而是:
某个真实故障终于证明了这里值得自动化。
总结
这次 A Tour of Go 多语言翻译项目 production 自动部署工作的最终结果是:
production release:
20260813-zh-CN-3852bc1
ready=103
pending=0
blocked=0
pages=103
articles=7
current:
正确切换
deployment lock:
已释放
go-tour.service:
active
localhost:
HTTP 200
public:
HTTP 200
而自动部署脚本最终保留的原则也很简单:
上传先 staging,权限统一归一化,文件必须校验,
current原子切换,不能只相信 systemdactive,新版本明确失败才自动回滚,状态不确定时不要猜。
经历了最初 722 行的复杂版本以后,我现在反而更认可这种方式:
一份生产脚本最重要的并不是能够自动处理所有理论异常,而是正常情况下足够省事,异常情况下足够容易理解。
以后没有真实部署故障证据,这份第一版脚本不会继续为了假设中的边界增加功能。
真正遇到新的生产问题时,再根据真实问题决定是否值得修改。
A Tour of Go 多语言翻译项目
本系列完整记录 A Tour of Go 多语言翻译项目从架构设计、整页翻译、结构保护、自动校验,到生产发布与后续维护的实际开发过程。
项目入口:
✅ 在线学习:A Tour of Go 简体中文版
✅ 项目源码:GitHub:shuijingwan/go-tour-i18n
当前第一阶段已完成简体中文版本。项目属于非官方社区多语言翻译项目,与 Go 官方无隶属或授权关系。

发表回复