Java 基础体系 · 第 35/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java 生产交付:容器、JVM 参数、探针、灰度、容量和回滚

生产交付不是把 JAR 文件复制到服务器上,而是把一个可验证、可观测、可停止、可扩容、可回退的运行单元交给调度系统。Java 服务的交付链路至少包含:

  1. 容器:确定进程、文件系统、网络、用户和资源边界;
  2. JVM 参数:把容器资源转换为堆、线程、元空间、直接内存和垃圾回收策略;
  3. 探针:让平台区分“进程活着”“应用已启动”“可以接收流量”;
  4. 灰度:在有限流量和有限风险下验证新版本;
  5. 容量:证明副本数和资源配额足以承受目标负载;
  6. 回滚:在代码、配置、数据库和流量都发生变化时恢复服务。

这些部分不是独立配置项。例如,错误的 Xmx 会导致容器被 OOMKilled;错误的存活探针会在 GC 高峰时反复重启实例;没有容量模型的灰度只能说明“少量流量下没出错”,不能说明正式发布安全。


一、先定义生产交付的边界

一个 Java 服务在容器中通常包含以下层次:

客户端
  │
  ▼
负载均衡 / Ingress / 服务网格
  │
  ▼
Pod
  ├── Java 进程
  │    ├── Java 堆
  │    ├── Java 线程栈
  │    ├── 元空间
  │    ├── Code Cache
  │    ├── Direct Buffer
  │    └── JVM / libc / 类库本地内存
  ├── 日志输出 stdout/stderr
  └── cgroup 资源限制
        ├── CPU limit / quota
        └── memory limit

这里的“容器内存”不是“Java 堆”。容器的内存限制约束的是整个进程组可使用的内存,堆只是其中一部分。粗略地说:

McontainerMheap+Mmetaspace+Mthread stacks+Mdirect+Mcode cache+Mnative+MoverheadM_{\text{container}} \geq M_{\text{heap}} + M_{\text{metaspace}} + M_{\text{thread stacks}} + M_{\text{direct}} + M_{\text{code cache}} + M_{\text{native}} + M_{\text{overhead}}

其中:

  • heap 是对象和大多数数组所在区域;
  • thread stacks 是 Java 线程和本地线程栈;
  • metaspace 存放类元数据;
  • direct 常见于 NIO、Netty、TLS 和文件映射;
  • code cache 存放 JIT 编译后的机器码;
  • native 包含 JVM 内部结构、压缩解压缓冲区、JNI 库和 libc 分配;
  • overhead 包含安全余量、突发分配和运行时波动。

因此,“容器限制 2 GiB,-Xmx2g”通常不是高利用率配置,而是把堆和所有非堆内存相加后必然逼近或超过上限的配置。


二、容器:把 Java 进程放入可控的运行环境

2.1 镜像、进程和用户

一个适合生产的 Dockerfile 应该满足:

  • 运行时镜像只包含运行所需文件;
  • 使用固定版本的 JDK/JRE 发行版,而不是浮动的 latest
  • 应用以非 root 用户运行;
  • 入口使用 exec 形式,使 Java 成为容器的直接子进程;
  • 日志输出到标准输出和标准错误,由平台负责采集。

示例:

FROM eclipse-temurin:25-jre

WORKDIR /app

RUN useradd --system --uid 10001 appuser \
    && chown -R appuser:appuser /app

COPY --chown=appuser:appuser build/libs/order-service.jar /app/app.jar

USER 10001:10001

ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=65 -XX:+ExitOnOutOfMemoryError"

ENTRYPOINT ["java", "-jar", "/app/app.jar"]

ENTRYPOINT 使用 JSON 数组形式,Java 会直接成为 PID 1。相反,下面的形式会额外启动 shell:

ENTRYPOINT java -jar /app/app.jar

shell 是否正确转发 SIGTERM 取决于 shell 和脚本实现。Kubernetes 删除 Pod 时通常先发送 SIGTERM,如果进程没有在终止宽限期内退出,再发送 SIGKILL。Java 进程收不到或没有正确处理 SIGTERM,就可能在连接尚未排空时被强制终止。

USER 不是装饰性配置。应用被攻破后,root 权限会扩大攻击面;不过非 root 不能替代应用层安全,仍然需要限制网络、文件权限、依赖漏洞和反序列化入口。

2.2 cgroup 与 JVM 的关系

