关于 jenkins-agent-l3-deploy
用于通过目标 Linux 主机上的 Jenkins 入站 Agent 执行 docker-compose 部署的技能,无需控制器发起 SSH。它将运行时密钥暂存于 tmpfs,并验证密钥已挂载且未出现在 `docker inspect` 中。
主要功能
- 01在目标 Agent 上部署 docker-compose
- 02将密钥暂存于 tmpfs
- 03以 Docker secrets 挂载密钥
- 04支持选择性部署服务
- 05密钥泄露或挂载缺失时验证失败
适用场景
- 01不使用控制器 SSH 部署 compose 堆栈
- 02避免运行时密钥进入 `.env` 和 `docker inspect`
- 03目标主机重启清除 tmpfs 后重新部署服务
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ rheinmir/ setup --skill jenkins-agent-l3-deploy安装后在 Claude Code 中输入/jenkins-agent-l3-deploy确认已加载。
详细介绍
Jenkins-agent L3 部署(无 SSH)
部署一个 docker-compose 栈,其中目标主机上的 Jenkins 入站 Agent 在本地运行 deploy.py + docker compose。控制器不会通过 SSH 登录。
运行时密钥会写入 tmpfs /run/payroll-sec(文件权限 0444,目录权限 0700),
并作为 Docker 密钥挂载到 /run/secrets/<name>;启动容器时,一个精简的入口点 shim 会将它们导出为环境变量,因此它们不会出现在 docker inspect
或 .env 中。
内容
目的与背景
- 目的: 构建/运行 Jenkins 流水线,通过目标主机上的入站 Agent 部署一个 docker-compose 栈(控制器不通过 SSH 登录),并使用 L3 密钥:仅存放在 tmpfs 上,作为 Docker 密钥挂载,不会出现在
docker inspect或.env中。 - 触发条件(何时使用,与 SSH 变体的区别):
- Agent(本技能,首选): 目标主机上运行入站 Agent →
sh步骤在本地执行;只需要一个Secret file凭据(凭据包)。控制器不会发起 SSH 出站连接。 - SSH 变体: 控制器通过
ssh/scp/rsync登录目标主机(需要SSH Username with private key凭据)。仅在无法在目标主机上运行 Agent 时使用。 - 用户提到“通过 Jenkins Agent 部署”“无 SSH 部署”“用 Agent 而不是 SSH”“Docker secrets L3”“从 docker inspect 中隐藏密钥”“密钥不落盘”,或正在将 Jenkins 部署流水线连接到 Linux 主机上的 docker-compose。
- Agent(本技能,首选): 目标主机上运行入站 Agent →
- 非目标: 不构建 SSH 变体(仅在无法运行 Agent 时使用);不修改应用代码来读取密钥(由 shim 入口点处理);不安装 Jenkins 控制器。
概念模型
架构(L3):
secret source (cred bundle)
→ deploy provisioner writes 7 files to tmpfs /run/payroll-sec (file 0444, dir 0700 root)
→ docker-compose `secrets:` mounts each at /run/secrets/<name> (tmpfs, ro)
→ secrets-entrypoint.sh in each image: for f in /run/secrets/*; export $(UPPER basename)=$(cat f); exec "$@"
⇒ secret NOT in `docker inspect` (set at runtime), NOT on disk (RAM only)
postgres 使用官方的 POSTGRES_PASSWORD_FILE;应用容器使用 shim 入口点,因此无需修改任何应用代码(可避免框架在中间件中导入密钥模块时触发 Edge 运行时的 fs 陷阱)。
输入与输出约定
| 字段 | 必需? | 含义 | |
|---|---|---|---|
| 输入 | Jenkins 控制器可从目标主机访问 :8080 + :50000 | 是 | Agent 必须能够连接 |
| 输入 | 目标 Linux:Java 21,用户属于 docker 组且配置 sudo NOPASSWD | 是 | 参见 Gotchas 1、4 |
| 输入 | Secret file 凭据(凭据包) | 是 | 不需要 SSH 凭据 |
| 输入 | 应用仓库包含 docker-compose.yml、deploy.py(.example)、Dockerfile + secrets-entrypoint.sh | 是 | 通过 SCM 检出,或使用已有工作区 |
| 输入 | 勾选要部署的服务(WEB/ETL/DB/NGINX/ALL) | 否 | 选择性部署;默认全部部署 |
| 输出 | 栈在目标主机上运行 | 是 | 构建控制台显示 Running on <target> |
| 输出 | 验证信号 | 是 | 所有容器均显示 CLEAN <c> + MOUNT <c>,smoke 测试返回 200,凭据包已 shred |
规则与能力
- RULE-01(必须):不要通过 compose
environment:传递密钥(会显示在docker inspect中,也会写入.env)。 - RULE-02(必须):不要将密钥文件存储为 0600(非 root 容器无法读取)——使用 0444 + 目录权限 0700。
- RULE-03(必须):如果可以运行 Agent,不要依赖 SSH 变体——Agent 可保持 SSH 关闭并在本地运行。
- RULE-04(必须):不要将
file://SCM URL 指向控制器无法访问的路径——使用网络 git URL。 - RULE-05(必须):Gotchas 1–5(每条都曾导致一次真实运行失败)必须纳入配置/流水线。
- 能力:配置 Jenkins 节点/凭据/作业;在目标主机上运行 shell、docker 和 sudo;读取 git 仓库(SCM)。不从控制器发起 SSH 出站连接。
失败边界
- 无法在目标主机上运行 Agent → 超出范围,使用 SSH 变体(本技能不支持)。
- Verify 阶段出现
LEAK <c>或NOMOUNT <c>→ 流水线失败(退出码 1),不可视为部署完成。 - Agent 因
UnsupportedClassVersionError/ docker-sudo 权限被拒 / 密钥权限为 0600 而崩溃 → 失败,按照 Gotchas 表修正后重新运行。 - 私有仓库尚未配置 deploy key / known_hosts → 检出阶段受阻。
操作
主要工作流
| 步骤 | 类型 | 输入 | 操作 | 输出/退出 | 失败/下一步 |
|---|---|---|---|---|---|
| W01 | 判断 | 目标基础设施 | 选择 Agent(优先)或 SSH 变体 | 决策 | Agent 无法运行 → 停止,使用 SSH 变体 |
| W02 | 操作 | Jenkins + 目标 | 设置入站 Agent(一次性):节点、Java 21、agent.jar、docker+sudo 用户、Secret file 凭据 | Agent 在线 | 遇到问题 1/4 → 修复并重试 |
| W03 | 操作 | 应用仓库 | 将 secrets-entrypoint.sh 烘焙到每个镜像中(ENTRYPOINT 在 CMD 之前);配置器以 0444/0700 写入 secret | 镜像 + 配置器 | — |
| W04 | 操作 | 作业 | Pipeline 模板(Sync → Deploy → Verify),代码来自 SCM | 作业运行 | checkout 出错 → B02 |
| W05 | 确定性 | 构建 | 验证成功(实际信号) | Running on <target>、CLEAN、MOUNT、smoke 200、凭据已清除 | LEAK/NOMOUNT → 失败 |
各步骤详情(W01–W05 的事实来源):
设置入站 Agent(一次性)
- Jenkins → Manage Jenkins → Nodes → New Node:Permanent Agent,label =
<target>, Remote root =/home/<user>/jenkins-agent,Launch = inbound (JNLP);复制 secret。 - 在目标机器上(Agent 必须能够访问控制器的
:8080+:50000):
使用同时属于 docker 组且拥有 sudo NOPASSWD 权限的用户运行 Agent。 在生产环境中,将sudo apt-get install -y openjdk-21-jre-headless # Java 21 — NOT 17 (see gotcha 1) J21=$(ls /usr/lib/jvm/java-21-openjdk-*/bin/java | head -1) mkdir -p ~/jenkins-agent && cd ~/jenkins-agent curl -sO http://<CONTROLLER>:8080/jnlpJars/agent.jar nohup "$J21" -jar agent.jar -url http://<CONTROLLER>:8080/ \ -secret <AGENT_SECRET> -name <target> -workDir ~/jenkins-agent > agent.log 2>&1 &nohup替换为 systemd unit(Restart=always)。 - 凭据:一个
Secret file凭据(凭据包)。不使用 SSH 凭据。
Pipeline 模板(声明式)
pipeline {
agent { label '<target>' } // runs ON the target, no ssh
environment { APP = '/home/<user>/<app-dir>' }
stages {
stage('Sync') { steps { sh '''
set -e
cp -f docker-compose.yml deploy.py.example "$APP/"
cp -f web/Dockerfile web/secrets-entrypoint.sh "$APP/web/"
cp -f etl/Dockerfile etl/secrets-entrypoint.sh "$APP/etl/"
cd "$APP" && cp -f deploy.py.example deploy.py
''' } }
stage('Deploy') { steps { withCredentials([file(credentialsId:'app-cred', variable:'CRED')]) { sh '''
set -e
mkdir -p /dev/shm/pl && chmod 700 /dev/shm/pl
cp "$CRED" /dev/shm/pl/cred.txt && chmod 600 /dev/shm/pl/cred.txt # gotcha 3
cd "$APP" && ln -sf /dev/shm/pl/cred.txt cred.txt
sudo python3 deploy.py --no-nginx --etl-url /etl
rm -f cred.txt; shred -uf /dev/shm/pl/cred.txt 2>/dev/null || rm -f /dev/shm/pl/cred.txt
''' } } }
stage('Verify') { steps { sh '''
set -e
for c in <containers>; do
docker inspect "$c" --format "{{range .Config.Env}}{{println .}}{{end}}" \
| grep -iqE "SESSION_SECRET=|AUTH_USERS=|PASSWORD=|DATABASE_URL=" && { echo "LEAK $c"; exit 1; } || echo "CLEAN $c"
docker exec "$c" ls /run/secrets >/dev/null 2>&1 && echo "MOUNT $c" || { echo "NOMOUNT $c"; exit 1; }
done
''' } }
}
}
通过 SCM checkout 提供工作区代码,或预先填充 Agent 工作区。
secrets-entrypoint.sh(烘焙到每个应用镜像中;ENTRYPOINT 在 CMD 之前)
#!/bin/sh
set -e
if [ -d /run/secrets ]; then
for f in /run/secrets/*; do [ -f "$f" ] || continue
name=$(basename "$f" | tr '[:lower:]' '[:upper:]'); export "$name=$(cat "$f")"
done
fi
exec "$@"
配置器(例如 deploy.py)将 secret 文件写入 tmpfs 目录,
权限模式为 0444(文件)/ 0700(目录),然后运行 docker compose up -d。
易踩问题(每项都会导致一次真实运行失败,应提前处理)
| # | 症状 | 修复方法 |
|---|---|---|
| 1 | Agent 因 UnsupportedClassVersionError: class 65.0 … up to 61.0 退出 | Jenkins 2.5xx 的 agent.jar 需要 Java 21,而非 17。 |
| 2 | 应用容器因 Permission denied /run/secrets/* 不断崩溃重启 | Compose 的非 swarm bind-mount 会保留源文件权限;将 secret 文件写为 0444(目录为 0700),以便非 root 容器用户读取。 |
| 3 | shred /dev/shm/.../cred: Permission denied | Jenkins file 凭据以 **0400(只读)**方式提供;先 cp,再 chmod 600,并使用 `shred -uf … |
| 4 | Pipeline 中 docker/sudo 出现 "permission denied" | Agent 进程用户必须属于 docker 组且拥有 sudo NOPASSWD 权限。 |
| 5 | 重启后 → 容器无法挂载 secret | /run 是 tmpfs → 重启后 secret 会被清除;重新运行部署(或使用启动钩子),在运行 compose up 前重新生成 secret。 |
验证成功(实际信号)
- 构建控制台显示
Running on <target>(证明构建运行在 Agent 上,而非控制器上)。 - 每个容器均显示
CLEAN <c>(secret 不存在于docker inspect中)。 - 显示
MOUNT <c>(secret 存在于/run/secrets)。 - 应用 smoke 测试(例如登录)返回 200。
- 凭据包已从
/dev/shm中彻底清除(未留在磁盘上)。
分支
| ID | 类型 | 条件 | 行为 | 跳过 / 失败 | 汇合 |
|---|---|---|---|---|---|
| B01 | 用户可选 | 仅部署部分服务(勾选 WEB/ETL/DB/NGINX,而非 ALL) | 选择性部署:booleanParam → deploy.py --services "$SVC" + --no-deps;对于 NGINX,单独 docker restart 代理 | ALL → 完整部署;未勾选任何项 → none = 仅 secret,不运行 up | W05 |
| B02 | 有条件必需 | 从 git 获取代码(生产环境:Pipeline script from SCM) | 配置 SCM;私有仓库 → 在控制器和 Agent 上配置 SSH deploy key + known_hosts | 实验环境没有 git server → Agent 执行 git clone file:///…/repo.git | W04 |
| B03 | 恢复 | 目标机器重启(/run tmpfs 清除了 secret) | 重新运行部署(或使用启动钩子),在运行 compose up 前重新生成 secret | — | W05 |
参考 — 分支详情
选择性部署(仅部署部分服务,而非全部)
暴露 booleanParam 复选框(WEB/ETL/DB/NGINX/ALL),并将所选 compose
服务传递给 provisioner。使用 --no-deps,这样勾选后只会重建该服务
(不会重建其依赖链):
if [ "$ALL" = true ]; then SVC=""; else
S=""; [ "$WEB" = true ] && S="$S web"; [ "$ETL" = true ] && S="$S etl"; [ "$DB" = true ] && S="$S postgres"
[ -z "$(echo $S|xargs)" ] && SVC=none || SVC=$(echo $S|xargs)
fi
deploy.py --services "$SVC" # "" = full ; "web etl" = those ; "none" = secrets only, no up
# provisioner runs: docker compose up -d --build --no-deps $SVC
[ "$NGINX" = true ] && docker restart <proxy-container> # proxy is a separate container, not compose
依赖关系图(depends_on)——请记录,让运维人员了解全新部署需要的依赖链:
web → etl → postgres(proxy/nginx 相互独立)。使用 --no-deps 时,勾选 web = 仅部署 web;首次全新部署时勾选整个依赖链(或 ALL)。已验证:单项勾选和组合勾选都会且只会影响所选服务。
从 git 拉取代码
生产环境:将作业配置为 Pipeline script from SCM(git 仓库 + scriptPath);Jenkins 会自动在 Agent 工作区执行 checkout scm,然后运行流水线。git URL 必须能被控制器和 Agent 访问(控制器获取 Jenkinsfile,Agent 检出工作区)——file:// 仅在拥有该文件的一侧可用;在没有 git 服务的实验环境中,让 Agent 执行 git clone file:///…/repo.git。
私有仓库(已使用 GitHub 测试,构建 SUCCESS):
- 对私有仓库进行 HTTPS 匿名克隆会失败——需要身份验证。
- 在仓库上使用只读 SSH 部署密钥;将私钥放入 Jenkins 的
SSH Username with private key凭据中(用户名为git);SCM URL 使用[email protected]:owner/repo.git,并配置对应的 credentialsId。 - **主机密钥注意事项:**在控制器和 Agent 的 known_hosts 中添加
github.com(ssh-keyscan github.com >> ~/.ssh/known_hosts),或将 git 主机密钥策略设为 "Accept first connection",否则 SSH 克隆会因主机密钥验证失败。 - GitHub 上部署密钥的公钥必须与 Jenkins 中的私钥匹配(比较
ssh-keygen -lf key.pub的指纹与 GitHub 显示的指纹)。
验证与停止
Verify 阶段是确定性关卡:出现 LEAK/NOMOUNT 时退出码为 1。需要人工查看:控制台中的 Running on <target>、smoke 登录返回 200、/dev/shm 中不再有凭据。满足 "Verify success" 中的 5 个信号后停止;遇到属于 Gotchas 表的问题 → 按 Fix 列修复对应项后重新运行构建,不要猜测表外的原因。
示例
- **正向:**payroll 应用(web/etl/postgres)——Agent 标签
<target>,Java 21,用户属于 docker group 且有 sudo NOPASSWD;构建时勾选 ALL → 控制台显示Running on <target>,所有容器均为CLEAN/MOUNT,登录返回 200,凭据包已从/dev/shmshred 删除。 - **边界/失败:**密钥以 0600 写入 → web 容器不断崩溃重启,报错
Permission denied /run/secrets/*(gotcha 2)→ provisioner 将权限改为 0444,目录改为 0700,然后重新运行;首次部署时只勾选 WEB → 由于--no-deps,web 单独启动,缺少 etl/postgres → 勾选整个依赖链web → etl → postgres(或 ALL)。