技术指南
CI/CD 部署排障:对齐构建、运行时与密钥边界
把 CI/CD、预览、容器与 VPS 的构建和运行时分开取证,安全分类失败并验证回滚。
本地成功只能证明本地那一条执行路径。CI runner、预览部署、生产函数、容器和 systemd 服务各自拥有构建物、运行身份、变量、DNS、出口与超时边界;排障的第一步是标出真正发起请求的进程,随后只在那个运行时采集最小证据。
来源提供环境地图,本文改写为发布证据链
归档来源完整列出本地、GitHub Actions、Vercel、Docker 与 VPS 的变量和网络差异,可保留的是定位执行位置、区分构建/运行时、按状态与请求 ID 分类以及脱敏日志。它把若干状态码直接归因为密钥、配额或网络,也用公网 IP 查询和供应商清单代替发布门禁。本文不沿用这些捷径,而是要求从真实运行时证明每一层,并保留未知。
先画出请求实际经过的执行链
| 阶段 | 它能证明什么 | 不能外推 |
|---|---|---|
| 开发 shell | 当前用户、当前目录和当前网络可用 | 后台服务、容器、CI 或生产函数 |
| CI job | 该事件与 job 获得了指定变量和构建网络 | 部署后的运行时秘密与出口 |
| 镜像构建 | 依赖和静态产物可生成 | 容器启动时的密钥、DNS 与上游可达 |
| 预览环境 | 特定提交与预览变量组合可工作 | 生产变量、域名、身份与流量 |
| 生产进程 | 当前实例的真实配置与请求结果 | 下一次部署、其他区域或后台任务 |
同一代码也可能在浏览器、服务端渲染、后台队列和定时任务分别发请求。为每条路径记录代码提交、发布 ID、运行时名称、进程身份、配置版本和目标端点;没有这些坐标的“本地正常、线上失败”无法复现。
把发布契约拆成构建、部署与运行三个面
| 对象 | 发布前固定 | 验收证据 |
|---|---|---|
| 构建物 | 提交、依赖锁、镜像摘要、构建参数 | 可重建;不含运行时秘密 |
| 部署配置 | 目标环境、发布 ID、变量名与秘密引用 | 预览和生产分别审阅差异 |
| 运行身份 | 服务账号、最小权限、密钥范围 | 进程实际身份和拒绝路径 |
| 网络路径 | DNS、代理、IPv4/IPv6、出口与 TLS 信任 | 从真实进程路径运行正/反探针 |
| 故障预算 | 超时、有限重试、并发和回滚触发 | 谁先超时、尝试次数与恢复结果 |
先保存不含秘密的运行时指纹
date --utc --iso-8601=seconds
uname -a
id
printf 'release=%s runtime=%s\n' "${RELEASE_ID:-missing}" "${RUNTIME_NAME:-missing}"
node --version 2>/dev/null || true
getent ahosts api.example.invalid 2>/dev/null || true
ss -tnp 2>/dev/null | sed -n '1,30p'不要用 `env`、`printenv` 或调试转储作为默认取证,因为值可能进入共享日志。变量检查只输出允许名单的 `set` / `missing`,并把变量值读取限制在真正发请求的进程;长度、前后缀和哈希也可能泄露低熵凭据,不应当作通用脱敏。
GitHub Actions 的秘密必须被 job 显式引用
GitHub 当前文档把 Actions secret 分为组织、仓库和 environment 范围,并说明 workflow 只有显式把 secret 作为 input 或环境变量时才能读取。Environment secret 只给引用该 environment 的 job,required reviewer 批准前不可访问;来自 fork pull request 的 workflow 也不会收到 Actions secrets。空值不应被误判为上游认证故障。
jobs:
runtime-contract:
environment: preview
permissions:
contents: read
env:
RUNTIME_NAME: preview
API_TOKEN: ${{ secrets.API_TOKEN }}
steps:
- name: Validate configuration without printing values
shell: bash
run: |
test -n "${RUNTIME_NAME:-}"
test -n "${API_TOKEN:-}"
printf 'runtime=%s api_token=set\n' "$RUNTIME_NAME"- 确认触发事件:push、手动运行、同仓 PR、fork PR 与 Dependabot 的秘密边界不同。
- 确认 job 真的声明目标 environment,且审批、分支和部署保护规则符合本次运行。
- 秘密只传给需要它的 step;第三方 action 固定版本,并审阅其输入和日志行为。
- 权限从 `contents: read` 等最小集合开始,不因需要一个 API Key 就扩大 `GITHUB_TOKEN`。
- 用合成凭据做 fork/缺失秘密的反向测试,不能在不受信代码中暴露生产 secret。
预览与生产是两份配置,不是同一份变量的别名
Vercel 当前文档允许变量分别作用于 Production、Preview、Development 或自定义环境;预览变量还可按分支覆盖。变量变更只应用到新的 deployment,旧 deployment 继续使用旧值。排障时必须记录 deployment ID 与目标环境,修改变量后创建新部署并验证,不能刷新旧实例后宣称已生效。
| 比较项 | Preview | Production |
|---|---|---|
| 发布物 | 提交与 deployment ID | 提交与 deployment ID |
| 变量 | 作用范围、分支覆盖、更新时间 | 作用范围、更新时间、轮换批次 |
| 域名与入口 | 预览 URL、访问保护 | 生产域名、代理/CDN 与真实入口 |
| 外部资源 | 隔离项目、测试数据、受限 Key | 生产项目、数据与最小权限 Key |
| 验收 | 正向功能和无 secret 反向路径 | canary、错误预算、旧发布回滚 |
构建需要的秘密与运行时秘密分开
Docker 当前文档明确指出,构建用 `ARG` 和 `ENV` 传递 secret 不合适,因为值会持久化到最终镜像或元数据;BuildKit secret mount 只在对应 `RUN` 指令期间临时暴露。运行时 API Key 不应为了通过构建检查而进入镜像,镜像也不能证明启动后的变量与网络。
# syntax=docker/dockerfile:1
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN \
npm ci --ignore-scripts
COPY . .
RUN npm run build
FROM node:24-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.mjs"]Docker Compose 的 secret 必须在 service 下显式授权,容器通常从 `/run/secrets/<name>` 读取;这比把敏感值扩散为所有进程都可见的环境变量更容易约束。无论使用文件、外部秘密管理器还是平台注入,都要测试目标应用是否支持、文件权限是否正确,以及旧实例轮换期间是否仍可工作。
SSH shell 有变量,不代表 systemd 服务拥有它
systemd 的 `EnvironmentFile=` 为服务进程读取独立变量文件,而不是继承操作者当前 shell。官方 `systemd.exec` 文档同时提醒环境变量不适合承载秘密,因为它们会沿进程树传播;支持的系统上可优先用 `LoadCredential=` / `LoadCredentialEncrypted=` 把凭据作为受限只读文件交给 unit。
[Service]
User=ai-app
Group=ai-app
UMask=0077
Environment=RUNTIME_NAME=production
Environment=RELEASE_ID=2026.08.17.1
LoadCredential=api-token:/etc/credstore/ai-app-api-token
ExecStart=/usr/bin/node /srv/ai-app/server.mjs
NoNewPrivileges=yes
PrivateTmp=yessudo systemd-analyze verify /etc/systemd/system/ai-app.service
sudo systemctl daemon-reload
sudo systemctl restart ai-app.service
systemctl show ai-app.service \
--property=User,Group,EnvironmentFiles,MainPID,ExecMainStatus
journalctl -u ai-app.service --since '-10 min' --no-pager从发请求的运行时逐层证明网络路径
| 层 | 最小探针 | 失败仍然未知 |
|---|---|---|
| 配置 | 端点存在、scheme/host/port 符合契约 | DNS 与连接是否成功 |
| DNS | 记录、地址族、解析器与时间戳 | 路由、TLS 与应用代理 |
| TCP | 目标地址、连接耗时与错误 | 证书、HTTP 与认证 |
| TLS | SNI、证书链、主机名与验证结果 | HTTP 路由与权限 |
| HTTP/API | 状态、响应头、请求 ID 与安全错误摘要 | 业务内容和完整功能 |
target=api.example.invalid
getent ahosts "$target"
curl --silent --show-error --output /dev/null \
--connect-timeout 5 --max-time 20 \
--write-out 'remote=%{remote_ip} connect=%{time_connect} tls=%{time_appconnect} status=%{http_code}\n' \
"https://$target/health"先分类观测,再决定改哪一层
| 观测 | 已证明 | 下一步 |
|---|---|---|
| 配置门禁失败 | 必需变量或端点缺失 | 修复注入范围,不发送网络请求 |
| DNS/TCP/TLS 错误 | 请求尚未取得可信 HTTP 响应 | 沿解析、路由、代理和证书继续定位 |
| 401/403 | 某个 HTTP 响应拒绝了请求 | 先确认响应来源,再核对身份、项目与权限 |
| 429 | 某个 HTTP 响应要求限速 | 核对响应来源、限制维度、并发与退避信息 |
| 5xx | 上游或中间层返回服务端错误 | 保存请求 ID/时间,有限重试并观察错误预算 |
| 应用超时 | 本地截止时间先到 | 保存客户端请求 ID,区分是否曾到达上游 |
状态码必须和响应来源、时间、目标环境及请求 ID 一起解释。OpenAI 当前 API 参考列出 `x-request-id` 和 rate-limit 响应头,并建议生产记录 request ID;还支持调用方提供唯一 `X-Client-Request-Id`,在超时、没有响应 request ID 时帮助确认请求是否到达。两者都不应包含用户数据或 secret。
为每一层设截止时间,只在可重试条件下有限退避
const clientRequestId = crypto.randomUUID();
const response = await fetch(apiUrl, {
headers: {
Authorization: `Bearer ${token}`,
"X-Client-Request-Id": clientRequestId,
},
signal: AbortSignal.timeout(20_000),
});
console.log({
runtime, release, status: response.status,
request_id: response.headers.get("x-request-id"),
client_request_id: clientRequestId,
});不要让 CI job、函数平台、反向代理、SDK 和业务代码都各自重试到上限。先画出总时间预算,给连接、单次请求和整个任务分别设截止;只对明确可重试且幂等的操作加入抖动退避,并服从受信响应提供的限制信息。认证失败和配置缺失不应靠重试放大。
日志只留下能关联发布与请求的最小字段
- 记录 UTC 时间、runtime、release/deployment、操作名、状态或错误类别、尝试次数。
- 有响应时记录服务端 request ID;无响应时保存不含业务数据的 client request ID。
- 不要记录 Authorization、Cookie、secret 值、完整提示词、原始用户输入或带凭据的代理 URL。
- 错误正文先结构化允许名单字段并限制长度;HTML 代理错误页和整段响应不得直接进入长期日志。
- 对日志访问、保留和删除设置权限;公开工单只分享脱敏后的最小片段。
用同一发布契约验证预览与生产 canary
- 构建后扫描镜像、归档、source map 和日志,确认运行时 secret 不存在。
- 预览使用隔离凭据与资源,验证成功、缺失 secret、错误身份和超时四条路径。
- 生产先发布一个 canary 实例,记录 deployment、运行身份、配置版本和网络路径。
- 从真实入口执行一个无副作用请求,并验证未授权路径仍被拒绝。
- 观察错误率、延迟、429/5xx、任务重复和资源写入;达到门槛才扩大流量。
回滚发布物和配置引用,不回写旧 secret 到代码
触发条件包括配置门禁、身份/项目错误、网络路径与基线不符、超时或服务端错误超过预算,以及不可解释的外部副作用。停止扩大流量,保留故障 deployment 的脱敏证据,把入口恢复到最后已知良好的发布物和它对应的配置引用;如果发生泄露,旧 secret 已不可复用,必须用新凭据完成旧版本恢复。
隔离演练验证了分类、脱敏与精确回滚
VPScope 在权限 0700 的一次性目录生成两份不含 secret 的构建物,以及 preview、production 和故障 candidate 三份合成运行配置。回环 Node API 只接受两枚合成 token:没有配置的进程在联网前以 configuration 退出;preview 得到 200 与 request ID;错误 candidate 得到 401 并归为 authentication;受控 429 保留 Retry-After;慢响应在 250 ms 本地截止并保留 client request ID。
构建物和全部诊断 JSON/日志都未出现合成 token 或 Authorization。候选发布指针切换后被恢复到基线,恢复文件 SHA-256 与基线完全相同,production 探针重新得到 200;监听和临时目录清理通过。演练没有运行 GitHub Actions、Vercel、Docker 或 systemd,没有调用 OpenAI 或外网,也没有使用真实 secret,因此不证明平台可用性、实际权限范围、区域网络或生产回滚时间。
CI/CD 运行时排障的可复查清单
- 请求发起者、提交、deployment、runtime、进程身份与配置版本已定位。
- 构建、部署配置和运行秘密分层,构建物与缓存不含运行时 secret。
- Actions 事件、job environment、审批、fork/Dependabot 和最小权限边界已核对。
- Preview、Production 与分支覆盖分别审阅,变量更新由新 deployment 验证。
- Docker 构建 secret 使用临时 mount;运行时凭据只交给实际服务。
- systemd 服务不依赖操作者 shell,凭据使用受限文件或 credentials 并验证 unit。
- DNS、TCP、TLS、HTTP 和业务层按真实运行路径逐层取证。
- 状态码只在确认响应来源后解释,request ID 与 client request ID 可关联。
- 日志没有 secret、认证头和原始用户数据,超时与重试受总预算约束。
- canary 的正向、反向与回滚均通过;泄露场景不会恢复已撤销的旧 secret。