CI/CD 管道一直在骗我:一次部署排障实录

原文: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-pythonpython-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 运行。pytestfrontend 变绿成功。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_HOSTDO_USERNAMEDO_SSH_KEYDO_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 会在守护进程启动时自动重启它们。


到底出了什么问题,全部汇总

把问题全列出来,清单长得有点好笑:

  1. 一个被 git 忽略的文件破坏了 CI 中的 Python 环境配置
  2. 固定的 Node 版本比某个依赖要求的版本落后了两个大版本
  3. 代码格式化漂移不知何时起已悄悄导致 lint 检查失败
  4. GitHub Actions 缓存后端的竞争条件导致 PR 检查出现误报(假阴性)
  5. CI 在每次代码晋升时毫无理由地运行两次
  6. 完全没有部署密钥
  7. 一个私钥被 shell 插值破坏
  8. 部署脚本的目标目录干脆是错的
  9. Traefik 无法知道容器何时尚未就绪
  10. 重启后没有任何东西会自动恢复

一旦找到,这些问题中没有一个是难修的。真正让这个过程变成苦差事的是,每个 bug 都隐藏着下一个——CI 必须先变绿,部署密钥才有意义;部署密钥必须有效,错误的目录路径才会暴露;部署必须真正 成功,Traefik 的时序缺口才会变得可见。一条包含十个串联小故障的流水线,看起来不像十个小故障。它看起来像一堵大墙,而穿越它的唯一方法就是修复最外层的失败,重新运行,看看下一层揭示出什么。

经验教训

如果你的“自动化”部署仍然需要有人手动拉取代码并重新构建,不要假定是部署任务本身坏了——检查一下它前面的“门控”环节是否 曾经 真的成功过。一条被红色 CI 运行静默且永久阻塞的流水线,从远处看,和一条根本不存在的流水线一模一样。修复通常不是一次大重写。而是像剥洋葱一样,一次剥掉一层失败,直到底层的东西最终有机会运行——然后相信下一条错误信息,无论它看起来多么不相关,都指向下一个真正的问题。

原文:https://dev.to/highcenburg/-the-cicd-pipeline-that-was-lying-to-us-a-deploy-debugging-story-9fi(作者 @highcenburg)

发布评论
全部评论(0)