Java 基础体系 · 第 35/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 生产交付:容器、JVM 参数、探针、灰度、容量和回滚
生产交付不是把 JAR 文件复制到服务器上,而是把一个可验证、可观测、可停止、可扩容、可回退的运行单元交给调度系统。Java 服务的交付链路至少包含:
- 容器:确定进程、文件系统、网络、用户和资源边界;
- JVM 参数:把容器资源转换为堆、线程、元空间、直接内存和垃圾回收策略;
- 探针:让平台区分“进程活着”“应用已启动”“可以接收流量”;
- 灰度:在有限流量和有限风险下验证新版本;
- 容量:证明副本数和资源配额足以承受目标负载;
- 回滚:在代码、配置、数据库和流量都发生变化时恢复服务。
这些部分不是独立配置项。例如,错误的 Xmx 会导致容器被 OOMKilled;错误的存活探针会在 GC 高峰时反复重启实例;没有容量模型的灰度只能说明“少量流量下没出错”,不能说明正式发布安全。
一、先定义生产交付的边界
一个 Java 服务在容器中通常包含以下层次:
客户端
│
▼
负载均衡 / Ingress / 服务网格
│
▼
Pod
├── Java 进程
│ ├── Java 堆
│ ├── Java 线程栈
│ ├── 元空间
│ ├── Code Cache
│ ├── Direct Buffer
│ └── JVM / libc / 类库本地内存
├── 日志输出 stdout/stderr
└── cgroup 资源限制
├── CPU limit / quota
└── memory limit
这里的“容器内存”不是“Java 堆”。容器的内存限制约束的是整个进程组可使用的内存,堆只是其中一部分。粗略地说:
其中:
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.max为2147483648,即 2 GiB;- 当前内存使用约 1.43 GiB;
cpu.max的100000 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_throttled 和 throttled_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 影响。线程数为 ,每线程栈预留或提交规模近似为 ,仅线程栈就可能消耗:
例如:
- 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 |
总和为:
这只是数学上刚好填满,生产上不可取。若把容器限制提高到 2.5 GiB,保留约 20% 余量:
因此可以先选择:
-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 最长允许约 秒启动。preStop 的 sleep 不是优雅停机本身,它只是为负载均衡摘流和端点传播留出时间。应用仍必须处理 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 是服务目标,例如:
若 30 天 SLO 为 99.9%,错误预算约为:
发布不能只看平均延迟。更有意义的发布门禁通常包括:
- 错误率没有显著上升;
- p95/p99 延迟未越过业务阈值;
- readiness 失败率稳定;
- GC 暂停、CPU throttling、OOM、线程池队列没有异常;
- 关键业务指标没有出现版本相关下降。
六、灰度:不是“部署两个版本”,而是控制流量和状态
灰度发布是让新版本只接收一部分可控流量,并用指标决定继续、暂停或回滚。至少涉及三个对象:
- 版本状态:旧版本、灰度版本、全量版本;
- 流量状态:0%、小比例、逐步扩大、全量;
- 数据状态:数据库 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 < 5 时进入 5% 灰度。哈希键必须稳定且存在;如果退化为随机请求,每次请求可能落到不同版本。
按请求头或测试账号分流适合内部验证,但容易把测试结果误当生产结果。测试账号的流量、数据规模和调用链通常与真实用户不同。
6.2 数据库兼容性决定回滚上限
应用回滚不等于数据库回滚。安全的 schema 变更常采用 expand-contract:
- Expand:新增可选列、表或索引,旧版本仍能运行;
- 部署能同时读写新旧结构的应用;
- 完成数据回填并验证;
- Contract:在确认旧版本不再运行后,再删除旧列或旧结构。
反例是直接删除旧列后发布新代码。新代码失败时回滚到旧二进制,但旧代码仍访问已删除的列,回滚反而无法恢复。
消息队列也有同样约束。生产期间可能同时存在旧消费者和新消费者,因此消息格式必须向后兼容,新增字段应允许旧消费者忽略,不能无条件改变字段语义或序列化协议。Java 原生序列化还会带来反序列化攻击和类版本耦合,跨服务消息更适合使用有明确 schema 和校验机制的协议,并限制类型解析范围。
6.3 灰度失败的诊断顺序
如果新版本 readiness 失败:
- 检查启动日志和配置是否加载成功;
- 检查探针路径、端口和超时;
- 检查 DNS、TLS、数据库连接和凭据;
- 检查资源限制、CPU throttling 和内存;
- 检查依赖服务是否只允许旧版本身份;
- 检查是否因为启动时同步预热导致超时。
如果新版本“探针正常但业务变慢”:
- 看请求分位延迟而非平均值;
- 对比新旧版本的 GC、分配速率、线程池队列;
- 检查数据库连接池是否耗尽;
- 检查 Trace 中慢点移动到了数据库、外部 HTTP 或锁;
- 查看是否只有灰度租户触发了新的数据路径。
七、容量:从并发、延迟和资源反推副本数
7.1 Little 定律
对稳定系统,Little 定律为:
其中:
- :系统内平均并发请求数;
- :吞吐率,单位为请求/秒;
- :平均响应时间,单位为秒。
例如,目标吞吐为 600 请求/秒,平均响应时间为 0.2 秒,则平均并发约为:
但这不等于服务只能配置 120 个线程。线程数还受阻塞比例、CPU 核数、下游连接池和队列策略影响。若请求中 80% 时间在等待数据库,增加少量并发可能提高吞吐;若请求主要消耗 CPU,线程数超过可运行 CPU 数后通常只会增加调度开销。
7.2 用实测单 Pod 容量计算副本
假设压测得到单个 Pod 在 SLO 约束下的安全容量为 75 请求/秒,目标峰值为 600 请求/秒,安全系数取 1.3:
因此至少需要 11 个有效副本,而不是简单用 600 / 75 = 8。安全系数用于覆盖流量波动、发布期间容量损失和下游抖动,但不能替代压测。
如果一次滚动发布要求旧版本和新版本同时存在,集群在发布瞬间可能需要:
若两边都是 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 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java 服务安全:反序列化、表达式注入、SSRF、TLS 和供应链
- 下一篇:Java 25 词法、表达式与运算符:求值顺序、提升、溢出和短路
- 延伸:JVM 内存与垃圾回收:堆、栈、元空间、G1、ZGC 和调优
- 延伸:Java 可观测性:Micrometer、OpenTelemetry、日志、指标、Trace 和 SLO
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论