现代 Linux 容器一般通过 cgroup 限制 CPU 和内存,通过 namespace 隔离进程、网络和挂载点。JVM 会读取容器可见的资源,而不是简单读取宿主机总内存;Java 25 的容器感知行为仍应以实际 JDK 发行版和启动日志为准。

可以在容器中检查:

java -XshowSettings:vm -version

输出中常能看到 JVM 识别到的最大堆等信息。诊断时还可以查看 cgroup v2:

cat /sys/fs/cgroup/memory.max
cat /sys/fs/cgroup/memory.current
cat /sys/fs/cgroup/cpu.max

典型结果:

2147483648
1536000000
100000 200000

其含义是:

  • memory.max2147483648,即 2 GiB;
  • 当前内存使用约 1.43 GiB;
  • cpu.max100000 200000 表示每 200000 微秒最多运行 100000 微秒,即约 0.5 个 CPU 配额。

CPU limit 不是“保证获得的 CPU”。在 Kubernetes 中,requests 主要用于调度和相对权重,limits 可能形成 CFS quota。CPU 被限流时,Java 线程可能不是阻塞在锁上,而是在 runnable 状态却拿不到时间片;这会导致请求延迟升高、GC 并发线程推进变慢、心跳超时。

可以用以下命令观察容器是否被限流:

cat /sys/fs/cgroup/cpu.stat

如果 nr_throttledthrottled_usec 持续增长,说明 CPU 配额正在限制进程。此时单纯增加线程数通常会制造更多上下文切换,并不能恢复吞吐。

2.3 不要依赖可写根文件系统

Java 应用常需要临时文件,例如:

  • TLS 或压缩库的临时数据;
  • 类库生成的临时文件;
  • heap dump;
  • JFR 或诊断输出。

可以把临时目录显式挂载到可写的临时卷,并让根文件系统只读:

volumeMounts:
  - name: tmp
    mountPath: /tmp
  - name: dumps
    mountPath: /dumps
volumes:
  - name: tmp
    emptyDir: {}
  - name: dumps
    emptyDir:
      sizeLimit: 2Gi
securityContext:
  readOnlyRootFilesystem: true

风险在于:emptyDir 通常属于 Pod 生命周期,Pod 被删除后 dump 也会消失。需要长期保留的 dump 应上传到受控对象存储,而不是假设本地文件永久存在。


三、JVM 参数:先建立内存模型,再选择参数

3.1 堆、栈、元空间和本地内存

Java 堆保存对象。-Xms 是初始堆大小,-Xmx 是最大堆大小。-Xms 不等于“应用一启动就一定提交全部物理内存”,不同 GC 和平台的提交行为有所区别,但把 Xms 设得很大仍可能提高启动期内存压力。

Java 线程栈由 -Xss 影响。线程数为 TT,每线程栈预留或提交规模近似为 SS,仅线程栈就可能消耗:

MstackT×SM_{\text{stack}} \approx T \times S

例如:

  • 500 个线程;
  • -Xss1m

仅按规模估算就是约 500 MiB 的地址空间或相关栈开销。实际物理使用取决于栈页是否触碰,但盲目增大 -Xss 会降低容器可容纳的线程数。StackOverflowError 不是简单地通过无限增大 -Xss 解决;递归算法本身可能才是根因。

元空间保存类元数据,受 -XX:MaxMetaspaceSize 约束。动态代理、脚本引擎、类加载器泄漏和热部署异常都可能使元空间持续增长。设置上限可以保护容器,但达到上限后通常表现为 OutOfMemoryError: Metaspace,不是优雅降级。

直接内存常用于 ByteBuffer.allocateDirect 和网络框架。可以设置:

-XX:MaxDirectMemorySize=256m

它限制的是 JVM 认可的直接缓冲区容量,不应被误认为能覆盖所有 native 内存。某些 JNI 库、内存映射文件和 libc 分配不一定受这个参数控制。

3.2 一个可计算的内存配置

假设容器限制为 2 GiB,服务的内存预算如下:

区域 预算
Java 堆 1200 MiB
线程栈 256 MiB
元空间 160 MiB
直接内存 256 MiB
Code Cache 和 JVM native 160 MiB
安全余量 16 MiB

总和为:

1200+256+160+256+160+16=2048 MiB1200 + 256 + 160 + 256 + 160 + 16 = 2048\text{ MiB}

这只是数学上刚好填满,生产上不可取。若把容器限制提高到 2.5 GiB,保留约 20% 余量:

