系统是被打出来的 · 系统设计实战手册
🔥 实战关卡一 · 真实事故 · PART 1 收官

GitLab 2017
救命的是一份临时快照

2017 年 1 月 31 日深夜,一位工程师在错误的服务器上清空了数据目录。真正让这一夜载入史册的不是那条命令,而是接下来的两个小时里,他们一层一层揭开五条恢复路径——正式灾备体系里的三条没能兑现,最后救场的,是一份为了做负载测试临时打的快照。

关于本页的事实边界:本页根据 GitLab 官方公开复盘改编,细节经过叙事化处理。 技术事实、时间线与决策链均取自页末所列官方原始文档,每个事实性陈述后以 [S1] 标注出处;氛围与心理描写为叙事化补充,不代表当事人的真实言行。 本页不出现任何真实人物姓名,也不虚构任何人物台词——与 GitLab 官方复盘的 blameless 精神一致。

关卡 1 · 1 现场一个为了别的目的打的快照

这一夜的开头,是一件完全正常、甚至值得表扬的事。

1 月 31 日下午,一位 GitLab 工程师准备在预发环境搭几台 PostgreSQL,试试用 pgpool-II 做读负载均衡,看能不能减轻数据库压力 [S1]。为了让压测结果更接近真实,他需要一份足够新的生产数据。

系统本来每 24 小时(01:00 UTC)会自动做一次这件事,但他想要更新的副本,于是在 约 17:20 UTC 手动打了一个生产库的 LVM 快照,灌进预发 [S1]。

记住这个动作。六个小时后,它会成为这家公司和 300 GB 数据之间损失最小的那条退路——而它当时的用途,只是做一次负载测试。

两个小时后,事情开始不对劲。

约 17:20 UTC

手动 LVM 快照。为压测而打,灌进预发环境。这是当晚后来实际被采用的那个恢复点,而做它的人当时并不知道 [S1]。

约 19:00 UTC

数据库负载开始升高,团队怀疑是垃圾内容(spam)造成的。很多用户无法在 issue 和合并请求下发表评论。把负载压下去花了好几个小时 [S1]。

(事后才查明)

部分负载来自一个后台任务:它正在删除一名 GitLab 员工及其关联数据——该账号被人恶意举报为滥用,因此被误安排了删除 [S1]。一个内容审核流程的疏漏,变成了数据库上的一场重负载。

约 23:00 UTC

复制断了。持续的高负载让从库复制开始落后;等它想追时,主库上它需要的 WAL 段已经被删掉了。由于当时没有启用 WAL 归档,从库只能手工重建 [S1]。

手工重建从库的步骤写在运维手册里,只有两步:清空从库的数据目录,然后跑 pg_basebackup [S1]。听起来五分钟的事。

接下来的一个半小时,是每一个值过夜班的工程师都认得的那种时间:

INCIDENT LOG · 约 23:00 – 23:30 UTC
[TRY] pg_basebackup —— 挂住不动,加了 --verbose 也没有任何有意义的输出
[TRY] 报错:主库可用复制连接不够(max_wal_senders)
[FIX] max_wal_senders:3 → 32
[ERR] PostgreSQL 拒绝启动:信号量创建过多
[WHY] max_connections = 8000(一年前设的,此前一直正常)
[FIX] max_connections:8000 → 2000 → PostgreSQL 正常启动
[TRY] pg_basebackup —— 还是挂住
[TRY] strace 查看 —— 卡在一个 poll 调用上,看不出别的信息
──────────────────────────────────────
此时约 23:30。已经连续处理故障约四个半小时。

这里有一个残忍的细节,是复盘里才写明的:pg_basebackup 那个"挂住"根本不是故障。它就是在安静地等主库开始发送复制数据——这是正常行为。当时 GitLab 的内部运维手册没写,官方文档也没写清楚 [S1]。

也就是说:这一个半小时里,一群疲惫的工程师在排查一个并不存在的故障,而工具从头到尾一言不发。

决策点 D1 · 本可以刹车的第一处

约 19:00。你发现负载里有一个后台任务在硬删除某个账号的全部关联数据。

B。这里有两个问题,而它们的紧迫性不一样。负载是眼前的痛,「为什么会有这个删除任务」是真正危险的问题——事实上它删的是一名员工的数据,起因是账号被恶意举报后误判 [S1]。一个能被外部人员触发、且会硬删除数据的自动流程,本身就是事故源。暂停它同时解决了负载和数据安全两件事。

