GitLab CI/CD:从 Merge Request Pipeline 到受控交付
Runner 清理了目录,凭证为什么还是泄漏了
某团队把内部项目和外部贡献项目放在同一个 Shell Runner 上。一次 Merge Request 的测试脚本读取了 Runner 用户 Home 下残留的配置,又从 Git Reflog 找到另一个项目曾拉取的私有子模块。管理员删除 Build 目录并重跑,Pipeline 恢复绿色,却没有处理已经外泄的 CI_JOB_TOKEN、Runner 身份、缓存对象和下游制品。
GitLab CI/CD 本质上是一套远程代码执行系统。GitLab 解析 .gitlab-ci.yml 与 Include,创建 Pipeline;Job 按 Stage 或 needs 依赖进入队列;Runner Manager 领取 Job,Executor 决定命令运行在 Shell、容器、虚机还是 Pod 中。配置文件、Runner、变量、缓存、Artifact 和 Environment 分属不同控制面,任何一处把不可信输入与高权限资源放在一起,都可能把普通测试变成供应链事故。
可靠的交付链要保持几条不变量:Merge Request 可以运行代码但不持有生产身份;Cache 可以丢失且不影响正确性;Artifact 有明确来源、摘要和保留期;发布提升同一份制品;同一生产资源只有一个变更者;Runner 完成任务后没有可供下一信任域继承的状态。
建立第一条可观察的 Pipeline
在测试项目放入以下三个文件:
{
"name": "gitlab-ci-lab",
"private": true,
"scripts": {
"test": "node test.mjs",
"build": "node build.mjs"
}
}// test.mjs
if (process.env.FORCE_FAILURE === "1") {
console.error("EXPECTED_FAILURE: forced by test fixture");
process.exit(23);
}
console.log("TEST_OK");// build.mjs
import { mkdirSync, writeFileSync } from "node:fs";
mkdirSync("dist", { recursive: true });
writeFileSync("dist/build.txt", `${process.env.CI_COMMIT_SHA ?? "local"}\n`);执行 npm install --package-lock-only && npm test && npm run build,先确认本机输出 TEST_OK 并生成 dist/build.txt。再用 FORCE_FAILURE=1 npm test 验证 Bash 退出码为 23;PowerShell 使用 $env:FORCE_FAILURE='1'; npm test; $LASTEXITCODE,之后删除该环境变量。
提交根目录 .gitlab-ci.yml:
stages: [verify, package]
workflow:
auto_cancel:
on_new_commit: interruptible
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
default:
image: node:24-bookworm-slim
interruptible: true
timeout: 15m
cache:
key:
files:
- package-lock.json
prefix: "npm-$CI_RUNNER_EXECUTABLE_ARCH"
paths:
- .npm/
policy: pull
verify:
stage: verify
script:
- npm ci --cache .npm --prefer-offline --ignore-scripts
- npm test
package:
stage: package
needs: [verify]
script:
- npm ci --cache .npm --prefer-offline --ignore-scripts
- npm run build
- cd dist && sha256sum build.txt | tee SHA256SUMS
- cd ..
- printf 'commit=%s\npipeline=%s\njob=%s\n' "$CI_COMMIT_SHA" "$CI_PIPELINE_ID" "$CI_JOB_ID" > dist/BUILD-INFO
artifacts:
name: "web-dist-$CI_COMMIT_SHA"
paths:
- dist/
expire_in: 14 days
access: developer创建 Merge Request 后,预期只生成一条 MR Pipeline,verify 输出 TEST_OK,package 等它成功后运行,Job 页面能下载包含 BUILD-INFO、SHA256SUMS 和 build.txt 的 Artifact。若同时出现 Branch Pipeline 与 MR Pipeline,说明 workflow:rules 没有承担 Pipeline 去重,单靠各 Job 的 rules 反复修补会产生状态不一致。
临时在 verify 增加 variables: { FORCE_FAILURE: "1" }。预期 Job 以 23 失败,package 不运行。恢复变量后再跑一次。这个实验把业务失败与 Runner Pending、镜像拉取失败、Artifact 上传失败分开:Pipeline 图中的停点就是第一条证据。
npm ci --ignore-scripts 适合无生命周期脚本的演示项目。真实项目若依赖原生编译,应在审查 lockfile 和安装脚本、隔离网络与变量后移除。固定容器镜像 Tag 仍可能漂移,生产构建应固定镜像 Digest;同时约束包管理器、锁文件、时区、locale 和归档时间戳。在两个干净 Runner 上构建同一 Commit 后比较 SHA-256,不一致时先找这些环境输入。
Pipeline 创建、调度与数据传递
workflow:rules 决定 Pipeline 是否存在,Job rules 决定 Job 是否进入已经创建的 Pipeline。CI_PIPELINE_SOURCE 是判断入口;路径、分支和变量规则混用时,要在 Pipeline Editor 或 CI Lint 查看合并后的配置,但语法通过不代表 Runner、网络和脚本可用。
Stage 提供粗粒度顺序,needs 建立精确 DAG。使用 needs 后要显式理解 Artifact 下载关系:
release-check:
stage: package
needs:
- job: package
artifacts: true
script:
- cd dist && sha256sum --check SHA256SUMSJob 之间不能依赖工作目录残留。必须传递的文件进入 Artifact;可重新下载的数据进入 Cache。dependencies 与 needs 同时出现容易制造隐蔽的数据路径,应由一种模型统一表达。
interruptible: true 表示新提交到来时,尚未进入不可中断阶段的旧 Pipeline 可以取消。它适合测试和构建,不适合已经修改共享环境的发布。Pipeline 级自动取消策略与 Job 的 interruptible 共同决定行为;只配置其中一个,不能保证旧运行真正停止。
Hosted 与 Self-managed Runner 如何落地
GitLab-hosted Runner 由平台管理,当前采用每 Job 新鲜 VM 并自动扩缩,适合标准工具链和无需进入企业内网的任务。Self-managed Runner 可以接入私网、专用硬件和定制执行器,但团队接管 Runner Manager、Executor、机器镜像、网络、缓存、日志、升级与清理。
自托管安装遵循系统包管理器或官方二进制入口。安装 Runner 应由平台账号执行,业务 Job 使用低权限执行身份。创建 Runner 的顺序是:
在项目、Group 或 Admin 的 CI/CD -> Runners 创建 Runner,限定作用域、Tag、是否接收 Untagged Job、是否只跑 Protected Ref。在目标主机安装 gitlab-runner,验证二进制来源和包签名。使用创建页给出的 Runner Authentication Token 执行 gitlab-runner register --url <gitlab-url> --token <runner-authentication-token>。
选择 Executor,并把标签写成能力与信任域,例如 linux,x64,untrusted-build,不要用含糊的 prod 代替权限模型。启动服务,运行只输出 CI_RUNNER_ID、CI_RUNNER_EXECUTABLE_ARCH 和镜像身份的探针 Job。退役时暂停 Runner、排空 Job、删除 GitLab 端 Runner、撤销 Authentication Token,再销毁主机和关联缓存。
Ubuntu 主机在按官方安装页配置 GitLab Runner 软件源后,可以用下面的命令完成服务安装和交互式注册:
sudo apt update
sudo apt install gitlab-runner
sudo gitlab-runner register \
--url "https://gitlab.example.com" \
--token "<runner-authentication-token>" \
--executor docker \
--docker-image "node:24-bookworm-slim"
sudo systemctl enable --now gitlab-runner
sudo gitlab-runner verify注册后检查 /etc/gitlab-runner/config.toml。concurrent 限制整个 Runner Manager 同时执行的 Job,单个 [[runners]] 的 limit 限制该 Runner 配置的并发;Docker Executor 应保持 privileged = false,混合信任池使用 pull_policy = "always",也不要把 /var/run/docker.sock 挂入 Job。静态主机可以通过 Runner Feature Flag 启用 Job 清理,但它仍不能替代一次性机器。修改配置后先执行 sudo gitlab-runner verify,再滚动重启 canary 实例。
退役时先在 GitLab UI 暂停接单并排空任务,再执行 sudo gitlab-runner unregister --name <runner-name>。使用新式 Runner 创建工作流时,这条命令可能只删除当前 Runner Manager,而不会删除 GitLab 端 Runner;随后还要在 Runner 管理页删除 Runner,或由受控管理程序调用删除 API,并确认列表中身份已经消失。最后删除主机上的 config.toml、.runner_system_id、工作目录和缓存,销毁机器;仅卸载软件不会撤销服务端身份。
Runner Authentication Token 标识 Runner,不是业务 API Token;复制带 Token 的 Runner 镜像可能产生多个身份相同的克隆并抢取任务。镜像应在启动时获得独立身份,注册材料保存在受控 Secret 系统,日志禁止打印 Token。
Executor 决定隔离强度。Shell Executor 直接以 Runner 用户执行代码,只能承载完全可信构建;Docker 非特权模式提供容器边界,但宿主内核和挂载仍是共享面;privileged 或 Docker Socket 接近宿主 root;Kubernetes、Docker Autoscaler 和 Instance Executor 更适合构建一次性环境。对混合信任项目,采用每 Job 新 VM/Pod,容量与 use count 设为一次使用,并把诊断日志送出临时机器。
静态 Runner 至少启用 Job 清理,并理解清理不能覆盖 Home、Docker Layer、宿主进程和外部 Cache。共享环境使用 GIT_STRATEGY: fetch 会复用 Git 工作副本,Reflog 与子模块对象可能跨 Job 残留;不可信池应使用干净 Clone 和一次性机器。
Runner 升级先核对 GitLab 与 Runner 兼容性,再通过 canary 池执行 Clone、Artifact、Cache、OIDC、容器和代理探针。排空旧池后保留上一镜像;不要在所有 Runner 上原地升级,失败时会同时失去交付能力。
Fork Merge Request 是配置与源码的双重输入
Fork MR 默认在 Fork 项目中运行,使用 Fork 的 CI 配置、资源和变量。父项目成员可以选择在父项目上下文运行,但此时执行的仍可能是 Fork 分支提供的配置,同时使用父项目设置、Runner、变量和触发者权限。点击“Run pipeline”不是普通重试,而是一次信任提升。
审查时不仅看业务源码,还要看 .gitlab-ci.yml、Include、脚本、包安装钩子、容器镜像和下载命令。Fork MR 不应接触父项目 Protected Variable、Protected Runner、云身份和生产网络。必要时通过项目设置禁止 Fork Pipeline 在父项目运行;已经创建的历史 Pipeline 保留原上下文,重试旧 Job 也可能继续使用旧权限,不能把“后来关闭设置”当作撤销。
同项目 MR 对 Protected Resource 的访问也要满足受保护源/目标分支、触发者权限等条件。即使平台允许,也要评估 MR 作者能否修改将要执行的配置。Masked 或 Hidden Variable 只减少日志误显,恶意脚本仍能主动外传;真正的隔离是让不可信 Job 根本拿不到变量并无法访问敏感网络。
Variable、Job Token 与 OIDC
非敏感参数可以进入 YAML,Secret 放入项目、Group 或外部 Secret Provider。Protected、Environment Scope、File、Masked/Hidden 分别控制不同问题:Ref、环境、载体和显示。File 类型把值写入临时文件,适合证书,并不自动赋予正确文件权限或保证脚本不读取。
CI_JOB_TOKEN 在 Job 期间提供短期 GitLab API 身份,跨项目访问应在目标项目维护最小 Allowlist。它继承触发用户和功能端点的约束,不应被视为“只能访问当前项目”。PAT、Deploy Token 和 Trigger Token 必须有独立 owner、到期时间和撤销流程,不能当作万能修复。
云访问使用 id_tokens 请求带明确 Audience 的 OIDC JWT:
deploy-production:
stage: deploy
environment:
name: production
deployment_tier: production
id_tokens:
CLOUD_ID_TOKEN:
aud: https://cloud.example.com
script:
- ./scripts/exchange-oidc.sh
- ./scripts/deploy-existing-artifact.sh dist/build.txt交换脚本直接从 CLOUD_ID_TOKEN 环境变量读取 JWT,关闭 Shell Trace,且不能把 Token 放进命令行参数。云端应校验 issuer、audience、project path、Ref、Ref 是否受保护、Environment 和部署层级等 Claim。只校验 GitLab 实例会让该实例上的其他项目尝试换取身份。JWT 很短命,但打印到 Trace、Artifact 或错误报告仍是泄漏;调试只解码必要字段并清理输出。
生产 Environment 需要限制 Allowed to deploy,并在可用时配置 Deployment Approval。when: manual 只是人工触发,不等于独立审批;Protected Branch、Protected Runner、Protected Variable、Protected Environment 和审批解决的是不同边界,不能互相替代。具体审批能力受 GitLab Offering 与 Tier 影响,应由平台基线检查实际实例。
Cache 投毒与 Artifact 身份
Cache Key 应包含锁文件、架构和信任域;默认 MR Job使用 policy: pull,可信默认分支上的专门 Job 才使用 pull-push。分布式 Cache 让不同 Runner 共享对象,也扩大污染影响。若使用对象存储,生命周期、加密、访问日志和跨项目 Prefix 都要由平台管理。
安全的反向实验是在测试项目让功能分支向过宽 Key 写入随机 nonce,再让默认分支恢复并输出该 nonce。出现相同值证明信任域串线。修复后把 Protected/Non-protected Cache 分离、Key 加锁文件摘要、MR 改为只读,并删除旧对象;重复实验应 Cache Miss 或只恢复可信对象。Cache Hit 不能成为正确性证据,定期运行无 Cache 构建才是。
Artifact 必须写明 paths、expire_in 和 access。未设置 expire_in 时由实例默认值决定;最新成功 Pipeline 的 Artifact 可能因实例/项目策略继续保留,存储预算不能只看 YAML。失败报告可用 when: always 短期保留,候选发布覆盖审批与回滚窗口,长期审计证据转入受控归档。
GitLab Job Artifact 本身不是不可伪造的供应链证明。构建时生成 SHA-256、Commit、Pipeline ID、Job ID 和 Runner 信息;下游下载后先校验摘要,再把制品推入支持不可变版本的仓库。需要更强来源保证时,在构建 Job 生成 SBOM 与签名/证明,并由独立策略验证者校验主体 Digest、Issuer 和项目身份。
让发布只提升一份候选制品
生产 Job 消费 package 的 Artifact,不再执行构建:
stages: [verify, package, deploy]
deploy-production:
stage: deploy
needs:
- job: package
artifacts: true
interruptible: false
environment:
name: production
deployment_tier: production
resource_group: production
script:
- cd dist && sha256sum --check SHA256SUMS
- cd ..
- ./scripts/deploy-existing-artifact.sh dist/build.txt
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
when: manualresource_group 只在同名资源组内串行,同一环境被多个项目修改时,要把跨项目编排纳入统一发布系统。处理模式还要符合部署幂等性:非幂等迁移不能为了“最新优先”随意取消旧任务。Pipeline 自动取消应在发布 Job 开始前停止,发布开始后由明确的超时、补偿和人工接管控制。
回滚选择上一份不可变制品或镜像 Digest,不在旧 Tag 下重建。Pipeline 保存制品身份、Environment、审批、触发者与部署结果;生产流量、数据迁移和回退顺序由发布 SOP 执行,CI 工具提供可信输入和审计证据。
从状态和日志定位第一故障点
没有 Pipeline 时检查 workflow:rules、CI_PIPELINE_SOURCE、Include 权限和合并后的配置。Pipeline 存在但 Job 长期 Pending,检查 Runner Tag、是否接受 Untagged Job、作用域、Paused/Offline 状态、Protected 属性和容量;Pending 说明脚本尚未执行。
镜像拉取失败先看 Registry 鉴权、代理、DNS、CA 和 Pull Policy。if-not-present 在混合信任共享 Runner 上可能复用被污染的本地镜像,应使用可信预置镜像或始终拉取。Artifact 未到下游时检查上游是否生成文件、上传日志、过期策略、needs:artifacts、访问权限和跨项目 Job Token Allowlist。
Protected Variable 不可见时核对 Pipeline 类型、源/目标分支是否受保护、是否来自 Fork、Environment Scope 和触发用户权限。不要通过取消 Protected 临时修复。OIDC 被拒绝时比较 JWT 的 iss、aud、sub、项目、Ref 与 Environment Claim,并检查代理能否访问 Discovery/JWKS。
Cache 命中却构建失败时,用 cache: [] 或新 Key 做无缓存复测,随后比较 Runner ID、Key、Protected 分界和对象创建者。CI_DEBUG_TRACE 可能暴露全部变量和命令展开,只能在受限场景短时开启,结束后删除日志并按泄漏事件评估凭证轮换。
Runner 污染后的隔离与恢复
发现陌生进程、跨项目文件、异常网络连接或 Secret 外传时,先暂停 Runner 和相关 Schedule/Trigger,取消尚未开始的 Pipeline。撤销 PAT、Deploy Token、Runner Authentication Token、云会话和相关 Job Token 授权;限制 Job Log 与 Artifact 访问;删除可疑 Cache;追踪由该 Runner 生成或签名的所有制品。
取证保留 Pipeline/Job/Runner ID、Commit、镜像 Digest、Executor 配置、网络日志和必要磁盘快照。恢复不是清空 Build 目录,而是从可信镜像重建 Runner 池,轮换身份,运行干净 Clone、无 Cache 构建、Artifact、OIDC 和网络探针,再以 canary 比例恢复调度。已经进入仓库的错误包应废弃版本并发布新版本,不能覆盖同版本掩盖事故。
容量、成本与长期治理
观察 Pipeline 创建量、排队年龄、Job 时长、失败分类、Runner 利用率、机器供应耗时、Cache 传输量、Artifact 增长和自动取消数量。矩阵、Retry、超大报告和长期保留会共同消耗 Compute、对象存储与网络。扩容以排队 SLO 和历史峰值为依据,示例阈值不能直接变成生产承诺。
平台团队维护 Runner 池、共享模板、实例限制和升级;安全团队维护 Variable、OIDC、Protected Resource 与审计基线;业务团队维护可本地复现的脚本和失败解释;发布 owner 管理 Environment、审批和恢复演练。共享 Include 固定到受保护且已审查的不可变 Ref,升级通过 Diff 和 canary;例外项记录 owner、原因与到期时间。
定期验证几个不变量:删除 Cache 后构建仍成功;相同输入在干净 Runner 上产生相同摘要;Fork MR 无法取得父项目身份;生产同一时刻只有一个修改者;每次部署都能追到 Commit、Pipeline、Job、Runner、Artifact 摘要和审批;上一份可信制品能在演练环境重新提升。达到这些条件,Pipeline 才不只是会执行 YAML,而是一条可治理的交付系统。
