如何制定一份真正能够有效预防生产事故的发生的检查清单
数一数你的部署检查清单上列出了多少项内容。如果项目数量超过了少数几个,那么这个清单的长度确实值得你再仔细考虑一下。 其中一部分原因可能是你的团队工作非常细致;而另一部分原因可能说明,你们的产品团队仍有许多基础设施相关的工作需要人工进行核查。 部署检查清单的内容会随着应用程序所面临的风险不同而有所变化——比如你的代码、数据以及部署流程可能会对用户造成哪些影响。其他一些项目,如证书、负载均衡器、镜像来源信息、自动扩展机制以及连接释放设置等,都是需要人工进行验证的环节,因为没有其他系统能够替你完成这些验证工作。 对于这些项目来说,不需要改进其表述方式;真正需要的是为它们指定负责人。值得思考的是,这个
数一数你的部署检查清单上列出了多少项内容。如果项目数量超过了少数几个,那么这个清单的长度确实值得你再仔细考虑一下。
其中一部分原因可能是你的团队工作非常细致;而另一部分原因可能说明,你们的产品团队仍有许多基础设施相关的工作需要人工进行核查。
部署检查清单的内容会随着应用程序所面临的风险不同而有所变化——比如你的代码、数据以及部署流程可能会对用户造成哪些影响。其他一些项目,如证书、负载均衡器、镜像来源信息、自动扩展机制以及连接释放设置等,都是需要人工进行验证的环节,因为没有其他系统能够替你完成这些验证工作。
对于这些项目来说,不需要改进其表述方式;真正需要的是为它们指定负责人。值得思考的是,这个负责人是否应该是你的产品团队。当然,这前提是你们已经开始在生产环境中运行应用程序,并且已经建立了相应的部署流程、回滚机制以及事件记录系统。
问题不在于如何开始安全地进行部署,而在于目前你所面临的各种风险中,哪些确实需要由人工来处理。
在这篇文章中,我认为部署检查清单的长度应该根据应用程序所面临的风险来进行调整,不应过长。清单上那些多余的条目,大多与那些仍需人工核查的基础设施相关的工作有关。
因此,我们可以先将这些条目分为两类:应用程序风险和基础设施风险,然后对这两类条目采取不同的处理方式。
对于应用程序相关的部分,检查项应该主要基于你们自己曾经遇到的问题来制定;而对于基础设施相关的部分,则需要添加一些那些即便是经验丰富的团队也容易忽略的验证项,比如后台进程、缓存数据、数据库模式变更以及回滚触发机制等。
对于基础设施相关的项目来说,每一项检查确实都非常重要。问题只是为什么这些项目需要由人工来确认,以及持续依靠人工进行这些操作会给你带来多少成本。
我们将涵盖的内容:
同一清单中的两种风险类型
打开你当前的部署检查清单,将所有条目分为两类进行整理。
第一列是应用风险。这种架构变更能否被撤销?旧版本的消费者能否理解新的数据格式?已经存储在缓存中的会话会发生什么变化呢?
这些问题都是你们的团队在提交拉取请求时做出的决策所导致的。团队之外的人无法回答这些问题,因为其他人并不了解你们的代码具体含义。
第二列是基础设施风险。证书是否有效?使用的镜像文件是否来自正确的版本?自动扩展规则设置得正确吗?日志数据是否正在被正常传输?之前的版本还存在吗?
这些都是与平台相关的常规性检查事项,并非针对特定应用程序做出的判断。每个生产团队都会面临这些问题,因此应该统一执行这些检查流程,而不是每次都手动重新核对。
以下是一个实际列表中这两列内容的呈现方式。假设你们的维基页面目前一次性列出了十项内容,按顺序排列后如下所示:
| 应用风险(每次部署时都会发生变化) | 基础设施风险(每次部署时都相同) |
|---|---|
full_name的迁移操作能否撤销? |
TLS证书在接下来的30天内是否仍然有效? |
| 旧版本的发票处理程序能否理解新的数据格式? | 当前要部署的镜像文件确实来自我们指定的版本吗? |
| 已经存储在Redis中的会话会如何被处理? | 当前的实例规模对应的自动扩展规则设置正确吗? |
PAYMENT_TIMEOUT_MS这个配置参数在生产环境中已经设置好了吗? |
新实例是否正在正常传输日志和监控数据? |
| 本周是否对这个服务进行了回滚测试? | 还可以回退到之前的版本吗? |
| 旧版本的实例在关闭之前是否已经释放了所有连接? |
仔细对比这两列内容,差异一目了然。左边的列中列出了与你们团队相关的特定名称、工具和配置参数,这些在其他公司可能毫无意义。
而右边的列中的内容在任何发布Web应用程序的公司中都是通用的。这就是关键所在——任何可以直接复制到别人维基页面上而无需修改的内容,其实都不属于你们团队需要做出的独立判断。
第一列是一份检查清单,而第二列则是一些显而易见、应该能够自动验证的事项。大多数团队都会将这两列内容放在同一份文档中,但随后又会抱怨这份文档太长了,不方便使用。
第一列:根据自身遇到的问题来制定检查清单
回顾你们最近发生的十次故障事件,针对每一件事件,思考一下在部署之前进行哪一项检查就能发现这些问题。很快你就会发现某种规律。
大多数团队都会发现,有三到四个常见的原因导致了大部分故障的发生:配置错误、不安全的架构变更、依赖项尚未准备好,或者回滚操作失败等等。
如果你只关注这四项关键因素,那么忽略其他四十多项问题其实并不会对结果产生太大影响。谷歌的SRE团队在关于发布工程的部分也提到了这一点,他们的目标就是让发布过程变得简单且可重复,而不是每次都依赖复杂的操作或英雄式的努力。
一种有效的事后分析机制能够确保这份清单始终保持最新状态,因为每发生一起故障,都应该在清单中添加相应的条目或删除不必要的条目。需要提醒的是:任何依赖人类记忆来运行的环节最终都可能会出问题,因为人会疲劳,而自动化流程却不会。
每一项操作都应当实现自动化,或者至少要说明为什么无法自动化。那些无法自动化的情况通常与你的数据模型有关,而这些内容恰恰应该被记录在第一列中。
值得探讨的应用程序相关问题
将这些问题添加到你在分析故障时所记录的内容中吧。它们涵盖了几乎所有团队都会遇到的故障类型,而且这些问题都与你的代码本身有关,而与系统架构无关。
我能在两分钟内撤销这个操作吗?这里指的不是是否有书面的回滚计划,而是这个回滚方案是否已经在本周、针对这项服务进行了测试。未经测试的回滚方案只是一种期望,并不能起到真正的控制作用。
这段代码所需的配置信息已经准备好了吗?
新的变量必须在前端代码加载之前就被设置好,而不是之后。十二因子应用架构要求将配置信息放在环境中,而不是代码中,这样就能确保配置信息的加载顺序是明确的。单独执行这个数据库操作是否安全?
关于这个问题,下面会有专门的讨论。应用程序能否如实反映自身的运行状态?
一个正在运行的程序并不意味着它就是正常工作的程序,因此健康检查机制必须能够判断出该程序实际依赖哪些资源。Kubernetes通过就绪性检测与活跃性检测来明确这一区分:就绪性检测用于判断流量是否能够到达实例,而活跃性检测则用于判断实例是否需要重新启动。如果将这两者混淆在一起,就会导致系统出现无休止的重启循环。编写准确的健康检查代码是开发人员的工作职责,而手动检查部署流程是否遵循了这些规则就不是开发人员的职责了。最初会有多少用户看到这个变更结果?
如果立即将所有用户都推送上新版本,那么任何错误都可能导致系统全面瘫痪;但如果只向5%的用户推送新版本,那么大多数错误只会导致短暂的问题出现。这些问题并没有直接询问代码本身是否正确,因为代码的正确性应该通过审查和测试来验证。而部署检查清单所关注的是另一个问题:即使代码是正确的,它的部署过程是否仍可能带来不良后果?
清单中很少被提及的三种应用程序风险
这些风险往往会困扰那些经验丰富的团队,因为拉取请求中没有任何线索能提示这些问题的存在。
首先需要关注的是后台工作进程。Web进程和队列消费者虽然都源自同一个代码库,但它们的执行时间并不总是同步的。在短短几分钟内,新的生产者可能会生成一些旧消费者无法读取的数据。
下面是导致这种问题的具体代码变更示例:一名开发人员对某个作业的数据结构进行了优化处理……
# 新的生产者代码。在审查过程中看起来并没有什么问题。
enqueue("sendinvoice", {
"customer": {"id": 42, "email": "ada@example.com"},
})
目前仍在生产环境中运行的那个消费者版本是旧版本,它试图读取一个已经不存在的键:
# 旧版本的消费者,在接下来的几分钟内仍然会继续运行
def send_invoice(payload):
customer = db.get_customer(payload["customer_id"]) # 会抛出 KeyError异常
这些尝试读取无效数据的操作会默默地失败,然后被放入死信队列中,几小时后才会被发现。
为了解决这个问题,应该在同一次发布中同时提供两种版本的消费者代码,这样无论是旧版本还是新版本的消费者都能正常读取数据:
# 安全的解决方案:在所有消费者都升级为新版本之前,继续使用旧的键
enqueue("send_invoice", {
"customer_id": 42, # 旧版本的消费者会读取这个键
"customer": {"id": 42, "email": "ada@example.com"}, # 新版本的消费者会读取这个键
})
之后再部署新版本的消费者,让队列中的数据逐渐被处理完毕,然后在后续的版本更新中删除旧的customer_id键。这种修改数据库字段的方式也适用于这里:应该分阶段进行添加、迁移和删除操作,而不能一次性全部完成。
序列化数据也是需要特别关注的部分。数据载入体同样需要遵循与数据库字段相同的向后兼容性规则。任何被写入缓存、会话存储或消息正文中的数据,实际上都构成了一个数据结构。
问题在于,由于没有相应的迁移文件,因此系统不会发出任何警告:
# 昨天的代码使用了这个键进行数据存储,现在这些数据仍然保存在Redis缓存中
cache.set(f"user:{user.id}", {"name": "Ada Lovelace"}, ex=86400)
# 今天的代码尝试读取这些数据
profile = cache.get(f"user:{user.id}")
first = profile["first_name"] # 在部署之前的所有缓存数据都会导致 KeyError异常
如果更改了被缓存的数据的结构,即使数据库本身没有发生变化,旧版本的消费者也会出现故障。因此应该使用新的缓存键来存储数据:
CACHE_VERSION = "v2" # 每当数据结构发生变化时,就更新这个版本号
def cache_key(user_id):
return f"user:{CACHE_VERSION}:{user_id}"
cache.set(cache_key(user.id), {"first_name": "Ada", "last_name": "Lovelace"}, ex=86400)
新版本的代码会使用新的键来读取数据,然后从数据库中重新获取所需的信息并继续执行后续操作。而旧的user:*格式的缓存条目则会自动失效。通过这样简单的一行代码来更新版本号,就可以避免系统出现故障。
第三点需要注意的是“冷启动能力”。在应用程序刚开始运行时,可用的实例数量可能会较少,因此需要一定的时间来预热连接池。如果应用程序预热连接池所需的时间为40秒,而系统的容错时间为30秒,那么在系统即将恢复正常运行的时候,它就会被强制关闭,从而导致服务中断。
readinessProbe:
httpGet: { path: /ready, port: 8080 }
periodSeconds: 5
failureThreshold: 6 # 在30秒后就会认为检测失败。而实际上应用程序需要40秒才能准备好
因此,应该在系统即将启动的时候立即将其关闭并重新启动,这样就会导致服务中断。为了解决这个问题,应该将这两个操作分开进行:
# 在初次启动时给予更长的准备时间
startupProbe:
httpGet: { path: /ready, port: 8080 }
periodSeconds: 5
failureThreshold: 18 # 允许90秒的时间来预热连接池
# 应用程序启动后,缩短准备时间
readinessProbe:
httpGet: { path: /ready, port: 8080 }
periodSeconds: 5
failureThreshold: 3
了解预热所需的时间属于应用层面的工作。在每次部署时都严格执行这一规定,应该是一次性设置好的配置项,而不是每次都需要重新读取的代码。
数据库迁移是回滚操作失败的高发区
你可以迅速恢复代码到之前的状态,但一旦删除了某个列,就无法再将其恢复回来。
没有任何工具能够让这个问题变得不再棘手,正因如此,这项规定才是列表中最为严格的要求。
绝对不要在同一次部署中同时发布数据库结构变更以及依赖这些变更的代码。应该分阶段进行:先添加新列而不修改旧列,然后进行部署;接着发布能够同时操作新旧列的代码,最后再发布仅能读取新列的代码。只有等到所有代码都不再引用旧列时,才能真正删除它。
这种操作模式被称为“逐步扩展与收缩”。例如,将users.name字段重命名为users.full_name,看似只涉及一行代码的修改,但实际上实际上需要分四次部署来完成:
-- 该字段是可空的,也没有默认值,因此不需要重新编写表结构,也不会导致长时间锁定数据库
ALTER TABLE users ADD COLUMN full_name varchar(255);
第一次部署只会添加新列,其他内容都不会发生变化:
def save_user(user, name):
user.name = name # 这仍然是数据存储的原始位置
user.full_name = name # 新字段开始被使用
接下来需要分批补录在第一次部署之前就已经存在的记录,这样就不会导致长时间锁定数据库:
-- 重复执行此操作,直到查询结果中不再有记录为止
UPDATE users SET full_name = name
WHERE full_name IS NULL
LIMIT 5000;
第三次部署会读取新字段的值,同时保留旧字段的兼容性,这样在需要回滚到第一次部署的状态时仍然可以正常操作:
display_name = user.full_name or user.name
第四次部署才是最终步骤。只有等到代码库中再也没有任何地方引用name>字段,且你确定不会再需要回滚到之前的版本时,才能进行这次部署:
ALTER TABLE users DROP COLUMN name;
这个过程确实比较繁琐。但正是因为在每个阶段,旧代码和新代码都可以继续正常运行,才使得快速回滚成为可能。
蓝绿部署技术会维护两个完整的开发环境,并在它们之间切换用户请求的路由。只有当数据层能够同时支持这两个版本时,这种技术才能发挥作用。而规范的数据库迁移流程才是整个机制的基础,部署策略则是建立在这一基础之上的。
还有一条规则:千万不要在部署过程中执行耗时较长的迁移操作。如果某个修改会导致大型表被锁定,那么即使部署流程显示操作成功,应用程序也会陷入瘫痪状态。因此,应该提前完成这些迁移操作,让部署过程仅仅成为代码的快速切换而已。
将部署与发布分开进行
“部署”意味着代码已经上传到服务器上,而“发布”则意味着用户能够真正使用到这些代码带来的功能。大多数团队会将这两者视为同一件事情来处理,因此每次部署都会让人感到充满风险。
将新的功能通过某个标志来控制其是否启用,这样就可以将新旧功能分开处理。在代码中,这意味着需要设置一个分支,并在某个特定位置来决定是否启用这个新功能:
def checkout(request, user):
if flags.enabled("newcheckout", user=user):
return new_checkout(request, user)
return legacy Checkout(request, user)
在部署时,先关闭这个标志。这样,新的功能路径虽然存在于服务器上,但暂时没有人能够使用它:
# flags.yaml — 部署配置:代码已上线,但新功能未启用
new_checkout:
enabled: false
确认应用程序运行正常后,再为你的团队启用这个新功能:
newcheckout:
enabled: true
audience: internal # 仅限内部员工使用
接下来,可以让1%的真实用户开始使用这个新功能,然后观察这一小部分用户的错误率和响应延迟情况:
new_checkout:
enabled: true
audience: percentage
percentage: 1
最后,让所有用户都能使用这个新功能,并同时设定一个清除日期:
newcheckout:
enabled: true
audience: percentage
percentage: 100
remove_by: 2026-11-01 # 在这个日期之前删除该标志及旧的Checkout功能
如果在任何环节出现问题,你只需将enabled: false设置为默认值,而无需重新部署代码,这样就能迅速恢复到之前的状态,整个恢复过程只需要几秒钟而已。
这种做法还能减少需要测试的项目数量。那些被标记为“默认关闭”的功能其实风险很低,因此可以跳过这些项目的测试,将检查重点放在那些真正存在风险的环节上。
需要注意的是,使用标志来控制功能的启用状态也会带来一些额外的开销,因为那些被关闭的功能最终会变成“无用的代码”。所以,在添加新的标志时,一定要设定一个清除日期。正是这个remove_by字段的存在,才使得这些配置能够被明确记录下来,而不会被人遗忘。
在发布产品之前确定好回滚触发条件
大多数情况下,回滚操作之所以会延迟进行,是因为事先没有人能就“什么情况属于错误”达成一致。在压力之下,人们往往会继续观察一段时间,看看问题是否会自行解决。
在部署之前,就把回滚触发条件明确写下来。可以选择两三个具体的指标,每个指标都要设定一个阈值以及相应的监测窗口。例如:
如果错误率超过1%,并且这种状态持续两分钟以上
如果95百分位处的响应延迟值是正常值的兩倍以上
如果请求队列的长度持续增加且没有恢复的趋势
一旦有任何一项指标超过了设定的阈值,就应该立即进行回滚操作,而不需要再进行讨论。需要注意的是,要关注百分位数值,而不是平均值。比如,如果现在有20个请求中只有1个请求的响应时间达到了8秒,那么平均响应时间可能并不会发生变化,但确实有一部分用户会受到影响。
在检测问题的时候也要诚实一些。如果你今天需要多长时间才能发现这些异常信号?如果这个时间超过了你的监测窗口的范围,那就说明问题出在你的监控机制上,而不是你的检查清单上。
第二步:将某些项目从测试列表中移除
现在回到基础设施相关的部分,对于每一项功能,都要重新思考一下是否还需要继续进行测试。不要问“这项功能是否有必要测试”,因为答案当然是肯定的。而是要问“为什么还要有人去测试这项功能?”
显而易见的解决办法就是实现自动化。证书更新过程应该被自动化处理;图像来源的验证也应当成为流程控制的一部分,确保未经验证的构建版本无法被推送;配置差异则应在部署阶段进行检查。
每当你完成一项自动化处理,相关内容就会从清单中删除掉。如果一个团队能持续这样做四个季度,他们的文档长度将会明显缩短。
不过,在完成了这些基础性的自动化工作之后,还需要关注那些剩余的问题,因为真正的难点往往就在这里。例如,“健康检查机制”就是其中之一:只有当新的系统实例通过所有检测后,流量才会被切换到这些新实例上;另外还有“零停机时间替换方案”:需要先断开现有连接,等待正在处理的请求完成,然后按顺序更换系统实例,并确保一切准备就绪后再恢复流量。
必须保证之前的版本能够随时被快速重新部署。对于每个拉取请求,都应该创建一个与生产环境相同的预览环境,这样“在测试环境中运行正常”这类问题就不再会成为故障的原因。
这些问题并不能通过简单的脚本编写来解决。它们都属于分布式系统领域的问题,在实际生产环境中才会出现一些边缘情况,因此直到这些问题得到彻底解决之前,相关步骤都仍然需要由人工来进行验证。
那些致力于认真解决这些问题的团队,已经不再只是编写脚本了。他们正在构建并维护一个内部的交付平台,这个平台实际上是一个真正的产品,拥有明确的开发路线图、值班人员轮换制度,以及持续的维护成本。
回滚机制最能清楚地体现这种差异。如果由内部团队来负责执行回滚操作,那么这个人就会在压力下完成这项工作;而只有通过反复练习,使这种练习本身成为一项常规性工作,才能保证回滚操作的可靠性。
保持简洁,将其保存在代码仓库中
最终留下来的应该是一份关于你的应用程序的清单,而且这份清单应该能够适应拉取请求模板的结构。在审核者面前设置六个复选框,总是比编写一篇维基页面更有效。
确认所做的更改与当前正在运行的版本兼容,包括作业负载和缓存数据等方面。
确保任何数据库结构上的变更都会在之前的部署版本中就已经被应用。
确认新的配置设置已经在目标环境中存在。
为回滚操作指定一个名称。
说明这些更改将如何传递给用户。
指定谁会在最初的15分钟内负责监控这些变更的执行情况。
最后那项内容常常被人们忽视。进行一次无人关注的部署,其实就是在赌博;这也正是为什么在项目结束时进行部署往往会带来麻烦的原因。部署本身并没有问题,真正的问题在于“没有人监督这个部署过程”。
要判断这份检查清单是否有效,可以参考这四个DORA指标。故障率的变化能说明你的检查机制是否能发现真正存在的问题;恢复时间则能反映你的回滚计划是否可靠;而部署频率则能表明这个流程是否已经变得过于复杂,以至于难以使用了。
那些频繁进行部署的团队,其生产环境出现问题的次数反而最少,因为当交付流程自动化程度足够高时,人类犯错的机会就会大大减少。
因此,应该把这份检查清单看作是记录人类仍在为系统承担的各种风险的工具。对于那些可以通过自动化手段完成的任务,就应该将其自动化;而对于剩下的那些任务,则必须清楚地认识到:保留这些检查项意味着需要继续维护支撑它们的基础设施。随着时间的推移,如果检查清单上的项目逐渐减少,那就说明这个系统正在逐步成熟;而如果列表中的项目数量始终很多,那往往说明这是有人故意做出的决定,而不是偶然发生的。
希望你喜欢这篇文章。你可以通过在LinkedIn上与我联系。
相关文章
在生产环境中进行部署时会发生什么?一场幕后揭秘之旅
你提交代码后,几分钟后,这些代码就会真正开始为用户提供服务。 在这两个时刻之间,有一系列复杂的流程在运转:代码构建、生成相应的成果文件、数据库迁移、健康检查以及流量分配调整。每一位生产工程师都依赖这一整套流程,而许多团队至今仍然亲自负责这些工作的执行。 部署基础设施如今已经悄然成为一种运营负担。它最初只是出于技术上的必要性而存在的——因为当时没有其他可行的解决方案,所以每个团队都必须自行搭建这套系统。 如今,这已经成为另一个需要工程师维护的系统,它会消耗大量的值班时间、开发资源,甚至还会在凌晨两点占用本该用于更重要工作的精力。 在这篇文章中,我们将详细探讨一次实际的生产环境部署过程:从代码构建
阅读全文
构建者设计模式:构建复杂对象的一种更有效的方法
有些对象非常简单,比如字符串、数字或布尔值。你可以用一行代码创建它们,然后继续进行其他操作。 而另一些对象则完全不简单。例如,一个轮播组件就需要包含项目数量、项目生成函数、控制器、高度、视图窗口比例、自动播放设置、页面切换回调函数以及无限滚动配置等信息。再比如,一个HTTP请求需要URL地址、请求头信息、认证令牌、请求体内容、超时时间以及重试逻辑。又或者,一条通知消息需要标题、正文、图标、发送渠道、优先级、声音效果、振动功能以及操作按钮等等。 当你需要构建这类对象时,一种常见的方法是使用带有大量参数的构造函数。这种方法虽然可行,但随着对象结构变得越来越复杂,就会带来一系列问题:这些参数很难区分
阅读全文
如何使测试工作更具可持续性
通过采用可持续的测试策略,你可以跳过那些不必要的测试,确保问题能够尽快被发现,并且只运行那些会受到代码变更影响的测试。记录每次测试所消耗的能量,同时利用静态代码分析工具,可以帮助你找出效率低下的地方,从而指导优化工作。 作者:本·林德斯
阅读全文
《消毒器使用手册:内存管理、初始化机制以及竞态条件问题》
一些最为危险的“原生故障”其实是由那些看似运行正常的程序所引发的。 加密操作会生成正确的密文,解析器也会拒绝处理格式错误的输入数据,缓存机制也能通过基准测试,候选发布版本也能通过所有的单元测试和集成测试。 然而,在这些看似正确的结果背后,某些组件仍然认为自己拥有已经被转移的对象控制权;某些成功路径在运行过程中并未初始化输出字段;有些终结器还在等待释放那些实际上已经由其他运行时机制控制的对象;还有两个线程会修改相同的状态,但调度器并没有选择那种会导致竞争条件出现的执行顺序。 然而,预期的输出结果中根本没有任何迹象能够揭示这些隐藏的问题。 第一次出现可观察到的故障可能要几个小时后才会发生:比如分配
阅读全文