订单服务完成滚动更新,12 个新副本全部 Ready;Argo CD 显示 Synced,Deployment 也显示 Available。但发布窗口内网关仍有 upstream reset,少数调用方超时后重试,订单库里却已经出现记录。
这不是一个“滚动发布失败”的单点故障,而是三条时间线没有被同时证明:
- 控制面:Deployment 是否完成副本替换;
- 数据面:网关是否已停止向旧 Pod 新派发请求,存量连接如何处理;
- 业务面:已经提交、但客户端未收到响应的订单,能否以同一身份得到确定结果。
本文以订单 API 为例,给出可迁移到多数 Gin + PostgreSQL + Kubernetes + Argo CD 服务的生产基线。配置中的数值都是压测和演练的起点,不是可直接照抄的容量结论;示例值、版本能力与实际网关语义必须在目标环境复核。
1. 先给事故分类,再决定证据和动作
不要先把所有发布期错误归入“Pod 摘流不彻底”。客户端看到一次失败,可能来自不同层,修复动作也完全不同。
| 用户现象 |
最先确认的证据 |
常见根因 |
正确处置 |
| 网关 connect failure / reset,应用无同请求日志 |
upstream IP、失败阶段、旧 Pod 终止时间 |
新请求仍被派发到已停止监听的旧 Pod;代理复用的上游连接失效 |
修正摘流时序、空闲连接回收与网关策略 |
| 应用收到请求,耗时接近预算 |
trace、排队、DB 池等待、锁等待 |
队列或依赖耗尽了预算 |
限流、拆分预算、治理依赖容量 |
| 客户端超时,库中已有订单 |
幂等键、事务提交、Outbox、订单状态 |
提交成功但响应在链路中丢失 |
用同一幂等键重放确定结果 |
| Argo CD 已 Synced,仍有业务错误 |
Git revision、实际 imageID、网关和业务 SLI |
控制面已收敛,数据面或业务面未收敛 |
以用户侧指标决定继续、暂停或回滚 |
1.1 终止并不是一个瞬时事件
Pod 进入 Terminating 后,至少有三件独立的事并发发生:kubelet 执行本地终止、控制面更新 EndpointSlice、各代理观察更新并处理存量连接。终止端点通常会暴露 terminating=true、ready=false;支持该语义的数据面还可参考 serving。但从 EndpointSlice 改变到每一个网关停止新选址,仍有传播与实现差异。
这张图不是时序保证。特别是:已有 HTTP/1.1 keep-alive 上游连接、HTTP/2 stream、service mesh sidecar 和外部负载均衡器都有自己的连接与路由行为。生产结论必须来自实际网关和协议路径的演练,而不是仅看 Deployment 状态。
2. 请求边界:取消、错误和请求身份走同一条链
2.1 Gin 只做 HTTP 适配,业务层只依赖标准 context.Context
gin.Context 来自对象池,不能在 handler 返回后交给异步 goroutine 持有。c.Copy() 只适合读取少量请求数据;它不应被当作"脱离请求生命周期的后台任务上下文"。请求内并行工作接收标准 context.Context,并必须在 handler 返回前收敛。跨请求任务(履约通知、事件投递)应在事务内写入 Outbox,由独立 worker 消费。
// HTTP 适配层:认证中间件已经写入可信主体。
func (h *OrderHandler) Create(c *gin.Context) {
principal := principalFrom(c)
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)
var in CreateOrderRequest
if err := c.ShouldBindJSON(&in); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
c.AbortWithStatusJSON(http.StatusRequestEntityTooLarge,
gin.H{"code": "BODY_TOO_LARGE"})
return
}
c.AbortWithStatusJSON(http.StatusBadRequest,
gin.H{"code": "INVALID_ORDER"})
return
}
result, err := h.orders.Create(c.Request.Context(), CreateOrderCommand{
TenantID: principal.TenantID,
ActorID: principal.UserID,
Key: c.GetHeader("Idempotency-Key"),
Items: in.Items,
})
writeResult(c, result, err) // 唯一提交 HTTP 响应的路径
}
MaxBytesReader 只限制读取字节数;若接口要求"只能有一个 JSON 值",应用还应使用受限 json.Decoder,确认第二次 Decode 返回 io.EOF。同时限制数组项目数、字符串长度、嵌套层数和幂等键字符集。若网关和应用的解压位置不同,压缩前后体积限制也必须统一定义。
建议用 gin.New() 显式安装中间件:
Trace / Request-ID → Access(外层)→ Recovery → 路由级认证、预算、准入 → Handler
Access 位于 Recovery 外层,才可记录 Recovery 已写入的最终状态。若 panic 发生在响应提交后,状态码不能安全重写;应记录 panic_after_write,并从网关侧观察响应截断或 reset。指标标签使用路由模板,订单号、用户 ID、trace ID 只进入日志或追踪。仅信任明确的代理 CIDR;没有转发代理时使用 SetTrustedProxies(nil)。
2.2 超时是取消信号,不是能抢写响应的后台计时器
func requestBudget(d time.Duration) gin.HandlerFunc {
return func(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), d)
defer cancel()
c.Request = c.Request.WithContext(ctx)
c.Next()
}
}
业务层必须把该 context 传给 QueryContext、BeginTx、http.NewRequestWithContext 等下游操作。请求取消时,handler 仍需要等待下游调用返回;不要把 c.Next() 放入 goroutine,再由外层与业务代码竞争写入 504。CPU 密集工作需要限制输入,并在批次之间检查 ctx.Err();无法及时中断的工作应交给独立执行单元。数据库侧再以语句超时和锁等待上限约束服务端工作。
错误映射应显式定义:
| 条件 |
建议外部结果 |
记录重点 |
| 客户端断开或请求上下文已取消 |
不伪装为业务 500 |
client_cancelled、阶段、已提交与否 |
| 应用预算耗尽且尚未写响应 |
依网关契约映射 504 |
哪个阶段耗尽预算 |
| 并发闸门拒绝 |
503;有可靠恢复时间才加 Retry-After |
路由、租户、闸门占用 |
| 幂等键复用但载荷不同 |
409 |
键的摘要,不能记录原始敏感载荷 |
| 事务已提交但响应可能丢失 |
重试/查询同一幂等操作 |
操作 ID、订单 ID、提交 revision |
3. 资源边界:先限制排队,再限制依赖占用
3.1 按剩余时间分配关键路径预算
以下仅为普通 JSON 下单的起点。串行阶段要相加,重试还要计算退避与后续步骤;每个下游超时不得大于父 context 的剩余时间。
| 环节 |
起始预算 |
说明 |
| 网关端到端预算 |
1,500 ms |
含网关排队、转发和响应回传 |
| 应用预算 |
1,100 ms |
给网关回写保留余量 |
| 定价 HTTP |
最多 300 ms |
受父 context 剩余时间限制 |
| PostgreSQL 阶段 |
最多 350 ms |
含取连接、事务、提交 |
| SQL / 锁等待 |
200 / 80 ms |
与应用连接池等待分开观测 |
只为幂等操作做重试;重试必须满足剩余预算足够、错误类别可重试、额外请求率在上限内。网关和 SDK 明确指定一处作为主要重试方。连接中断并不代表远端没有提交,因此"重试"与"幂等重放"必须配套设计。
Go 服务端的 ReadHeaderTimeout、ReadTimeout、WriteTimeout、IdleTimeout 作用不同。尤其 WriteTimeout 限制写入,并不会自动停止 handler 内部计算。SSE、上传和 WebSocket 应采用独立的读写、心跳和退出策略,不能套用普通 JSON API 的超时。
3.2 准入闸门应位于昂贵操作之前
func limitInFlight(maxConcurrent int) gin.HandlerFunc {
if maxConcurrent <= 0 {
panic("maxConcurrent must be positive")
}
slots := make(chan struct{}, maxConcurrent)
return func(c *gin.Context) {
select {
case slots <- struct{}{}:
defer func() { <-slots }()
c.Next()
default:
c.AbortWithStatusJSON(http.StatusServiceUnavailable,
gin.H{"code": "OVERLOADED"})
}
}
}
按租户的速率配额用于公平性;按路由成本的并发闸门用于保护 CPU、内存、连接池和锁。租户配额耗尽通常返回 429,服务总体容量不足通常返回 503。二者不能混淆,也不能只监控 DB pool:连接池应是受控资源,而非系统最后一条无限队列。
稳态 1,000 RPS、平均耗时 50 ms,按 Little 定律约有 50 个在途请求。这只是一条压测起点。逐级提高并发,观察吞吐拐点、P99、闸门拒绝、DB WaitCount 窗口增量及锁等待;在尾延迟陡升前设置上限。
3.3 用发布峰值 Pod 数计算连接预算
假设 HPA 最大 12、maxSurge: 2,并在演练中发现终止中最多还有 3 个 Pod 持有连接。若数据库给该服务 180 条连接,worker 预留 27 条,则 API 预算为 153,发布峰值按 17 个 Pod 计算,SetMaxOpenConns(9) 才是可讨论的起点。
API 可用连接 = 数据库给该服务的预算 - worker / migration / 其他保留
每 Pod 上限 = floor(API 可用连接 / 发布期间同时存活 Pod 峰值)
这个公式要合并同进程的所有池、迁移工具、旁路任务与其他服务。扩容 Pod 前先重算总账,不要把连接池等待误判成"副本不够"。事务中的查询复用同一个 Tx,避免已持有连接又经 DB 等待第二条连接。
HTTP Client 也要复用 Transport,设置调用级并发控制与适当的 MaxConnsPerHost。读完小响应体有助于 HTTP/1.1 复用;异常或大响应采用有界读取。HTTP/2 的连接数与请求并发不是同一个容量指标。
4. 生命周期边界:摘流、等待传播、排空必须有明确所有者
4.1 三类探针各回答一个问题
| 探针 |
问题 |
不应承担的职责 |
| startup |
初始化是否完成 |
长期承载依赖健康判断 |
| readiness |
此副本现在是否应接收新流量 |
证明所有外部依赖永远正常 |
| liveness |
重启本进程能否恢复进展 |
把共享 DB 故障变成全量重启 |
管理端口可避免探针被业务并发闸门阻塞,但它必须仍能反映真实启动与服务状态。共享 DB 故障时,是否让 readiness 失败取决于服务是否还能提供安全的部分能力;让全部副本同时下线前,必须验证网关在无 endpoint 时的响应。
4.2 选择一种摘流时序,不要叠加两次传播等待
有两种常见方案,二选一并在预发实测。
方案 A:应用信号摘流(优先用于需要精确控制的服务)
- 进程收到 SIGTERM;
- 立刻把 readiness 置为 false;
- 保持监听,在已测得的传播窗口内接住迟到的新请求;
- 调用
Shutdown 停止接受新连接、等待在途请求;
- 超时后记录并进入强制关闭分支。
它的好处是摘流状态由应用直接表达;前提是应用有机会在 SIGTERM 到达后保留这段窗口,因此宽限期必须覆盖传播等待与排空。
方案 B:原生 preStop.sleep 延迟信号(适合已验证支持该能力的集群)
- Pod 开始终止,EndpointSlice 更新与
preStop 并发开始;
- sleep 期间应用仍可能接受迟到请求;
- sleep 结束后 kubelet 发送 SIGTERM,应用把 readiness 置为 false 并
Shutdown。
该方案的目的不是"应用先摘流",而是让路由传播先走一段时间;不能同时再把同一段传播等待加入应用排空流程。preStop、正常退出和尾部余量共用 terminationGracePeriodSeconds。对于不支持原生 sleep 的版本,可使用轻量 exec 兼容实现,但它消耗容器资源;先在目标 API server 做 server-side dry-run。
// 方案 A 中由 SIGTERM 触发;ready 由 /readyz 读取。
func gracefulStop(server *http.Server, ready *atomic.Bool,
propagation, drain time.Duration) error {
ready.Store(false)
// 用实测分位数确定,而非固定迷信 5 秒。
timer := time.NewTimer(propagation)
defer timer.Stop()
<-timer.C
ctx, cancel := context.WithTimeout(context.Background(), drain)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
return errors.Join(err, server.Close())
}
return nil
}
主进程应使用 signal.NotifyContext 等待 serve 错误或退出信号;收到信号后在主 goroutine 等待关闭完成,把 http.ErrServerClosed 当作预期退出。独立 worker 先停止领取新任务、等待已领取任务,再关闭共享连接池。WebSocket 和 hijack 连接不由 Server.Shutdown 自动处理,需自行追踪、通知与等待。
4.3 预算必须留在终止宽限期以内
不要让各阶段之和恰好等于宽限期。一个更安全的演练起点是:
| 阶段 |
起始值 |
| 路由传播等待 |
5 s |
| HTTP 排空 |
12 s |
| worker 收尾 |
5 s |
| 调度和尾延迟余量 |
13 s |
terminationGracePeriodSeconds |
40 s |
记录 termination_started、readiness_disabled、last_request_dispatched_to_old_pod、listener_shutdown_started、shutdown_finished、forced_close。按节点、可用区、协议和网关实例计算分布,再定最终值。不要从"没有报错的一次发布"推导传播窗口。
5. 业务边界:幂等键解决重放,状态机解决竞争
5.1 显式建模幂等操作的处理中状态
CREATE TYPE idempotency_status AS ENUM ('PROCESSING', 'SUCCEEDED', 'FAILED');
CREATE TABLE order_idempotency (
tenant_id bigint NOT NULL,
actor_id bigint NOT NULL,
operation text NOT NULL,
idem_key varchar(128) NOT NULL CHECK (length(idem_key) > 0),
body_hash bytea NOT NULL CHECK (octet_length(body_hash) = 32),
status idempotency_status NOT NULL,
http_status integer,
result_json jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, actor_id, operation, idem_key),
CHECK (
(status = 'PROCESSING' AND http_status IS NULL AND result_json IS NULL) OR
(status IN ('SUCCEEDED', 'FAILED') AND http_status IS NOT NULL)
)
);
请求哈希必须来自可规范化的业务字段:定义字段集合、规范化版本、数字表示与数组顺序。相同 (tenant, actor, operation, key):
| 情况 |
契约 |
| 同键、同哈希、已完成 |
重放原 HTTP 状态与业务结果 |
| 同键、不同哈希 |
409 IDEMPOTENCY_KEY_REUSED |
| 同键、同哈希、处理中 |
在剩余预算内等待/短轮询;到期返回 409 IDEMPOTENCY_IN_PROGRESS 与短 Retry-After,客户端使用同一键重试或查询 |
| 首次事务回滚 |
占位、订单、结果与 Outbox 一同回滚 |
| 已提交但响应丢失 |
同键重试返回第一次已经确定的结果 |
在 PostgreSQL READ COMMITTED 下,流程应避免把单个 ON CONFLICT DO NOTHING 的空 RETURNING 错当成"不存在":
INSERT ... ON CONFLICT DO NOTHING RETURNING 尝试创建 PROCESSING 占位;
- 插入者在同一事务内创建订单、写 Outbox、将记录更新为最终结果后提交;
- 未插入者用下一条 SQL 语句按主键读取记录并核验 hash;在当前请求剩余预算内处理
PROCESSING;
- 清理任务只删除超过重放承诺窗口的终态记录,不能删除仍在处理或待对账的记录。
保留期限至少覆盖 SDK 重试、异步回调与人工补偿窗口。缓存可以加速,但唯一约束是最终裁决者。对于外部支付等副作用,把稳定业务操作 ID 作为提供方幂等键,并保留查询/对账路径;本地事务无法替外部系统提供 exactly-once。
5.2 状态转换用条件更新表达竞争
UPDATE orders
SET status = 'CANCELLED', updated_at = now()
WHERE tenant_id = $1 AND id = $2 AND status = 'CREATED'
RETURNING id;
影响零行后重读状态,区分已取消、状态冲突和不存在。用户取消、延迟关单、消息消费都复用同一个应用用例和领域状态机;幂等取消可返回相同成功结果,真正的非法转换再返回冲突。租户和主体来自可信认证上下文,所有查询都执行资源级访问控制。
5.3 Outbox/Inbox 只承诺可恢复的至少一次交付
| 故障窗口 |
工程处理 |
| 订单写入后进程崩溃 |
订单和 Outbox 同事务,relay 恢复后继续发送 |
| Kafka 确认后 relay 未标记发送 |
允许重发,event_id 稳定不变 |
| 消费写库成功、offset 未确认 |
(consumer, event_id) 唯一约束与业务写同事务 |
| 分区并行出现 offset 缺口 |
顺序消费,或只提交连续完成的最高 offset |
| 反序列化/永久业务错误 |
写入可审计隔离流,定义人工修复和重放入口 |
消息顺序取决于分区键与消费模型。即使 Outbox 可靠,多个 relay 并发、同订单事件乱序和跨系统副作用仍需按业务规则处理。
5.4 依赖方向与配置所有权
cmd/order-api/ 启动、依赖装配、退出编排
internal/transport/http/ Gin 路由、认证、DTO、错误映射
internal/application/ 下单、关单、事务与幂等编排
internal/domain/ 订单状态转换与领域规则
internal/infra/ PostgreSQL、Kafka、外部 HTTP 适配
deploy/ 镜像定义、Kustomize 与 Argo CD 配置
由应用用例层定义其消费的存储接口,基础设施实现接口,由入口显式注入。这样 HTTP、Kafka 消费与定时任务可复用同一业务语义,而不会把 Gin 或数据库细节传入领域层。
生产配置按变更方式划分:监听地址、连接池上限和资源上限属于部署配置,在启动期做完整校验;低风险业务开关可走动态配置;密钥只从专用密钥通道注入。热更新必须先解析和验证完整新配置,再原子替换不可变快照,失败时继续使用旧版本。若 Kubernetes Service 与 Nacos 等系统同时参与服务发现,必须为每条调用链指定唯一选址权,并分别验证两个数据面的摘流。HTTP 传播 W3C Trace Context,Kafka 在消息头传播关联信息;日志保留可检索的 order_id、event_id 与发布 revision。
6. 部署边界:一次构建,以同一 digest 晋级
CI 至少运行格式检查、go vet、go test -race、依赖与镜像扫描。Race detector 只覆盖真实执行到的路径,因此并发重放、取消和关闭逻辑必须在测试中被触发。生产晋级以注册表返回的 digest 为输入,而不是可变 tag;构建镜像和运行镜像也应在 CI 参数中固定到 digest。
ARG GO_IMAGE
ARG RUNTIME_IMAGE
FROM ${GO_IMAGE} AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -o /out/order-api ./cmd/order-api
FROM ${RUNTIME_IMAGE}
COPY --from=build /out/order-api /order-api
USER 65532:65532
ENTRYPOINT ["/order-api"]
漏洞例外需有影响范围、所有者和失效时间。环境仓库的生产 PR 受分支保护;CI 凭证仅能构建、推送与提交发布 PR,不能直接修改生产集群。
6.1 基线清单:安全、调度与终止一起验证
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-api
spec:
replicas: 12
minReadySeconds: 5
progressDeadlineSeconds: 180
strategy:
type: RollingUpdate
rollingUpdate: {maxSurge: 2, maxUnavailable: 0}
selector:
matchLabels: {app: order-api}
template:
metadata:
labels: {app: order-api}
spec:
serviceAccountName: order-api
automountServiceAccountToken: false
terminationGracePeriodSeconds: 40
securityContext:
runAsNonRoot: true
runAsUser: 65532
seccompProfile: {type: RuntimeDefault}
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector: {matchLabels: {app: order-api}}
containers:
- name: api
image: registry.example.com/orders/order-api@sha256:REPLACE_WITH_REAL_DIGEST
imagePullPolicy: IfNotPresent
ports:
- {name: http, containerPort: 8080}
- {name: admin, containerPort: 9090}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: ["ALL"]}
resources:
requests: {cpu: "500m", memory: 256Mi}
limits: {cpu: "1", memory: 512Mi}
startupProbe:
httpGet: {path: /startupz, port: admin}
periodSeconds: 2
failureThreshold: 30
readinessProbe:
httpGet: {path: /readyz, port: admin}
periodSeconds: 2
failureThreshold: 2
livenessProbe:
httpGet: {path: /livez, port: admin}
periodSeconds: 10
failureThreshold: 3
若使用方案 B,再添加经目标集群验证的 preStop.sleep 或兼容 hook,并从应用侧传播等待中移除重复等待。若使用方案 A,不要为了"更保险"再叠加同样时长的 preStop。
Kustomize 只维护一个可复用 base;生产 overlay 只固定命名空间与已验证 digest,避免复制整份 Deployment 后逐渐漂移。
# deploy/k8s/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources: [deployment.yaml, service.yaml]
---
# deploy/k8s/base/service.yaml
apiVersion: v1
kind: Service
metadata:
name: order-api
spec:
selector: {app: order-api}
ports:
- {name: http, port: 80, targetPort: http}
---
# deploy/k8s/overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: orders
resources: [../../base]
images:
- name: registry.example.com/orders/order-api
digest: sha256:REPLACE_WITH_64_HEX_DIGEST
在合并前渲染 overlay,校验每一个生产容器镜像都具有真实 digest,再执行目标环境的服务端校验:
kubectl kustomize deploy/k8s/overlays/prod > /tmp/order-api-rendered.yaml
kubectl apply --dry-run=server -f /tmp/order-api-rendered.yaml
还需要按平台能力补齐:仅允许网关、DNS、监控与必要依赖流量的 NetworkPolicy;最小化 RBAC;环境适配的 PDB;节点/可用区分散策略;资源配额与实际调度余量。PDB 主要约束 Eviction API 的自愿中断,不替代 Deployment 的滚动更新策略。
6.2 GitOps 管期望状态,不替代发布判定
Argo CD 的 Synced 表示目标资源已按其语义收敛,不表示网关已停止旧路由、长连接已处理、订单重放正确。生产回滚应:先确认问题 revision;灰度控制器存在时先暂停或中止放量;提交恢复到已验证镜像与兼容配置组合的 Git 变更;再同时观察 Argo revision、ReplicaSet、Pod imageID、网关错误曲线和订单成功率。
# 由平台管理员预置在 argocd 命名空间;orders 命名空间须预先创建。
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: orders-prod
namespace: argocd
spec:
sourceRepos: [https://git.example.com/platform/orders-env.git]
destinations:
- {server: https://kubernetes.default.svc, namespace: orders}
clusterResourceWhitelist: []
namespaceResourceWhitelist:
- {group: apps, kind: Deployment}
- {group: "", kind: Service}
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: order-api-prod
namespace: argocd
spec:
project: orders-prod
source:
repoURL: https://git.example.com/platform/orders-env.git
targetRevision: main
path: deploy/k8s/overlays/prod
destination:
server: https://kubernetes.default.svc
namespace: orders
syncPolicy:
automated: {selfHeal: true, prune: false}
retry:
limit: 3
backoff: {duration: 5s, factor: 2, maxDuration: 30s}
生产示例先关闭自动 prune,删除资源通过独立变更审查。开启 selfHeal 会纠正受管理字段的漂移,因此变更授权应由 RBAC、分支保护与准入策略共同承担。若接入 HPA,通常应停止让 Deployment 清单管理 replicas,并在迁移前验证字段所有权与 Argo CD 差异策略;若保留副本数字段,则审查精确的 ignoreDifferences 规则及 RespectIgnoreDifferences=true 的初次创建行为。
若需要百分比流量灰度,使用 Argo Rollouts 或与网关集成的灰度控制器;仅靠副本数只能近似权重,连接复用会让真实分流进一步偏离。每个阶段按版本与路由观察请求量、连接错误率、业务 5xx、P99 和订单成功率;样本不足或指标源不可用时暂停晋级。
7. 验收:相同请求集先验证时间关系,再进集群取证
7.1 最小模型只证明时序,不冒充真实网络
def replay(close_ms):
switch_ms = 200
shutdown_budget_ms = 120
work_ms = 80
stats = {"ok": 0, "stale_route": 0, "forced_close": 0}
for sent_ms in range(-100, 500, 10):
if sent_ms >= switch_ms:
stats["ok"] += 1
elif sent_ms >= close_ms:
stats["stale_route"] += 1
elif sent_ms + work_ms > close_ms + shutdown_budget_ms:
stats["forced_close"] += 1
else:
stats["ok"] += 1
assert sum(stats.values()) == 60
return stats
assert replay(0) == {"ok": 40, "stale_route": 20, "forced_close": 0}
assert replay(300) == {"ok": 60, "stale_route": 0, "forced_close": 0}
它说明监听过早关闭会让路由尚未完成切换的请求落到不可接受连接;并不模拟 TCP reset、HTTP/2、sidecar、重试和真实代理连接池。这些必须通过预发演练验证。
7.2 三轮预发演练及门禁
| 轮次 |
场景 |
关键观察 |
通过条件 |
| A 基线 |
同一数据集、同一到达率,对照立即关闭与候选摘流策略;关闭客户端/网关重试 |
首尝试连接错误、5xx、P99、旧/新 Pod 分组 |
候选策略无新请求派发到已关闭监听;无非预期 reset |
| B 容量与强制关闭 |
打开正常重试,注入 DB 池等待和锁等待;设置宽限期不足对照 |
尝试/原始请求比、闸门拒绝、池等待、forced_close |
正常预算下无超期强制关闭;重试放大有上限 |
| C 一致性与回滚 |
同键并发、同键异参、提交后丢响应;发布兼容旧版本的恢复 PR |
订单数、事件数、幂等结果、Git revision、imageID |
一次业务操作仅一笔订单与一条预期事件;恢复曲线达标 |
每轮固定观察窗口,记录计划发送、实际发送、压测端丢弃、首尝试失败、最终失败、重试次数和请求 ID。低频故障多轮重复;网关与采集端时钟统一。将"没有进入应用的失败"与"应用取消的请求"分开归类。
kubectl -n orders get endpointslice -l kubernetes.io/service-name=order-api -w -o yaml
kubectl -n orders get pods -l app=order-api -w -o wide
kubectl -n orders rollout status deployment/order-api --timeout=180s
kubectl -n orders logs <old-pod> -c api --timestamps
argocd app diff order-api-prod
argocd app history order-api-prod
旧 Pod 删除前必须由集中日志系统保留其终止日志;kubectl logs --previous 通常只对应同一 Pod 内上一次容器实例,不能替代已删除旧 Pod 的日志取证。
8. 合并生产 PR 前检查表
- 业务层仅依赖标准 context;关键 I/O 可响应取消;HTTP 响应只有一条提交路径。
- 网关、应用、下游、SQL/锁等待、重试和连接池在同一份剩余预算模型中。
- 闸门置于昂贵操作前;应用并发、DB 连接和发布峰值 Pod 数在同一容量账本。
- 已选择并实测一种摘流时序,未重复计算传播等待;终止宽限期有显式余量。
- HTTP/1.1 keep-alive、实际 HTTP/2/mesh 路径、WebSocket(若有)都完成发布演练。
- 同键重放、同键异参、处理中冲突、提交后响应丢失和重复投递都有可断言业务不变量。
- 预发与生产使用同一应用 digest;构建/运行基础镜像也固定;SBOM 与来源证明可查。
- 生产清单通过渲染、镜像 digest 校验、server-side dry-run 和策略检查。
- 回滚能从 Git revision 找到实际制品,并从用户侧错误率、P99 和订单成功率确认恢复。
一次滚动发布真正完成,必须同时满足:控制器替换了副本、数据面停止向旧实例派发新请求、已提交但响应丢失的订单能被同一操作身份稳定重放。把这三条证据置于一条统一时间线,发布成功才是用户真正感受到的成功。
适用范围:本文假定普通 HTTP JSON 服务、PostgreSQL 事务和 Kubernetes Deployment。preStop.sleep、EndpointSlice 条件、网关连接排空、Argo CD 差异策略均受集群及组件能力影响;发布前请以目标环境的官方文档、渲染结果和预发演练为最终依据。
如果你想进一步交流 Kubernetes 摘流、幂等设计或 GitOps 发布实践,欢迎到云栈社区与开发者们一起讨论。