Spring Boot 容器化运行:从 Jar、PID 1 到生产资源治理
把 Spring Boot Jar 复制进 JRE 镜像只能说明“容器可能启动”。生产还要回答:镜像中的 JDK 与项目字节码是否匹配,JVM 如何识别 cgroup CPU/内存,为什么 Xmx 不能等于容器限制,环境变量最终是否覆盖了 application.yml,Tomcat 监听的是 localhost 还是容器网卡,Actuator 的 liveness/readiness 应检查什么,SIGTERM 能否到达 JVM,Heap Dump 写在哪里,以及滚动发布时新实例何时才能接流量。
学习目标
学完后,应能:
- 画出源码、Jar、镜像、容器、JVM、Spring容器、Tomcat和请求之间的完整链路。
- 分别构建 JDK 8/Spring Boot 2.x 与 JDK 17/Spring Boot 3.x 镜像,并理解版本边界。
- 解释 JVM 作为 PID 1、Exec form、Shell form、SIGTERM、SIGKILL 和优雅停机。
- 按容器总内存预算 Heap、Metaspace、Direct Memory、线程栈、Code Cache 和 native memory。
- 验证 JDK 8 具体更新版本的 cgroup 支持,而不是只看大版本。
- 解释 Docker环境、Spring Environment、Profile、命令行参数和外部配置文件的覆盖关系。
- 正确设计 liveness、readiness、startup和Docker healthcheck,不制造重启风暴。
- 处理日志、时区、字符集、CA证书、非root权限、Dump和敏感配置。
- 通过 inspect、logs、stats、Actuator、JFR、jcmd/Arthas 和监控完成生产排查。
一、Spring Boot进入容器后的完整链路
flowchart TD
A["Java源码与pom.xml"] --> B["Maven编译、测试、打包"]
B --> C["可执行Spring Boot Jar"]
C --> D["Dockerfile构建镜像"]
D --> E["JRE、Jar和镜像配置"]
E --> F["docker run或编排平台创建容器"]
F --> G["JVM成为容器主进程"]
G --> H["SpringApplication.run"]
H --> I["Environment与Profile准备"]
I --> J["ApplicationContext刷新和Bean创建"]
J --> K["内嵌Tomcat监听端口"]
K --> L["Readiness通过后接入流量"]
L --> M["请求进入Filter、Servlet、Controller和Service"]Docker 与 Spring Boot 分工:
| 层 | 负责内容 | 不负责内容 |
|---|---|---|
| Docker镜像 | JRE、Jar、系统库、默认入口 | 不理解Spring Bean和配置优先级 |
| Docker容器 | namespace、cgroup、网络、挂载、信号 | 不保证应用业务健康 |
| JVM | 字节码、Heap、GC、JIT、线程 | 不负责Docker端口NAT |
| Spring Boot | 配置、IOC、自动配置、WebServer、Actuator | 不替代容器资源限制和网络 |
| 编排平台 | 副本、流量、探针、发布和重启 | 不修复代码泄漏、慢SQL和错误健康语义 |
二、版本边界:JDK 8重要,但Spring Boot 3不能运行在JDK 8
| 组合 | 是否可行 | 说明 |
|---|---|---|
| Spring Boot 2.7.x + JDK 8 | 可行 | 适合学习和遗留系统;社区版本已停止常规支持,生产需评估厂商支持和安全补丁 |
| Spring Boot 2.7.x + JDK 17 | 可行 | 迁移JDK时常用过渡组合,仍需依赖兼容验证 |
| Spring Boot 3.x + JDK 17+ | 可行 | Boot 3最低Java版本是17,并迁移到Jakarta命名空间 |
| Spring Boot 3.x + JDK 8 | 不可行 | 启动前就会遇到字节码/JDK版本不兼容 |
不能把“Docker镜像里换成 JDK 8”当作兼容方案。Java编译目标、依赖字节码、Spring Boot版本和基础镜像JRE必须匹配。
常见错误:
UnsupportedClassVersionError说明 class 文件由更高版本 Java 编译,而当前 JRE 无法识别。检查:
java -version
javap -verbose -classpath app.jar <某个类> | grep 'major version'容器启动失败时还要确认镜像内实际 JRE,而不是宿主机 java -version。
三、可运行Demo:JDK 8与Spring Boot 2.7
为了演示 JDK 8,以下使用 Spring Boot 2.7.18。它是 2.7 社区线最后版本之一,适合兼容学习;生产遗留系统应评估受支持发行版、安全补丁和升级到现代 JDK 的计划。
3.1 项目结构
order-api/
├── pom.xml
├── Dockerfile
└── src/main/
├── java/com/example/order/OrderApplication.java
├── java/com/example/order/HealthController.java
└── resources/application.yml3.2 pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>order-api</artifactId>
<version>1.0.0</version>
<properties>
<java.version>8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>3.3 启动类
package com.example.order;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}3.4 最小接口
package com.example.order;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HealthController {
@GetMapping("/api/ping")
public Map<String, Object> ping() {
Map<String, Object> result = new LinkedHashMap<String, Object>();
result.put("status", "ok");
result.put("time", Instant.now().toString());
result.put("processors", Runtime.getRuntime().availableProcessors());
result.put("maxMemory", Runtime.getRuntime().maxMemory());
return result;
}
}这个接口把 JVM 看到的处理器数和最大Heap打印出来,便于比较容器限制与JVM视图。它不是生产管理接口,真实系统应使用受控Actuator和指标。
3.5 配置
server:
address: 0.0.0.0
port: 8080
shutdown: graceful
spring:
application:
name: order-api
lifecycle:
timeout-per-shutdown-phase: 30s
management:
endpoints:
web:
exposure:
include: health,info
endpoint:
health:
probes:
enabled: true
show-details: never版本注意:Actuator Probe、可用性状态和部分配置项在不同 Spring Boot 小版本行为可能变化,必须以项目实际版本测试。show-details: never 避免公网暴露依赖细节,但管理端点仍应限制在内网并鉴权。
四、JDK 8生产镜像示例
# syntax=docker/dockerfile:1
FROM maven:3.8.8-eclipse-temurin-8 AS build
WORKDIR /src
COPY pom.xml ./
RUN --mount=type=cache,target=/root/.m2 \
mvn -B dependency:go-offline
COPY src ./src
RUN --mount=type=cache,target=/root/.m2 \
mvn -B clean verify
FROM eclipse-temurin:8-jre-jammy AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --gid 10001 app \
&& useradd --uid 10001 --gid app --create-home app
WORKDIR /app
COPY --from=build --chown=10001:10001 /src/target/order-api-1.0.0.jar /app/app.jar
USER 10001:10001
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
STOPSIGNAL SIGTERM
ENTRYPOINT ["java", "-jar", "/app/app.jar"]为什么这样写:
| 配置 | 原因 |
|---|---|
| Maven阶段与运行阶段分离 | 最终镜像不携带Maven、JDK编译器和源码 |
| 运行阶段使用JRE | 应用运行通常不需要完整JDK,但诊断工具策略需另设计 |
| 固定UID/GID | 便于Volume和宿主机权限一致治理 |
| Exec form ENTRYPOINT | JVM直接成为容器主进程并接收信号 |
STOPSIGNAL SIGTERM | 与Spring优雅停机配合 |
JAVA_TOOL_OPTIONS | JVM原生读取,不依赖Shell展开字符串 |
| 安装curl | 仅为本Demo健康检查;生产可改用外部探针或专用工具,需权衡镜像体积和攻击面 |
完整 Dockerfile 构建、缓存和 Secret 原理见 Dockerfile从构建原理到生产镜像。
五、构建与启动JDK 8 Demo
5.1 构建
docker build \
--tag order-api:jdk8-demo \
.检查镜像:
docker image inspect \
--format 'id={{.Id}} user={{.Config.User}} entry={{json .Config.Entrypoint}} cmd={{json .Config.Cmd}}' \
order-api:jdk8-demo5.2 运行
docker run -d \
--name order-api-jdk8 \
--label training=spring-boot-container \
--memory=1g \
--cpus=1.5 \
-p 127.0.0.1:18080:8080 \
-e SPRING_PROFILES_ACTIVE=container \
--restart=no \
order-api:jdk8-demo5.3 验证
docker ps -a --filter name=order-api-jdk8
docker logs --timestamps --tail 200 order-api-jdk8
docker inspect order-api-jdk8
docker stats --no-stream order-api-jdk8
curl -fsS http://127.0.0.1:18080/api/ping
curl -fsS http://127.0.0.1:18080/actuator/health
curl -fsS http://127.0.0.1:18080/actuator/health/liveness
curl -fsS http://127.0.0.1:18080/actuator/health/readiness如果某个 Probe 路径在实际 Spring Boot 版本中不存在,应检查 Actuator 版本、配置和端点映射,不要把404直接当成容器网络故障。
六、JDK 17与Spring Boot 3示例
Spring Boot 3 项目把 Maven Parent 改为组织批准的 Spring Boot 3.x 版本,并设置:
<properties>
<java.version>17</java.version>
</properties>若代码使用 Servlet、Validation、JPA 等API,还要完成 javax.* 到 jakarta.* 的迁移;仅替换JDK镜像不能完成Boot 2到3升级。
运行镜像示例:
FROM eclipse-temurin:17-jre-jammy
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --gid 10001 app \
&& useradd --uid 10001 --gid app --create-home app
WORKDIR /app
COPY --chown=10001:10001 target/order-api.jar /app/app.jar
USER 10001:10001
EXPOSE 8080
ENV JAVA_TOOL_OPTIONS="-XX:InitialRAMPercentage=25.0 -XX:MaxRAMPercentage=60.0 -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai"
STOPSIGNAL SIGTERM
ENTRYPOINT ["java", "-jar", "/app/app.jar"]MaxRAMPercentage=60 只是演示起点,不是所有服务的标准答案。还要根据线程数、Direct Buffer、Metaspace、Agent、JNI和流量压测决定。
七、JVM作为PID 1意味着什么
Exec form:
ENTRYPOINT ["java", "-jar", "/app/app.jar"]JVM通常直接成为容器PID 1。
Shell form:
ENTRYPOINT java -jar /app/app.jar通常经过 /bin/sh -c,Shell可能成为PID 1。
flowchart TD
A["docker stop"] --> B["Docker向PID 1发送SIGTERM"]
B --> C{"PID 1是谁"}
C -- "JVM" --> D["JVM接收信号并执行Shutdown Hook"]
C -- "Shell" --> E{"Shell是否正确转发"}
E -- "否" --> F["Spring Boot收不到SIGTERM"]
E -- "是" --> D
D --> G["停止接流量并关闭资源"]
F --> H["等待超时后SIGKILL"]7.1 入口脚本必须使用exec
错误:
#!/bin/sh
java ${JAVA_OPTS:-} -jar /app/app.jar "$@"更合理:
#!/bin/sh
set -eu
exec java ${JAVA_OPTS:-} -jar /app/app.jar "$@"exec 让 JVM 替换 Shell。变量分词和转义仍要谨慎,优先使用 JVM 原生 JAVA_TOOL_OPTIONS 或编排平台数组参数。
7.2 PID 1还涉及子进程回收
普通Spring Boot单JVM通常子进程不多。如果应用会启动外部命令、浏览器、FFmpeg或脚本,PID 1还要正确回收孤儿子进程。复杂场景可评估轻量init,但不要把supervisor作为一个容器塞入多个无关服务的理由。
八、优雅停机全过程
Spring Boot 2.3+支持内嵌WebServer优雅停机配置,具体实现和版本应按项目验证:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s容器宽限时间也要大于或等于应用需要的时间:
docker stop --time 40 order-api-jdk8完整过程:
flowchart TD
A["编排平台先摘除新流量"] --> B["向容器PID 1发送SIGTERM"]
B --> C["Spring发布ContextClosedEvent"]
C --> D["WebServer停止接收新请求"]
D --> E["等待在途请求完成"]
E --> F["关闭线程池、连接池和消费者"]
F --> G{"是否在宽限期内退出"}
G -- "是" --> H["进程正常退出"]
G -- "否" --> I["平台发送SIGKILL"]8.1 为什么先摘流再发SIGTERM
如果负载均衡仍不断发送新请求,应用很难在宽限期内清空在途请求。Kubernetes通常通过 readiness、Endpoint变化和PreStop/终止流程协调,但仍存在传播时间,需要结合业务连接、长请求和负载均衡行为设计。
8.2 哪些资源需要关闭
- Tomcat/Undertow/Netty连接。
- Hikari连接池。
- 自定义ExecutorService。
- Kafka/RocketMQ消费者。
- 定时任务。
- 文件和网络连接。
- 批处理与锁。
Spring管理的Bean可通过生命周期回调关闭;手工创建、未注册为Bean的线程池不会自动按预期关闭。
8.3 长连接和长任务
WebSocket、SSE、文件上传、批处理和消息消费可能超过普通30秒。不能无限增加宽限时间,需要:
- 新任务停止准入。
- 任务可中断或可恢复。
- 消费者停止拉取并提交正确Offset。
- 状态和幂等保证重试安全。
- 平台最大终止时间与业务SLA协调。
九、容器内存不是Java Heap
容器内存限制约束整个cgroup中的进程和内存,不只 Heap。
容器总内存
├── Java Heap
├── Metaspace
├── Direct Memory
├── 每个线程的Native Stack
├── Code Cache
├── GC和JIT本地结构
├── JNI与本地库
├── Agent/APM
├── 文件映射与部分页缓存计量
└── 子进程9.1 为什么Xmx不能等于容器限制
假设:
容器限制 = 1 GiB
Xmx = 1 GiBHeap之外没有预算。即使Heap尚未达到Xmx,线程栈、Direct Memory和Metaspace也可能使容器总内存超过限制,内核直接杀死JVM,出现 OOMKilled=true 和 Exit 137,而应用日志中未必有 Java OOM。
9.2 一个可解释的预算示例
对于1GiB容器,初始压测预算可以是:
| 区域 | 示例预算 | 说明 |
|---|---|---|
| Heap | 384MiB | 根据对象存活和GC压测调整 |
| Metaspace | 128MiB | 框架、代理和动态类影响 |
| Direct Memory | 128MiB | Netty/NIO/驱动需重点关注 |
| 线程栈 | 100MiB | 约200线程×512KiB,仅示意 |
| Code Cache/JIT/GC/native | 160MiB | JVM和Agent等 |
| 安全余量 | 124MiB | 流量尖峰、页和估算误差 |
总预算必须通过实际压测、NMT、RSS、线程数和长期运行验证。不同服务不能套同一张表。
9.3 JDK 8参数示例
对于明确验证过的1GiB容器,可从保守配置实验:
-Xms384m
-Xmx384m
-XX:MaxMetaspaceSize=128m
-XX:MaxDirectMemorySize=128m
-Xss512k把它们通过运行环境设置:
docker run -d \
--memory=1g \
-e 'JAVA_TOOL_OPTIONS=-Xms384m -Xmx384m -XX:MaxMetaspaceSize=128m -XX:MaxDirectMemorySize=128m -Xss512k -Dfile.encoding=UTF-8 -Duser.timezone=Asia/Shanghai' \
order-api:jdk8-demo限制过小会把正常波动变成 OOM,限制过大又失去保护。设置后必须压测和观察。
十、JDK 8容器感知为什么必须看更新版本
“JDK 8支持Docker”不是对所有8u版本和发行版都成立的简单结论。
历史上:
- 早期JDK 8可能按宿主机内存和CPU计算。
- 一些版本使用实验性cgroup参数。
- 后续更新版和不同发行版回移了
UseContainerSupport等能力。 - cgroup v2支持又与更晚更新和发行版补丁有关。
因此必须记录:
docker exec order-api-jdk8 java -version
docker exec order-api-jdk8 java -XX:+PrintFlagsFinal -version关注具体版本中实际存在的:
UseContainerSupport。MaxRAMPercentage。ActiveProcessorCount。- Heap相关最终值。
不要把旧教程中的:
-XX:+UnlockExperimentalVMOptions
-XX:+UseCGroupMemoryLimitForHeap与现代 UseContainerSupport 无条件混用。先检查目标JVM支持和默认值,否则可能直接因“不认识参数”启动失败。
10.1 如何验证JVM看到的Heap
接口 /api/ping 返回 Runtime.maxMemory()。也可以:
docker exec order-api-jdk8 java -XX:+PrintFlagsFinal -version 2>&1 |
grep -E 'MaxHeapSize|InitialHeapSize|UseContainerSupport|MaxRAMPercentage'把输出与 docker inspect 的 HostConfig.Memory 对比。
十一、JDK 17容器资源识别
现代JDK通常更完整识别cgroup CPU和内存:
docker exec order-api-jdk17 java -XshowSettings:system -version
docker exec order-api-jdk17 java -XX:+PrintFlagsFinal -version但仍不能假定自动值一定适合业务:
- JVM只知道资源限制,不知道业务延迟和对象存活率。
- 百分比Heap不会自动为大量Direct Buffer和线程留够空间。
- Sidecar、同Pod其他容器和节点压力属于更上层约束。
- APM Agent可能显著增加类、线程和native内存。
十二、CPU限制如何影响JVM线程数
JVM会根据可用处理器数决定或影响:
- GC并行线程。
- JIT编译线程。
- ForkJoinPool common并行度。
- 某些框架默认线程池。
旧JDK 8如果看到宿主机64核,而容器只允许2核,可能创建过多GC或业务线程,增加上下文切换和内存。
验证应用看到的CPU:
curl -fsS http://127.0.0.1:18080/api/ping
docker inspect --format 'nanoCpus={{.HostConfig.NanoCpus}} cpuQuota={{.HostConfig.CpuQuota}} cpuPeriod={{.HostConfig.CpuPeriod}}' order-api-jdk8若具体JVM支持,可评估:
-XX:ActiveProcessorCount=2这不是通用补丁;应先修正JDK版本、验证cgroup识别,再按压测设置。
十三、线程池和连接池不能只按宿主机配置
容器只有1核却配置:
Tomcat maxThreads = 1000
业务线程池 = 500
Hikari maximumPoolSize = 200不会自动提高吞吐,可能导致:
- 线程栈占用大量内存。
- 上下文切换。
- 数据库连接数爆炸。
- 请求在多个队列重复排队。
- 超时后重试风暴。
需要从完整链路预算:
入口并发
↓
Tomcat线程
↓
业务线程池
↓
数据库连接池
↓
数据库最大连接与吞吐Little's Law可辅助估算并发:系统平均并发约等于吞吐率乘平均停留时间,但真实系统还要考虑P99、突发、超时和队列上限。线程池配置应配合压测和下游容量,而不是按CPU核数公式机械决定所有IO服务。
十四、Spring Boot配置如何进入容器
可能来源:
镜像内application.yml
Dockerfile ENV
docker run --env-file
docker run -e
Compose env_file/environment
挂载的外部application.yml
SPRING_APPLICATION_JSON
java -jar后的命令行参数
配置中心Docker 只负责把环境变量和文件提供给进程。Spring Boot 再根据自己的 PropertySource 与优先级生成 Environment。
14.1 环境变量宽松绑定
Spring属性:
spring.datasource.url
spring.datasource.hikari.maximum-pool-size
management.endpoints.web.exposure.include常见环境变量:
SPRING_DATASOURCE_URL
SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE
MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE最终是否正确绑定要通过启动日志、Actuator /env、/configprops 或应用行为验证。生产暴露这些端点必须鉴权和脱敏。
14.2 Profile
docker run -e SPRING_PROFILES_ACTIVE=prod order-api:1.0它选择Profile,不会自动生成 application-prod.yml,也不会保证数据库地址正确。检查:
docker inspect --format '{{json .Config.Env}}' order-api
docker logs --tail 200 order-api14.3 外部配置文件
docker run -d \
--mount type=bind,source=/data/order/config,target=/app/config,readonly \
order-api:1.0Spring Boot会在其配置搜索路径中查找文件,但具体文件名和位置受版本、spring.config.location、spring.config.additional-location 等影响。挂载成功不代表Spring一定读取。
还要防止空宿主机目录遮住镜像默认配置。使用:
docker inspect --format '{{json .Mounts}}' order-api14.4 修改配置后为什么restart可能没用
修改 docker run -e 或 Compose environment 后,重启旧容器不会重建 Env。需要按新配置重建,再 inspect 验证。
十五、Secret不能写进镜像和普通环境
不推荐:
ENV DB_PASSWORD=123456
COPY application-prod.yml /app/application.yml也不推荐把生产密码长期放在普通 Compose .env。原因:
- image inspect/history可能暴露。
- container inspect可查看Env。
- CI日志和诊断包可能收集。
- 镜像被其他环境拉取。
生产使用 Secret Manager、Vault、Kubernetes Secret配合加密/RBAC,或受控只读文件挂载。应用需要支持从文件或Secret SDK读取,并处理轮换。
十六、端口和监听地址
应用:
server:
address: 0.0.0.0
port: 8080运行:
docker run -p 127.0.0.1:18080:8080 order-api:1.0关系:
宿主机127.0.0.1:18080
↓ Docker端口发布
容器IP:8080
↓
Tomcat监听0.0.0.0:8080如果 Spring Boot 只监听:
127.0.0.1:8080容器内 curl localhost 可能成功,但Docker转发到容器网卡地址会失败。
检查:
docker port order-api
docker inspect --format '{{json .NetworkSettings.Ports}}' order-api
docker exec order-api sh -c 'ss -lntp || netstat -lntp'极简镜像没有ss/netstat时使用外部探针、诊断容器或Network Namespace工具。
十七、Liveness、Readiness与Startup语义
17.1 Liveness
回答:进程是否已经进入无法恢复、需要重启的状态。
不应把数据库短暂不可用直接放进 liveness,否则:
数据库抖动
↓
所有应用liveness失败
↓
平台同时重启所有实例
↓
连接和启动流量进一步冲击数据库17.2 Readiness
回答:当前实例是否可以接收用户流量。数据库、Redis、配置或预热未就绪时,可以让 readiness 失败并摘流,但不一定重启进程。
17.3 Startup
慢启动应用在初始化完成前可能无法通过 liveness。Kubernetes startupProbe用于保护启动期,成功后再启用liveness判断,避免应用尚未启动就反复杀死。
17.4 Docker Healthcheck
Dockerfile:
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/actuator/health/readiness || exit 1必须确认:
- 镜像内确实有curl。
- endpoint不需要交互登录。
- 不向公网暴露敏感细节。
- 探针耗时短且不会打爆依赖。
- Docker单机Health状态本身不会自动实现Kubernetes式流量摘除。
Actuator完整原理见 Spring Boot Actuator。
十八、日志应该写哪里
容器推荐应用日志输出 stdout/stderr:
logging:
pattern:
console: "%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} %-5level [%thread] %logger{36} traceId=%X{traceId:-} - %msg%n"Docker日志:
docker logs --timestamps --tail 200 order-api
docker inspect --format '{{json .HostConfig.LogConfig}}' order-api生产需要:
- Docker日志轮转。
- Fluent Bit/Filebeat/平台日志采集。
- 服务名、环境、实例和traceId。
- 敏感字段脱敏。
- 日志级别动态治理。
如果业务要求文件日志,必须明确挂载、轮转、权限、采集和磁盘告警。写进容器可写层会随容器删除,并可能撑大OverlayFS。
十九、时间、时区、Locale和字符集
JVM时间点应以UTC语义存储,展示时按业务时区转换。容器时区可通过JVM参数明确:
-Duser.timezone=Asia/Shanghai字符集:
-Dfile.encoding=UTF-8但要理解:
- 数据库连接有自己的时区和字符集参数。
- JSON时间格式受Jackson配置影响。
- Linux系统时区、JVM默认时区、数据库会话时区是不同层。
- 容器通常共享宿主机内核时钟,时间漂移应查宿主机NTP,而不是只改TZ。
- JDK镜像是否包含完整时区数据和字体要验证。
二十、非root与文件权限
镜像使用:
USER 10001:10001应用可能需要写:
/tmp。- 日志目录。
- 上传目录。
- Heap Dump。
- 临时解压目录。
检查:
docker exec order-api sh -c 'id; umask; ls -ldn /app /tmp /dumps'
docker inspect --format '{{json .Mounts}}' order-api容器和宿主机比较数字UID/GID,不比较用户名。不要用 chmod 777 解决,应使用固定UID/GID、COPY --chown、Volume目录所有权、只读Mount和最小权限。
二十一、证书、HTTPS与TrustStore
Spring Boot调用企业HTTPS服务时,可能需要企业CA。选择包括:
- 在基础镜像OS信任库安装CA。
- 构建受控Java TrustStore。
- 通过只读Secret挂载TrustStore。
- 使用平台证书管理。
错误做法:关闭TLS校验或信任所有证书。容器中出现:
PKIX path building failed应检查服务端证书链、域名、系统时间、代理CA和JVM TrustStore。不要因为宿主机curl成功就认定JVM一定使用同一信任库。
二十二、Heap Dump、ErrorFile和诊断目录
JVM参数示意:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/dumps
-XX:ErrorFile=/dumps/hs_err_pid%p.log运行时挂载:
docker run -d \
--mount type=bind,source=/data/order/dumps,target=/dumps \
-e 'JAVA_TOOL_OPTIONS=-Xms384m -Xmx384m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps -XX:ErrorFile=/dumps/hs_err_pid%p.log' \
order-api:jdk8-demo注意:
- Dump可能含密码、Token、患者/订单数据。
- 需要加密、权限、保留期限和审计。
- Heap Dump可能接近Heap大小,先确认磁盘。
- 容器OOM被内核直接SIGKILL时,JVM可能来不及生成Heap Dump。
- Dump路径若在容器可写层,容器删除后会丢失。
二十三、JDK还是JRE镜像:诊断工具怎么选
JRE镜像较小,但通常没有完整 jcmd、jstack、jmap。选择:
| 方案 | 优点 | 风险 |
|---|---|---|
| 生产镜像使用JDK | 诊断方便 | 镜像更大、工具更多 |
| 生产JRE+独立debug镜像 | 运行面小,诊断按需 | 需要版本匹配和namespace附着方案 |
| JFR/APM预置 | 可持续观测 | 有性能、存储和数据安全成本 |
| Arthas按需 | Java诊断丰富 | 附着权限、版本、资源和审计要治理 |
不能在事故时随意从互联网下载未知诊断Jar到生产容器。工具来源、版本、校验和操作权限必须受控。
二十四、Spring Boot容器观测指标
至少监控:
容器层
- CPU使用与throttling。
- Working Set/RSS和内存限制。
- OOMKilled与RestartCount。
- PIDs。
- 网络收发和错误。
- 块IO。
- 可写层/Volume磁盘。
JVM层
- Heap各区使用。
- GC次数、停顿和分配速率。
- Metaspace。
- Direct Buffer。
- 线程数和状态。
- Class加载。
- Code Cache。
- Process RSS与CPU。
Spring/业务层
- HTTP QPS、错误率、P50/P95/P99。
- Tomcat线程和排队。
- Hikari活动、空闲、等待和超时。
- Redis、MQ、HTTP客户端连接池。
- 订单、支付和采集业务成功率。
只监控容器CPU和内存,不能解释业务为什么失败。
二十五、商业Compose中的Spring Boot服务配置
下面是完整订单系统 Compose 中的 order-api 服务配置片段,用于突出 JVM、资源、探针、Dump 和日志设置;它依赖名为 mysql 的同网络服务,不能脱离完整项目单独复制运行。MySQL、Redis、网络、Volume和依赖健康的完整可运行栈见 Docker Compose从项目模型到商业编排。
services:
order-api:
image: "registry.example.com/order/order-api:${APP_VERSION:?required}"
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: "jdbc:mysql://mysql:3306/order_db?serverTimezone=Asia/Shanghai"
SPRING_DATASOURCE_USERNAME: order_app
JAVA_TOOL_OPTIONS: >-
-Xms384m
-Xmx384m
-XX:MaxMetaspaceSize=128m
-XX:MaxDirectMemorySize=128m
-Xss512k
-Dfile.encoding=UTF-8
-Duser.timezone=Asia/Shanghai
ports:
- "127.0.0.1:18080:8080"
mem_limit: 1g
cpus: 1.5
pids_limit: 300
stop_grace_period: 40s
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/actuator/health/readiness"]
interval: 15s
timeout: 3s
start_period: 60s
retries: 3
volumes:
- type: bind
source: /data/order/dumps
target: /dumps
restart: unless-stopped
logging:
driver: json-file
options:
max-size: "20m"
max-file: "5"这个示例没有把数据库密码写入YAML。真实项目通过Secret文件或Secret Manager提供。配置中的资源数字只是起点,必须按压测调整。
启动前:
docker compose config
docker compose pull
docker compose up -d --wait启动后:
docker compose ps -a
docker compose logs --timestamps --tail 200 order-api
docker compose top order-api
docker inspect <order-api容器>
docker stats --no-stream <order-api容器>二十六、从Git到生产的商业发布链路
flowchart TD
A["Git提交固定commit"] --> B["Maven编译与自动化测试"]
B --> C["构建不可变Jar"]
C --> D["多阶段构建镜像"]
D --> E["漏洞扫描、SBOM和签名"]
E --> F["推送镜像并记录Digest"]
F --> G["测试环境按Digest部署"]
G --> H["启动、Probe、资源和业务验证"]
H --> I["同一Digest灰度到生产"]
I --> J["Readiness通过后逐步接流量"]
J --> K["观察错误率、P99、GC和业务成功率"]
K --> L{"是否异常"}
L -- "是" --> M["停止放量或回滚"]
L -- "否" --> N["完成发布并保留审计"]回滚不只是切换镜像:数据库结构、配置、MQ消息、缓存格式和API协议也必须向前/向后兼容。
二十七、常见故障排查
27.1 容器立即退出
docker ps -a --filter name=order-api
docker inspect --format '{{json .State}}' order-api
docker logs --timestamps --tail 300 order-api
docker inspect --format 'path={{json .Path}} args={{json .Args}} image={{.Image}}' order-api检查:
- JRE和字节码版本。
- Jar路径。
- Entrypoint/Cmd。
- 配置语法。
- 必填环境变量。
- 端口冲突。
- 非root权限。
- 数据库启动失败是否导致应用退出。
27.2 容器Running但访问不到
docker port order-api
docker inspect --format '{{json .NetworkSettings.Ports}}' order-api
curl -v http://127.0.0.1:18080/actuator/health容器内能访问localhost、宿主机不能访问时,检查 server.address 是否只绑定127.0.0.1、-p是否映射到真实server.port,以及防火墙和入口代理。
27.3 配置不生效
docker inspect --format '{{json .Config.Env}}' order-api
docker inspect --format '{{json .Mounts}}' order-api
docker inspect --format 'path={{json .Path}} args={{json .Args}}' order-api再使用受保护Actuator /env 或 /configprops 确认Spring最终值和来源。检查是否只restart了旧容器而没有重建。
27.4 Exit 137
docker inspect --format 'exit={{.State.ExitCode}} oom={{.State.OOMKilled}} memory={{.HostConfig.Memory}}' order-api
docker events --since 1h --filter container=order-api结合宿主机内核日志和历史监控,区分容器OOM、宿主机OOM、主动kill和stop超时。确认容器OOM后再区分Heap、Direct、Metaspace、线程栈和native。
27.5 停止超过宽限期
检查:
- JVM是否PID 1。
- Shell是否exec。
server.shutdown=graceful。- Spring shutdown phase timeout。
- Docker/Compose/K8s宽限时间。
- 是否有未关闭线程池、长请求或消息消费。
27.6 Healthy但业务失败
Probe可能只检查进程。继续看:
- 真实入口HTTP。
- 数据库和Redis连接池。
- 线程池。
- 错误率和P99。
- 订单/支付/采集成功率。
- HealthIndicator是否表达正确依赖语义。
二十八、商业案例一:Xmx等于容器限制
现象:
容器memory=1GiB
JVM -Xmx1g
运行一段时间后Exit 137
应用没有Java heap space日志证据:
OOMKilled=true。- Heap尚未达到1GiB。
- 线程数和Direct Buffer增长。
- 容器RSS超过cgroup限制。
根因:把容器总内存错误等同Java Heap,没有为Heap外内存留预算。
修复:按实际测量重新预算Heap、Direct、线程和native,设置监控与长期压测。扩大容器内存可以止损,但不是证明根因已修复。
二十九、商业案例二:发布时请求被强制中断
现象:每次发布都有少量订单请求失败,容器退出码143后偶尔137。
发现:
- Dockerfile使用Shell form,JVM不是PID 1。
- Spring未开启graceful shutdown。
- 平台宽限时间10秒,小于订单接口P99。
- 下线流量与SIGTERM几乎同时发生。
修复:Exec form、开启Spring优雅停机、先摘流、调整宽限期、让业务操作幂等并验证在途请求完成。Exit 137来自停止超时强杀,不是内存OOM。
三十、商业案例三:容器内时间正常但数据库时间差8小时
现象:日志时间看起来正确,MySQL查询结果偏差8小时。
根因可能分布在:
- Linux容器时区。
- JVM
user.timezone。 - Jackson序列化时区。
- JDBC
serverTimezone。 - MySQL global/session time_zone。
- 字段使用TIMESTAMP还是DATETIME。
修复不是只加 TZ=Asia/Shanghai,而是定义统一时间语义:时间点以UTC/Instant存储和传输,业务展示时转换;逐层验证JVM、JDBC、数据库会话和JSON。
三十一、面试标准回答
Spring Boot容器化启动全过程
CI把源码编译测试并打成可执行Jar,Docker构建镜像把JRE、Jar和入口配置固化;运行时Docker创建namespace、cgroup、网络、挂载和可写层,按Entrypoint启动JVM。JVM执行SpringApplication.run,准备Environment和Profile、刷新ApplicationContext、创建Bean并启动内嵌Tomcat。Actuator readiness通过后平台才应接入流量,停止时SIGTERM到JVM,Spring完成优雅停机后退出。
为什么Xmx不能等于容器内存限制
容器限制约束整个cgroup,除了Heap还有Metaspace、Direct Memory、线程栈、Code Cache、GC/JIT native结构、JNI、Agent和子进程。Xmx等于限制会让Heap外没有预算,即使没有Java Heap OOM也可能触发cgroup OOM Killer和Exit 137。应按实测RSS、NMT、线程和Direct Buffer预算并保留余量。
JDK 8在容器中要注意什么
不能只说JDK 8,要记录具体更新版本和发行版。早期8u可能按宿主机资源计算Heap、GC线程和availableProcessors,后续版本或厂商回移了UseContainerSupport、百分比内存和cgroup v2支持。应在目标镜像内用java -version、PrintFlagsFinal和应用Runtime值验证,不能无条件复制旧实验参数。
Spring Boot容器如何优雅停机
使用Exec form让JVM成为PID 1,配置Spring Boot graceful shutdown和shutdown phase timeout;编排平台先让readiness失败/摘流,再发SIGTERM,并给足宽限时间。Spring停止接新请求、等待在途请求并关闭线程池、连接池和消费者;超时后的SIGKILL没有清理机会,所以长任务还要可中断、幂等和可恢复。
Liveness和Readiness怎么设计
Liveness判断进程是否不可恢复、是否需要重启;Readiness判断实例当前是否能接流量。数据库短暂不可用通常影响readiness而不是liveness,否则依赖抖动会触发所有应用重启形成风暴。慢启动再配startup保护启动期。探针必须短超时、低成本、不能暴露敏感细节。
配置进入容器后为什么仍可能不生效
Docker只把环境变量和挂载文件提供给进程,Spring Boot再按PropertySource优先级和宽松绑定生成Environment。变量名映射、Profile、外部配置路径、命令行参数和配置中心都可能覆盖。先inspect容器Env、Mounts和Args,再用受保护Actuator env/configprops或启动日志确认最终值;修改Compose配置后只restart旧容器通常不会重建Env。
三十二、学习验收
不看答案完成:
- 构建并运行JDK 8/Spring Boot 2.7 Demo,记录完整JDK版本。
- 说明Spring Boot 3为什么不能运行在JDK 8,并完成JDK 17镜像。
- 证明Exec form下JVM为PID 1,并观察SIGTERM停机日志。
- 为1GiB容器编写Heap外预算,使用stats和JVM指标验证。
- 比较JDK 8与17在相同CPU/内存限制下看到的资源。
- 使用环境变量、Profile和只读外部配置,并证明最终PropertySource。
- 分别制造liveness/readiness错误,说明重启与摘流后果。
- 以非root运行,验证配置、日志、临时目录和Dump权限。
- 将Heap Dump写入受控挂载,并说明数据安全和空间风险。
- 完成一次先摘流、SIGTERM、在途请求结束、容器退出的发布实验。
