Skip to content

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 写在哪里,以及滚动发布时新实例何时才能接流量。

学习目标

学完后,应能:

  1. 画出源码、Jar、镜像、容器、JVM、Spring容器、Tomcat和请求之间的完整链路。
  2. 分别构建 JDK 8/Spring Boot 2.x 与 JDK 17/Spring Boot 3.x 镜像,并理解版本边界。
  3. 解释 JVM 作为 PID 1、Exec form、Shell form、SIGTERM、SIGKILL 和优雅停机。
  4. 按容器总内存预算 Heap、Metaspace、Direct Memory、线程栈、Code Cache 和 native memory。
  5. 验证 JDK 8 具体更新版本的 cgroup 支持,而不是只看大版本。
  6. 解释 Docker环境、Spring Environment、Profile、命令行参数和外部配置文件的覆盖关系。
  7. 正确设计 liveness、readiness、startup和Docker healthcheck,不制造重启风暴。
  8. 处理日志、时区、字符集、CA证书、非root权限、Dump和敏感配置。
  9. 通过 inspect、logs、stats、Actuator、JFR、jcmd/Arthas 和监控完成生产排查。

一、Spring Boot进入容器后的完整链路

mermaid
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必须匹配。

常见错误:

text
UnsupportedClassVersionError

说明 class 文件由更高版本 Java 编译,而当前 JRE 无法识别。检查:

bash
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 项目结构

text
order-api/
├── pom.xml
├── Dockerfile
└── src/main/
    ├── java/com/example/order/OrderApplication.java
    ├── java/com/example/order/HealthController.java
    └── resources/application.yml

3.2 pom.xml

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 启动类

java
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 最小接口

java
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 配置

yaml
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生产镜像示例

dockerfile
# 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 ENTRYPOINTJVM直接成为容器主进程并接收信号
STOPSIGNAL SIGTERM与Spring优雅停机配合
JAVA_TOOL_OPTIONSJVM原生读取,不依赖Shell展开字符串
安装curl仅为本Demo健康检查;生产可改用外部探针或专用工具,需权衡镜像体积和攻击面

完整 Dockerfile 构建、缓存和 Secret 原理见 Dockerfile从构建原理到生产镜像

五、构建与启动JDK 8 Demo

5.1 构建

bash
docker build \
  --tag order-api:jdk8-demo \
  .

检查镜像:

bash
docker image inspect \
  --format 'id={{.Id}} user={{.Config.User}} entry={{json .Config.Entrypoint}} cmd={{json .Config.Cmd}}' \
  order-api:jdk8-demo

5.2 运行

bash
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-demo

5.3 验证

bash
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 版本,并设置:

xml
<properties>
    <java.version>17</java.version>
</properties>

若代码使用 Servlet、Validation、JPA 等API,还要完成 javax.*jakarta.* 的迁移;仅替换JDK镜像不能完成Boot 2到3升级。

运行镜像示例:

dockerfile
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:

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

JVM通常直接成为容器PID 1。

Shell form:

dockerfile
ENTRYPOINT java -jar /app/app.jar

通常经过 /bin/sh -c,Shell可能成为PID 1。

mermaid
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

错误:

sh
#!/bin/sh
java ${JAVA_OPTS:-} -jar /app/app.jar "$@"

更合理:

sh
#!/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优雅停机配置,具体实现和版本应按项目验证:

yaml
server:
  shutdown: graceful

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

容器宽限时间也要大于或等于应用需要的时间:

bash
docker stop --time 40 order-api-jdk8

完整过程:

mermaid
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。

text
容器总内存
├── Java Heap
├── Metaspace
├── Direct Memory
├── 每个线程的Native Stack
├── Code Cache
├── GC和JIT本地结构
├── JNI与本地库
├── Agent/APM
├── 文件映射与部分页缓存计量
└── 子进程

9.1 为什么Xmx不能等于容器限制

假设:

text
容器限制 = 1 GiB
Xmx = 1 GiB