A 是当晚实际发生的路径的近似:把注意力全放在压负载上。它的问题在于,删除任务跑完之后数据就真的没了——而且没有人在那个时刻问过"它凭什么在删"。故障处置里最贵的错误,往往不是做错了动作,而是没问一个问题。

C 是本能反应,也是本书反复出现的错误答案。扩容需要时间(第 5 章我们已经算过这笔时间账),而且它解决的是"扛得住",不是"该不该发生"。给一个不该运行的任务扩容,等于花钱加速一场事故。

D 方向对,力度不够。限速能立刻缓解数据库压力,是个合理的止血动作——但它默认了"这个删除是应该发生的"。如果这个前提本身是错的,限速只是让错误发生得更慢。先暂停、再判断、最后才决定要不要限速重跑,顺序不能反。

决策点 D2 · 本可以刹车的第二处

约 23:25。你已经连续处理故障四个多小时,工具静默、文档没写、strace 也看不出名堂。

B。这不是技术判断,是人因工程。四个半小时的连续故障处置之后,人对"我在哪台机器上"这类基础事实的把握会显著下降——而接下来要执行的恰好是一条不可逆的命令。换人的成本是十五分钟的交接,不换人的成本是 300 GB。事实上,那个"挂住"的真相正是由一位当时不在场的工程师后来指出的 [S1]:新鲜的眼睛看见了疲惫的眼睛看不见的东西。

A 就是当晚实际走的那一步,并且它的推理完全合理——工程师确实怀疑之前几次 pg_basebackup 在数据目录里留下了文件 [S1]。合理的推理 + 疲劳 + 两个长得一模一样的终端,就是这一夜的全部配方。请注意:这个选项之所以是错的,不是因为推理错了,而是因为它把一条不可逆命令交给了一个已经工作四个半小时的人。

C 的直觉非常好,但它在这一夜救不了场——它救的是下一夜。动手前先给主库留一个快照,正是「危险操作前先建立退路」的标准做法。它之所以只能算半对,是因为此刻主库并没有出问题,团队完全意识不到自己正在接近主库;真正让 C 有价值的,是把它变成制度:任何触碰数据目录的操作前一律先快照,而不是靠当事人当场想起来。

D 会让你更快地走到同一个悬崖边。「彻底重装」意味着更多的删除动作、更多次在两台机器之间切换终端,而你的判断力正在下降。判断力已经下降时,正确动作是减少不可逆操作的次数,不是增加。

决策点 D3 · 本可以刹车的最后一处

约 23:30。你决定清空数据目录重来。手指停在回车上——按下之前,还能做什么?

B。关键区别在于让系统回答,而不是让人回忆。窗口标题、提示符颜色、你三分钟前敲的 ssh——这些都是记忆,而记忆正是凌晨最不可靠的东西。让机器当场打印出主机名和"我是主库还是从库",这个事实无法被疲劳篡改。这也是为什么现代运维会给危险命令加"说出目标身份再确认"的护栏:护栏防的不是无知,是疲劳。

A 是绝大多数人的做法,也确实拦下过很多事故——但它拦不住这一次。当晚的问题恰恰是工程师确信自己在从库上 [S1]。当一个人已经确信时,再看一眼只会强化确信,因为你看到的是你以为会看到的东西。视觉确认对抗不了"我以为"。

C 是整页最危险的一个选项,因为它听起来最专业。命令来自手册这件事是真的,手册本身也没错。但手册假设你在正确的机器上——手册管的是「怎么做」,从来管不了「在哪做」。这一夜之后,GitLab 复盘里反复出现的正是这类"流程正确但上下文错误"的问题。

D 有价值,但它把一个 30 秒的问题变成了一个要等人回复的问题。凌晨的群里可能没人醒着,而等待本身会推着你去点那个回车。四眼原则的正确形态是阻塞式的——工具要求第二个人确认才放行,而不是靠你自觉去问。这一点在关卡三(Knight Capital)会以 4.6 亿美元的标价重新出现。

回车按下去了。命令执行在了主库上 [S1]。

他在发现的一两秒后就终止了进程。约 300 GB 的数据已经不在了 [S1]。

关卡 1 · 2 揭牌五条恢复路径,一条一条打开