Mheap=2560256160256200256=1432 MiBM_{\text{heap}} = 2560 - 256 - 160 - 256 - 200 - 256 = 1432\text{ MiB}

因此可以先选择:

-Xms1g
-Xmx1400m
-Xss512k
-XX:MaxMetaspaceSize=160m
-XX:MaxDirectMemorySize=256m

这里的 -Xss512k 只是示例,是否足够要通过压测和线程栈深度验证;线程多不代表一定能安全降低栈大小。使用过小的栈可能导致 StackOverflowError 或本地调用失败。

比起固定写死 -Xmx,也可以使用容器比例参数:

-XX:MaxRAMPercentage=60
-XX:InitialRAMPercentage=20

比例参数以 JVM 识别到的容器内存为基础,适合多个环境共享同一启动模板;但它只决定堆,不能自动替线程栈、直接内存和元空间建立可靠预算。对于内存限制固定、故障代价高的生产服务,显式 Xmx 通常更容易审计。

JAVA_TOOL_OPTIONS 会被 JVM 读取,适合平台注入通用参数,但必须防止低信任输入控制它。JDK_JAVA_OPTIONS 也会影响 Java 启动,不能把来自请求、配置中心用户输入或不可信租户的字符串直接拼入这些变量。

3.3 GC:G1 和 ZGC 的选择边界

Java 25 中,G1 是常用的通用收集器。G1 将堆划分为 Region,维护跨 Region 的引用信息,在并发标记后选择存活率较低的 Region 进行混合回收。其目标是在吞吐和暂停时间之间取得平衡。

ZGC 侧重低暂停时间,使用并发标记、并发重定位等机制,把更多工作放到与应用线程并发的阶段。低暂停不等于无限吞吐,也不等于没有 Stop-The-World 阶段;根扫描、线程同步、安全点等阶段仍可能造成暂停。

选择应从约束开始:

  • 如果主要目标是稳定吞吐,且暂停目标并不极端,先用 G1;
  • 如果堆较大或尾延迟对业务极其敏感,才评估 ZGC;
  • 如果 CPU 配额紧张,ZGC 的并发工作会与应用竞争 CPU;
  • 如果对象分配速率过高,任何收集器都可能因为回收跟不上而失败。

G1 示例:

-Xms1400m
-Xmx1400m
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=5,filesize=20M

MaxGCPauseMillis 是软目标,不是 SLA 保证。把它设置得很小,可能导致 G1 更积极地工作、降低吞吐,甚至无法满足目标。

ZGC 示例:

-Xms1400m
-Xmx1400m
-XX:+UseZGC
-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime,level,tags

Java 25 的具体 ZGC 模式和默认行为应以该版本官方文档及启动日志为准,不应复制旧版本中关于“是否分代”的固定结论。生产上线前必须用相同 JDK、相同容器 CPU/内存限制和代表性流量比较:

  • p50、p95、p99 延迟;
  • GC 暂停和并发周期;
  • CPU 使用与限流;
  • 分配速率;
  • Full GC 或 allocation stall;
  • RSS 与 cgroup OOM 状态。

诊断命令示例:

jcmd <pid> VM.flags
jcmd <pid> GC.heap_info
jcmd <pid> GC.class_histogram
jcmd <pid> Thread.print

这些命令需要目标 JVM 允许 attach,且容器内通常要安装 JDK 工具。GC.class_histogram 可能造成明显停顿,不能在高峰期无控制地反复执行。


四、探针:启动、就绪和存活是三个不同问题

4.1 三种状态

**启动状态(startup)**回答:“应用是否还在初始化?”

初始化可能包含:

  • Spring 容器创建;
  • 类加载和 JIT 预热;
  • 数据库连接池建立;
  • 配置拉取;
  • 缓存预热;
  • 密钥和证书加载。

**就绪状态(readiness)**回答:“此实例现在是否可以接收流量?”

如果数据库是该请求路径的硬依赖,数据库不可用时就绪检查可以失败;如果数据库只是异步上报依赖,则不应因为上报失败把整个业务实例摘除。

**存活状态(liveness)**回答:“进程是否陷入无法自行恢复的状态?”

存活检查不应简单等价于“数据库连接成功”。数据库短暂故障时,所有实例同时因 liveness 失败而重启,可能把外部故障升级为重启风暴。

状态流转可以表示为:

stateDiagram-v2
    [*] --> Starting
    Starting --> Ready: startup probe 成功
    Starting --> Failed: startup 超时
    Ready --> NotReady: 依赖异常/主动摘流
    NotReady --> Ready: 依赖恢复且通过检查
    Ready --> Unhealthy: 进程死锁或无法处理请求
    NotReady --> Unhealthy: 超过恢复阈值
    Unhealthy --> [*]: liveness 失败后重启

关键路径是:启动阶段先由 startup probe 保护;startup 成功后,readiness 决定是否接流量;liveness 只负责发现不可自愈状态。Kubernetes 在 startup probe 成功前通常不会执行 liveness 和 readiness 的判断。

4.2 HTTP 探针的实现原则

探针接口应轻量、无副作用,并设置超时。不要在 /health 中执行全表扫描、复杂 RPC 或触发缓存重建。

一个可用的返回约定:

GET /internal/health/live
HTTP/1.1 200 OK

{"status":"UP"}
GET /internal/health/ready
HTTP/1.1 503 Service Unavailable

{"status":"DOWN","reason":"database pool exhausted"}

readiness 的失败不是应用崩溃,而是告诉流量层暂时不要发送新请求。响应体可以包含原因,但不应泄露数据库地址、密码、内部拓扑或堆栈。

如果使用 Spring Boot Actuator,生产上应通过独立管理端口或网络策略保护 actuator 端点,不能把 /actuator/env、线程 dump、配置刷新接口直接暴露到公网。若自己实现接口,必须确保探针不绕过认证边界后泄露诊断信息;一种常见做法是只在集群内部开放极少数固定路径,并用 NetworkPolicy 限制来源。

Kubernetes 配置示例:

containers:
  - name: order-service
    image: registry.example.com/order-service:2025.09.1
    ports:
      - name: http
        containerPort: 8080
    startupProbe:
      httpGet:
        path: /internal/health/startup
        port: http
      periodSeconds: 5
      timeoutSeconds: 2
      failureThreshold: 60
    readinessProbe:
      httpGet:
        path: /internal/health/ready
        port: http
      periodSeconds: 5
      timeoutSeconds: 2
      failureThreshold: 3
    livenessProbe:
      httpGet:
        path: /internal/health/live
        port: http
      periodSeconds: 10
      timeoutSeconds: 2
      failureThreshold: 3
    lifecycle:
      preStop:
        exec:
          command: ["/bin/sh", "-c", "sleep 10"]

startupProbe 最长允许约 60×5=30060 \times 5=300 秒启动。preStopsleep 不是优雅停机本身,它只是为负载均衡摘流和端点传播留出时间。应用仍必须处理 SIGTERM,停止接收新任务、等待在途请求结束,并在终止宽限期内退出。

如果 Java 应用使用 Spring,关闭阶段要确认:

  • Web 服务器停止接受新连接;
  • 连接池不再分配新连接;
  • 消费者停止拉取消息;
  • 已接收消息完成或进入可重试队列;
  • 事务正确提交或回滚;
  • 最终在 terminationGracePeriodSeconds 内退出。

五、可观测性:探针只能判断状态,不能解释原因

生产交付至少需要日志、指标和 Trace 三类信号。

5.1 日志

日志应是结构化事件,例如:

{
  "time":"2025-09-01T12:00:00.123Z",
  "level":"WARN",
  "service":"order-service",
  "version":"2025.09.1",
  "trace_id":"4bf92f3577b34da6a3ce929d0e0e4736",
  "order_id":"masked",
  "event":"db_timeout",
  "duration_ms":1200
}

不要记录密码、完整 Token、TLS 私钥、身份证号或未经脱敏的支付信息。日志输出到 stdout 便于容器采集;若写文件,必须明确轮转、磁盘上限和丢失策略,否则日志本身会触发磁盘满。

5.2 指标

Micrometer 中常见指标类型包括:

  • Counter:累计请求数、错误数;
  • Gauge:当前队列长度、连接池使用数;
  • Timer:请求耗时分布;
  • Distribution Summary:响应大小等分布。

不要把用户 ID、订单 ID、完整 URL 作为高基数 label。高基数会造成时间序列数量膨胀,监控系统本身可能成为故障源。

部署可观测性代理时,OpenTelemetry Collector 可以接收 OTLP,将 Metrics、Logs、Traces 转发到后端。Trace 的价值在于把一次请求在网关、服务、数据库和消息系统之间串起来;但采样、脱敏、上下文传播和异步线程切换必须统一,否则会出现“有 trace_id 但没有完整链路”。