Heap之外没有预算。即使Heap尚未达到Xmx,线程栈、Direct Memory和Metaspace也可能使容器总内存超过限制,内核直接杀死JVM,出现 OOMKilled=true 和 Exit 137,而应用日志中未必有 Java OOM。

9.2 一个可解释的预算示例

对于1GiB容器,初始压测预算可以是:

区域示例预算说明
Heap384MiB根据对象存活和GC压测调整
Metaspace128MiB框架、代理和动态类影响
Direct Memory128MiBNetty/NIO/驱动需重点关注
线程栈100MiB约200线程×512KiB,仅示意
Code Cache/JIT/GC/native160MiBJVM和Agent等
安全余量124MiB流量尖峰、页和估算误差

总预算必须通过实际压测、NMT、RSS、线程数和长期运行验证。不同服务不能套同一张表。

9.3 JDK 8参数示例

对于明确验证过的1GiB容器,可从保守配置实验:

text
-Xms384m
-Xmx384m
-XX:MaxMetaspaceSize=128m
-XX:MaxDirectMemorySize=128m
-Xss512k

把它们通过运行环境设置:

bash
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支持又与更晚更新和发行版补丁有关。

因此必须记录:

bash
docker exec order-api-jdk8 java -version
docker exec order-api-jdk8 java -XX:+PrintFlagsFinal -version

关注具体版本中实际存在的:

  • UseContainerSupport
  • MaxRAMPercentage
  • ActiveProcessorCount
  • Heap相关最终值。

不要把旧教程中的:

text
-XX:+UnlockExperimentalVMOptions
-XX:+UseCGroupMemoryLimitForHeap

与现代 UseContainerSupport 无条件混用。先检查目标JVM支持和默认值,否则可能直接因“不认识参数”启动失败。

10.1 如何验证JVM看到的Heap

接口 /api/ping 返回 Runtime.maxMemory()。也可以:

bash
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和内存:

bash
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:

bash
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支持,可评估:

text
-XX:ActiveProcessorCount=2

这不是通用补丁;应先修正JDK版本、验证cgroup识别,再按压测设置。

十三、线程池和连接池不能只按宿主机配置

容器只有1核却配置:

text
Tomcat maxThreads = 1000
业务线程池 = 500
Hikari maximumPoolSize = 200

不会自动提高吞吐,可能导致:

  • 线程栈占用大量内存。
  • 上下文切换。
  • 数据库连接数爆炸。
  • 请求在多个队列重复排队。
  • 超时后重试风暴。

需要从完整链路预算:

text
入口并发

Tomcat线程

业务线程池

数据库连接池

数据库最大连接与吞吐

Little's Law可辅助估算并发:系统平均并发约等于吞吐率乘平均停留时间,但真实系统还要考虑P99、突发、超时和队列上限。线程池配置应配合压测和下游容量,而不是按CPU核数公式机械决定所有IO服务。

十四、Spring Boot配置如何进入容器

可能来源:

text
镜像内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属性:

text
spring.datasource.url
spring.datasource.hikari.maximum-pool-size
management.endpoints.web.exposure.include

常见环境变量:

text
SPRING_DATASOURCE_URL
SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE
MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE

最终是否正确绑定要通过启动日志、Actuator /env/configprops 或应用行为验证。生产暴露这些端点必须鉴权和脱敏。

14.2 Profile

bash
docker run -e SPRING_PROFILES_ACTIVE=prod order-api:1.0

它选择Profile,不会自动生成 application-prod.yml,也不会保证数据库地址正确。检查:

bash
docker inspect --format '{{json .Config.Env}}' order-api
docker logs --tail 200 order-api

14.3 外部配置文件

bash
docker run -d \
  --mount type=bind,source=/data/order/config,target=/app/config,readonly \
  order-api:1.0

Spring Boot会在其配置搜索路径中查找文件,但具体文件名和位置受版本、spring.config.locationspring.config.additional-location 等影响。挂载成功不代表Spring一定读取。

还要防止空宿主机目录遮住镜像默认配置。使用:

bash
docker inspect --format '{{json .Mounts}}' order-api

14.4 修改配置后为什么restart可能没用

