【避坑指南】宿主机 SSH 免密已通,为何 Jenkins 流水线仍报 Permission denied?
一、 问题现象
在进行持续集成与部署(CI/CD)时,需要在构建阶段将产物通过 scp 发送至目标服务器,或者通过 ssh 触发远程启动脚本。
-
宿主机终端测试: 在运行 Jenkins 的宿主机终端上,切换至当前用户执行免密登录,完全正常且无需输入密码:
bashssh appuser@192.168.1.100 "echo 免密打通成功" # 输出:免密打通成功 -
Jenkins Pipeline 构建报错: 在流水线执行相同的命令时,任务直接卡住并报错失败:
text+ scp -o StrictHostKeyChecking=no app.tar appuser@192.168.1.100:/data/tgz Permission denied, please try again. appuser@192.168.1.100: Permission denied (publickey,gssapi-keyex,gssapi-with-mic,password). scp: Connection closed ERROR: script returned exit code 255
二、 核心根因剖析
很多开发者会困惑:"明明我在服务器上跑通了,为什么 Jenkins 跑就报权限拒绝?"
核心原因在于:执行命令的"主体身份"与"运行环境"错位。
1. 容器环境隔离(最常见场景)
当 Jenkins 以 Docker 容器 形式运行时,流水线里的 sh 步骤是在 Jenkins 容器内部 执行的:
- 宿主机环境 :你之前配置并打通的密钥存放在宿主机的
/home/youruser/.ssh/下。 - Jenkins 执行环境 :容器内部运行的是一个独立的系统,使用的身份通常是内置的
jenkins用户(根路径为/var/jenkins_home/)。 - 结论 :容器内部没有宿主机的私钥,发起 SSH 请求时自然找不到可用的认证信息;而 CI/CD 环境属于非交互式终端,无法让人输入密码,因此连接被远端直接切断。
2. 为什么不推荐直接在容器内折腾系统免密?
有人会尝试进入容器内部执行 ssh-keygen 和 ssh-copy-id,但这种做法往往会引来新的问题:
- 加密算法不兼容 :较旧的 Jenkins 镜像默认生成的
ssh-rsa密钥类型,在现代高版本 Linux 服务端上会被默认弃用,导致握手直接断开(报错kex_exchange_identification: Connection closed)1, 2。 - 无状态脆弱性:一旦容器被重建或升级,容器内手工配置的 SSH 授权和公私钥极易丢失。
三、 标准解决方案:使用 Jenkins Credentials 插件注入私钥
最佳实践思路 :不要依赖底层 Linux 系统的免密机制,直接将现有的有效私钥托付给 Jenkins 凭据中心,在执行流水线时以变量方式安全注入。
第一步:获取宿主机上已打通的私钥文本
在之前测试免密成功的宿主机终端上,打印你的私钥:
bash
cat ~/.ssh/id_rsa
完整复制输出的整段内容(包含 -----BEGIN ... PRIVATE KEY----- 和 -----END ... PRIVATE KEY----- 前后两行)。
第二步:在 Jenkins 凭据中录入该私钥
- 进入 Jenkins 后台:系统管理 (Manage Jenkins) → 凭据 (Credentials)。
- 点击进入全局域(Global credentials),选择 Add Credentials (添加凭据)。
- 按照如下配置填写:
- 类型 (Kind) :选择
SSH Username with private key - ID :填写一个易记的标识,例如
deploy-ssh-key(流水线中需引用) - Username :填写目标服务器的登录用户名(例如
appuser) - Private Key :选择
Enter directly,点击 Add,把刚才复制的私钥完整粘贴进去。
- 类型 (Kind) :选择
- 点击 Create (保存)。
第三步:修改 Jenkinsfile 流水线脚本
在执行 scp 和 ssh 的阶段,使用 withCredentials 代码块包裹命令。Jenkins 会在运行时将私钥以临时文件的形式安全地提供给 -i 参数使用:
groovy
stage('发布及远程执行') {
steps {
script {
def remoteUser = "appuser"
def remoteIp = "192.168.1.100"
def tarFile = "${env.WORKSPACE}/app.tar"
def deployCmd = "sh /data/shell/run.sh"
echo ">>>> 正在向目标节点推送文件..."
// 使用 sshUserPrivateKey 注入凭据
withCredentials([sshUserPrivateKey(credentialsId: 'deploy-ssh-key', keyFileVariable: 'KEY_FILE')]) {
sh """
# 1. 使用临时私钥文件传输安装包
# 注意:StrictHostKeyChecking=no 必须带等号,避免交互挂起
scp -o StrictHostKeyChecking=no -i "\${KEY_FILE}" ${tarFile} ${remoteUser}@${remoteIp}:/data/tgz
# 2. 使用临时私钥执行远程启停指令
ssh -o StrictHostKeyChecking=no -i "\${KEY_FILE}" ${remoteUser}@${remoteIp} "${deployCmd}"
"""
}
}
}
}
四、 方案总结与核心价值
| 对比维度 | 方案一:在容器内配置免密 | 方案二:Jenkins 凭据插件(推荐) |
|---|---|---|
| 配置复杂度 | 需进入容器处理密钥、排查目录权限及协议兼容 | 只需在 UI 粘贴一次私钥,即配即用 |
| 容器迁移/重建 | 容器重建或销毁后,免密配置容易丢失 | 凭据保存在 Jenkins 内部,容器重建不受影响 |
| 安全性 | 私钥以明文文件形式长期存留在容器路径中 | 运行时动态生成临时密钥文件,执行完毕自动销毁脱敏 |
| 算法兼容性 | 易踩旧版 OpenSSH 算法协商失败的坑 | 抹平环境差异,稳定可靠 |
通过将认证过程转交 Jenkins 凭据体系管理,彻底规避了"宿主机能连、容器不能连"的环境权限盲区,构建任务即可稳定实现无人值守的自动化发布。