Git子模块拉取失败?SSH公钥认证与多密钥配置排查指南
有过在深夜对着终端瞪眼的人都知道Permission denied (publickey)这个报错一旦出现第一反应大多是“密钥是不是坏了”“是不是又被封了”但偏偏主仓库能正常拉只有子模块submodule拉不下来。这种一半能通、一半不通的诡异状态最容易让人绕弯路。这篇就把我实际踩过、排查过的路径完整走一遍从 SSH 认证的原理、子模块 URL 的推导规则到多密钥场景下的配置写法一次性讲清楚适合正在被子模块折磨的开发者也适合想搞懂 SSH 公钥认证到底怎么回事的初学者。1. 先搞清楚报错发生的场景和子模块的基本机制1.1 子模块是怎么工作的子模块本质上就是一个独立的 Git 仓库被嵌进另一个仓库里。父仓库不保存子模块的文件内容只保存一个指向子模块某个具体提交commit的引用以及一份.gitmodules配置文件。这个文件里记录了子模块的远程地址、本地路径等关键信息。当你执行git submodule update --init --recursive时Git 做的事情其实很直白读取.gitmodules里的 URL把子模块仓库 clone 到指定目录下再 checkout 到父仓库记录的那个提交。也就是说git submodule update在底层就是一个“先 clone 后 checkout”的过程。这也解释了为什么报错会出现在拉子模块的时候因为主仓库你已经 clone 过了但子模块仓库是“全新的”Git 需要重新和托管平台建立一次认证。而认证方式SSH 还是 HTTPS是由.gitmodules里的 URL 决定的不是由父仓库决定的。这一条是整个排查的关键后面会反复提到。1.2 这个报错到底是在哪一步爆出来的Permission denied (publickey)这句话直译是“权限被拒绝公钥方式”意思是 SSH 服务器接受了你的连接请求但在认证阶段没有通过拒绝了你提供的公钥。在实际操作中这个报错出现的时机有几种克隆父仓库时用了 SSH但子模块 URL 是另一个无法访问的主机。父仓库用 HTTPS 克隆但.gitmodules里子模块 URL 写的是 SSH 地址。子模块能识别出来但当前这台机器上根本没有对应的私钥。有多个密钥文件SSH 客户端默认尝试的密钥不是托管平台认得的那一个。多账号场景下同一个托管平台绑定了个人账号和机器人账号配错了密钥。很多人觉得“我能 SSH 访问主仓库那子模块也能访问”这个直觉并不总是成立。因为子模块的 URL 是独立配置的它可以指向另一个域名、另一个账号甚至另一台服务器。只要 URL 的协议走了 SSH而 SSH 认证链路上任何一个环节出问题这个报错就会出现。2. 根因定位为什么明明能拉主仓库子模块却认证失败2.1 SSH 认证链路拆解要理解这个报错得先把 SSH 公钥认证的流程捋一遍。当你在终端执行ssh githost时客户端会做这么几件事读取~/.ssh/下的密钥文件。尝试用默认文件名id_rsa、id_ed25519等或ssh-agent里已加载的密钥发起认证。如果服务器上有对应的公钥并且允许该账号登录认证通过。如果没有任何一个密钥被接受就抛出Permission denied (publickey)。关键点在于第 2 步。SSH 客户端并不是“拿着所有密钥挨个试”也不是“自动找到能用的那把钥匙”。它的默认行为是先看当前目录有没有可用密钥再看ssh-agent里有没有已经加载的密钥最后看~/.ssh/config里有没有为当前 Host 指定IdentityFile。这三条路径是“或”的关系但优先级和实际效果并不相同。而 Git 在执行git submodule update时使用的 SSH 命令和你在终端手动敲ssh是一样的都可以被GIT_SSH_COMMAND环境变量覆盖。所以排查方向就很明确了看看 Git 用的这把 SSH 钥匙到底是不是托管平台认识的那一把。2.2 最常见的三类根因我把实际工作中遇见的案例归纳了一下90% 的情况逃不出下面三类。第一类是“密钥没被找到”。新生成的密钥没有放进ssh-agent而且文件名不是默认的id_ed25519或id_rsa。SSH 客户端尝试默认密钥失败后不会主动去读id_ed25519_deploy这种自定义文件名。结果就是人肉敲命令能用Git 自动跑就废。第二类是“密钥太多选错了”。多账号环境下不同平台、不同项目的密钥都放在~/.ssh/下SSH 客户端默认会优先尝试默认文件名而这些默认文件里可能没有对应这台服务器的公钥。更麻烦的是如果你把多个私钥都ssh-add进 agentSSH 会把它们全部尝试一遍托管平台只会接受匹配的那一个其余全部报错。第三类是“子模块 URL 的协议和预期不一致”。.gitmodules里的 URL 写的是 SSH但当前这台机器压根没配置 SSH 密钥或者父仓库本身是用 HTTPS 拉下来的。子模块的 URL 是独立的不会因为你父仓库用什么协议就自动“继承”它只认.gitmodules里写的那个地址这会在后面详细展开。3. 手把手排查三条命令加一步调试定位问题3.1 第一件事确认子模块的 URL 是哪种协议不要凭记忆猜直接看配置。在父仓库根目录执行git config -f .gitmodules --get-regexp url输出类似这样submodule.core.url gitgit.example.com:group/core.git submodule.web.url https://git.example.com/group/web.git看到git开头说明走的 SSH看到https://走的就是 HTTPS。这两者一旦混在一起而你又没有为 SSH 地址配置好对应的密钥报错几乎是必然的。还有一种隐藏情况.gitmodules里写的是相对路径例如url ../core.git这种写法在 Git 官方文档里是有明确规范的相对路径会基于父仓库的远程 URL 来推导。如果你父仓库的 remote URL 是gitgit.example.com:group/parent.git那子模块会推导成gitgit.example.com:group/core.git去掉父仓库名再拼上../core.git指向的部分。如果父仓库是 HTTPS 的子模块也会推导成 HTTPS。这个机制特别容易坑人因为你在.gitmodules里看不到完整的 URL推导结果只有 Git 自己知道。3.2 第二件事验证你能否直接 SSH 访问托管平台这个测试最简单效果也最直观。假设你的平台地址是git.example.com执行ssh -T gitgit.example.com不同平台的响应文案不一样但只要不是Permission denied (publickey)就说明认证链路本身是通的。我习惯用这条命令作为“是否能 SSH 连接”的第一判断标准。如果这里就报错了那问题就出在密钥本身、agent 加载状态、SSH config 配置或平台侧公钥绑定和 Git 无关。如果这里能通但子模块拉取还是报错那问题就在 Git 拿到的 URL 或 Git 调用的 SSH 命令和你手动执行的不一致。3.3 第三件事用调试模式看 ssh 到底在用哪个密钥这是整个排查里最有价值的一步。带上-vverbose参数强制 SSH 输出详细日志ssh -vT gitgit.example.com重点看日志中Offering public key那一行。它会明确告诉你 SSH 尝试了哪个密钥文件、是否被服务器接受。比如Offering public key: /home/user/.ssh/id_ed25519 Authenticated successfully.或者Offering public key: /home/user/.ssh/id_ed25519 Offering public key: /home/user/.ssh/custom_key如果你看到 SSH 从头到尾只尝试了默认的id_ed25519而你真正用来绑定的密钥叫id_ed25519_work那问题就很清楚了不是密钥坏了是 SSH 根本没找到它。3.4 一个容易被忽略的问题ssh-agent 里加载的密钥过期ssh-agent是长期驻留在后台的程序用来缓存你的私钥避免每次连接都手动输入密码。但注意如果在图形界面环境或重启后没有重新执行ssh-addagent 可能是空的。检查当前 agent 里有哪些密钥ssh-add -l如果提示The agent has no identities.说明一个密钥都没加载。这种情况非常常见你重启了电脑重新打开终端结果 Git 拉子模块时报 publickey 错误但手动ssh -T gitgit.example.com又能通——原因可能就是某个 shell 配置里自动加载了密钥而你实际操作终端的环境不一致。4. 对症下药每种根因的修复方案4.1 密钥没加载把它加进 ssh-agent如果你确认密钥文件存在只是没被加载一条命令就能解决eval $(ssh-agent -s) ssh-add ~/.ssh/id_ed25519_work第一条命令启动 agent如果已经在运行会直接复用第二条命令把指定的密钥加进内存。之后验证一下ssh-add -l看到密钥的指纹就说明加载成功。注意ssh-add添加的是私钥不是公钥。私钥以~/.ssh/id_xxxx命名公钥通常多一个.pub后缀。这个是最容易搞反的地方——把公钥加到 agent 里没用agent 要的是私钥。如果是在 macOS 上还可以把ssh-add --apple-use-keychain ~/.ssh/id_ed25519_work和系统钥匙串结合起来省去每次开机重新加载的麻烦。Windows 上则建议开启 OpenSSH Authentication Agent 服务并把私钥通过ssh-add添加。4.2 多密钥选错用 SSH config 指定 IdentityFile如果你手上有多个密钥分别对应不同的托管平台或不同的账号强烈建议用~/.ssh/config来约束。一个典型的配置如下Host work.git.example.com HostName git.example.com User git IdentityFile ~/.ssh/id_ed25519_work IdentitiesOnly yes Host personal.git.example.com HostName git.example.com User git IdentityFile ~/.ssh/id_ed25519_personal IdentitiesOnly yes这里的重点是IdentitiesOnly yes。这个参数告诉 SSH我只用IdentityFile指定的密钥不要拿着 agent 里的所有密钥去挨个试。没有这一行SSH 会把 agent 里的密钥全部尝试一遍很容易触发托管平台的限流而且日志里会多出一堆无效的Offering public key。配置完成后你可以把子模块 URL 里的git.example.com换成work.git.example.com这样 Git 在 SSH 时就会自动读取对应规则。不过要记得.gitmodules里的 URL 是可以直接改的改完后需要用git submodule sync把本地配置同步过来。还有一种更隐性但实用的配置方式是用GIT_SSH_COMMANDGIT_SSH_COMMANDssh -i ~/.ssh/id_ed25519_work -o IdentitiesOnlyyes git submodule update --init这适合临时调试比如只为了验证某个密钥能不能用就不必改全局配置文件了。4.3 URL 协议不一致统一改成 SSH 或 HTTPS有一种方法是让所有子模块都走 HTTPS这样完全不依赖 SSH 密钥也基本不会遇到publickey问题。前提是你所在网络环境允许访问远程托管平台的 HTTPS 端口通常没问题并且你能接受每次操作可能要求输入账号密码或者配置好凭据缓存。操作方法很简单git config -f .gitmodules submodule.core.url https://git.example.com/group/core.git git submodule sync git submodule update --init注意git submodule sync这步不能省。它会把.gitmodules里的 URL 同步到.git/config和子模块仓库的 remote 配置中。很多人改完.gitmodules直接执行git submodule update发现还是走旧地址就是因为没同步。如果你的团队默认都使用 SSH那就优先保证统一 SSH。把 HTTPS 地址改成gitgit.example.com:group/core.git这种形式并确保前面几步的 SSH 认证链路是通的。4.4 托管平台侧没有添加公钥这个原因特别容易在“新机器”上遇到。你新换了一台电脑生成了新密钥但还没把公钥加到托管平台账号下。此时你拉主仓库可能是通过 HTTPS 或其他人共享的部署密钥完成的子模块一旦走 SSH就会立刻暴露“这台机器没有可用公钥”的事实。确认方法还是那条命令ssh -T gitgit.example.com如果报Permission denied (publickey)就基本可以确定平台侧没认你这把钥匙。解决方案是在平台上添加公钥找到账号设置里的 SSH Keys 或 Deploy Keys把~/.ssh/id_xxx.pub的内容整段粘贴进去。还有一种约束是“部署密钥只能访问单一仓库”如果子模块分布在多个仓库下一个部署密钥覆盖不全也会报权限错误。这种情况要么给每个仓库分别加部署密钥要么改用账号级公钥。4.5 相对路径子模块的 URL 推导坑前面提到过.gitmodules里的相对路径../xxx.git会跟着父仓库 remote URL 走。当父仓库 remote URL 是 SSH、而子模块相对路径推导出来的主机名带上了奇怪的别名时也容易触发 publickey 问题。排查方法很简单直接看当前父仓库的 remote 地址git remote -v如果你想彻底摆脱推导规则带来的不确定性最简单的做法是把.gitmodules里的相对路径写成完整 URL。这样做虽然会在多个仓库之间复制粘贴时不太美观但在排障时能省掉一大半猜谜过程。5. 把整套流程沉淀为习惯从 clone 到 update 的完整姿势5.1 一套推荐的操作流程经过几次半夜排障后我给自己定了一套“无脑流程”现在基本不会再被这种问题卡住。第一步新机器初始化密钥后立刻验证平台连接ssh -T gitgit.example.com确认能通再执行任何 clone 操作。这样如果后面报错至少能确定不是“这台机器压根没权限”。第二步clone 主仓库时统一协议。如果团队习惯 SSH确保主仓库和子模块的 URL 都是 SSH如果只是临时拉代码看看就全部用 HTTPS别混着用。第三步更新子模块时习惯性带上--init --recursivegit submodule update --init --recursive--init会根据.gitmodules初始化本地配置--recursive会嵌套拉取所有子模块的子模块。缺一个都可能留下伏笔后面莫名其妙报错。第四步如果还有问题执行git submodule sync再试一次。这个命令做的工作是把.gitmodules的最新配置同步到本地.git/config很多人改了 URL 之后忘记同步导致拉取还是用旧地址。第五步真到要动密钥了熟练使用ssh-add -l、ssh -vT、GIT_SSH_COMMAND这三个工具不要凭感觉改文件。5.2 输出一个检查清单检查项命令或操作通过标准子模块 URL 协议git config -f .gitmodules --get-regexp url明确看到每个子模块的完整地址SSH 手动认证ssh -T gitgit.example.com无 Permission denied 报错agent 密钥状态ssh-add -l至少有一个密钥且与预期一致调试日志ssh -vT gitgit.example.com找到被接受的Offering public keySSH config检查~/.ssh/config的 IdentityFile 和 IdentitiesOnly每个 Host 有明确对应密钥同步配置git submodule sync执行后无异常输出把这个清单贴在项目 README 或者自己的笔记软件里下次再遇到基本五分钟内定位。6. 常见问题快查表与我的踩坑记录6.1 快查表现象可能原因优先尝试手动 ssh 能通Git 拉子模块报 publickeyagent 没加载密钥 / Git 调用的 SSH 配置不同ssh-add -l确认 agent 状态默认密钥不是托管平台认识的那把多密钥但没有指定SSH config 加IdentityFile和IdentitiesOnly父仓库 HTTPS 正常子模块报 SSH 权限错误.gitmodules里是 SSH 地址统一协议或修改.gitmodules子模块 URL 是相对路径父仓库 remote 是 SSH 但子模块推导异常改为完整 URL新换电脑后拉取失败公钥没添加到平台ssh -T验证重新添加公钥子模块文件夹存在但内容为空更新失败的历史遗留先git submodule deinit再重新 init6.2 踩坑记录第一次踩这个坑是一台刚装好系统的机器。主仓库用 HTTPS 拉的子模块.gitmodules里写的 SSH于是报 publickey。当时我不知道子模块 URL 是独立配置的一直怀疑是系统 SSH 配置坏了反复重装 openssh-client 折腾了一晚上。后来才知道submodule.sync这个命令能解决大半“改了配置但好像没生效”的问题。第二次比较隐蔽同一台机器上有两个账号一个个人账号一个用于自动化任务的机器人账号。我把两个密钥都添加到了 agent 里结果 SSH 在认证时先尝试个人账号的密钥托管平台不认直接拒绝。当时也是Permission denied (publickey)。最后是靠ssh -vT看到日志里所有Offering public key都失败才意识到是 SSH 把两把钥匙全扔给服务器而服务器只认其中一把。加一行IdentitiesOnly yes就解决了。所以现在我对这类问题的态度是先承认“SSH 很强大但它不会读心”。它不会自动挑选正确的密钥不会自动匹配子模块的 URL 推导结果更不会因为你昨天刚用过就记住配置。一切都需要明确写在配置里或者明确执行在命令行里。理解了这一点绝大多数 Git 子模块的 SSH 认证问题都只是按照排查链路一步步走就能落到根因上的寻常故障而已。