5.3 SLO 与发布判断

SLO 是服务目标,例如:

可用性=成功请求数总请求数\text{可用性} = \frac{\text{成功请求数}}{\text{总请求数}}

若 30 天 SLO 为 99.9%,错误预算约为:

199.9%=0.1%1 - 99.9\% = 0.1\%

发布不能只看平均延迟。更有意义的发布门禁通常包括:

  • 错误率没有显著上升;
  • p95/p99 延迟未越过业务阈值;
  • readiness 失败率稳定;
  • GC 暂停、CPU throttling、OOM、线程池队列没有异常;
  • 关键业务指标没有出现版本相关下降。

六、灰度:不是“部署两个版本”,而是控制流量和状态

灰度发布是让新版本只接收一部分可控流量,并用指标决定继续、暂停或回滚。至少涉及三个对象:

  1. 版本状态:旧版本、灰度版本、全量版本;
  2. 流量状态:0%、小比例、逐步扩大、全量;
  3. 数据状态:数据库 schema、缓存格式、消息格式是否兼容。

一种基本流程:

sequenceDiagram
    participant CI as CI/CD
    participant Reg as 镜像仓库
    participant Sched as 调度平台
    participant LB as 流量层
    participant Obs as 可观测平台
    participant DB as 数据库

    CI->>Reg: 推送不可变镜像 digest
    CI->>Sched: 创建新版本灰度副本
    Sched->>LB: 灰度实例先保持摘流
    Sched->>Obs: 等待 startup/readiness
    LB->>Sched: 发送小比例流量
    Obs-->>CI: 返回错误率/延迟/资源指标
    CI->>LB: 扩大或停止灰度流量
    CI->>DB: 执行向后兼容 schema 变更

灰度实例必须使用不可变镜像,例如:

registry.example.com/order-service@sha256:...

只使用 order-service:latest 会导致“同一个标签实际内容变化”,回滚时无法证明拿回的到底是哪一个构建产物。

6.1 灰度流量的三种常见方式

按随机比例分流适用于无状态请求。缺点是同一用户可能在不同版本之间跳转,暴露出会话、缓存和幂等问题。

按用户或租户分流适合验证完整业务链路。需要稳定哈希,例如:

bucket=hash(tenantId)mod100\text{bucket} = \operatorname{hash}(\text{tenantId}) \bmod 100

bucket < 5 时进入 5% 灰度。哈希键必须稳定且存在;如果退化为随机请求,每次请求可能落到不同版本。

按请求头或测试账号分流适合内部验证,但容易把测试结果误当生产结果。测试账号的流量、数据规模和调用链通常与真实用户不同。

6.2 数据库兼容性决定回滚上限

应用回滚不等于数据库回滚。安全的 schema 变更常采用 expand-contract:

  1. Expand:新增可选列、表或索引,旧版本仍能运行;
  2. 部署能同时读写新旧结构的应用;
  3. 完成数据回填并验证;
  4. Contract:在确认旧版本不再运行后,再删除旧列或旧结构。

反例是直接删除旧列后发布新代码。新代码失败时回滚到旧二进制,但旧代码仍访问已删除的列,回滚反而无法恢复。

消息队列也有同样约束。生产期间可能同时存在旧消费者和新消费者,因此消息格式必须向后兼容,新增字段应允许旧消费者忽略,不能无条件改变字段语义或序列化协议。Java 原生序列化还会带来反序列化攻击和类版本耦合,跨服务消息更适合使用有明确 schema 和校验机制的协议,并限制类型解析范围。

6.3 灰度失败的诊断顺序

如果新版本 readiness 失败:

  1. 检查启动日志和配置是否加载成功;
  2. 检查探针路径、端口和超时;
  3. 检查 DNS、TLS、数据库连接和凭据;
  4. 检查资源限制、CPU throttling 和内存;
  5. 检查依赖服务是否只允许旧版本身份;
  6. 检查是否因为启动时同步预热导致超时。

如果新版本“探针正常但业务变慢”:

  1. 看请求分位延迟而非平均值;
  2. 对比新旧版本的 GC、分配速率、线程池队列;
  3. 检查数据库连接池是否耗尽;
  4. 检查 Trace 中慢点移动到了数据库、外部 HTTP 或锁;
  5. 查看是否只有灰度租户触发了新的数据路径。

