1. 先把报错拆成两半:写文件失败和读数据中断是两件事
git lfs pull卡在百分之几十不动,接着甩出这么一行:
cannot write data to tempfile "/root/8dc6d01e84acccd8a5769d5": LFS: unexpected EOF第一次撞见这条报错,绝大多数人的第一反应是去搜unexpected EOF,然后顺着网络方向一路排查。方向不算错,但只对了一半。这条报错的句式结构是cannot write data to tempfile "<路径>": <底层错误>,冒号前面是动作失败,冒号后面是失败的原因。也就是说,程序在"把拿到的数据写进临时文件"这个动作上出了问题,而 EOF 只是它观察到的现象之一。
把两半拆开看,排查范围立刻收窄:一半是"临时文件到底能不能写",另一半是"数据流有没有被提前掐断"。这两件事的根因完全不同——前者指向磁盘、权限、inode、文件系统状态;后者指向网络链路、并发压力、超时阈值、远端对象完整性。实操中最容易犯的错误是:明明是磁盘满了,却一直在调并发数;或者明明是链路在中间被切断,却反复去chmod目录。
我做过一次粗略统计,在构建节点和开发容器上遇到的这类报错,磁盘与 inode 相关占一半以上,权限与临时目录配置占两成左右,剩下的才是真正的传输中断。这个比例和大多数人的直觉正好相反——大家总以为 EOF 一定是网络问题。
1.1 报错文本里的两个断点
tempfile "/root/8dc6d01e84acccd8a5769d5"这一段信息量非常大。第一,它给出了临时文件的具体路径,而路径里没有.git/lfs/tmp这样的子目录,说明这个临时文件是由操作系统的临时目录机制生成的,不是仓库内部的缓存目录。第二,这个路径落在/root下,而/root在绝大多数发行版里的权限是drwx------,只有 root 能进。
第三,文件名是一串 16 进制的随机串,没有扩展名,这是典型的CreateTemp风格命名,用来避免并发写入时的文件名冲突。这种文件在正常流程里活不过几秒——数据写完立刻被 rename 到.git/lfs/objects下的正式位置。如果它被留在了原地,说明写入流程中途就断了。
再看后半句LFS: unexpected EOF。unexpected EOF是 Go 语言io包里io.ErrUnexpectedEOF的文本形式,语义很精确:读到了数据流的末尾,但还没读够预期的长度。它和EOF不一样,EOF表示"干净地读完了",unexpected EOF表示"读到一半对面没了"。在 LFS 的下载路径里,这个错误通常来自把 HTTP 响应体往临时文件里拷贝的那一步。
1.2 unexpected EOF 是谁抛出来的
LFS 的下载流程大致是:先向远端请求一批对象的下载地址,拿到地址后按并发数开若干条连接,每条连接把响应体流式写入一个临时文件,写完之后校验大小和哈希,通过才 rename 成正式对象。整个过程是流式的,不会把整个文件读进内存——大文件动辄几个 G,内存根本扛不住。
流式写入意味着任何一环出问题都会在"拷贝"这一步暴露。HTTP 响应头里通常带Content-Length,拷贝逻辑知道应该读多少字节。如果实际读到的字节数小于这个值,连接就断了,抛出的就是unexpected EOF。触发这个断点的原因有好几类:链路中途被设备重置、服务端在传输过程中关闭连接、中间缓存层返回了不完整的内容、或者读端自己因为写盘失败提前退出导致计数对不上。
最后一种情况经常被忽略:写盘失败也会表现为 EOF。临时文件所在的文件系统写满之后,写操作返回ENOSPC,拷贝循环中断,上层的错误包装可能只保留了"数据没读够"这个表象,而把真正的写错误吞掉了。这也是为什么这条报错必须两半一起查。
提示:不要看到 EOF 就直接认定是网络问题。先把冒号前半句当成独立故障处理一遍,能省掉大量无效排查。
1.3 用进度条和退出时机判断卡在哪一段
有一个成本极低的判断方法:观察报错出现时进度条的状态。git lfs pull在下载阶段会显示Receiving objects之类的进度,在检出阶段(把对象从缓存复制到工作区)进度条形态不一样。
如果报错出现在下载进度推进的过程中,比如走到 60% 突然中断,那问题在传输段,重点查链路、并发、超时、远端对象。如果报错出现在下载已经 100%、开始往工作区落文件的时候,那问题在检出段,重点查工作区所在磁盘的空间和权限。如果连进度条都没出现就报错,那大概率是临时目录本身不可用,连第一个临时文件都创建不出来。
还有一种情况是每次都卡在同一个百分比。这种"可复现的固定位置"几乎可以锁定为某个具体对象有问题,而不是随机性的网络抖动。反过来,如果每次卡的位置都不同,那更像是资源类的间歇性故障——磁盘余量在临界点反复横跳,或者并发把带宽打满后随机断连。
2. 临时文件落在 /root 这件事本身就是线索
很多人扫一眼路径就跳过去了,觉得那只是个随机生成的文件名。实际上/root这个位置直接决定了后续一大半的排查方向。默认情况下,类 Unix 系统的临时目录是/tmp,一个专门挂了 tmpfs 或者独立分区的目录,权限是1777,任何用户都能写。临时文件跑到/root下面,说明有人改过默认值。
改这个值的途径不止一条。环境变量TMPDIR是最直接的一条,很多运行时和工具链都会优先读它。有些容器镜像在构建阶段为了规避/tmp被挂载成noexec或者容量太小,会在Dockerfile里写一行环境变量把临时目录指到用户主目录。这一步在单用户场景下没什么问题,一旦换成非 root 用户跑构建,立刻就炸。
2.1 TempDir 这个值是从哪来的
排查的第一步是把当前生效的临时目录配置打出来,而不是靠猜。三条命令足够:
echo "TMPDIR=$TMPDIR" echo "HOME=$HOME" echo "USER=$(id -un) uid=$(id -u)"如果TMPDIR的值是/root,那报错路径就完全解释得通了。如果TMPDIR是空的,那就得看更下一层的逻辑——有些工具在TMPDIR未设置时会退回到HOME,而HOME在某些 CI 环境里被设成了/root,即便当前执行用户并不是 root。
还要注意环境变量的继承链。在本地 shell 里echo $TMPDIR是空的,不代表构建脚本里也是空的。CI 平台的 runner 经常在任务启动时注入一批环境变量,.bashrc、.profile、systemd的服务单元、容器的ENV指令,任何一处设置了都会生效。我遇到过最隐蔽的一次是某台构建机在/etc/environment里全局设了TMPDIR=/root,本地交互式登录看不出来,因为登录 shell 又覆盖回去了,只有非交互式的构建进程才会读到那个值。
2.2 git lfs env 是最快的旁证
与其一层层追环境变量,不如直接问 LFS 自己。git lfs env会把当前生效的路径配置全部列出来,其中TempDir一行直接对应报错里的目录:
git lfs env输出大致长这样:
git-lfs/3.x.x git version 2.x.x Endpoint=<远端地址> (auth=none) LocalWorkingDir=/workspace/repo LocalGitDir=/workspace/repo/.git LocalGitStorageDir=/workspace/repo/.git/lfs LocalMediaDir=/workspace/repo/.git/lfs/objects LocalReferenceDirs= TempDir=/root ConcurrentTransfers=8 TarballDir=TempDir=/root这一行如果和报错路径对得上,问题定位就完成了一半。同时这份输出还顺手告诉了你另外两个关键值:LocalMediaDir是对象缓存的落盘位置,ConcurrentTransfers是当前并发数。这两个值在后面的章节里都要用到。
顺带说一句,git lfs env还有一个容易被忽视的用途:确认 LFS 是否真的装好了、版本是否匹配。有些环境里git lfs命令能跑,但 git 的 filter 配置没生效,git lfs env会显示git config filter.lfs.clean之类的缺失告警。
2.3 非 root 用户撞上 /root 的典型场景
最典型的翻车场景是这样的:镜像构建阶段用 root 跑过一次git clone加git lfs pull,把东西准备好;运行阶段切到普通用户,再去同一个目录跑构建。这时候.git/lfs下面一部分文件的属主是 root,普通用户既写不了缓存目录,也写不了临时目录,于是报错。
判断方法很直接:
ls -ld /root ls -ln .git/lfs .git/lfs/objects | head -20如果.git/lfs下面出现0 0这样的 uid/gid,而当前用户不是 0,基本就确认了。修复方式是把整个工作副本的所有权改回来:
sudo chown -R "$(id -u):$(id -g)" /workspace/repo注意范围,只改仓库目录,不要去动/root本身的权限。见过有人为了省事直接chmod 777 /root,这是在拿系统安全换一次构建通过,非常不划算。正确的做法是把临时目录挪到一个专门为构建准备的位置,比如/var/tmp/build,权限设成1777,让它承担TMPDIR的职责。
还有一种变体:sudo git lfs pull跑通了,普通用户跑不通,于是有人索性在脚本里全程加sudo。这么干短期能过,长期会制造出更难查的问题——工作区里一半文件属主是 root,后续的git checkout、git clean都会出现莫名其妙的权限拒绝。
3. 磁盘与 inode:最容易忽略的写失败真凶
把临时目录权限排除掉之后,下一个必须查的是空间。这里说的空间有两个维度:字节容量和inode 数量。只查前者是新手最容易踩的坑,因为很多"磁盘明明还有空间"的现场,实际上是 inode 耗尽了。
LFS 仓库的特点是对象数量多、单个对象大。一次性pull下来,.git/lfs/objects下面会多出成千上万个目录和文件,每个对象在缓存里至少占一个文件、一个目录项,加上临时文件阶段的双份占用,inode 消耗比普通代码仓库高一个量级。
3.1 df -h 与 df -i 要一起看
两条命令一起跑,缺一不可:
df -h / /root /workspace df -i / /root /workspacedf -h给出字节维度的余量,df -i给出 inode 维度的余量。判断标准不是"还剩多少",而是"够不够这次操作"。
这里有个必须建立的估算习惯:git lfs pull的峰值磁盘占用约等于LFS 对象总大小 × 2。原因是下载阶段临时文件和最终对象会同时存在一小段时间,写完才 rename。外加工作区检出时还要再占一份。所以一个 3G 的 LFS 仓库,峰值可能要 6 到 9G。如果分区只剩 4G,跑到一半写失败完全合理。
预估这次要拉多少数据,有个前置命令很好用:
git lfs fetch --dry-run它不会真的下载,只把将要拉取的对象和总大小列出来。在动手前跑一遍,把输出里的总量和自己的剩余空间对一下,能避免大部分"跑到 80% 炸掉"的尴尬。
df -i的读法类似,看IFree那一列。现代大分区上 inode 通常是够的,但容器场景下不一定——某些存储驱动给容器的可写层分配的 inode 数量远小于宿主机的分区。
3.2 被删除却仍占着空间的句柄
有一类情况特别迷惑人:df -h显示磁盘快满了,但du -sh把整个目录树扫一遍,加起来对不上账,中间差了十几个 G。这种差额通常是"已删除但句柄未释放"的文件——某个长驻进程还攥着一个文件的句柄,文件在目录树里已经消失了,但磁盘块没还回去。
LFS 场景下这个进程可能是上一次中断的git-lfs子进程,也可能是构建脚本里起的某个后台服务,日志文件被rm掉了但进程还在往里写。排查命令:
lsof +L1 2>/dev/null | head -20+L1表示列出链接数小于 1 的打开文件,也就是已经被删掉但还开着的。输出的SIZE/OFF列能看出这些"幽灵文件"占了多少空间。处理方法是对应进程重启或者直接 kill 掉,空间立刻回来。
我遇到过一次很典型的:构建节点上有个后台日志采集进程,日志文件被轮转脚本删了但进程没重启,日志继续往那个已删除的 inode 里写。磁盘一路涨到 98%,du扫出来的总量只有一半。清理办法不是删文件,是重启那个采集进程。
3.3 容器与配额场景下的隐藏上限
容器里df的输出要小心解读。可写层通常是叠加文件系统,df -h /显示的往往是宿主机分区的容量,而容器实际能用的空间受限于存储驱动的配额,两者可能差很远。表现就是df说还有 20G,写到某个点突然ENOSPC。
判断方法看挂载信息:
mount | grep -E ' / | /workspace | /root '如果/那一行显示的是叠加类型的文件系统,而/workspace是独立的卷挂载,那结论很明确:把TMPDIR指到/workspace下面的某个目录,让临时文件落在有真实配额保障的卷上,比跟可写层较劲靠谱得多。
另外一类是用户级配额。多用户共享的构建机上,管理员可能给每个账号设了磁盘配额,df看到的是全分区余量,你自己的额度早就见底了。检查命令:
quota -s 2>/dev/null || echo "quota not enabled"配额超限时,写操作返回的错误码和磁盘满是一样的,但df上完全看不出来。这种情况只能靠配额命令确认,或者换个有空间的路径挂载点。
还有一个容易漏的点:/tmp被挂成 tmpfs 时,它占的是内存。tmpfs 的容量上限通常是物理内存的一半,写入超过这个值会直接失败。如果TMPDIR指向 tmpfs 而 LFS 对象又很大,很容易撞上这个上限,而且表现和磁盘满一样。
4. EOF 出现在传输末端时的排查路径
空间和权限都排除干净之后,才轮到真正的传输问题。unexpected EOF在网络层面的本质是"连接在数据传完之前结束了"。这条链路上任何一环提前收工都会造成这个结果,从客户端到服务端,中间可能还隔着负载均衡和缓存层。
排查这类问题的思路是从可调参数入手,逐步缩小范围,而不是一上来就怀疑远端。
4.1 并发数把带宽打满之后
LFS 默认并发下载数是 8。这个值在带宽充裕、延迟低的网络里没什么问题,但在带宽有限或者链路质量一般的环境里,8 条连接同时跑会把可用带宽切得很碎,每条连接的速度都很慢,反而更容易触发超时和中断。
调低并发是最简单也最有效的止血手段:
git config lfs.concurrenttransfers 2 git lfs pull从 8 降到 2,总下载时间未必变长——很多时候反而更快,因为减少了连接之间的相互干扰和重传。这个参数写在仓库级配置还是全局配置看场景:CI 里建议写在仓库级,跟着代码走,避免不同机器行为不一致。
配套的还有超时参数。LFS 有几个相关的配置项:
| 配置项 | 作用 | 默认值 | 大文件场景建议 |
|---|---|---|---|
lfs.concurrenttransfers | 并发传输数 | 8 | 2 到 4 |
lfs.activitytimeout | 单次传输无进展超时(秒) | 30 | 120 到 300 |
lfs.dialtimeout | 建立连接超时(秒) | 30 | 保持默认或略增 |
lfs.tlstimeout | 握手超时(秒) | 30 | 保持默认 |
activitytimeout是最值得调的。它的语义是"多久没有任何数据流动就判定失败",不是总耗时。大文件在慢链路上偶尔会有一段卡顿,30 秒的窗口偏紧,调到 300 秒能显著减少误判。但也不能无限大,否则真出问题时挂在那里干等。
4.2 大对象与超时窗口
单个对象特别大的时候,问题会更集中。一个 2G 的对象在 10Mbps 的有效带宽下,理论传输时间接近半小时。中间只要有一次几十秒的停顿,就会被判超时。而且大对象一旦失败,重试又是从头开始——虽然后续版本在.git/lfs/incomplete里会留下未完成的记录尝试续传,但残留记录本身有时也会导致反复卡在同一位置。
关于.git/lfs/incomplete,我的经验是:如果同一个对象连续失败三次以上,与其反复重试,不如把残留记录清掉重来。清掉之后从零开始,往往一次就过。
ls -la .git/lfs/incomplete 2>/dev/null rm -rf .git/lfs/incomplete git lfs fetch --include="path/to/big/file" --exclude=""--include加--exclude=""的组合是精确拉取单个路径的写法,注意--exclude必须显式置空,否则默认配置里的排除规则仍然生效,会出现"命令跑了但什么都没拉"的情况。这个细节在文档里不显眼,踩过一次就忘不掉。
4.3 远端对象缺失与缓存层截断
如果某个对象无论怎么重试、换什么并发数都失败,而其他对象都正常,那要怀疑远端。LFS 对象在远端存储里丢失或者损坏的情况并不罕见,尤其是仓库做过迁移、对象存储换过桶之后。
判断方法是用 trace 打开一次完整请求,看服务端返回的响应码和Content-Length:
GIT_TRACE=1 GIT_TRANSFER_TRACE=1 GIT_CURL_VERBOSE=1 \ git lfs fetch --include="path/to/suspect/file" 2>&1 | tail -80重点看几处:请求返回的状态码是不是 200、响应头里的长度字段有没有、实际读到的字节数是多少。如果服务端返回 200 但长度明显偏小,或者干脆没有长度字段,那基本可以判定为远端对象不完整,客户端这边怎么调都没用,只能推给仓库维护方重新上传。
还有一层是中间缓存。静态资源类服务经常在前面挂缓存层,缓存了一份不完整的内容之后,所有客户端拿到的都是残缺的。判断特征是同一个人在别的网络环境下能拉下来,而你这边每次都断在同一个字节位置。这种只能通过更换访问入口或者让维护方刷新缓存解决。
提示:
GIT_CURL_VERBOSE=1的输出量很大,一定要配合tail或者重定向到文件再翻,直接刷屏会把关键信息冲掉。
5. 一条从日志到复现的定位链路
前面几章把可能的原因分门别类讲了一遍,实际操作时不可能全都试一遍。这一章给出一条按成本排序的定位链路,从零成本命令到需要复现的验证,逐层收窄。
5.1 按成本从低到高的排查顺序
排序原则很简单:先做不改任何东西、不花时间的检查。改配置、清缓存、重装环境都属于"有副作用"的操作,必须放在最后。
| 顺序 | 动作 | 耗时 | 能排除的问题 |
|---|---|---|---|
| 1 | git lfs env | 1 秒 | 临时目录位置、并发数、路径配置 |
| 2 | df -h+df -i | 1 秒 | 容量与 inode 不足 |
| 3 | ls -ld临时目录,ls -ln .git/lfs | 1 秒 | 权限与属主错乱 |
| 4 | lsof +L1 | 3 秒 | 已删除文件的句柄泄漏 |
| 5 | mount看挂载类型 | 1 秒 | 只读挂载、叠加文件系统配额 |
| 6 | git lfs fsck | 视仓库大小 | 本地缓存对象损坏 |
| 7 | 并发降到 2 重试 | 视数据量 | 带宽争抢导致的断连 |
| 8 | trace 抓单个对象 | 视数据量 | 远端对象完整性 |
| 9 | 换目录、换机器复现 | 视环境 | 客户端环境问题还是服务端问题 |
前五步加起来不超过十秒,却能覆盖大半的故障场景。养成先跑这五条的习惯之后,排查时间会从半小时压缩到两分钟。
git lfs fsck值得单独说一句。它校验本地缓存里的对象哈希是否和指针文件一致,输出里带corrupt或者missing的行就是要处理的。本地缓存损坏的典型表现是检出阶段报错,或者文件拉下来了但内容是错的。修复方式是把损坏的对象删掉重新拉:
git lfs fsck --objects rm -rf .git/lfs/objects/ab/cd/<完整oid> git lfs pull删除时注意路径结构,LFS 的对象按 oid 的前两位和后两位分两级目录存放,完整路径是.git/lfs/objects/ab/cd/<oid>。
5.2 打开 trace 抓一次完整请求
环境层面的检查都过了,问题还在,就需要看协议层面的细节。打开 trace 跑一次最小复现:
GIT_TRACE=1 GIT_TRANSFER_TRACE=1 GIT_CURL_VERBOSE=1 \ git lfs fetch --include="path/to/file" 2>&1 | tee /tmp/lfs-trace.log日志里找三类信息。第一类是 HTTP 状态码,200正常,403是权限或签名过期,404是对象不存在,5xx是服务端故障,不同状态码的处置方式完全不同。第二类是响应头的长度字段和实际接收字节数的对比,这个差值直接告诉你断了多少。第三类是错误出现的时机,是在建立连接阶段、响应头阶段,还是传输中途。
有个细节值得注意:trace 日志里如果反复出现同一条连接的重试记录,但最终都成功,那说明链路质量确实不好,但还不至于失败;如果某条连接从头到尾只出现一次且直接失败,那更像是被中途掐断,而不是超时重试耗尽。
把日志留下来还有一个好处:如果最终判定是远端问题,这份日志就是提给仓库维护方的最有力证据,比一句"我拉不下来"有用一百倍。
5.3 单对象复现与二分定位
如果失败是间歇性的、每次卡在不同位置,全局排查很难有结论。这时候用二分法缩小范围。先确认是不是特定文件的问题:
git lfs ls-files | head -50列出所有 LFS 管理的文件,挑几个不同大小、不同目录的,逐个用--include拉取。如果某个特定文件每次都失败,问题锁定在这个对象上;如果每个文件单独拉都成功,但一起拉就失败,那问题在并发和资源上。
更彻底的做法是把 fetch 和 checkout 拆开,分别观察:
GIT_LFS_SKIP_SMUDGE=1 git clone <仓库地址> repo-test cd repo-test git lfs fetch --all git lfs checkoutGIT_LFS_SKIP_SMUDGE=1让克隆阶段跳过 LFS 文件的实体下载,只拿指针文件,速度极快。然后在干净环境里单独跑fetch,这一步只涉及下载和写缓存,不涉及工作区。如果fetch成功而checkout失败,那问题在工作区那一侧,和第 3 章的磁盘空间直接相关;如果fetch本身就失败,那问题在传输侧,回到第 4 章。
这个拆分手法在 CI 里也很有用。把fetch和checkout分成两个独立步骤,日志分开,出问题时一眼就能看出是哪个阶段。
6. 修好之后的收尾动作与长期预防
问题解决之后不要急着收工。中断过的 LFS 拉取会在本地留下各种残留:临时目录里的半截文件、incomplete里的记录、.git/lfs/objects里大小不对的条目。这些东西不清理,下次拉取可能又撞上同样的问题。
6.1 清理残片与重新校验
标准收尾三件套:
rm -rf .git/lfs/tmp/* rm -rf .git/lfs/incomplete git lfs fsck git lfs prune --dry-run前两条清残留,第三条校验缓存完整性,第四条看看有多少对象可以被回收。prune --dry-run只列出不删除,确认清单没问题再执行真正的git lfs prune。这个命令会删掉"当前分支和最近提交里已经不再引用"的旧对象,释放空间。清理之后重新git lfs pull,如果一次过,说明之前的失败确实是残留导致的。
需要提醒的是prune的副作用:删掉的对象下次要用时得重新下载。在不限流量的本地开发机上无所谓,在按流量计费的 CI 上要权衡一下。
6.2 把临时目录挪到合适的位置
如果根因是临时目录配置不当,正确的修法不是去改/root的权限,而是显式指定一个专用目录。在构建脚本开头加一行:
export TMPDIR=/workspace/.tmp mkdir -p "$TMPDIR"选位置有三个标准:和缓存目录在同一个分区(避免跨设备 rename 失败或者退化成复制)、有足够的空间余量、当前用户有写权限。放在工作副本外面的独立目录通常比放在仓库里面好,因为仓库目录可能被git clean扫到。
跨设备 rename 这个坑值得展开说。临时文件写完之后要 rename 到.git/lfs/objects,如果临时目录和缓存目录不在同一个文件系统上,rename 会以EXDEV失败,有些实现会退化成"复制加删除",多一次完整的磁盘写入,速度和空间占用都翻倍。所以TMPDIR最好和.git在同一个卷上。
如果是整个 LFS 缓存都该搬家,用这个配置:
git config lfs.storage /data/lfs-cache改完之后git lfs env里的LocalMediaDir会指向新位置,所有对象都存到那儿去。这在系统盘小、数据盘大的机器上特别有用。
6.3 长期预防的几条硬规矩
在构建环境里跑 LFS,有几条规矩是我踩了多次之后固定下来的。
第一条,在拉取之前做空间检查并留出两倍余量。CI 脚本里加一段判断,剩余空间小于待拉取量的两倍就直接失败并给出明确提示,而不是让 LFS 跑到一半崩掉。这个检查本身只要几行脚本,但能省掉大量"为什么构建随机失败"的排查时间。
第二条,并发数按环境能力设,不要用默认值。共享构建机、带宽受限的机房、单核小内存的容器,这些场景下默认的 8 都偏高。统一设成 2 到 4,稳定性提升明显,总耗时通常不会变差。
第三条,把 LFS 缓存目录纳入监控。.git/lfs/objects体积会随着时间单调增长,除非主动prune。定期看一眼du -sh .git/lfs和git lfs prune --dry-run的预计回收量,比等磁盘报警再动手从容得多。
第四条,清理和拉取不要并行。见过有脚本一边git lfs pull一边跑prune,两者争夺同一批对象,必然出问题。任何涉及对象目录的写操作都要串行化。
最后分享一个我自己的使用习惯:每次在新机器或者新镜像上第一次跑 LFS 之前,先跑一遍git lfs env加df -h加df -i,三个命令十秒钟,把这台机器的"底牌"看清楚。这个习惯是从一次连续三天的随机构建失败里换来的——当时所有精力都花在查网络,最后发现是构建镜像的可写层只有 10G,而仓库的 LFS 对象有 8G。那之后我就再也不跳过这十秒了。