修改 docker run -e 或 Compose environment 后,重启旧容器不会重建 Env。需要按新配置重建,再 inspect 验证。

十五、Secret不能写进镜像和普通环境

不推荐:

dockerfile
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读取,并处理轮换。

十六、端口和监听地址

应用:

yaml
server:
  address: 0.0.0.0
  port: 8080

运行:

bash
docker run -p 127.0.0.1:18080:8080 order-api:1.0

关系:

text
宿主机127.0.0.1:18080
    ↓ Docker端口发布
容器IP:8080

Tomcat监听0.0.0.0:8080

如果 Spring Boot 只监听:

text
127.0.0.1:8080

容器内 curl localhost 可能成功,但Docker转发到容器网卡地址会失败。

检查:

bash
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,否则:

text
数据库抖动

所有应用liveness失败

平台同时重启所有实例

连接和启动流量进一步冲击数据库

17.2 Readiness

回答:当前实例是否可以接收用户流量。数据库、Redis、配置或预热未就绪时,可以让 readiness 失败并摘流,但不一定重启进程。

17.3 Startup

慢启动应用在初始化完成前可能无法通过 liveness。Kubernetes startupProbe用于保护启动期,成功后再启用liveness判断,避免应用尚未启动就反复杀死。

17.4 Docker Healthcheck

Dockerfile:

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:

yaml
logging:
  pattern:
    console: "%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} %-5level [%thread] %logger{36} traceId=%X{traceId:-} - %msg%n"

Docker日志:

bash
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参数明确:

text
-Duser.timezone=Asia/Shanghai

字符集:

text
-Dfile.encoding=UTF-8

但要理解:

  • 数据库连接有自己的时区和字符集参数。
  • JSON时间格式受Jackson配置影响。
  • Linux系统时区、JVM默认时区、数据库会话时区是不同层。
  • 容器通常共享宿主机内核时钟,时间漂移应查宿主机NTP,而不是只改TZ。
  • JDK镜像是否包含完整时区数据和字体要验证。

二十、非root与文件权限

镜像使用:

dockerfile
USER 10001:10001

应用可能需要写:

  • /tmp
  • 日志目录。
  • 上传目录。
  • Heap Dump。
  • 临时解压目录。

检查:

bash
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校验或信任所有证书。容器中出现:

text
PKIX path building failed

应检查服务端证书链、域名、系统时间、代理CA和JVM TrustStore。不要因为宿主机curl成功就认定JVM一定使用同一信任库。

二十二、Heap Dump、ErrorFile和诊断目录

JVM参数示意:

text
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/dumps
-XX:ErrorFile=/dumps/hs_err_pid%p.log

运行时挂载:

bash
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镜像较小,但通常没有完整 jcmdjstackjmap。选择:

方案优点风险
生产镜像使用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从项目模型到商业编排

yaml
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提供。配置中的资源数字只是起点,必须按压测调整。

启动前:

bash
docker compose config
docker compose pull
docker compose up -d --wait

启动后:

bash
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到生产的商业发布链路

mermaid
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 容器立即退出

bash
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但访问不到

bash
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 配置不生效

bash
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

bash
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等于容器限制

现象:

text
容器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。

三十二、学习验收

不看答案完成:

  1. 构建并运行JDK 8/Spring Boot 2.7 Demo,记录完整JDK版本。
  2. 说明Spring Boot 3为什么不能运行在JDK 8,并完成JDK 17镜像。
  3. 证明Exec form下JVM为PID 1,并观察SIGTERM停机日志。
  4. 为1GiB容器编写Heap外预算,使用stats和JVM指标验证。
  5. 比较JDK 8与17在相同CPU/内存限制下看到的资源。
  6. 使用环境变量、Profile和只读外部配置,并证明最终PropertySource。
  7. 分别制造liveness/readiness错误,说明重启与摘流后果。
  8. 以非root运行,验证配置、日志、临时目录和Dump权限。
  9. 将Heap Dump写入受控挂载,并说明数据安全和空间风险。
  10. 完成一次先摘流、SIGTERM、在途请求结束、容器退出的发布实验。

关联知识点