原文:https://dev.to/highcenburg/-the-cicd-pipeline-that-was-lying-to-us-a-deploy-debugging-story-9fi(作者 @highcenburg)
我有一个 GitHub Actions 工作流,名叫 Deploy to DigitalOcean。它已在仓库中存在数周,配置看似完整——SSH action、密钥引用,一应俱全。然而,每次我推送后端变更后,依然需要手动 SSH 到 droplet 执行 git pull。
直到我真正去查找原因,才停下来思考为什么会这样。结果发现,这个管道并非以某种明显的方式损坏,而是以六种互不相关的小毛病层层叠加,每一层都掩盖了下一层。以下是我是如何按实际发现的顺序逐一排查它们的。
第一幕:从未运行的部署作业
配置在纸面上看起来是正确的。deploy.yml 的触发条件如下:
on:
workflow_run:
workflows: ['CI']
branches: ['main']
types: [completed]
jobs:
deploy:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
这很合理:仅在 CI 通过时才部署。因此,首先值得检查的不是部署作业本身,而是 CI 是否曾经在 main 分支上成功过。
gh run list --workflow=ci.yml --limit 5
每一行记录:failure。每一次部署运行:skipped。部署管道本身并没有坏——它只是完全按照配置在执行,在一个从未在 main 分支上变绿(成功)的 CI 工作流之后才触发。我之所以没有把这两点联系起来,是因为失败记录和缺失的部署记录位于 Actions 界面中的两个不同标签页。
第二幕:两个 Bug 躲在同一件风衣下
gh run view <run-id> 显示有两个作业因完全无关的原因失败。
`linter` 作业 立即终止:
The specified python version file at: .python-version doesn't exist.
.python-version 文件明明就在仓库根目录里。事实并非如此——它被列在 .gitignore 中,是某个 pyenv 样板文件遗留下来的,没人质疑过这一行。actions/setup-python 的 python-version-file 输入要求该文件在检出时必须实际存在,而一个被 gitignore 的文件,无论在本地磁盘上看起来多么真实,永远不会进入 CI 环境。
`frontend` 作业 则因不同错误终止:
Error: [vitest-pool]: Failed to start forks worker for test files ... Caused by: TypeError: webidl.util.markAsUncloneable is not a function
这个错误与我的测试代码无关。ci.yml 锁定了 node-version: '20',但 jsdom@30(Vitest 的一个依赖项)要求:
"engines": { "node": "^22.22.2 || ^24.15.0 || >=26.0.0" }
Node 20 没有 markAsUncloneable——jsdom 需要这个函数。每次前端测试运行都在任何测试文件执行之前就崩溃了。而实际的后端测试套件(pytest)在整个过程中一直都在通过;它只是被两个与代码测试完全无关的基础设施 bug 投票否决了。
修复方法:取消忽略 .python-version 文件并提交。将 node-version 提升至 '22'。两行代码的差异,消除了两个根本原因。
第三幕:一直存在的技术债
提交修复后,再次观察 CI 运行。pytest 和 frontend 变绿成功。linter 再次失败——这次是不同的原因:
black....................................................................Failed - hook id: black reformatted job_board/jobs/services.py isort....................................................................Failed - hook id: isort Fixing job_board/jobs/services.py djLint formatting for Django.............................................Failed - hook id: djlint-reformat-django 1 file was updated.
格式漂移在三个文件中悄然累积:一个 black 希望拆分的多行导入语句、一个缺少尾部换行符的 .envs 文件、以及一个 djLint 希望换行的 index.html <meta> 标签。这些在本地都不成问题,因为没人将 pre-commit 钩子接入自己的 git 工作流。问题只在 CI 中显现,而它一直就在那里失败,只是无人察觉。
这里的修复几乎是机械性的:CI 自身的钩子输出就是差异。直接按输出应用,无需重新推导——当格式化工具已经明确告诉你什么是“正确格式”时,便不存在歧义了。
第四幕:缓存不稳定与重复运行
一旦真正的 CI 流程开始运行,又出现了两个小问题:
缓存后端竞争。 PR 触发的构建开始间歇性失败,错误如下:
Error: cannot parse bake definitions: ERROR: failed to solve: failed to load cache key: repository does not contain ref refs/pull/166/merge
这是一个已知的 docker/bake-action 加上 GitHub Actions 缓存的小问题:PR 的合并引用会在 PR 更新或合并时被重新计算(或失效),如果缓存后端仍在解析它。这从未影响过 main 分支上的推送触发运行——那个才是真正的部署门控——但它在每个提升 PR 上留下了一个错误的红叉。Buildx 的 GHA 缓存后端有一个专门为此设计的逃生出口:
django.cache-from=type=gha,scope=django-cached-tests,ignore-error=true
ignore-error=true 将缓存恢复失败降级为缓存未命中,而不是让整个任务失败。
重复的 CI 运行。 当同时使用 pull_request: branches: [main] 和 push: branches: [main] 作为触发器时,每次提升都会通过 CI 运行完全相同的提交两次——一次在 PR 打开时,另一次在它合并的瞬间。由于部署只关心合并后 main 分支的状态,修复方法是完全删除 pull_request 触发器,让 push 做唯一重要的工作。
在此过程中,我还将 frontend 的 lint/测试任务完全从后端 CI 中移除——前端通过 Vercel 部署,它已经作为单独的 PR 检查运行自己的构建/lint 管道。没有理由让前端的拼写错误门控 DigitalOcean 后端部署,所以 frontend/** 也被添加到 paths-ignore——仅前端的提交不再触发后端重建。
第五幕:CI 终于变绿了。部署仍然失败。
第一次,CI 在推送到 main 时显示 success。而第一次,Deploy to DigitalOcean 实际上运行了,而不是显示 skipped。它在 12 秒内失败:
ssh.ParsePrivateKey: ssh: no key found ssh: handshake failed: ssh: unable to authenticate, attempted methods [none], no supported methods remain
gh secret list --repo <org>/<repo>
零行。DO_HOST、DO_USERNAME、DO_SSH_KEY、DO_PORT——一个都不存在。工作流一直在引用四个从未创建的 secret。CI 通过完成了它的任务;它只是暴露了下一层。
第六幕:两个 SSH 陷阱
设置这些时出现了两个值得记录的错误,因为它们都容易犯也容易误诊。
陷阱 1 — 从盒子内部使用 `ssh-copy-id`。 尝试在已经 SSH 到该 droplet 的情况下将公钥安装到 droplet 上是无效的:ssh-copy-id 会打开一个新的出站连接回到同一主机以安装密钥,但该连接还没有有效的密钥进行认证。经典的先有鸡还是先有蛋问题。一旦你已经在目标机器的 shell 中,跳过 ssh-copy-id,直接追加:
cat ~/.ssh/id_ed25519.pub >> ~/.ssh/authorized_keys chmod 700 ~/.ssh chmod 600 ~/.ssh/authorized_keys
陷阱 2 — 被破坏的 secret 内容。 即使在公钥被服务器端信任后,部署仍然以完全相同的 ssh: no key found 错误失败。私钥通过字符串插值(--body "...")而不是直接文件重定向设置为 GitHub secret,这已经静默地折叠了多行 PEM 块。修复方法是不要让任何东西触碰格式:
gh secret set DO_SSH_KEY --repo <org>/<repo> < ~/.ssh/id_ed25519
重定向原始文件保证逐字节保真,包括 -----BEGIN/END----- 行和 shell 字符串会吃掉的每个内部换行符。
第七幕:正确的密钥,错误的目录
随着认证终于工作,部署脚本实际上到达了 droplet 并运行——遇到新错误:
err: bash: line 1: cd: /home/***/apps/gigglegigs: No such file or directory err: fatal: not a git repository (or any of the parent directories): .git err: open /home/***/production.yml: no such file or directory
deploy.yml 假设检出位于 ~/apps/gigglegigs。但并不是——在 droplet 上快速执行 ls ~ 显示实际克隆位于 ~/GiggleGigs。该路径显然是从另一个项目的部署配置中复制粘贴的,并且从未实际验证过,因为工作流之前从未达到过那一步。一行修复,部署脚本终于从头到尾运行:构建、collectstatic、迁移、up -d,全部绿色。
第八幕:首次真正的部署让站点挂了
那次完全绿色的部署后两分钟,管理员的用户列表开始返回 502 Bad Gateway 错误。此时本能反应是恐慌——首次自动化部署,站点就挂了?但日志讲述了一个远不那么戏剧化的故事:
django-1 | PostgreSQL is available django-1 | 186 static files copied to '/app/staticfiles', 538 post-processed. django-1 | [2026-09-01 12:48:17 +0000] [1] [INFO] Starting gunicorn 21.2.0 django-1 | [2026-09-01 12:48:17 +0000] [1] [INFO] Listening at: http://0.0.0.0:5000
Gunicorn 在 12:48:17 才完成启动——经历了等待 Postgres、执行 collectstatic、以及 worker 进程分叉。而 502 错误的时间戳是 12:48:16,早了一秒。什么都没崩溃;我只是在 docker compose up -d 已经杀掉旧 django 容器而新容器还没开始监听的精确时间窗口内刷新了管理页面。
这算不上应用本身的 bug,而是部署配置中的一个缺口。这里的 Traefik 使用静态的 file 提供者,无条件地将流量路由到 http://django:5000,完全不感知容器是否就绪:
services:
django:
loadBalancer:
servers:
- url: http://django:5000
每次部署,无一例外,都会撞上这个窗口期。正确的修复方案是采用主动健康检查,让 Traefik 在容器真正响应请求前停止向其发送流量:
services:
django:
loadBalancer:
servers:
- url: http://django:5000
healthCheck:
path: /healthz/
hostname: api.example.com
interval: '5s'
timeout: '3s'
这个配置有两点值得指出。首先,/healthz/ 路径之前不存在——它需要是一个极其简单、无需认证的视图(不访问数据库,不做身份验证,只返回 200 OK)。因为将基础设施健康状况与真实的业务端点耦合,意味着权限变更或一个慢查询会悄无声息地拖垮你的健康检查。其次,hostname 参数并非可选:如果省略,Traefik 发出的健康检查请求会携带 Host: django(内部服务名称),而 Django 的 ALLOWED_HOSTS 设置会正确地将其视为 DisallowedHost 并返回 400 错误,导致 Traefik 认为该容器永久不健康。将 hostname 设置为真实的公共域名,能让健康检查的请求从 Django 的角度来看,与真实流量无异。
既然已经在编辑 production.yml,我为所有长期运行的服务(除了那个一次性备份容器)都添加了 restart: unless-stopped。没有它的话,一次服务器重启——无论是数字海洋维护窗口、崩溃还是任何其他原因——都会让所有容器停止运行,直到有人注意到并 SSH 进来手动重启它们。unless-stopped 意味着 Docker 会在守护进程启动时自动重启它们。
到底出了什么问题,全部汇总
把问题全列出来,清单长得有点好笑:
- 一个被 git 忽略的文件破坏了 CI 中的 Python 环境配置
- 固定的 Node 版本比某个依赖要求的版本落后了两个大版本
- 代码格式化漂移不知何时起已悄悄导致 lint 检查失败
- GitHub Actions 缓存后端的竞争条件导致 PR 检查出现误报(假阴性)
- CI 在每次代码晋升时毫无理由地运行两次
- 完全没有部署密钥
- 一个私钥被 shell 插值破坏
- 部署脚本的目标目录干脆是错的
- Traefik 无法知道容器何时尚未就绪
- 重启后没有任何东西会自动恢复
一旦找到,这些问题中没有一个是难修的。真正让这个过程变成苦差事的是,每个 bug 都隐藏着下一个——CI 必须先变绿,部署密钥才有意义;部署密钥必须有效,错误的目录路径才会暴露;部署必须真正 成功,Traefik 的时序缺口才会变得可见。一条包含十个串联小故障的流水线,看起来不像十个小故障。它看起来像一堵大墙,而穿越它的唯一方法就是修复最外层的失败,重新运行,看看下一层揭示出什么。
经验教训
如果你的“自动化”部署仍然需要有人手动拉取代码并重新构建,不要假定是部署任务本身坏了——检查一下它前面的“门控”环节是否 曾经 真的成功过。一条被红色 CI 运行静默且永久阻塞的流水线,从远处看,和一条根本不存在的流水线一模一样。修复通常不是一次大重写。而是像剥洋葱一样,一次剥掉一层失败,直到底层的东西最终有机会运行——然后相信下一条错误信息,无论它看起来多么不相关,都指向下一个真正的问题。
原文:https://dev.to/highcenburg/-the-cicd-pipeline-that-was-lying-to-us-a-deploy-debugging-story-9fi(作者 @highcenburg)