七、容量:从并发、延迟和资源反推副本数

7.1 Little 定律

对稳定系统,Little 定律为:

L=λWL = \lambda W

其中:

  • LL:系统内平均并发请求数;
  • λ\lambda:吞吐率,单位为请求/秒;
  • WW:平均响应时间,单位为秒。

例如,目标吞吐为 600 请求/秒,平均响应时间为 0.2 秒,则平均并发约为:

L=600×0.2=120L = 600 \times 0.2 = 120

但这不等于服务只能配置 120 个线程。线程数还受阻塞比例、CPU 核数、下游连接池和队列策略影响。若请求中 80% 时间在等待数据库,增加少量并发可能提高吞吐;若请求主要消耗 CPU,线程数超过可运行 CPU 数后通常只会增加调度开销。

7.2 用实测单 Pod 容量计算副本

假设压测得到单个 Pod 在 SLO 约束下的安全容量为 75 请求/秒,目标峰值为 600 请求/秒,安全系数取 1.3:

N=600×1.375=10.4=11N = \left\lceil \frac{600 \times 1.3}{75} \right\rceil = \lceil 10.4 \rceil = 11

因此至少需要 11 个有效副本,而不是简单用 600 / 75 = 8。安全系数用于覆盖流量波动、发布期间容量损失和下游抖动,但不能替代压测。

如果一次滚动发布要求旧版本和新版本同时存在,集群在发布瞬间可能需要:

Npeak=Nold+NnewN_{\text{peak}} = N_{\text{old}} + N_{\text{new}}

若两边都是 11 个副本,集群要能承受约 22 个 Pod 的资源需求。没有这部分容量,发布时就可能出现 Pending Pod、流量集中到旧实例或新实例过载。

7.3 资源 request 与 limit

一个 Pod 的资源配置示例:

resources:
  requests:
    cpu: "1000m"
    memory: "2Gi"
  limits:
    cpu: "2000m"
    memory: "2560Mi"

memory limit 是硬边界,超过后可能被内核 OOM killer 终止;这不是 Java 一定能捕获的 OutOfMemoryError。因此应用出现 OOMKilled 时,不能只看 Java 堆 dump,还要查看容器事件和 cgroup 使用量。

cpu request 过低会让调度器把多个高负载 Pod 放到同一节点;cpu limit 过低则可能产生 throttling。对于延迟敏感服务,必须通过压测验证 limit,而不是只根据“平均 CPU 很低”设置上限,因为 GC、JIT、TLS 握手和突发请求会产生短时峰值。

容量测试还应覆盖:

  • 冷启动和滚动发布;
  • 外部依赖变慢;
  • 数据库连接池耗尽;
  • GC 周期;
  • 大响应和大请求;
  • 节点丢失;
  • 灰度期间旧新版本共存。

八、回滚:恢复代码不等于恢复系统

8.1 回滚触发条件

回滚应由预先定义的条件触发,而不是等用户大量投诉。例如:

  • 错误率连续多个窗口超过基线;
  • p99 延迟超过 SLO 阈值;
  • OOM、CrashLoopBackOff 或 readiness 失败率明显升高;
  • 数据库连接池、线程池或消息积压持续恶化;
  • 关键业务成功率下降;
  • 安全扫描发现不可接受的高危依赖或错误配置。

指标窗口不能短到把一次 GC 停顿误判为版本故障,也不能长到错误已经扩散。应同时比较新旧版本和同一时间段基线。

8.2 Kubernetes 回滚示例

如果使用 Deployment:

kubectl rollout status deployment/order-service -n prod
kubectl rollout history deployment/order-service -n prod
kubectl rollout undo deployment/order-service -n prod
kubectl rollout status deployment/order-service -n prod

每一步的目的不同:

  • rollout status 确认当前发布是否完成;
  • rollout history 查看 ReplicaSet 版本历史;
  • rollout undo 恢复到上一个 Deployment revision;
  • 第二次 rollout status 验证回滚本身,而不是假设命令成功就代表服务恢复。

如果镜像使用 digest,回滚应明确指向已验证的 digest,而不是重新拉取同名标签。回滚后要验证:

  • 新版本副本是否完全摘流;
  • 旧版本是否通过 startup/readiness;
  • 负载均衡是否已更新端点;
  • 错误率和延迟是否恢复;
  • 消费者是否重复消费或积压;
  • 数据库 schema 是否仍被旧代码支持。

