如果你在某次 clone 完主仓库之后,跟着执行git submodule update --init --recursive,屏幕上却蹦出一行Permission denied (publickey),先别急着改文件权限,也别反反复复ssh-add。这个报错我在实际工作中前前后后遇到过七八次,其中有一次因为先入为主地认为是密钥没配好,折腾了大半天,最后才发现问题根本不是密钥,而是子模块的 URL 配置改了之后漏了一步同步操作。
这篇内容不打算做成一本正经的官方文档,我想把 Git 拉取子模块时的 publickey 报错整个拆开:它最常见的触发原因是什么、排查顺序应该怎么排、SSH 认证链路里到底哪一环会断、多密钥场景下又该怎么配,最后再给你一套可以直接抄的检查清单。如果你正在被这个报错卡住,或者打算把排查方法沉淀到团队文档里,这篇应该能帮你省下不少时间。
1. 报错现场还原:主仓库能拉,子模块却被拒
这个报错最迷惑人的地方在于:很多时候主仓库明明是正常 clone 的,偏偏拉子模块的时候就失败。这就产生了一个错觉——"我本地网络没问题、Git 也能连远端,那问题肯定出在密钥文件上"。实际上,主仓库能拉和子模块能不能拉,是两件独立的事,不能混在一起判断。
1.1 两种典型的"一半成功一半失败"
我遇到的现场基本可以归成两类。
第一类是主仓库用 HTTPS 地址,子模块却配了 SSH 地址。团队里很常见:主仓库地址大家从网页上拷贝,默认就是 HTTPS;而某个子模块仓库因为有写权限需求,维护者习惯性地在.gitmodules里填了git@...这种 SSH 格式。这时候你本地如果没有登记过 SSH 公钥,就会看到 publickey 报错,而主仓库因为走的是 HTTPS 凭据或用户名密码,完全不受影响。
第二类是主仓库和子模块都是 SSH,但主仓库能 clone、子模块仍然报 publickey。这种情况通常意味着 SSH 认证链路在"子模块所在的远端"这一层断了。比如子模块放在另一个独立域名的代码仓库上,而你的公钥只登记在主仓库所在的平台;又或者 agent 里加载的私钥和该子模块要求的不是同一把。总之,子模块相当于一次全新的远程仓库访问,主仓库的"认证通过"并不会自动传导给子模块。
很多刚接触 Git 子模块的人会在这里被带偏:看到主仓库能拉,就认定自己的 SSH 环境没问题,于是反复检查本地~/.ssh目录,越查越糊涂。正确的处理心态是:把主仓库和子模块当成两个独立的认证对象,分别判断。
1.2 "publickey"这行字到底在说什么
Permission denied (publickey)这行信息本身是有价值的。它说明 SSH 的 TCP 连接已经建立、SSH 协议已经跑通,但服务器在检查客户端身份时,没有找到任何它认识的公钥。
可以把 SSH 认证理解成进一栋需要门禁的楼:客户端先报上名字,再出示一种"钥匙"(私钥对应的公钥),服务器在自己的授权名单里查这枚公钥,查到就放行,查不到就会拒绝,并提示Permission denied (publickey)。注意,这个阶段完全不涉及主机指纹校验,那是更早一步的"确认这栋楼是不是你要找的那栋楼"。
Git 里常见的几个 SSH 相关错误要分清楚,不然排查方向会跑偏:
| 报错内容 | 实际含义 | 排查方向 |
|---|---|---|
Permission denied (publickey) | 身份认证失败,服务器不认你的公钥 | 密钥文件、agent、远端授权 |
Host key verification failed | 服务器指纹不在 known_hosts 或指纹变化 | 检查 known_hosts |
Connection refused / timeout | 网络层都没通 | 网络连通性、端口、服务状态 |
如果看到Permission denied (publickey)但网络是通的,说明问题层级停留在"认证",不是网络也不是 Git 配置问题。明确了这一点,后面所有的排查才有方向。
2. 先看 .gitmodules:子模块地址的协议决定认证方式
很多人一拿到 publickey 报错就直接钻到 SSH 配置里去翻,其实第一步应该看的是子模块本身使用什么协议。子模块的取数地址写在.gitmodules文件里,这个文件决定子模块的远端 URL 形态;而 URL 的协议形态直接决定 Git 会走哪一套认证流程。
2.1 三条命令看清子模块的远程地址
建议按下面的顺序先把现场信息捞出来:
cat .gitmodules这一步会直接显示当前仓库声明的所有子模块。重点看每个子模块的url字段,判断它是 HTTPS 地址、SSH 地址还是相对路径。
git config --list | grep -i submodule这一步看的是子模块在本地 Git 配置中的实际值,后面会专门讲它和.gitmodules的关系,这里先习惯性地看一眼。
git submodule status这一步能看到子模块当前的检出状态。如果子模块完全没拉下来,它前面通常会有一个-前缀。
.gitmodules里常见的 URL 无非几种:以https://开头的是 HTTPS;以git@开头,或者ssh://开头的是 SSH;还有一种以./或../开头的相对路径,Git 会根据父仓库的 URL 自行推导。
2.2 URL 的协议形态为什么这么关键
如果子模块 URL 是 HTTPS,Git 会走 HTTPS 认证流程,通常使用凭据管理器、用户名密码或 token。理论上来说,HTTPS 流程不会返回Permission denied (publickey),如果返回的是其他认证失败提示,那就是 HTTPS 凭据问题。反过来,如果 URL 是git@或ssh://开头,就必须走 SSH 公钥认证,此时出现Permission denied (publickey)才符合预期。
这也是我在排查时最先看.gitmodules的原因:先判断报错方向和协议是不是匹配的。如果 URL 明明是 HTTPS 却报 publickey,那概率极小,多半是前面有一层 SSH 包装或者 url 重写规则在起作用;而 URL 如果明确是 SSH,那就安心顺着 SSH 链路往下查。
这里也顺手分享一个经验:团队内部子模块的协议最好统一,不要主仓库一个协议、子模块另一个协议。协议混用的仓库,基本都会在某个新同事的机器上触发一次这样的报错,纯粹是给自己人添堵。
2.3 相对路径子模块更容易引向错误方向
有一种更隐蔽的情况:.gitmodules里的 URL 是类似../lib.git这样的相对路径。Git 解析这种 URL 时,会基于父仓库的 URL 来做推导。也就是说,如果你 clone 父仓库时用的是 A 地址,实际访问子模块时可能被重写成一个完全不同的 B 地址;如果你在本地配置了url.insteadOf之类的改写规则,推导结果也会跟着变。
相对路径不是不能用,但一旦出现 publickey 报错,很多人都懵了:明明自己在.gitmodules里看到的地址是../lib.git,根本不涉及任何主机名,怎么还会报主机认证失败?这时候不要盯着相对路径本身,而是要看父仓库 clone 用的真实 URL 是什么,因为子模块实际访问的就是父仓库 URL 推导出来的那个远端。
3. 密钥链路的四层体检:文件、agent、远端一个都别跳
排到这一节,才算真正进入 SSH 认证链路本身。链路大致是:私钥文件存在且权限正常 → ssh-agent 正确加载该私钥 → SSH 客户端到远端进行认证 → 远端授权名单里存在对应公钥。任何一层断了,都有可能出现 publickey 报错。下面我按顺序讲,不建议跳步。
3.1 第一层:密钥文件是否存在、权限是否正常
先在~/.ssh目录下看实际文件:
ls -la ~/.ssh/常见私钥文件名是id_rsa或id_ed25519,对应公钥文件是id_rsa.pub或id_ed25519.pub。如果这个目录里什么都没有,说明你根本还没生成过密钥对,那就直接生成一个:
ssh-keygen -t ed25519 -C "your_email@example.com"一路回车即可,生成完之后用下面两条命令确认权限:目录~/.ssh最好是 700,私钥文件必须是 600,公钥可以是 644。
chmod 700 ~/.ssh chmod 600 ~/.ssh/id_ed25519为什么要强调权限?OpenSSH 对私钥文件权限非常敏感。如果私钥文件权限过宽,比如 644,SSH 客户端会直接拒绝使用这个私钥,宁可继续尝试下一个。很多人在这一步卡住不是因为没有密钥,而是因为密钥文件的权限太"开放"了。
Windows 上还有一个容易忽略的点:直接对C:\Users\你的用户名\.ssh执行 chmod 不完全等同于 Linux 权限模型。如果你无论如何设置权限仍然提示 publickey,可以检查一下目录是否被 Windows 同步服务或杀毒软件占用,必要时用管理员权限打开 PowerShell,先icacls收紧权限再试。
3.2 第二层:ssh-agent 是否递出了正确的钥匙
密钥文件存在只是前提,SSH 客户端实际认证时需要从 ssh-agent 里取私钥。
ssh-add -l这条命令会列出 agent 当前加载的所有私钥指纹。如果提示The agent has no identities,说明 agent 里是空的,需要手动加载:
eval "$(ssh-agent -s)" ssh-add ~/.ssh/id_ed25519第一次执行ssh-add时,如果私钥设置了 passphrase,会提示输入密码。之后 agent 会一直保留这个私钥,当前会话内不需要重复输入。
还有一个非常实际的坑:如果 agent 里同时加载了多把私钥,OpenSSH 会逐个尝试,直到服务器接受了其中一把。某些远端对认证失败次数有严格限制,试到一定次数就直接断开,返回类似too many authentication failures或直接表现为 publickey 失败。所以并不是"钥匙越多越好",有时候反而需要精简 agent 里的密钥数量,或者用后面章节提到的IdentitiesOnly严格控制尝试范围。
3.3 第三层:用 ssh -T 与 ssh -v 做现场实测
当本地文件、agent 都看起来没问题时,用一个最小命令直接验证远端认证:
ssh -T git@example.com-T的含义是不分配交互终端,这是 Git 类场景最常用的验证方式。如果认证通过,远端通常会返回一条欢迎信息,比如告诉你当前认证用户是谁;如果认证失败,还是会回到Permission denied (publickey)。
如果ssh -T也是同样的报错,那就再加一个参数看细节:
ssh -vT git@example.comverbose 输出会比较长,不用从头读,重点找几类关键日志:
- 看到
Offering public key字样,说明 SSH 客户端已经在尝试出示你本地的某把公钥; - 看到
Send pubkey后立刻跟着授权失败信息,说明公钥被远端拒绝了; - 看到日志里根本没有
Offering public key这一行,说明 SSH 客户端压根没打算拿你手头的那把钥匙去试。
我的习惯是拿ssh -vT的输出作为分水岭:日志里有Offering且被拒,问题在远端授权;日志里没有Offering,问题在本地选钥匙的逻辑,多半需要检查~/.ssh/config或 agent 加载情况。
3.4 别把 host key 验证失败和 publickey 搞混
排查过程中经常有人误伤known_hosts。Host key verification failed和Permission denied (publickey)完全是两个层面的问题。
known_hosts记录的是远端服务器的身份指纹。当远端服务器重建、更换密钥,或者全新主机首次连接时,SSH 会提示 host key 校验失败。这时可以用:
ssh-keygen -R "主机名"删除旧指纹后重新连接。但不要因为看到 publickey 报错就去清空known_hosts,这两者没有关系。清空之后只是多冒出一条确认 host key 的提示,并不会让你的公钥突然被远端接受。
我在带团队时最常看到的场景是:同事 publickey 报错,自己把~/.ssh/known_hosts整个删掉了,然后发现报错依旧,这才回头认真查密钥文件。排查链路一旦跳步,时间成本翻倍。
4. 多密钥场景:如何让不同子模块认准不同的钥匙
如果你只有一把私钥,而且已经登记到远端平台,那 publickey 报错大概率不用走到这一节。但现实是很多开发者一台机器上既有个人项目,又有团队内部项目,还可能对接多个不同的 Git 托管平台,每套环境各发一把密钥。这时候光有密钥还不够,麻烦的是 SSH 客户端不知道"该用哪一把去试哪台主机"。
4.1 为什么"钥匙都在 agent 里"反而坏事
有些人习惯把所有私钥一股脑全部ssh-add进 agent,觉得一定能覆盖所有情况。实际效果恰恰相反:agent 里钥匙越多,SSH 客户端越容易乱序尝试。当 SSH 客户端尝试了一把某托管平台根本不认识的公钥后,对方会拒绝;接着尝试下一把;如果钥匙数量很多,可能还没轮到正确的那把,对方已经因为尝试次数过多断开了。
另一个常见现象是:你的私钥文件名不是默认的id_rsa或id_ed25519,而是类似company_key、personal_ed25519这种自定义名字。Git 调用 SSH 时,如果没有显式指定使用哪个私钥文件,OpenSSH 默认只找那几个固定的默认文件名。这就造成了"钥匙明明在磁盘上、而且权限正确,但 Git 就是用不上它"的尴尬。
解决这个问题的思路,不是继续往 agent 里加钥匙,而是让 SSH 客户端能分辨"访问哪台主机时应该出示哪把钥匙"。
4.2 用 ~/.ssh/config 给每个 Host 指定专属密钥
~/.ssh/config是 OpenSSH 的客户端配置文件。它的作用很像一个路由表,能根据你连接的 HostName 或别名,决定使用哪个私钥文件、使用哪些参数。下面是一个常见的多密钥配置示例:
Host work HostName git.example.com User git IdentityFile ~/.ssh/company_key IdentitiesOnly yes Host personal HostName git.personal.example.com User git IdentityFile ~/.ssh/personal_ed25519 IdentitiesOnly yes其中:
Host后面的别名,会出现在 Git URL 中;HostName是真实的远程主机名;IdentityFile显式指定私钥文件;IdentitiesOnly yes强制客户端只使用这里指定的密钥,不要理会 agent 里的其他钥匙。
配置完之后,原来 URL 写的是git@git.example.com:team/subproject.git,现在可以在.gitmodules里改成git@work:team/subproject.git。SSH 客户端一看到work这个别名,就会找到配置里对应的私钥文件,而不会拿 agent 里的一堆密钥去瞎试。
我这里特别强调一下IdentitiesOnly yes。它是多密钥配置里的关键参数,不加这一行,即使你写了IdentityFile,agent 里的其他密钥依然可能被拿去尝试,问题依旧。加上之后,认证尝试被严格限定在你指定的私钥上,既快又干净。
4.3 但别直接把本地别名提交进团队仓库
用 Host 别名来区分密钥很方便,但有一个必须注意的界限:.gitmodules是随仓库提交的,会影响所有协作者。如果你把git@work:...这种本地别名写进.gitmodules,别人没有对应的~/.ssh/config,拉取时就会一头雾水。
所以正确的做法是,团队仓库里的子模块 URL 统一用完整的合法地址,个人本地可以通过git config覆盖或者 url 改写来适配自己的多密钥环境。这一点我会在下一章专门展开,因为它和另一个常见坑——改完配置仍然报错——高度相关。
5. 改完 .gitmodules 还报错:十有八九忘了 git submodule sync
这一节是我最想重点讲的经验,因为很多人已经在前面查完了密钥、确认了权限,甚至把.gitmodules里的 URL 改成了正确地址,执行git submodule update却依然报 publickey。问题往往出在一个极少会想到的命令上:git submodule sync。
5.1 子模块 URL 其实存在两份
很多人习惯性地认为子模块地址只有.gitmodules一份,其实还藏了另一份在本地 Git 配置里。
一个子模块被初始化时,Git 会做两步操作:先读取.gitmodules,然后把 URL 写入当前仓库的.git/config,生成形如submodule.<名称>.url的配置项。之后真正执行 fetch 时,Git 默认使用的其实是.git/config里保存的 URL 地址。
这带来的直接影响就是:如果你只改了.gitmodules,没有同步到.git/config,那 Git 实际去 fetch 子模块时用的还是旧地址。旧地址认不了你的公钥,自然继续报Permission denied (publickey)。
我用生活点的比喻:.gitmodules是子模块的登记表,.git/config是本次执行用的调度单。改了登记表却没重新下发调度单,干活的时候拿的还是旧单子。
5.2 什么时候必须跑 sync,以及命令怎么带
.gitmodules里 URL 被修改过、整个仓库从旧地址迁移到新地址、通过git config手动改过子模块地址,这三种情况都需要重新同步。基础命令是:
git submodule sync如果子模块里面还有嵌套子模块,建议直接带上--recursive:
git submodule sync --recursive执行完 sync 后,再拉取子模块:
git submodule update --init --recursive同步之后,可以用下面的命令确认.git/config里的地址已经更新:
git config --list | grep '^submodule'看到输出的 URL 和.gitmodules一致,才说明这次修改真正生效了。
我印象很深的一次事故,是团队仓库从旧域名迁移到新域名,运维同学在.gitmodules里批量替换了所有 URL,大家拉取时仍然一个接一个报错。最后发现每个人本地git config里的子模块 URL 都还写着旧域名。挨个执行git submodule sync之后,问题立刻消失。这个命令值得刻在脑门上。
5.3 只想本地改 URL 不污染团队的几种姿势
如果你为了适配自己的多密钥场景,或者临时使用一个镜像源,需要修改子模块 URL,但又不希望提交到仓库里影响队友,有两种干净的办法。
第一种是直接修改本地.git/config:
git config submodule.<名称>.url git@example.com:team/subproject.git这种改动只存在于当前仓库的本地配置,.gitmodules完全不受影响,队友也不会被波及。
第二种是利用url.insteadOf做全局的地址改写:
git config url."https://example.com/new/".insteadOf "git@example.com:old/"这个配置对符合前缀的所有 URL 生效,适合大批量替换场景,但要注意它的影响范围比第一种大。如果你只想精确控制某一个子模块,我建议优先用第一种方式。
6. 一张可以直接照做的排查清单与团队建议
最后把这几年和这个报错交手的心得收敛成一套可复用的动作。遇到 publickey 报错,按顺序过一遍,能大幅缩短定位时间。
6.1 按顺序执行的自检清单
| 检查项 | 对应命令/动作 | 通过条件 |
|---|---|---|
| 子模块协议 | cat .gitmodules | 确认 URL 是否走 SSH,HTTPS 报 publickey 本身就很异常 |
| 远端连通 | ssh -T git@example.com | 返回欢迎信息视为通过,继续报 publickey 则继续 |
| 密钥文件 | ls -la ~/.ssh/ | 存在对应私钥,权限不松散 |
| agent 状态 | ssh-add -l | 包含目标私钥的指纹 |
| 详细日志 | ssh -vT git@example.com | 能看到Offering public key且被接受 |
| 子模块同步 | git submodule sync --recursive | 确认.git/config的 URL 与.gitmodules一致 |
| 远端授权 | 托管平台侧检查 | 对应公钥已登记到平台或仓库授权名单 |
这套清单的核心逻辑是:先判断方向,再检查本地环境,最后才怀疑远端授权。别一开始就发工单问平台管理员,大部分时候问题都在自己这儿。
6.2 放进团队文档的命令与建议
如果你的团队经常有新人加入,建议在内部文档里直接写一段标准初始化流程:
git clone <仓库地址> cd <仓库目录> git submodule sync --recursive git submodule update --init --recursive新同事环境配置环节,也可以建议他们完成这三件事:生成 SSH 密钥、把公钥登记到平台的 SSH keys 设置页、执行ssh -T git@example.com确认远端认证通过。这三步做完了,大部分子模块 publickey 问题在首次接触项目前就被扼杀了。
如果 CI 构建环境里也在拉取子模块,记得 CI 环境也要配置单独的部署密钥,并在构建脚本里加入 sync 和 update 命令,防止线上构建因为同样的坑留白。
6.3 我自己实践后的两点习惯
我自己的习惯是这样的:第一,.gitmodules里永远不写本地 Host 别名,只写完整的合法地址,这个是底线;第二,拉取包含子模块的仓库前,无条件先执行一遍git submodule sync --recursive,再执行 update,哪怕我确定没有改过任何配置,多跑一步也不会有坏处。
另外,每次更换电脑或重新装机后,我都会第一时间执行ssh-add并确认 agent 里加载的私钥指纹。这个微小的习惯帮我省掉了不少和报错信息对视的时间。如果你也经常被 Git 子模块的 publickey 报错绊住,不妨先从.gitmodules看起,再按链路一层层查下去,很多时候答案其实就在离报错不远的地方。