Skip to content

发布安全:新版本能接班,旧版本也要能退场 ​

一次发布会让新旧版本同时存在,让连接在旧实例上继续运行,还可能改变数据库结构。只确认“新进程启动了”远远不够:新版本要准备好接流量,旧版本要停止接新任务并处理完已有工作,数据也要允许两个版本共存。

这篇文章把优雅启停、健康检查、灰度、蓝绿、金丝雀、功能开关和回滚连成一条发布流程。正文的 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: 中止连接并记录未完成工作
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
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]

  1. 先添加兼容字段或结构,不立即删除旧接口。
  2. 发布能处理新旧结构的代码,按需要双写或兼容读取。
  3. 有界回填历史数据,核对数量、内容和约束。
  4. 切换读取路径,并观察兼容回滚窗口。
  5. 确认旧版本与旧消费者全部退场后,再删除旧结构。

“双写”也不自动一致:同库可以放进同一事务,跨库或跨服务需要相应的重试、对账和一致性方案。回填要限速、能重入、能暂停;不然发布代码还没问题,后台迁移先压垮数据库。

回滚对象容易执行的动作执行前必须核实
应用镜像切回已知版本仍能读写当前 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 的服务镜像。

依据 ​

  1. Kubernetes:Liveness, Readiness, and Startup Probes:探针用途和 startup 的顺序关系,2026-10-13 核对。
  2. Go 1.27.2 net/http server.go:Shutdown、Close、Serve 返回值和 hijacked 连接限制;本机 go doc 交叉核验。
  3. Go 1.27.2 os/signal:NotifyContext;context:取消传播。
  4. Kubernetes:Pod termination:preStop、宽限期、端点与终止。
  5. Google SRE Workbook:Canarying Releases:金丝雀、对照与发布判断。
  6. Kubernetes:Deployments:滚动参数、进度状态和回滚边界。
  7. OpenFeature Specification:Flag Evaluation:默认值和求值上下文等机制;开关负责人、权限及清理是本文设计建议。
  8. 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.0

main.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

保留各书来源与许可 · 示例运行条件以正文为准