接下来发生的事,才是这一夜真正的教材。团队去找备份,并在 Slack 上求助 [S1]。GitLab 当时有五条可走的恢复路径。请跟着他们的顺序,一层一层打开。

第 1 层复制(从库)✗ 从库的数据目录早在 23:00 就为了重建被清空了,主库刚刚也被清空。两侧都没有数据,无从恢复 [S1]。 复盘里还补了一句:复制的用途本来就是 failover,不是灾备 [S1]。
第 2 层每 24 小时 pg_dump → S3✗ S3 桶是空的。原因是备份用的 pg_dump 是 9.2 版,而数据库跑的是 PostgreSQL 9.6——大版本不匹配会让 pg_dump 直接报错退出 [S1]。 为什么版本会错?因为备份跑在一台普通应用服务器上,那里没有 PostgreSQL 数据目录,打包工具因此回退到了 9.2 [S1]。
第 2 层的第二重失效失败通知也没送到✗ 定时任务报错是会发通知的——走邮件。但 cron 邮件没有配置 DMARC,被收件方拒收了。 官方复盘的原话是:「我们从来不知道备份在失败,直到为时已晚。」 [S1]
第 3 层Azure 磁盘快照✗ 给存 Git 数据的 NFS 服务器开了,唯独没给数据库服务器开——理由写在复盘里: 「因为我们假设其它备份手段已经够了。」 [S1] (顺带一提,这条路径就算开了也很慢:跨存储账户恢复可能要几小时到几天,曾有一次花了一周以上 [S1]。)
第 4 层每 24 小时自动 LVM 快照△ 存在,而且可用——但它是事发前将近 24 小时的 [S1]。官方复盘把它称为 the alternative:用它能恢复,代价是丢掉近一整天的数据。
第 5 层17:20 那个手动 LVM 快照✓ 损失最小的恢复点,也是最终被采用的那一条。事发前约 6 小时,一位工程师为了做 pgpool-II 负载测试临时打的 [S1]。 它不属于灾备体系——复盘明确写着,LVM 快照的用途是把生产数据拷到预发,并不是为灾难恢复设计的 [S1]。
这是本关卡的核心事实,也是它和坊间流传的版本不一样的地方:既不是"五层备份全部失效",也不能说"四层都是空的"。五条路径里,前三条确实没能兑现(复制两侧皆已清空、S3 桶是空的、数据库根本没开快照);但两份 LVM 快照都还在——团队要做的是在"丢近 24 小时"和"丢约 6 小时"之间选一个。

真正有力量、也完全站得住的说法是:制度准备的恢复点太旧,一次与灾备无关的临时操作反而成了最佳选择。如果那天下午他没有想要一份更新的数据,GitLab 丢的就不是六小时,而是将近一天。

团队最终选了第 5 层,因为「这是把数据损失降到最小的唯一选项」 [S1]。

然后是恢复,恢复比事故更长

恢复瓶颈

把数据从预发主机拷回生产花了约 18 小时。磁盘是网络盘、被限速到约 60 Mbps;没有网络瓶颈也没有 CPU 瓶颈,瓶颈就在磁盘 [S1]。

为什么这么慢

预发环境用的是便宜档位的存储,为省成本 [S1]。一个在预发环境上省下的成本,在生产恢复时以 18 小时的形式还了回来。

2/1 约 17:00 UTC

数据库恢复完成(尚不含 webhooks),状态回到 1 月 31 日 17:20 UTC [S1]。

2/1 约 18:00 UTC

补回 webhooks,收尾完成 [S1]。恢复过程中把所有数据库序列加了 100,000,以免复用事故前可能已经用过的 ID [S1]。

站点宕机
约 18 小时
▲ 官方复盘表述
丢失数据窗口
17:20–00:00
▲ UTC,约 6 小时 40 分
受影响项目
约 5,000
● 官方估计
受影响评论
约 5,000
● 官方估计
受影响新用户
约 700
● 官方估计
Git 仓库 / wiki
未丢失
● 单独存储

值得注意的是最后一格:代码仓库和 wiki 一点没丢,因为它们和数据库分开存储 [S1]。对一家代码托管公司来说,这是那一夜最大的运气——也是"把不同性质的数据分开放"这个决定,在多年以后替他们挡下的一刀。

还有一件事:他们把恢复过程直播了

