主题
发布安全:新版本能接班,旧版本也要能退场
一次发布会让新旧版本同时存在,让连接在旧实例上继续运行,还可能改变数据库结构。只确认“新进程启动了”远远不够:新版本要准备好接流量,旧版本要停止接新任务并处理完已有工作,数据也要允许两个版本共存。
这篇文章把优雅启停、健康检查、灰度、蓝绿、金丝雀、功能开关和回滚连成一条发布流程。正文的 HTTP 输出来自本机真实的回环网络实验;Kubernetes 行为按官方文档解释,当前没有集群,因此不会把配置示例写成已经验证的零停机方案。
启动、就绪、存活是三个问题
| 检查 | 想回答的问题 | Kubernetes 中失败的主要后果 | 应避免的设计 |
|---|---|---|---|
| startup | 启动阶段是否已经完成? | 超过配置的失败阈值后重启;成功前其他两类探针不执行 | 用过短 liveness 阈值反复杀掉正常慢启动 |
| readiness | 现在能否接这类流量? | 将 Pod 标为未就绪,影响流量端点选择 | handler 刚监听端口就无条件 ready |
| liveness | 进程是否陷入需要重启才能恢复的故障? | 达到阈值后触发重启 | 下游数据库短暂失败就让所有实例重启 |
这是 Kubernetes 探针的用途分工,具体执行频率和阈值由配置决定。readiness 失败不等于重启,liveness 成功也不等于所有业务依赖正常。[1]
优雅启动可以按“读配置并校验 → 建立必要连接 → 加载必要状态 → 具备最小服务能力 → ready”的顺序组织。预热若不是正确性必需,可以有节制地在接流量后继续进行;如果所有副本一起全量预热,把缓存冷启动变成数据库洪峰,发布仍会失败。
liveness 应尽量回答本实例能否继续工作。如果数据库慢,重启应用往往只会增加建连和缓存重建,不能修数据库。readiness 是否检查数据库也要取决于业务:没有数据库就无法完成任何必要请求时,可以标未就绪;仍能提供缓存读或其他独立接口时,一刀切摘掉整个实例可能扩大故障。探针设计应与接口能力和路由边界一致。[1]
旧实例先收口,再排空
正在绘制图示…
查看图源代码
stateDiagram-v2
[*] --> Starting
Starting --> Ready: 初始化完成
Ready --> Draining: 收到停止指令
Draining --> Stopped: 活跃请求和后台任务完成
Draining --> ForcedClose: 清理预算用尽
ForcedClose --> Stopped: 中止连接并记录未完成工作
Draining 阶段有两个同时存在的事实:实例不该接新的业务请求,已经接下的请求仍可能正常完成。因而 readiness 可以失败,liveness 仍成功;把两者绑在同一个布尔值上,可能在排空阶段触发不必要的重启。
本地程序先启动一个只监听 127.0.0.1 随机端口的 HTTP 服务。一个 /work 请求已经进入 handler,被测试 channel 暂时挡住;随后把 ready 设为 false。实际输出:
text
ready before: 200
ready while draining: 503
live while draining: 200此时还没关闭 listener,所以可以真正通过 HTTP 观察探针状态。/work 入口也检查 ready,排空后到达的新请求返回 503。这个保护不能替代路由摘流,但能应对摘流信息传播过程中仍然到达的请求。
Go 的 Shutdown 做什么,没做什么
http.Server.Shutdown(ctx) 关闭 listener、关闭空闲连接,并等待活跃连接回到空闲。它不会打断已经在处理的普通请求;如果等待 context 到期,会返回错误,而不是保证所有 handler 已经退出。它也不负责管理被 hijack 的连接,例如某些 WebSocket 实现,应用需要另外通知并等待它们结束。[2]
go
done := make(chan error, 1)
go func() { done <- r.Server.Shutdown(ctx) }()
// 等 Serve 返回 ErrServerClosed,确认 listener 已关闭。
<-r.Stopped
r.App.Release() // 让已经接下的请求完成完整程序实际输出:
text
shutdown waits for active request: true
active request status: 200
shutdown completed: true程序在 listener 已关闭、handler 还没释放时检查 Shutdown 尚未完成,再释放请求。这比先 Sleep 一段固定时间、猜测请求应该已经结束更能说明调用关系。客户端把响应体读完并关闭,最后关闭空闲连接。
负例用一个已经取消的 context 调用 Shutdown。调用返回 context.Canceled,活跃 handler 仍未结束;随后调用 Server.Close,连接关闭使请求 context 取消,handler 按取消分支退出。这个测试同时说明两件事:Shutdown 超时不是强杀,强制关闭后业务也必须有处理取消和未完成工作的规则。
真正接入操作系统停止信号时,可以使用 signal.NotifyContext 捕获所需信号,但用于排空的 context 要从仍有效的父 context 创建。直接把“收到 SIGTERM 后已经取消”的 signal context 传给 Shutdown,会让清理立刻结束。主 goroutine 也不能在 ListenAndServe 返回 ErrServerClosed 后立即退出,必须等待排空完成。[2][3]
后台消费者应先停止领取新任务,再处理或安全放弃已有任务;日志、追踪与缓冲写入使用独立且有限的刷新预算。不要把请求结束后的后台提交偷偷挂在已取消的请求 context 上,也不要无期限等待一个不响应取消的 goroutine。
Pod 宽限期要覆盖整段退场过程
Kubernetes 的 Pod 终止流程涉及 preStop、终止信号、端点更新和宽限期。宽限期从终止流程开始计时,preStop 的时间也占用其中;超期后运行时会强制终止仍在运行的容器。端点变化与进程清理并不是一个跨系统原子动作,因此负载均衡器、代理和现有连接的传播时间需要实际测量。[4]
| 一段退场工作 | 为什么要计入预算 |
|---|---|
| 停止准入并让路由传播 | 新流量可能短时继续到来 |
| 处理活跃 HTTP/RPC | 请求有自己的剩余 deadline |
| 排空任务消费者和长连接 | 不一定受 HTTP Shutdown 管理 |
| 保存检查点或确认任务 | 影响重启后的重复执行和恢复 |
| 刷新日志、指标并退出 | 主进程不能提前结束 |
“preStop 里睡 10 秒”只有在十秒确实覆盖目标环境传播时才有帮助,也会消耗宽限期。它不能替代应用的停止准入和排空逻辑。若总宽限期 30 秒,预处理用了 10 秒,又给 Shutdown 30 秒,应用自己的预算已经超过平台允许值。
灰度、金丝雀、滚动、蓝绿,分别控制哪件事
| 方式 | 主要控制对象 | 合适用途 | 主要风险和成本 |
|---|---|---|---|
| 滚动更新 | 按批替换实例 | 常规版本迭代,限制同时下线数量 | 新旧版本共存,资源不足会卡住,不能自动证明业务健康 |
| 灰度发布 | 按人群、租户、区域或比例放量 | 分阶段暴露新版本或功能 | 样本偏差、跨版本会话、路由一致性 |
| 金丝雀 | 小范围新版本与对照组比较 | 在全量前发现真实流量下回归 | 指标选择、样本量、观察时长和自动判定 |
| 蓝绿部署 | 两套环境间切换流量 | 快速切换代码环境,保留旧环境 | 额外容量、连接迁移、共享数据无法跟着简单切回 |
这些不是互斥套餐:可以先建绿色环境,再把少量流量作为金丝雀,最后逐步切到绿色。灰度是逐步暴露的思路,金丝雀强调用小范围结果支持继续或停止的判断。[5]
Kubernetes Deployment 的 maxSurge 和 maxUnavailable 控制替换过程中的副本变化。即便设为 maxUnavailable: 0,也仍可能因探针错误、依赖过载、旧连接中断或数据不兼容造成业务失败。progressDeadlineSeconds 用于报告进展问题,不能把它当成原生自动业务回滚器。[6]
按副本数量的 1/10 放新版本,也未必等于精确 10% 请求。连接复用、负载不均、会话黏性会影响实际流量分布;要按请求比例或固定用户群控制,需要相应路由能力并验证观测。
金丝雀判定要看用户结果,也要能停止
先选一个影响范围小但有代表性的组,比较相同用户旅程中的成功率、延迟分布和业务正确性。CPU 下降可能只是请求提前报错了,所以资源指标只能作为辅助。对低流量接口,观察几分钟没有错误不能证明风险很低;对缓存刷新、账单任务等周期行为,还要覆盖足够长的观察周期。[5]
一个实用的发布判定表可以写成:
| 项目 | 发布前明确的内容 |
|---|---|
| 人群 | 哪些租户、区域、接口进入金丝雀,是否具有代表性 |
| 对照 | 同时间旧版本的数据,如何排除请求混合差异 |
| 成功条件 | 关键请求成功率、延迟和业务不变量的阈值 |
| 停止条件 | 错误预算消耗、严重日志、数据异常等触发器 |
| 动作 | 停止放量、切回流量、关闭功能,分别由谁执行 |
| 样本要求 | 最小事件数与观察时长,低流量时怎么办 |
发布系统还应保留版本标识,让指标和日志可以按版本拆分。否则新版本只占 1%,其高错误率可能被全局平均掩盖。自动化能缩短处理时间,但指标缺失、采集失败和误判也应有明确策略,不能把“没收到数据”直接当成健康。
功能开关把代码部署与用户暴露分开
代码可以先部署,但功能保持关闭;再按固定用户分组逐步开启,发现问题时关闭入口。这减少了必须重新打包发布才能停止功能的情况,但开关只影响它控制的路径,不能撤销已经发出的通知、已经写入的数据或已经执行的扣款。
| 开关设计点 | 原因 |
|---|---|
| 明确默认值与读取失败行为 | 配置系统故障时仍要有可预测行为 |
| 对同一用户或租户稳定分组 | 避免一次流程前后进入不同实现 |
| 限制权限并记录修改 | 改开关也是生产行为变更 |
| 给紧急关闭路径独立验证 | 正常放量能用不等于事故时能关闭 |
| 记录负责人、到期时间与删除计划 | 两套长期并存分支增加测试组合和维护负担 |
开关与权限检查是不同机制。关闭某个 UI 入口,不等于服务端禁止未授权调用;安全控制不能只依赖前端或可任意变动的实验分组。配置也要做兼容性测试,旧进程可能不认识新开关值。[7]
真正决定能不能回滚的,常常是数据库
新版本把字段 name 直接改名为 display_name,旧版本还在读 name,滚动期间就会失败。若新版本写出了旧版本无法识别的数据格式,切回旧镜像也无法恢复。
更稳妥的方式是分阶段扩展再收缩:[8]
- 先添加兼容字段或结构,不立即删除旧接口。
- 发布能处理新旧结构的代码,按需要双写或兼容读取。
- 有界回填历史数据,核对数量、内容和约束。
- 切换读取路径,并观察兼容回滚窗口。
- 确认旧版本与旧消费者全部退场后,再删除旧结构。
“双写”也不自动一致:同库可以放进同一事务,跨库或跨服务需要相应的重试、对账和一致性方案。回填要限速、能重入、能暂停;不然发布代码还没问题,后台迁移先压垮数据库。
| 回滚对象 | 容易执行的动作 | 执行前必须核实 |
|---|---|---|
| 应用镜像 | 切回已知版本 | 仍能读写当前 schema、事件和配置 |
| 流量路由 | 回旧环境 | 旧环境容量、连接、缓存和依赖仍可用 |
| 功能开关 | 关闭新入口 | 已发生副作用如何处理 |
| 数据迁移 | 回退或向前修复 | 是否丢数据,旧格式能否表达新值 |
破坏性数据变化可能只能向前修复,不能机械执行 down migration。回滚预案要写明“什么异常触发、谁判断、使用哪个已验证版本、数据怎么处理、恢复后验什么”。在发布前演练一次,才知道预案中是否缺镜像、权限或迁移步骤。
验证范围与使用方式
Go 1.27.2 / Linux amd64 下已真实运行回环 HTTP 请求、排空、探针状态和强制关闭负例,go test、go test -race、go vet 通过。没有运行 Kubernetes、真实 SIGTERM、WebSocket、数据库迁移或多版本路由测试,不能据此宣称端到端零停机。
文末 YAML 是 apps/v1 Deployment 的阅读模板。镜像名称为占位符,不对应已发布应用;它要求真实应用在端口 8080 实现探针和信号清理。实验程序使用随机回环端口、完成演示后退出,不能直接作为该 Deployment 的服务镜像。
依据
- Kubernetes:Liveness, Readiness, and Startup Probes:探针用途和 startup 的顺序关系,2026-10-13 核对。
- Go 1.27.2 net/http server.go:Shutdown、Close、Serve 返回值和 hijacked 连接限制;本机 go doc 交叉核验。
- Go 1.27.2 os/signal:NotifyContext;context:取消传播。
- Kubernetes:Pod termination:preStop、宽限期、端点与终止。
- Google SRE Workbook:Canarying Releases:金丝雀、对照与发布判断。
- Kubernetes:Deployments:滚动参数、进度状态和回滚边界。
- OpenFeature Specification:Flag Evaluation:默认值和求值上下文等机制;开关负责人、权限及清理是本文设计建议。
- GitLab:Avoiding downtime in migrations:应用共存时的兼容迁移方法,不能直接当成所有数据库的锁行为保证。
完整本地实验与未运行配置
前三个文件在空目录中运行。测试不需要 root,不操作外部服务;各网络客户端设置有限超时,清理路径会关闭服务器和释放测试请求。
sh
go run .
go test -v ./...
go test -race ./...
go vet ./...go.mod
go
module example.com/release
go 1.27.0main.go
代码块 · 139 行 · 点击收起 / 展开
go
package main
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"sync"
"sync/atomic"
"time"
)
type App struct {
ready atomic.Bool
started, exited, release chan struct{}
startOnce, exitOnce, releaseOnce sync.Once
}
func NewApp() *App {
a := &App{started: make(chan struct{}), exited: make(chan struct{}), release: make(chan struct{})}
a.ready.Store(true)
return a
}
func (a *App) Release() { a.releaseOnce.Do(func() { close(a.release) }) }
func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/live":
w.WriteHeader(200)
case "/ready":
if !a.ready.Load() {
http.Error(w, "draining", 503)
return
}
w.WriteHeader(200)
case "/work":
if !a.ready.Load() {
http.Error(w, "draining", 503)
return
}
a.startOnce.Do(func() { close(a.started) })
defer a.exitOnce.Do(func() { close(a.exited) })
select {
case <-a.release:
fmt.Fprint(w, "done")
case <-r.Context().Done():
return
}
default:
http.NotFound(w, r)
}
}
type Running struct {
App *App
Server *http.Server
URL string
Stopped chan error
}
func Start() (*Running, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, err
}
a := NewApp()
srv := &http.Server{Handler: a, ReadHeaderTimeout: time.Second}
r := &Running{a, srv, "http://" + ln.Addr().String(), make(chan error, 1)}
go func() { r.Stopped <- srv.Serve(ln) }()
return r, nil
}
func Get(c *http.Client, url string) (int, error) {
resp, err := c.Get(url)
if err != nil {
return 0, err
}
defer resp.Body.Close()
_, err = io.Copy(io.Discard, resp.Body)
return resp.StatusCode, err
}
func run() error {
r, err := Start()
if err != nil {
return err
}
defer r.Server.Close()
defer r.App.Release()
c := &http.Client{Timeout: 2 * time.Second}
defer c.CloseIdleConnections()
status, err := Get(c, r.URL+"/ready")
if err != nil {
return err
}
fmt.Println("ready before:", status)
work := make(chan int, 1)
errs := make(chan error, 1)
go func() { code, e := Get(c, r.URL+"/work"); work <- code; errs <- e }()
<-r.App.started
r.App.ready.Store(false)
status, err = Get(c, r.URL+"/ready")
if err != nil {
return err
}
fmt.Println("ready while draining:", status)
status, err = Get(c, r.URL+"/live")
if err != nil {
return err
}
fmt.Println("live while draining:", status)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
done := make(chan error, 1)
go func() { done <- r.Server.Shutdown(ctx) }()
if err = <-r.Stopped; !errors.Is(err, http.ErrServerClosed) {
return err
}
select {
case err = <-done:
return fmt.Errorf("shutdown returned before request completed: %v", err)
default:
fmt.Println("shutdown waits for active request:", true)
}
r.App.Release()
fmt.Println("active request status:", <-work)
if err = <-errs; err != nil {
return err
}
err = <-done
fmt.Println("shutdown completed:", err == nil)
return err
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}main_test.go
代码块 · 57 行 · 点击收起 / 展开
go
package main
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestDrainingKeepsLivenessAndRejectsNewWork(t *testing.T) {
a := NewApp()
a.ready.Store(false)
for path, want := range map[string]int{"/ready": 503, "/work": 503, "/live": 200} {
rr := httptest.NewRecorder()
a.ServeHTTP(rr, httptest.NewRequest("GET", path, nil))
if rr.Code != want {
t.Fatal(path, rr.Code)
}
}
}
func TestShutdownDeadlineDoesNotKillActiveHandler(t *testing.T) {
r, err := Start()
if err != nil {
t.Fatal(err)
}
defer r.Server.Close()
defer r.App.Release()
c := &http.Client{Timeout: 2 * time.Second}
defer c.CloseIdleConnections()
clientDone := make(chan struct{})
go func() { _, _ = Get(c, r.URL+"/work"); close(clientDone) }()
<-r.App.started
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err = r.Server.Shutdown(ctx); !errors.Is(err, context.Canceled) {
t.Fatal(err)
}
select {
case <-r.App.exited:
t.Fatal("Shutdown killed handler")
default:
}
if err = r.Server.Close(); err != nil {
t.Fatal(err)
}
select {
case <-r.App.exited:
case <-time.After(time.Second):
t.Fatal("handler ignored Close cancellation")
}
<-clientDone
if err = <-r.Stopped; !errors.Is(err, http.ErrServerClosed) {
t.Fatal(err)
}
}deployment.yaml
代码块 · 53 行 · 点击收起 / 展开
yaml
# Reading template only. Not deployed. Replace the image and implement the endpoints.
# This manifest does not run the local random-port demonstration program as-is.
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 3
minReadySeconds: 10
progressDeadlineSeconds: 300
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
terminationGracePeriodSeconds: 30
containers:
- name: api
image: REPLACE_WITH_YOUR_VERIFIED_IMAGE_DIGEST
ports:
- containerPort: 8080
resources:
requests:
cpu: 250m
memory: 128Mi
limits:
memory: 256Mi
startupProbe:
httpGet:
path: /ready
port: 8080
periodSeconds: 2
failureThreshold: 30
readinessProbe:
httpGet:
path: /ready
port: 8080
periodSeconds: 2
failureThreshold: 1
livenessProbe:
httpGet:
path: /live
port: 8080
periodSeconds: 10
failureThreshold: 3