
1. 生产环境拉代码为什么“输密码”这条路走不通我先讲个场景。某天凌晨一点生产环境上一个数据同步任务挂了我看日志发现卡在一句git pull上提示输入密码。部署机上早就设过SSH免密怎么会突然要密码后来一查是运维值班的同事重置了密码或者某次安全策略要求所有账号密码90天强制轮换部署账号的密码一过期所有依赖密码登录的脚本全部哑火。这类问题我在不同团队见过太多次背后都是一个思路问题生产环境拉取代码不应该依赖交互式密码。先说清楚一个概念这里聊的“生产环境git拉取代码”通常不是指开发者在笔记本上git pull而是指部署机、应用服务器、CI执行机上定期或按需从代码托管平台拉取最新代码可能是一个cron任务可能是发布平台触发的一个Shell脚本也可能是容器构建阶段的基础镜像拉代码。这些场景的共同点是无人值守需要机器以一个固定身份去访问代码仓库而且整个过程不能被人为打断。那么密码登录为什么走不通我归纳了三个致命问题。第一密码认证无法自动化。没有人坐在终端前敲密码脚本就卡死在Username for https://...:这样的交互提示上。有些团队图省事把密码明文写在脚本里比如git clone https://user:pass...这在生产环境简直是把钥匙挂在门口。且不说密码会出现在Shell历史、进程列表、日志采集里一旦这台机器被攻破代码仓库等于全线失守。第二密码会变且变了之后没人记得去同步。企业安全策略普遍会要求定期改密哪怕不改某天管理员重置了账号密码所有依赖旧密码的部署任务同时失败。你可以想象一下凌晨被报警电话叫醒的场景。第三密码无法区分身份。多人共同维护一台部署机如果用同一个账号密码操作出了问题根本查不到是谁在什么时候拉过代码。而公钥认证天然绑定到一对密钥是谁的私钥签的名服务端一查便知。这里要澄清一个常见误区SSH免密不等于裸奔。很多人一听“免密”就觉得不安全实际上SSH公钥认证是比密码更可靠的机制——密码可以被钓鱼、被猜测、被撞库而私钥文件只要权限锁好、不泄露攻击面小得多。我们做生产环境免密不是为了图省事是为了让变更可控、可自动化、可审计。所以这篇文章就是围绕生产环境SSH免密的完整链路展开的公钥认证到底怎么工作、权限错误为什么会成为隐形杀手、一份可以直接照抄的配置清单以及我在实际运维中踩过的四个坑。无论是刚接手生产环境的小白还是被免密问题困扰过多次的老手应该都能从里面找到对应的解法。2. SSH免密背后的机制从握手到签名验证很多人在配免密时只会照抄命令一旦出问题就抓瞎。我建议先花五分钟搞懂公钥认证的完整流程后面排查问题会顺手得多。2.1 一次免密登录的完整流程假设部署机上有一个账号deployer它生成了一对密钥私钥deploy_ed25519和公钥deploy_ed25519.pub。公钥被我们手动添加到了代码托管平台的后台作为该账号的访问凭证。当部署机执行git pull时底层其实是SSH客户端连上了托管平台的SSH服务整个过程是这样的客户端发起TCP连接双方交换版本信息和加密算法列表。服务端下发自己的主机公钥客户端检查这个公钥是否在known_hosts中防止中间人攻击。双方协商出会话密钥后续通信全是加密的。服务端说来证明你持有私钥。客户端用私钥对一段包含时间戳、会话ID的数据做签名。服务端用authorized_keys或托管平台后台里存储的公钥去验签验签通过即认定身份无误。注意这个流程里并不存在“密码”的传递。私钥始终留在客户端本地公钥在服务端两者是数学上的配对关系。这也就是为什么公钥不怕公开甚至可以从容地放在代码平台后台而私钥一旦泄露等于账号拱手让人。2.2 “免密”和“免交互”是两码事很多人在生成密钥时设置了passphrase口令短语也就是私钥本身的保护密码。这样一来虽然服务端认证不需要密码了但SSH客户端在使用私钥时还是会问你刚才设置的passphrase脚本照样卡住。所以对于生产环境的部署密钥通常有两种做法生成时不设置passphrase私钥文件权限锁到600靠操作系统文件权限保护它生成时设置passphrase然后通过ssh-agent把私钥加载到内存中脚本运行时SSH_AUTH_SOCK指向agent实现“一次解锁多次免密”。我个人的习惯是如果这台部署机只跑固定的几个自动化任务用第一种就够了前提是私钥不能到处复制如果部署机上有多个运维同事会登录操作建议用第二种这样私钥永远不会以明文形态散落在多台机器上。这里还有一个容易被忽略的细节如果你在crontab或定时任务里用了第二种方式SSH_AUTH_SOCK这个环境变量很可能没有传递进来脚本会发现agent不存在又退回交互式输入passphrase。后面第五部分我会专门讲这个坑。2.3 密钥算法选型ED25519优先RSA保留生成密钥时的算法选择在真实现场里会直接影响兼容性。我推荐优先用ED25519ssh-keygen -t ed25519 -C deployerprod-app-01 -f ~/.ssh/deploy_ed25519 -N -t指定算法-C是注释建议写成“账号用途主机”的格式方便日后在服务端后台里辨认-f指定私钥路径-N 表示不设置passphrase。为什么用ED25519它密钥短、性能好、安全性高现代Linux系统自带的OpenSSH都支持。但如果你的生产环境里还跑着比较老的操作系统比如某个还在用旧版本依赖的遗留机器SSH客户端可能不认ED25519此时就退而求其次用RSAssh-keygen -t rsa -b 4096 -C deployerprod-legacy-02 -f ~/.ssh/deploy_rsa -N 这里-b 4096是必须的低于2048位的RSA密钥在现代安全基线里已经过不了审计。选型原则说白了新系统一律ED25519老系统才考虑RSA 4096。3. 权限问题才是“免密失败”的头号隐形杀手我在帮别人排查免密问题时发现一个规律十次失败里至少有六次是权限问题而不是密钥本身配错了。SSH对文件和目录权限极其苛刻这是它安全设计的一部分但这恰恰也是新手最容易翻车的地方。3.1 目录和文件权限的硬规则先给出一份我整理的权限对照表按这个来基本不会出问题路径归属权限说明~/.ssh/部署账号本人700 (rwx------)目录仅本人可进入~/.ssh/authorized_keys部署账号本人600 (rw-------)仅在服务端需要~/.ssh/id_ed25519部署账号本人600 (rw-------)私钥权限过宽会直接报错~/.ssh/id_ed25519.pub部署账号本人644 (rw-r--r--)公钥可以公开家目录~部署账号本人700或755不能被其他用户写为什么SSH这么“矫情”因为authorized_keys文件决定了谁能免密登录这台机器如果它被其他用户改成600、644甚至777任何能写这个文件的人都可以把自己的公钥塞进去然后堂而皇之地登录进来。私钥文件同理如果权限是644意味着同机器上的其他用户能直接读取私钥免密认证等于形同虚设。所以SSH客户端发现私钥权限过宽时会拒绝使用并给出Permissions 0644 for id_ed25519 are too open之类的报错。我把这个理解为“门卫的洁癖”门卫允许你进门的唯一前提是钥匙只有你自己拿得到文件权限就是这一规则的落地点。3.2 用命令修正权限的标准姿势很多时候密钥文件是从别处拷贝过来的scp或U盘复制之后默认权限通常是644直接使用会报错。正确的修法如下chmod 700 ~/.ssh chmod 600 ~/.ssh/deploy_ed25519 chmod 644 ~/.ssh/deploy_ed25519.pub如果是服务端维护authorized_keys还需要保证文件归属正确chown deployer:deployer ~/.ssh/authorized_keys chmod 600 ~/.ssh/authorized_keys这里必须强调chown这条命令在克隆旧环境时特别容易漏。比如从临时运维账号的目录下复制authorized_keys文件属主可能还是原来的老账号SSH服务端校验时会认为文件归属异常直接拒绝认证。3.3 sudo、环境变量与“明明配好了却不生效”生产环境的权限问题不只是文件权限还包括进程权限和环境变量。我见过很多次这样的操作一个运维同学用root登录部署机执行sudo -u deployer git pull结果报Could not open a session: Permission denied。排查半天密钥明明在/home/deployer/.ssh/下权限也对为什么还是不行原因在于sudo默认会重置环境变量尤其是HOME。执行sudo -u deployer时新进程拿到的是deployer的家目录这其实没问题但如果执行的是纯sudo git pull此时HOME还是/rootSSH客户端默认去/root/.ssh/找私钥自然找不到部署账号的密钥。更隐蔽的是SSH_AUTH_SOCK的丢失。前面提到过如果用ssh-agent方式管理密钥ssh-agent是跑在某个用户会话里的SSH_AUTH_SOCK指向这个会话的socket文件。一旦通过sudo切换到另一个用户或者通过cron启动的脚本没有继承这个环境变量SSH客户端就找不到agent免密自然失败。所以我会在脚本里强制指定SSH应该用哪把钥匙不依赖环境变量推断GIT_SSH_COMMANDssh -i /home/deployer/.ssh/deploy_ed25519 -o IdentitiesOnlyyes export GIT_SSH_COMMANDIdentitiesOnlyyes的意思是只用我指定的这把私钥不要拿agent里或默认路径下所有钥匙挨个试——多个密钥在手上时这个参数能避免很多“服务端不认识你”的怪问题。4. 一套可以直接照抄的免密配置操作清单理论讲完下面给一份完整的实操清单。这套流程我在多个生产环境里跑过多次按顺序执行一般不出幺蛾子。4.1 创建专用的部署账号永远不要用root去拉生产代码也尽量不要用某个开发同事的个人账号。正确做法是创建一个专用的部署账号useradd -m -s /bin/bash deployer passwd -l deployer第二行passwd -l是锁掉密码登录只保留SSH公钥认证通道。这个操作的好处是即使有人猜到密码也无法从网络侧直接登录该账号。接着确认这个账号拥有读取项目目录的权限。这里有个常见分歧部署代码是放在/home/deployer/下还是放在/data/www/这样的公共目录下我见过两种方案都跑得好关键是目录属主必须与运行服务的用户一致或者通过组权限控制。比如应用以www用户运行而部署账号是deployer两个账号都要能读写代码目录最好建一个共同的组groupadd app usermod -aG app deployer usermod -aG app www chown -R deployer:app /data/www/simpledemo chmod -R grwx /data/www/simpledemo这样拉代码时用deployer跑应用时用www两者在组层面共享目录权限既不用开root也避免拉完代码后文件属主错乱导致服务没权限读文件。4.2 生成密钥对并放置到位切换到部署账号生成密钥sudo -i -u deployer ssh-keygen -t ed25519 -C deployerprod-app-01 -f ~/.ssh/deploy_ed25519 -N 确认权限chmod 700 ~/.ssh chmod 600 ~/.ssh/deploy_ed25519然后复制公钥内容cat ~/.ssh/deploy_ed25519.pub把输出的公钥粘贴到代码托管平台后台的“部署公钥”或“SSH密钥”位置。4.3 配置SSH连接参数生产机器如果不止一台或者平台默认的SSH端口不是22建议在~/.ssh/config里写清楚规则Host git.code.internal HostName git.code.internal User git Port 22 IdentityFile /home/deployer/.ssh/deploy_ed25519 IdentitiesOnly yes StrictHostKeyChecking accept-newStrictHostKeyChecking accept-new是OpenSSH较新版本里的策略第一次连接陌生主机时自动接受其主机指纹并写入known_hosts但之后如果指纹变化会拒绝连接。这个模式对无人值守场景很友好既避免了首次连接卡在yes/no确认上又保留了后续对主机指纹变化的校验。如果没有config文件也可以用GIT_SSH_COMMAND环境变量实现同样的效果两种方式选一种即可都写在脚本里更保险。4.4 验证免密是否生效配置完成后不要急着跑部署先手动验证sudo -i -u deployer GIT_SSH_COMMANDssh -i /home/deployer/.ssh/deploy_ed25519 -o IdentitiesOnlyyes \ git ls-remote gitgit.code.internal:simpledemo/simpledemo.git HEAD如果返回一串commit哈希说明免密链路已经通了。如果没有返回依次排查密钥是否放进了平台后台、平台账号是否被禁用、公钥与私钥是否配对、客户端是否真的用了指定的私钥。这里提醒一句代码托管平台里面同一把公钥不要同时挂到多个账号名下。如果某个同事离职后被删了账号而你的部署公钥挂在那个账号下所有部署任务会同时瘫痪。正确做法是给部署公钥一个独立的系统账号或部署密钥入口完全不依赖个人账号生命周期。4.5 拉取方式与分支策略生产环境建议使用固定分支和固定深度避免把不必要的提交历史拉下来cd /data/www/simpledemo GIT_SSH_COMMANDssh -i /home/deployer/.ssh/deploy_ed25519 -o IdentitiesOnlyyes \ git fetch origin git reset --hard origin/production用fetch reset --hard而不是直接git pull是因为生产环境要保证代码和远端分支完全一致不允许本地有未提交的修改干扰发布。--hard会丢掉本地改动这在使用前必须确认没有人在部署机上直接改代码——事实上部署机上本来就不应该改代码。4.6 把拉取后的权限重新收敛拉取代码后新文件的所有者可能不是运行用户比如deployer拉的代码文件属主是deployer:app而应用以www身份运行。这时候必须同步修正权限cd /data/www/simpledemo find . -type d -exec chmod 755 {} \; find . -type f -exec chmod 644 {} \; chown -R deployer:app .或者更简单点直接chown -R deployer:app /data/www/simpledemo。这一步经常被漏掉结果表现出来就是“代码拉了服务也重启了但页面报500”——一查运行用户没权限读代码文件。5. 生产环境踩坑实录这是我最常被问到的四个故障这一节我挑四个真实场景还原整个排查过程而不是直接给答案。因为排查思路比结论更能帮助你在下次遇到同类问题时快速定位。5.1 known_hosts里的旧指纹导致拉取失败某次平台通知代码托管服务器的证书和主机密钥要更换我照常在部署机上执行git pull结果报错 WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! 这个报错的意思是客户端记录的服务器主机指纹变了连接被StrictHostKeyChecking策略拦住了。常见原因有两个服务器重装系统后主机密钥变化或者IP被重新分配给了一台新机器。正确做法是先把旧指纹删掉再重新连接ssh-keygen -R git.code.internal然后重新执行git pull首次连接会提示是否信任新主机指纹确认后写入known_hosts。如果你用的是accept-new策略第一次验证时也会自动接受但请注意如果生产环境的known_hosts里混入了可疑指纹不要图省事直接删光整个文件而是先定位这台服务器到底在哪些机器上被信任过避免中间人攻击的风险敞口扩大。5.2 定时任务里免密失效HOME环境变量的锅有个部署脚本单独手动执行一切正常一挂到crontab里就报Permission denied (publickey)。我在脚本里加了set -x后看到SSH尝试读取的私钥路径是/root/.ssh/...而不是部署账号的。原因在于cron默认不会加载普通用户登录时的环境变量HOME被设成了执行用户的默认值。我这个脚本又是通过root的crontab调度的所以HOME/rootSSH自然去/root/.ssh找密钥。修复方式是在脚本开头强制指定HOME和SSH密钥路径#!/bin/bash export HOME/home/deployer export GIT_SSH_COMMANDssh -i /home/deployer/.ssh/deploy_ed25519 -o IdentitiesOnlyyes cd /data/www/simpledemo git fetch origin git reset --hard origin/production如果脚本是通过sudo执行还需要注意su -和sudo -i的区别。su deployer只切换用户不加载登录环境su - deployer会切到deployer的家目录并加载其环境变量。在crontab里调度时最好在任务开头就把该export的export掉不要依赖任何登录环境。5.3 拉取后文件属主不对导致服务起不来有一次发布后服务启动失败排查发现是deployer从仓库拉代码后vendor目录下的可执行文件权限变成了deployer:app而服务是以www身份启动某些需要写权限的缓存目录没有组写权限应用直接抛异常。那次之后我把发布脚本的收尾动作固化成标准步骤拉代码、重置权限、检查属主、再重启服务。顺序很重要——先修权限再启动否则服务启动时如果已经生成了部分缓存文件这些文件的属主又会是www后续deployer再拉代码时遇到冲突反而更麻烦。5.4 私钥权限过宽被SSH拒绝某同事从自己笔记本上把私钥scp到部署机然后跑部署报错Permissions 0644 for /home/deployer/.ssh/deploy_ed25519 are too open.他以为是平台上公钥没配好反复检查了一小时。实际上就是复制私钥后忘了chmod 600。这也印证了第三节的说法SSH对私钥权限的检查是硬性的权限不对直接拒绝使用不管密钥配得多正确。修法很简单chmod 600 ~/.ssh/deploy_ed25519我给生产环境定了一条规矩私钥文件一律不允许通过scp或U盘在机器之间传递。要换机器部署直接在目标机器上生成新密钥对然后把公钥加到平台后台。这样既能规避权限问题也能保证私钥不会在传输过程中泄露。6. 服务端的权限收敛部署账号只做它能做的事前面讲的都是客户端视角但生产环境的安全基线要求我们把服务端的权限也锁好。很多团队倒在最后这一步。6.1 部署公钥与开发者个人账号密钥的区别托管平台通常提供两种SSH密钥入口一个是绑定在个人账号下的“用户密钥”一个是绑定在具体项目上的“部署密钥”。生产环境拉代码优先用“部署密钥”或独立系统账号而不是拿某个开发者的个人账号密钥。原因很简单个人账号是给人用的它有推送代码、管理分支、修改设置的权限。一旦部署机上的私钥泄露等于给了攻击者一个线上代码仓库的“开发者”入场券。而部署密钥通常只针对单个或几个项目开放权限可以被配置成只读攻击面小得多。6.2 最小权限的具体做法部署账号在服务端的权限收敛我按这个顺序来代码仓库只给只读权限不给推送权限若不得不推送比如发布时需要打版本标签限定只能推送到特定分支或标签且走审批流在托管平台开启审计日志记录该部署账号所有的拉取操作定期轮换部署公钥比如半年换一次到期自动失效。有个细节值得注意如果部署机通过deployer账号能SSH登录托管平台的服务器这个账号很可能就是“系统账号”而非仅“仓库只读账号”。很多托管平台的Git用户是刻意做成无法交互登录的比如shell是/usr/bin/git-shell。如果你发现平台后台有这个选项记得确认部署账号没有交互登录的能力。6.3 误开写权限的后果我有一次排查“生产环境代码被人改了”的事故最后发现是某个项目把部署公钥配成了可推送而部署机上的脚本又恰好有一个git push --force的隐藏逻辑。虽然那次不是恶意的但教训很实在部署密钥没收写权限很多意外就不会发生。给服务端权限做收敛本质上就是给“万一私钥泄露”留一层底线。这层底线在平时看似多余出事时能救命。7. 让免密配置在半年后依然干净我的几条运维习惯最后分享几条我个人在长期运维中逐渐养成的习惯不算什么高深理论但都是实打实避免过故障的做法。第一把免密配置写成可重复执行的脚本而不是手工操作。我习惯在一个脚本里完成创建账号、生成密钥、配置目录权限、写入~/.ssh/config、验证连通性这些动作。这样不管是新装部署机还是灾后重建都能用同一份逻辑快速恢复也避免手工操作漏掉细节。第二每季度在部署机上自查一次密钥权限和known_hosts告警。我会写一个巡检脚本检查私钥权限是否为600、authorized_keys是否为600、部署目录属主是否正常并把结果发到值班群。权限异常往往能提前暴露问题而不用等到凌晨报警才去排查。第三任何一次“换机器”“换平台”“改域名”都先跑一遍免密验证再动发布。跳过了这一步后面十有八九要加班。验证命令很简单就是前面提到的git ls-remote几秒钟的事。第四不把部署机的私钥到处复制。一台机器一套密钥公钥进平台后台私钥锁死在当前机器的账号目录里。如果团队里有同事离职或轮岗优先考虑轮换公钥而不是临时改配置。回到开头那个凌晨被电话叫醒的场景。免密、权限、密钥管理这些东西都印证了一个朴素的道理运维工作里最花时间的并不是配置本身而是对配置背后的机制理解不到位。搞懂了SSH握手流程、看穿了权限检查的硬规则、摸清了脚本与定时任务里环境变量的传递路径所谓“生产环境免密”就只是几行配置的事而不是一个随时会爆炸的坑。