GitLab 在恢复期间做了三件在当时看来相当反直觉的事:把进度记录在一份公开可见的文档里;把恢复过程放到 YouTube 直播,峰值约 5,000 人观看,一度是全站第 2 的直播;同时用状态账号在推特通报 [S1]。CEO 在复盘里写下「丢失生产数据是不可接受的」并公开道歉 [S1]。

这里也有一条边界值得学:那份文档最初包含了误删数据的工程师的名字——是他本人加上去的,他本人也不介意公开;但 GitLab 表示今后会打码,因为别的工程师未必愿意 [S1]。透明的对象是事实与流程,不是人。

关卡 1 · 3 教训三条,每条都花了 300 GB

壹
没演练过恢复的备份,等于没有备份。GitLab 的五层里,第 2 层在持续失败而没人知道,第 3 层压根没给数据库开,第 1 层的用途从来就不是灾备。这三条如果做过一次真实的恢复演练,任何一条都会当场暴露。备份的成功标准不是"任务跑了",是"上个月我们从它恢复出来过"。顺带一提:通知链路也要演练——第 2 层的致命之处不是它失败了,是它失败的消息被 DMARC 吃掉了。
貳
危险命令的护栏,防的是疲劳不是无知。那位工程师知道主库和从库的区别,也知道那条命令的后果——他只是在连续处理故障四个半小时后,以为自己在另一台机器上。所以有效的护栏必须是让机器说话的(打印目标身份、要求二次确认、阻塞式的第二人复核),而不是靠人"再看一眼"。同样重要的是换人机制:真相后来是由一位当晚不在场的工程师指出的。
叁
恢复时间是设计出来的,不是祈祷出来的。18 小时的恢复不是因为数据多,是因为数据在一条被限速到约 60 Mbps 的链路另一端——那是当初为省成本做的选择。你的 RTO(恢复时长目标)等于你最慢的那条恢复路径,而这条路径通常藏在"反正只是预发环境"这句话里。第 15 章会把 RTO/RPO 正式定义一遍,它们的值在事故当天已经由过去的采购决定写好了。

关卡 1 · 4 带回「秒抢」你现在就能做的四件事

此刻的「秒抢」:约 1 万用户、12 个人、一主一从的 MySQL(第 3 章刚加的从库)。GitLab 那一夜的五层,你现在有几层?诚实的答案通常是一层,而且是第 1 层——那层在 GitLab 的复盘里排第一个失效。

GitLab 的失效层「秒抢」现在的状态本周就能做的动作关联章节
第 1 层 复制 ≠ 灾备 刚加的从库,团队心里默认它"就是备份" 在架构图上把从库标注为「高可用」而不是「备份」;误删数据时从库会忠实地把删除也复制过去 第 3 章
第 2 层 备份静默失败 有一个每日 dump 的定时任务,没人看过它的输出 给备份任务加成功心跳:超过 26 小时没有新备份就告警。不要用邮件做唯一通道 第 14 章
第 4/5 层 恢复没演练 从来没有从备份恢复过 本季度做一次计时恢复演练:从备份拉起一个可查询的实例,记录耗时。这个耗时就是你真实的 RTO 第 16 章
护栏 不可逆命令 生产和预发的终端长得一模一样 给生产终端换配色 + 提示符打印主机名与主从角色;危险命令走带二次确认的脚本,不裸敲 第 16 章

最后留一个问题,它会跟着你走完这本书的后半程:GitLab 那一夜,救命的是一个不属于灾备体系的临时快照。你的系统里,此刻有没有类似的"其实靠运气在兜底"的东西?如果有,它是不是也随时可能因为"这次不需要更新的数据"而消失?

第 16 章我们会给这个问题一个正式的答案:把运气变成演练。

📄 原始来源(本页全部事实性陈述的出处)

  1. [S1] GitLab 官方复盘 Postmortem of database outage of January 31(2017-02-10)—— https://about.gitlab.com/blog/postmortem-of-database-outage-of-january-31/
  2. [S2] GitLab 事发当日说明 GitLab.com Database Incident(2017-02-01)—— https://about.gitlab.com/blog/gitlab-dot-com-database-incident (本页事实一律以 S1 为准)

逐句核对清单见构建仓库 sources/FACTS-level1.md;来源原文留档见 sources/raw/gitlab-2017-postmortem.txt。二手文章未被采信为事实依据。