背景#

一条典型的 Jenkins 加 VM 部署链路是这样的:Jenkins 构建镜像、推到私有仓库,然后用凭据库里的一把 SSH key 登录目标 VM,把 docker-compose.yml、.env 和部署脚本拷过去,再执行 sudo docker compose up -d。每个环境有几台 VM,同一把 key 能登录这个环境的所有目标机,登录用户有免密 sudo。

这条链路好用的地方很实在:部署就是流水线的最后一个 stage,成败当场可见,回滚就是拿旧 tag 再跑一次。它的问题在信任的分布上。Jenkins 控制器同时握着源码、构建、凭据和通往每台 VM 的登录通道,能控制它的人就能以 root 身份登录所有目标机。

这篇讨论把最后一步反过来:VM 定时去拉「现在应该跑哪个镜像」,自己完成部署,Jenkins 不再登录 VM。讨论有三个前提:服务跑在 VM 上的 Docker Compose 里,短期内不迁 K8s;CI 继续用 Jenkins;目标是缩小 CI 被攻破时的影响面,构建本身是否可信只在讲验签时带到。

push 链路里,信任集中在 Jenkins 控制器#

集中的地方有四处,前两处来自 Jenkins 本身,后两处来自部署脚本的常见写法。

登录凭据和解密它的 key 在同一台机器上#

Jenkins 的凭据是加密存放的。按 Jenkins 开发者文档,加密用的 key 在 $JENKINS_HOME/secrets/,保护这些 key 的 master key 也在这个目录里,所以拿到控制器的文件系统,就能把凭据全部解出来。Controller Isolation 文档还提到,跑在 built-in node 上的构建对控制器文件系统的访问权限和 Jenkins 进程相同,官方建议把 built-in node 的执行器设为 0。如果构建还跑在控制器上,能改 Jenkinsfile 的人离凭据库只差一步。

日志遮蔽和权限控制防的是失误#

withCredentials 会把日志里出现的密码替换成 ****,这能防止流水线作者不小心把密码打出来,挡不住有意这么做的人。Credentials Binding 插件页的安全警告里,就列着几个遮蔽失效的版本。Replay 允许改了脚本再跑一遍,2022-02-15 的安全公告里有一条 CVE-2022-25180:旧版 Pipeline: Groovy 插件在 Replay 时会把原构建的密码参数带进新构建,有 Run/Replay 权限的人就能拿到这些值。所以能改流水线或能 Replay 的账号,实际上都能拿到这条流水线用到的凭据。

应用配置跟着流水线走#

.env 里的数据库密码这类应用配置,通常也存在 Jenkins 里(托管配置文件或 secret file 类型的凭据),部署时先写进构建工作区,再拷到 VM。这些值只有应用需要,CI 却每次都要经手。部署脚本如果再把密码拼进远端命令(ssh host "DB_PASS='…' bash deploy.sh"),部署期间它会出现在两端进程的 /proc/<pid>/cmdline 里。按 proc(5),不加 hidepid 挂载选项时,所有用户都能访问所有 /proc/<pid> 目录,这是默认行为。

跳板机上的 inbound agent#

目标 VM 在 Jenkins 连不到的网段时,常见做法是在跳板机上跑一个 inbound agent,由它代为 SSH。构建时,登录凭据和 .env 都会被送到这台跳板机上。inbound agent 连接控制器用的 secret 是对 agent 名字做 HMAC 得到的,JENKINS-70168 写明它是确定的,这个 issue 也还没有关闭。跳板机出了问题,删掉节点再用同一个名字重建,secret 不会变。

这四处有一个共同前提:push 模式要求 CI 能登录生产机。这个能力可以收窄,比如专用的 deploy 用户只允许执行一个部署脚本,或者用 SSH 短期证书替代长期 key(见后面的趋势一节),但 CI 手里总有一份登录生产机的能力。OWASP 的 CI/CD Top 10 把这类问题归在 CICD-SEC-6 Insufficient Credential Hygiene。