8.3 不能安全回滚的情况

以下情况通常不能直接二进制回滚:

  • 数据库已删除旧字段;
  • 新版本写入旧版本无法解析的消息;
  • 新版本改变缓存值结构且没有版本隔离;
  • 新版本执行了不可逆的数据迁移;
  • 密钥、证书或协议已轮换,旧版本无法使用;
  • 新旧版本对同一业务状态使用不同语义。

因此发布前应准备兼容策略:

  • 缓存键带 schema 版本;
  • 消息同时支持旧、新格式;
  • 数据库变更先 expand 后 contract;
  • 密钥轮换保留一段重叠有效期;
  • 特性开关把高风险行为与二进制发布解耦;
  • 保留旧镜像、配置和迁移记录。

特性开关也可能成为故障源。开关默认值、配置缓存、变更审计和故障时的降级行为都必须明确,否则“关闭新功能”本身可能因为配置中心不可用而失败。


九、安全与供应链是交付链的一部分

容器交付会扩大依赖和权限的影响范围,因此构建阶段应固定依赖版本并生成 SBOM,扫描基础镜像和第三方库漏洞。构建产物最好由 CI 签名,部署平台校验签名或 digest;这样可以避免镜像仓库标签被覆盖后仍被错误部署。

TLS 配置需要区分:

  • 传输加密:防止链路被窃听;
  • 身份验证:校验证书链和主机名;
  • 双向 TLS:额外验证客户端证书;
  • 密钥管理:限制私钥访问和轮换权限。

关闭主机名校验或信任所有证书,会让“加密连接”退化为可被中间人攻击的连接。

Java 服务常见的交付安全边界还包括:

  • 禁止不受信任输入直接进入 Java 原生反序列化;
  • 表达式引擎限制可访问类、方法和反射能力;
  • SSRF 对目标地址做协议、域名、解析后 IP 和重定向校验;
  • 出站网络默认最小权限,不能只依赖 URL 黑名单;
  • 依赖升级后运行回归测试,特别是序列化、TLS 和日志组件;
  • 不把 actuator、JMX、heap dump 下载接口暴露到公网。

SSRF 校验不能只检查字符串是否以 http:// 开头。攻击者可能利用 DNS 重绑定、十进制或 IPv6 地址、重定向和云元数据地址绕过检查。服务端应限制允许的目标集合,并在连接前后都校验解析结果和网络范围。


十、一个可执行的交付验证顺序

在进入正式流量前,可以按以下顺序验证:

# 1. 验证镜像中 Java 版本和 JVM 参数
docker run --rm image@sha256:... java -version
docker run --rm image@sha256:... java -XshowSettings:vm -version

# 2. 启动容器并检查端口
docker run --rm -p 8080:8080 image@sha256:...

# 3. 验证探针
curl -i http://127.0.0.1:8080/internal/health/startup
curl -i http://127.0.0.1:8080/internal/health/ready
curl -i http://127.0.0.1:8080/internal/health/live

# 4. 施加代表性流量并观察
# 请求错误率、p99、RSS、GC、CPU throttling、线程池和连接池

# 5. 发送终止信号,验证优雅退出
docker stop --time=30 <container>

预期结果不是三个接口都永远返回 200,而是:

  • 初始化期间 startup 返回失败或未就绪,但不会被 liveness 提前重启;
  • 依赖故障时 readiness 可以返回 503,流量被摘除;
  • 进程本身陷入不可恢复状态时 liveness 最终失败;
  • SIGTERM 后停止新请求,完成允许完成的在途请求,并在 30 秒内退出;
  • 容器内存接近上限时有明确告警,而不是直接等待 OOMKilled 才发现问题。

最终,生产交付的正确性可以概括为一条可验证链路:

可复现镜像
  → 可解释的资源预算
  → 与生命周期匹配的探针
  → 可观测的灰度流量
  → 经过压测的容量
  → 兼容数据库和消息变更
  → 可证明的回滚路径

其中任何一环缺失,发布都可能在“应用代码没有异常”的情况下失败:容器可能先杀死进程,探针可能制造重启风暴,灰度可能掩盖长尾延迟,容量可能在双版本共存时不足,回滚可能因数据库不可逆变更而失效。生产交付的目标不是让发布命令成功,而是让系统在成功、异常和恢复三种状态下都具有可预测行为。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。