每一次停机都有代价
Gartner经常引用的每分钟停机损失5600美元的数据来自2014年。考虑到通货膨胀以及企业现在对持续在线服务的依赖程度,大多数中型SaaS公司的实际数字应该在每分钟8000至15000美元之间。但没有人会记在电子表格里的成本才是真正造成伤害的:用户信任。在结账过程中遇到502错误的客户不会提交支持工单。他们会直接离开。
好消息是,零停机部署策略不再是拥有专门平台工程团队的公司的专属领域。相关工具已经成熟。模式已有完善的文档记录。对于大多数工作负载,你只需花一个下午的专注工作就能实现。
坏消息是,”零停机”这个词已经成为营销术语,而不仅仅是工程实践。存在权衡、故障模式和边缘情况,而这些往往是那些光鲜亮丽的概述文章会跳过的内容。本文将介绍三种主流方法,它们共同面临的数据库迁移问题,以及何时应该采用这些方法的坦诚评估。
蓝绿部署:概念起点
蓝绿部署是最古老、最直观的零停机部署策略。你维护两个完全相同的生产环境。一个(蓝色)处理实时流量。另一个(绿色)处于空闲状态或运行新版本。当新版本通过验证后,你将负载均衡器切换到指向绿色环境。蓝色环境则成为回退选项。
使用Nginx的实际工作方式
切换本身很简单。这是一个展示该机制的最小Nginx上游配置:
# /etc/nginx/conf.d/upstream.conf
upstream app_backend {
# 蓝色环境(当前在线)
server 10.0.1.10:8080;
# server 10.0.1.20:8080; # 绿色环境(待命)
}
# 切换方法:注释掉蓝色,取消注释绿色,然后重新加载
# nginx -s reload 会触发优雅交接 — 不会断开连接
使用HAProxy时,模式类似,但你开箱即用就能获得更复杂的健康检查功能:
backend app_servers
option httpchk GET /health
http-check expect status 200
# 蓝色(活跃)
server blue 10.0.1.10:8080 check
# 绿色(待命 — 切换前禁用)
server green 10.0.1.20:8080 check disabled
要将流量切换到绿色版本,您可以通过 HAProxy 的运行时 API 或统计套接字启用绿色服务器并禁用蓝色版本。无需编辑配置文件,也无需重新加载。
优势与劣势
蓝绿部署提供了任何部署策略中最干净的回滚方案。出了问题?切回去即可。旧版本仍在运行,已预热并准备就绪。您无需重建任何内容。
代价是基础设施。您需要始终保持双倍的计算能力。对于一个小型的无状态 API,这点开销可以忽略不计。但对于一个包含 16 个应用服务器和专用工作队列的工作负载,成本会迅速增加。一些团队通过将闲置环境用于暂存环境或批处理来缓解这个问题,但这会引入自身的复杂性。
另一个被低估的问题是会话状态。如果您的应用将会话存储在内存中(虽然不应该这样做,但很多应用确实这么做了),切换会丢弃所有活跃的会话。粘性会话、Redis 支持的会话存储或基于 JWT 的身份验证可以解决这个问题,但您需要在第一次部署前解决它,而不是在部署过程中。
金丝雀发布:信任,但要验证
金丝雀部署采用更为谨慎的方法。不是一次性切换所有流量,而是将一小部分流量路由到新版本并观察结果。如果错误率保持稳定且延迟没有激增,您就逐渐增加百分比,直到新版本处理所有流量。
这是 Google 和 Netflix 等大规模运营商推崇的策略,而且理由充分。它能捕获任何暂存环境测试都无法发现的问题:真实负载下的细微性能退化、测试数据未覆盖的用户数据边缘情况,以及服务间的交互效应。
实际流量分割
Nginx 原生支持基于权重的上游分发:
upstream app_backend {
server 10.0.1.10:8080 weight=95; # 稳定版本
server 10.0.1.20:8080 weight=5; # 金丝雀版本
}
为了更精细的控制,Nginx Plus 或 Envoy 为您提供基于百分比的路由功能,并能将特定用户或请求路径固定到金丝雀版本。如果您已经在运行 Istio,它通过 VirtualService 资源使金丝雀路由成为一等概念。
需要关注哪些指标
金丝雀部署的全部意义在于您是基于数据驱动来做是否继续推进的决定。重要的指标包括:
- 错误率(5xx响应) — 最明显的信号。将金丝雀版本的错误率与基线版本比较,而不是与零比较。两者都是0.1%的错误率是可以接受的。基线版本0.1%而金丝雀版本0.8%则是个问题。
- P95和P99延迟 — 平均值会隐藏问题。如果金丝雀版本的中位延迟相同,但P99延迟翻倍,说明存在一个影响最复杂请求的回归问题。
- 业务指标 — 转化率、结账完成率、API调用成功率。这些指标能捕获那些不会抛出错误但会产生错误结果的bug。
- 资源消耗 — 金丝雀实例的CPU和内存使用情况。内存泄漏在五分钟的冒烟测试中不会显现,但在三十分钟的真实流量下就会暴露。
像Kayenta(来自Netflix/Google)或Flagger(用于Kubernetes)这样的自动化金丝雀分析工具可以比较金丝雀版本和基线版本之间的这些指标,并自动进行升级或回滚。对于较小的团队,使用Grafana仪表板和人工决策也能很好地工作。
滚动更新:Kubernetes的默认策略
滚动更新一次(或小批量)替换一个旧版本的实例。在部署过程中的任何时刻,一些实例运行旧代码,一些运行新代码。一旦所有实例都被替换,部署就完成了。
这是Kubernetes和Docker Swarm中的默认策略,这意味着许多团队已经在使用它,而无需做出刻意的选择。
Kubernetes滚动更新配置
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-app
spec:
replicas: 4
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1 # 更新时最多有1个pod不可用
maxSurge: 1 # 更新时最多有1个额外pod
template:
spec:
containers:
- name: app
image: registry.example.com/app:2.1.0
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 5
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe至关重要。没有它,Kubernetes会将流量发送到仍在启动的pod上。maxUnavailable和maxSurge参数控制着部署的激进程度。设置maxUnavailable: 0和maxSurge: 1可以确保在更新期间始终保持完全容量,代价是临时多运行一个pod。
Docker Swarm等效配置
docker service update
--image registry.example.com/app:2.1.0
--update-parallelism 1
--update-delay 30s
--update-failure-action rollback
web-app
--update-delay 标志是 Kubernetes 所没有直接对应的功能。它强制在每个实例更新之间暂停,给你时间在下一个实例轮转之前发现问题。
混合版本问题
滚动更新有一个基本特性,蓝绿部署和金丝雀部署在更可控的方式下也共享这一特性:在某个时期内,旧代码和新代码会同时处理流量。如果新版本更改了 API 响应格式,两种格式都会同时存在。如果新版本以不同的结构写入数据,这两种结构将同时存在于你的数据库中。
这是可以管理的,但前提是你必须为此进行设计。向后兼容的更改是必需的,而不是可有可无的。
比较:选择正确的策略
| 因素 | 蓝绿部署 | 金丝雀部署 | 滚动更新 |
|---|---|---|---|
| 回滚速度 | 即时(切换负载均衡器) | 快速(停止金丝雀流量) | 中等(重新部署旧版本) |
| 基础设施开销 | 高(2倍容量) | 低-中等 | 低(1个额外实例) |
| 混合版本持续时间 | 无(原子切换) | 可控 | 整个部署过程 |
| 复杂度 | 低 | 高(指标管道) | 低(内置到编排器中) |
| 最适合 | 关键应用、小型集群 | 高流量、风险敏感型 | 无状态服务、Kubernetes 原生 |
| 数据库迁移风险 | 中等 | 高(两个版本同时读写) | 高(两个版本同时读写) |
没人想谈论的数据库问题
每一种零停机部署策略都会遇到同样的障碍:数据库。你可以整天交换应用服务器,但是添加一个没有默认值的 NOT NULL 列的模式迁移会破坏仍在运行的旧应用程序代码。
扩展-收缩模式(有时称为并行更改)是标准解决方案。它分为三个阶段:
- 扩展:添加新列作为可空列或设置默认值。部署同时写入旧列和新列的代码。旧代码继续工作,因为它会忽略新列。
- 迁移:回填现有行。这可以作为后台作业运行。无停机,无锁定(如果你正确批处理)。
- 收缩:一旦所有行都已填充且旧代码完全退役,删除旧列并移除双写逻辑。如果需要,添加 NOT NULL 约束。
这会将一次迁移转变为三次部署。它更慢。它需要纪律。而且,当你的模式需要更改时,这是唯一真正适用于零停机部署策略的方法。
像 gh-ost(用于 MySQL)和 pgroll(用于 PostgreSQL)这样的工具通过执行不锁定表的在线模式更改来自动化此过程的某些部分。如果你每月进行迁移超过几次,它们值得评估。
功能标志:将部署与发布解耦
过去十年中部署实践最重大的转变是将部署(将代码放在服务器上)与发布(向用户提供功能)分离。功能标志使这种分离变得具体。
使用功能标志,你部署的代码包含一个条件检查后的新功能。默认情况下,该功能是关闭的。一旦部署稳定,你就可以切换标志来启用它。如果该功能导致问题,你可以在不重新部署任何内容的情况下将其关闭。
最安全的部署是用户视角下没有任何变化的部署。功能标志使这成为默认状态。
你不需要商业功能标志服务来实现这一点。对于大多数团队来说,由数据库表或环境变量支持的简单实现就足够了。当你拥有超过少量标志并需要审计跟踪或逐步推出时,LaunchDarkly、Unleash(开源)和 Flipt值得考虑。
所需的纪律是清理。永远存在的功能标志会成为技术债务。每个标志都应该有一个过期日期,或者在功能确认稳定后有一个移除它的工单。跳过这一步骤的团队最终会充满死分支的代码库,以及没有人确定是否可以移除的标志。
小型团队的实用实现
如果你是一个由一到五名工程师组成的团队,运行少量服务,这里有一个诚实的建议:从你已经在使用的编排器上的滚动更新开始,添加健康检查,然后就可以完成了。这涵盖了你需要的大部分内容(80%)。
具体步骤:
- 实现一个
/health端点,检查数据库连接性,并在应用准备好接收流量时返回 200 状态码。在此之前不要返回。 - 配置你的编排器使用该端点作为就绪检查。Kubernetes 原生支持这一点。Docker Swarm 支持健康检查。即使是 Nginx 后面的简单 systemd 服务也可以通过定期健康检查轮询来实现这一点。
- 设置你的部署一次替换一个实例,替换之间至少有 30 秒的延迟。
- 确保你的数据库迁移是向后兼容的。如果一个迁移不是向后兼容的,将其分成两个:一个是向后兼容的,另一个是在旧代码消失后进行清理的。
稍后再添加金丝雀分析,当你有足够的流量和可观测性栈使其有意义时。每分钟针对50个请求运行金丝雀分析不会告诉你太多信息。而针对5,000个请求运行则会。
当”直接重启”完全没问题时
并非每个服务都需要零停机部署。这可能是个有争议的观点,但很务实。
如果你的服务是一个内部工具,只在工作时间被20人团队使用,那么在凌晨2点的维护窗口期间重启10秒钟,不是一个值得工程化解决的问题。停机成本实际上为零,因为没有人使用它。
同样,如果你的服务处理来自队列的异步作业,重启它只会导致处理短暂暂停,而不是数据丢失。队列会缓冲作业,服务恢复后会继续处理。对于许多后台工作进程,systemctl restart app是一个完全有效的部署策略。
真正需要零停机部署的服务具有以下特征:
- 它们处理来自用户或其他服务的同步请求,这些请求无法透明地重试。
- 它们处理的流量规模下,即使几秒钟的停机也意味着数千个失败请求。
- 它们在无法安排维护窗口的环境中运行(全球用户群、SLA承诺、实时数据处理)。
如果以上条件都不适用,请将你的工程时间投资到其他地方。过早优化部署管道就像过早优化代码一样真实存在。
前进的道路
零停机部署策略是一个范围谱系,而非二元选择。你不会一步从”重启并祈祷”发展到带有自动回滚功能的完整金丝雀分析。大多数团队的进展过程如下:
- 阶段1:健康检查和优雅关闭。你的应用在停止前会排空连接。你的负载均衡器知道实例何时未就绪。
- 阶段2:使用就绪门控的滚动更新。新实例必须通过健康检查才能接收流量。
- 阶段3:高风险变更的功能标志。部署本身不会改变任何面向用户的功能。发布是一个独立的、可逆的操作。
- 阶段4:为流量规模和业务关键性值得进行可观测性投资的服务进行金丝雀分析。
大多数团队会发现,阶段1和阶段2消除了绝大多数与部署相关的事件。阶段3和阶段4适用于当你拥有规模、团队和运营成熟度,使其值得投入时。首先打好基础。没有完善的健康检查、向后兼容的迁移和优雅关闭处理,再复杂的工具也毫无意义。
