背景:Windows 上的 Virtualbox 虚拟机。Ubuntu 14.04.1 LTS,3.13 内核。ext4 文件系统。
作死:前几天一直在该虚拟机上开发网站,做了 N 多 commit,以为 git push 了,但事实上 push 失败了。
悬疑:今天妹子 git pull 了一下,发现没有任何更新,然后说我这几天都没干活。
悲剧:登录到虚拟机里一看,项目目录里有几个刚写的文件变成了 0 字节的空文件。(ext4 这么稳定,一定是母机里万恶的 NTFS 和 Virtualbox 惹的祸) .git 目录里好多文件也变成了 0 字节的空文件。git 提示仓库已损坏。
1 2 3 4 $ git status error: object file .git/objects/71/cbcbbc9d06a74f2fd8ea9109b81b88086f1430 is empty error: object file .git/objects/71/cbcbbc9d06a74f2fd8ea9109b81b88086f1430 is empty fatal: loose object 71cbcbbc9d06a74f2fd8ea9109b81b88086f1430 (stored in .git/objects/71/cbcbbc9d06a74f2fd8ea9109b81b88086f1430) is corrupt
1 2 3 4 $ git fsck error: object file .git/objects/00/837a7e1f8afb8da8609369f7acf95fe9b7fc5b is empty error: object file .git/objects/00/837a7e1f8afb8da8609369f7acf95fe9b7fc5b is empty fatal: loose object 00837a7e1f8afb8da8609369f7acf95fe9b7fc5b (stored in .git/objects/00/837a7e1f8afb8da8609369f7acf95fe9b7fc5b) is corrupt
几天来写的代码是不是这样就灰飞烟灭了呢?我们知道,当你删除一个东西的时候,你只是删除了这个东西在当前三维空间中的引用,而这个东西的本体仍然存在于四维时空之中。穿越大法,走起!
寻找遇难者 首先,我们看看哪些文件在这场灾难中被 truncate(变成 0 字节)了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 $ find . -size -1b ./config/__init__.py ./app/static/css/style.css ./app/views/home.py ./app/views/user.py ./app/templates/404.html ./app/templates/common-footer.html ./app/templates/about.html ./app/templates/community-rules.html ./app/templates/settings.html ./app/templates/copyright.html ./app/templates/course.html ./.git/objects/20/1588d6dac033f6c313f2bf4f0fd01c81276632 ./.git/objects/35/ea71c044277cb8ac874699ead4edfafe4a4cfa ./.git/objects/1d/3221615759851d3ff16a65f614432c4ae857ee ./.git/objects/1d/7a4d6633a5e5301442a0c92c349b50d8ad0e8c ./.git/objects/2e/647f1c50f883442680962f404247d29b018b16 ./.git/objects/7c/fdee2b6ef8d2cddfd9b41bca2600e3d6fba4e0 ...(数十个 object 遇难)
这里的 .git 目录就是 git 基于文件系统的数据库了。git 把提交进去的文件打上时间戳,按照自己的格式压缩存储进一个 key-value 数据库,既可以按照文件内容索引(git status 是怎么工作的?),又可以按照 commit 编号或者 tag 索引(git checkout 是怎么工作的?)。.git/objects 目录里长长的 SHA-1 值就是索引 key。
与正常的版本库比对 我们再去重新 git clone 一份代码,看看这些消失的 git object,在原来的版本库里是否存在。
新克隆一个 git 仓库之后,惊奇地发现库里没有 SHA-1 值的 git object,只有一个大 pack 文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 . ./index ./info ./info/exclude ./branches ./logs ./logs/refs ./logs/refs/remotes ./logs/refs/remotes/origin ./logs/refs/remotes/origin/HEAD ./logs/refs/heads ./logs/refs/heads/master ./logs/HEAD ./hooks ./hooks/applypatch-msg.sample ./hooks/pre-push.sample ./hooks/pre-rebase.sample ./hooks/pre-applypatch.sample ./hooks/prepare-commit-msg.sample ./hooks/post-update.sample ./hooks/update.sample ./hooks/pre-commit.sample ./hooks/commit-msg.sample ./config ./description ./objects ./objects/info ./objects/pack ./objects/pack/pack-d4da3e51cfa0c0650e2b3b663d71bb1f8ce4d825.idx ./objects/pack/pack-d4da3e51cfa0c0650e2b3b663d71bb1f8ce4d825.pack ./packed-refs ./refs ./refs/remotes ./refs/remotes/origin ./refs/remotes/origin/HEAD ./refs/heads ./refs/heads/master ./refs/tags ./HEAD
1 2 3 4 $ ls -l objects/pack/ total 7400 -r--r--r-- 1 vagrant vagrant 84456 May 23 14:58 pack-d4da3e51cfa0c0650e2b3b663d71bb1f8ce4d825.idx -r--r--r-- 1 vagrant vagrant 7491426 May 23 14:58 pack-d4da3e51cfa0c0650e2b3b663d71bb1f8ce4d825.pack
使用 git unpack-objects 就可以把这些文件展开。注意需要把原来在 .git 目录里的 pack 文件移动出来而非复制出来,否则聪明的 git 会检测到 objects 目录里的 pack 文件已经有相同的 object,就不会展开了。
1 2 3 $ mv .git/objects/pack/pack-d4da3e51cfa0c0650e2b3b663d71bb1f8ce4d825.pack . $ git unpack-objects < pack-d4da3e51cfa0c0650e2b3b663d71bb1f8ce4d825.pack Unpacking objects: 100% (2978/2978), done.
这下那些长长的 SHA-1 值回来了。
1 2 3 4 5 6 7 8 9 ./.git/objects/8d ./.git/objects/8d/16e06ef2c91ffc329868da4a124370191400e0 ./.git/objects/8d/a67cc2c2787e6eac1f3b327664f0f62fde6535 ./.git/objects/8d/1d7a4fca5bd4994c30cc0ea9c743aa67474735 ./.git/objects/8d/54da727431e0560228f8f240082ec58e39fed8 ./.git/objects/4f ./.git/objects/4f/1c8299469a493380b25765c464e33141a95fe6 ./.git/objects/4f/ef67cbe5a6c70327a63a309d0e8b780a2b278c ...
现在我们可以回到受损的版本库里,比较受损版本库与上游版本库的区别:
1 2 3 4 5 6 7 8 9 10 11 $ find . -size -1b -exec ls ../newrepo/{} \; ... ../newrepo/./.git/objects/20/1588d6dac033f6c313f2bf4f0fd01c81276632 ../newrepo/./.git/objects/35/ea71c044277cb8ac874699ead4edfafe4a4cfa ../newrepo/./.git/objects/1d/3221615759851d3ff16a65f614432c4ae857ee ../newrepo/./.git/objects/1d/7a4d6633a5e5301442a0c92c349b50d8ad0e8c ../newrepo/./.git/objects/2e/647f1c50f883442680962f404247d29b018b16 ls: cannot access ../newrepo/./.git/objects/7c/fdee2b6ef8d2cddfd9b41bca2600e3d6fba4e0: No such file or directory ls: cannot access ../newrepo/./.git/objects/00/837a7e1f8afb8da8609369f7acf95fe9b7fc5b: No such file or directory ls: cannot access ../newrepo/./.git/objects/78/2ed6614f481f77b358aeb5955439292b551a2c: No such file or directory ...
这些 No such file or directory 的,就是上游仓库并不存在的 git object,也就是上次 push 后新提交(add 或 commit)进仓库的内容。除非使用文件系统级的恢复技术,这些 git object 是很难再找回来了。
从 git 数据库里提取文件 一个已经被 commit 或 add 到 git 仓库里的文件,在工作目录里有一份拷贝,在 git 数据库(.git 目录)里有另一份拷贝。只要两份拷贝里有一份是可以用的,数据就仍然能找回来。我们主要关心的是,工作目录里那些已经丢失的文件,能否从 git 数据库的拷贝里发现。
首先尝试打开一个 git object,发现是乱码。file 一下,发现也很乱。
1 2 $ file .git/objects/4f/1f12f7a41593de4fc4131df05fb05e517e717a .git/objects/4f/1f12f7a41593de4fc4131df05fb05e517e717a: VAX COFF executable not stripped
RTFM 总是有用的。Git Object 格式 告诉我们,git object 是把文件内容做了 deflate 压缩后存储的。我们知道 gzip 也是用的 zlib 的 deflate 压缩,不过 gz 文件有特殊的头尾。与其写一段代码调用 gzip 库,不如把 gzip 的文件头给补上,直接调用 gunzip 来解压。(我怎么知道的?浏览器返回的 HTTP 请求经常也是 deflate 压缩啊,从抓包记录里解压这个是必备技能啊)
1 2 3 4 5 6 7 8 $ printf "\x1f\x8b\x08\x00\x00\x00\x00\x00" | cat - .git/objects/4f/1f12f7a41593de4fc4131df05fb05e517e717a | gunzip | head -n 5 gzip: stdin: unexpected end of file blob 1176{% extends "layout.html" %} {% block content %} <div class="container"> <div class="row float-element">
我们看到了明文!且慢,gzip: stdin: unexpected end of file 是什么?难道是文件损坏了?非也,gz 文件末尾有 8 个字节来存储 CRC32 和解压后的文件大小用作校验,我们没补上这些信息。只要文件内容出来了就行。
如何检查文件是完整的呢?git object 的文件头写明了原始文件的大小。hexdump 可以看到,解压之后的文件里,第一个字符串代表 git object 类型,这里的 blob 表示是文件存储;第二个十进制整数表示原始文件的大小;然后一个 \0 表示文件头结束,后面就是原始文件内容了。
1 2 3 4 5 6 7 8 9 10 11 12 13 $ printf "\x1f\x8b\x08\x00\x00\x00\x00\x00" | cat - .git/objects/4f/1f12f7a41593de4fc4131df05fb05e517e717a | gunzip | hexdump -C | head gzip: stdin: unexpected end of file 00000000 62 6c 6f 62 20 31 31 37 36 00 7b 25 20 65 78 74 |blob 1176.{% ext| 00000010 65 6e 64 73 20 22 6c 61 79 6f 75 74 2e 68 74 6d |ends "layout.htm| 00000020 6c 22 20 25 7d 0a 7b 25 20 62 6c 6f 63 6b 20 63 |l" %}.{% block c| 00000030 6f 6e 74 65 6e 74 20 25 7d 0a 0a 3c 64 69 76 20 |ontent %}..<div | 00000040 63 6c 61 73 73 3d 22 63 6f 6e 74 61 69 6e 65 72 |class="container| 00000050 22 3e 0a 20 20 3c 64 69 76 20 63 6c 61 73 73 3d |">. <div class=| 00000060 22 72 6f 77 20 66 6c 6f 61 74 2d 65 6c 65 6d 65 |"row float-eleme| 00000070 6e 74 22 3e 0a 0a 0a 20 20 20 20 3c 64 69 76 20 |nt">... <div | 00000080 63 6c 61 73 73 3d 22 63 6f 6c 2d 6d 64 2d 38 22 |class="col-md-8"| 00000090 3e 0a 20 20 20 20 20 20 3c 68 34 20 63 6c 61 73 |>.
文件头 10 个字节,加上文件内容 1176 字节,恰好是解压后的文件大小 1186 字节,说明解压后的文件并不缺少东西。
1 2 3 $ printf "\x1f\x8b\x08\x00\x00\x00\x00\x00" | cat - .git/objects/4f/1f12f7a41593de4fc4131df05fb05e517e717a | gunzip | wc gzip: stdin: unexpected end of file 42 81 1186
如果我们希望去掉那个讨厌的 git object 文件头,可以用 sed 把第一个 \0 及之前的内容去掉:
1 2 3 4 5 6 $ printf "\x1f\x8b\x08\x00\x00\x00\x00\x00" | cat - .git/objects/4f/1f12f7a41593de4fc4131df05fb05e517e717a | gunzip 2>/dev/null | sed -z 1d | head -n 5 {% extends "layout.html" %} {% block content %} <div class="container"> <div class="row float-element">
然后我们就可以把受损的 git 库里的 git objects 全部解压到 recovery 目录,能解压多少算多少。
1 2 3 4 5 6 7 8 $ mkdir -p ../recovery $ find .git/objects/ | while read f; do printf "\x1f\x8b\x08\x00\x00\x00\x00\x00" | cat - $f | gunzip 2>/dev/null | sed -z 1d > ../recovery/$(echo $f | cut -s -d/ -f3,4 --output-delimiter="") 2>/dev/null; done
得到的 recovery 目录就会像这样:
1 2 3 4 5 6 $ ls -l ../recovery/ |head -n 5 total 33584 -rw-rw-r-- 1 vagrant vagrant 0 May 23 18:03 00 -rw-rw-r-- 1 vagrant vagrant 241 May 23 18:03 0008584b7db75782df11f35983b59a94e89fa201 -rw-rw-r-- 1 vagrant vagrant 9867 May 23 18:03 000bb86306810c5b7f020313b1db2c559d47d7d9 -rw-rw-r-- 1 vagrant vagrant 241 May 23 18:03 000ea7ccc6842e84bb1caa4fbb2010b5a28eb32b
从代码里大海捞针 到了这一步,熟悉文件系统恢复的同学一定有似曾相识的感觉。当文件系统的目录树损坏,只能根据 inode 的 magic number 找到散落在磁盘上的文件时,我们丢失了文件的路径信息和元数据,连文件名是什么都不知道(因为它存储在目录里),只能看到文件内容。诚然,我们可以扫描所有零散的文件,试图重建一部分目录树,但对目前这个恢复为数不多的几个代码文件的需求来说,是杀鸡用牛刀了。
回忆新写的代码中一些 unique 的片段,再从这些解压得到的文件中 grep,就有一定的可能找到被文件系统吞噬的代码。
1 2 3 4 5 6 $ grep 'review-comment-' -r ../recovery/ ../recovery/81011e32805a9d2978cd0127bcc8974b1e17f935: [<span class="glyphicon glyphicon-comment grey left-pd-md" aria-hidden="true"></span> <span id="review-comment-count-{{review.id}}">{{ review.comment_count }}</span>](javascript: show_review_comments({{ review.id }})) ../recovery/81011e32805a9d2978cd0127bcc8974b1e17f935: $('#review-comment-count-' + review_id).parent().find('span.glyphicon').addClass('grey'); ../recovery/81011e32805a9d2978cd0127bcc8974b1e17f935: $('#review-comment-count-' + review_id).parent().find('span.glyphicon').removeClass('blue'); ../recovery/81011e32805a9d2978cd0127bcc8974b1e17f935: $('#review-comment-count-' + review_id).parent().find('span.glyphicon').addClass('blue'); ../recovery/81011e32805a9d2978cd0127bcc8974b1e17f935: $('#review-comment-count-' + review_id).parent().find('span.glyphicon').removeClass('grey');
万幸的是,新写的这几个文件在 git 数据库里的 object 都还在。
结语 代码恢复了,PM 妹子自然很高兴。我打开尘封近半年的博客,写下了这篇总结。
两个教训:
代码一定要勤 commit & push,这样团队里其他成员也能及时知晓自己的进展,不至于被指责没干活。
不管这桩坏事是 Virtualbox 还是 ext4 干的,或者是某个神秘黑客的恶作剧,都不能太相信虚拟机里文件系统的稳定性。