VM 上的 pull:清单仓库加一个固定的部署脚本#

OpenGitOps 的四条原则里,有两条描述的就是这种做法:agent 自动从源头拉取期望状态(Pulled Automatically),并持续对照实际状态去调和(Continuously Reconciled)。K8s 上由 Argo CD、Flux 这类控制器来做;VM 上,最小的实现由三样东西组成:

  • 清单仓库:每个环境、每个服务一个文件,只写镜像引用 registry.example.com/app@sha256:…。CI 推完镜像,把 digest 写进对应的文件。
  • VM 上的部署脚本,由 systemd timer 定时触发:拉清单,和当前运行的 digest 比对,一致就退出;不一致就校验引用、拉镜像、用本机的模板渲染 compose、docker compose up -d、做健康检查,结果写进日志。
  • 本机的 .env:属主 root,权限 0600,单独维护,不经过 CI。
flowchart TB subgraph PUSH["push(Jenkins 登录 VM)"] J1["Jenkins 控制器
SSH key 和 .env"] -->|"推镜像"| R1["镜像仓库"] J1 -->|"SSH + sudo 部署"| V1["VM"] V1 -->|"拉镜像"| R1 end subgraph PULL["pull(VM 自己拉)"] J2["Jenkins"] -->|"推镜像"| R2["镜像仓库"] J2 -->|"提交 digest"| G["清单仓库"] V2["VM 上的部署脚本
本机 .env"] -->|"只读 token 拉清单"| G V2 -->|"只读账号拉镜像"| R2 end V1 ~~~ J2

两种做法最大的差别在箭头方向。push 里有一条从 Jenkins 指向 VM 的登录通道;pull 里所有连接都由 VM 发起,VM 只需要能出站访问 git 和镜像仓库。

清单文件和定时器大致如下(示意):

# envs/prod/app.yaml
image: registry.example.com/app@sha256:<digest>
# /etc/systemd/system/app-deploy.timer
[Timer]
OnBootSec=2min
OnUnitActiveSec=1min
RandomizedDelaySec=20s

[Install]
WantedBy=timers.target

清单里放什么,决定了清单仓库被写坏时损失有多大。清单只放数据(镜像 digest,至多再加几个白名单字段),compose 模板和部署脚本放在 VM 上,由配置管理安装、走代码评审。原因是 compose 文件本身就能挂载宿主机目录、开特权容器、挂 docker.sock,谁能往仓库里推 compose,谁就等于拿到了 VM 的 root。现成的 Compose GitOps 工具(比如 doco-cd)和 Ansible 自带的 ansible-pull,都是在 VM 上执行仓库里的 compose 或 playbook。用它们没有问题,只是仓库的写权限要按 VM 的 root 权限来管。

部署脚本只接受指定仓库前缀加 @sha256: 的引用,拒绝 tag,清单写的是哪个镜像,跑的就是哪个镜像。回滚就是 revert 清单里的那次提交,下一轮定时任务会把旧版本部署回去。

pull 能解决的问题,和解决不了的#

能解决的是 push 链路里 CI 手上的那部分权限:

  • Jenkins 不再持有任何 VM 的登录凭据。控制器被攻破后,攻击者能做的是往清单仓库写 digest、往镜像仓库推镜像,登录不了 VM。
  • 部署不需要指向 VM 的入站 SSH。AWS CodeDeploy 管理机房里的机器时也是这个模型:agent 只通过出站 HTTPS 443 和服务通信。
  • .env 不再经过 CI,Jenkins 里也就没有应用的数据库密码。
  • 部署脚本每一轮都和实际运行的 digest 比对,有人手工改过的容器,下一轮会被改回来。

CNCF 2023 年的 GitOps microsurvey 里,62% 的受访者把「不让人直接接触 Kubernetes 和生产环境」列为 GitOps 带来的安全收益,说的是同一件事。

解决不了的有三件:

  • 能写清单、又能推镜像的人,仍然可以部署任意镜像。要堵住这一点,VM 在部署前要验签:用 cosign 签名,VM 上 cosign verify 通过才部署;用 Podman 的话,可以在 policy.json 里给仓库配 sigstoreSigned,拉取时强制验签。签名私钥不能放在 Jenkins 里,否则控制器被攻破时攻击者照样能签。SLSA 的 Build L3 要求的也是签名材料和用户定义的构建步骤隔离。
  • VM 上的清单只读 token 和镜像仓库只读账号仍然是长期凭据,只是权限从「登录并 sudo」缩到了「读」。
  • 人排障时还是要登录 VM,这部分要靠 PAM 或 SSH 短期证书单独收紧。

改成 pull 的代价#

代价集中在四个地方。

部署结果从同步变成异步#

push 模式下,部署失败就是流水线变红。pull 模式下,Jenkins 只知道清单提交成功了,VM 有没有部署、健康检查过没过,要靠部署脚本另外回报:写进集中日志,或者暴露一个指标(当前 digest、上次结果),CI 提交清单后轮询它。K8s 上这一环也没有完全解决,CNCF 2025 年的 Argo CD 用户调查提到,多数团队在环境之间推进应用时,仍然靠手工步骤或自己写的脚本。

有顺序和状态的部署更难写#

push 脚本是命令式的,先跑数据库迁移再起应用、先停 A 再起 B、有状态的流处理作业先做 savepoint 再升级,都可以按顺序写下来。pull 的部署脚本只看到「期望状态变了」,这些顺序和前置条件要么编码进脚本,要么拆成单独的步骤,比如数据库迁移留在 CI 里跑,用一个只能改表结构的账号。服务之间耦合越多,这部分越费事。

每台 VM 多了一个要维护的组件#

部署脚本、定时器、两个只读凭据都要装到每台 VM 上,之后还要能升级,这通常意味着要有配置管理。脚本本身的 bug 会让整个环境停在旧版本上,所以手工部署的步骤要留着,作为退路。

清单仓库成了新的高价值目标#

push 时最值钱的是 Jenkins,改成 pull 之后清单仓库也是:能往生产路径推提交的人,就能决定生产跑什么。生产路径要开分支保护,要求 MR 和具名的两人审批,CI 只能开 MR。清单只放数据、VM 只认 digest 再加验签,都是为了让这个仓库被写坏时损失有上限。

规模小的时候,push 不一定是更差的选择。几台 VM、一个小团队,用专用的 deploy 用户加短期证书把 push 收紧,改动比引入 pull 小得多。Kamal 就是 push 模型,官网写的是新服务器除了加一把 SSH key 不需要别的准备。pull 的收益随 VM 数量、环境数量和 CI 的暴露面一起增长。

业界采用情况#

K8s 上,pull 已经是主流的部署方式。Flux 和 Argo 分别在 2022-11-30 和 2022-12-06 从 CNCF 毕业。CNCF 2025 年的 Argo CD 用户调查里,受访者管理的 Kubernetes 集群有近 60% 用 Argo CD 交付应用,97% 的受访者在生产上用它(2023 年是 93%)。2023 年的 GitOps microsurvey(220 份回答)里,60% 的受访者已经用了一年以上;阻力主要来自人,46% 提到团队不愿意把控制权交给自动化。

VM 上没有找到同口径的调查数据,但 pull 模式由来已久,只是形式各不相同:

  • 配置管理的 agent:Chef Infra Client 以守护进程运行时,默认每 1800 秒拉一次配置并收敛,另加一段随机延迟(splay)分散服务器的压力。ansible-pull 是 Ansible 自带的同类模式。
  • 云厂商的部署 agent:AWS CodeDeploy 管理机房里的机器时,只要求机器能出站访问 443,部署任务由 agent 去取。
  • 容器运行时:podman auto-update 对配置了 registry 策略的容器,去镜像仓库检查同一个镜像引用有没有更新,有就拉下来并重启所在的 systemd unit,重启失败默认回滚到旧镜像,配套的 timer 默认每天零点跑一次。它跟踪的是 tag,和按 digest 部署的做法不同,要可信就得配合 policy.json 里的验签。
  • 操作系统:Red Hat 在 2025-05-20 宣布 image mode for RHEL 正式可用(RHEL 9.6 和 10 起),系统本身是一个由 bootc 安装的容器镜像。bootc-fetch-apply-updates.service 会检查源镜像仓库、下载新镜像并重启,上游默认不启用,发行版可以默认打开。

push 也仍然有人在用,上一节的 Kamal 就是例子。

相关的技术趋势:短期身份、验签、镜像化的 OS#

和这件事相关的趋势有三个,方向一致:去掉长期凭据,部署前验证来源,让 VM 原生支持从仓库拉镜像。

CI 的身份从长期密钥换成短期令牌和证书#

GitHub Actions 支持用 OIDC 换取云厂商的短期凭据,CI 里不再需要存云账号的密钥。Jenkins 也有对应的 oidc-provider 插件,给构建签发 JWT 格式的 ID token,外部系统按作业、分支授权。

SSH 登录也在往同一个方向走:用 SSH CA 取代静态的 authorized_keys,按需签发带短 TTL 的证书。Vault 的 SSH secrets engine 按角色签发带 TTL 的证书,文档示例是 30 分钟;Teleport 的 Machine ID 专门给 Jenkins 这类 CI 发短期证书,它的文档开头也提醒,Jenkins 是一个「notoriously difficult to secure」的工具。这条趋势对 push 帮助最大:登录凭据从长期 key 变成几十分钟就失效的证书,CI 被攻破时,泄露出去的证书很快就没用了。

部署前验签#

npm 的 provenance 在 2023-09-26 正式可用,provenance 用 Sigstore 签名。容器镜像这边,cosign 负责签名和验证,Podman 的 policy.json 能在拉取时强制验签。SLSA v1.1 把构建的可信程度分成三级:L1 要求有 provenance,L2 要求 provenance 由托管的构建平台生成并签名,L3 要求签名材料不能被用户定义的构建步骤接触到。对 pull 来说,「谁能写清单」和「什么能被部署」要靠验签才能分开。

VM 本身也在按拉镜像的方式更新#

bootc 让操作系统像容器镜像一样构建、推到仓库、在机器上拉取更新;Podman 的 auto-update 让 systemd 管理的容器自己跟进镜像更新。两者合起来,VM 上的系统和应用都可以由机器自己从仓库拉取,需要自己写的部署脚本会越来越少。这条路还在早期:image mode for RHEL 2025 年 5 月才正式可用,Podman auto-update 跟踪 tag 的语义也和按 digest 部署有冲突。

小结#

  • push 部署的风险集中在 CI 控制器:它同时握着登录所有 VM 的凭据和解密它们的 key,日志遮蔽和权限控制挡不住能改流水线的人。
  • pull 把方向反过来,VM 按清单里的 digest 自己拉取,CI 不再能登录 VM,.env 也不再经过 CI。
  • 清单只放数据,VM 只认 digest;要防住「能写清单的人部署任意镜像」,还要在 VM 上验签,签名私钥不能放在 CI 里。
  • 代价在部署结果的反馈、有顺序和状态的部署、每台 VM 上的维护,以及清单仓库本身的保护。VM 少、团队小的时候,用短期证书收紧 push 可能更划算。
  • K8s 上 pull 已是主流;VM 上有配置管理 agent、CodeDeploy、Podman auto-update、bootc 这些先例,但没有找到同口径的采用率数据。
  • 短期身份、部署前验签和镜像化的 OS 这三个趋势,会让 VM 上的 pull 越来越少依赖自己写的脚本。

相关文章#

参考资料#