【阶段 9】运维部署:Docker、Kubernetes 与监控

47次阅读
没有评论

阶段 9 ·
从「能写代码」到「能上线稳定运行」。上线只是开始,能监控、能排障、能扩容才算闭环。


1. 导语

前 8 个阶段,你学会了用 Spring Boot 3.2.x
写接口、连数据库、做缓存、保证事务、设计安全。但你写的每一行代码,最终都要跑在一台真实的机器上、被真实的用户访问。代码写完只是「交付了一半」,剩下的一半是:它能不能稳定地跑下去?出问题的时候你能不能在一分钟内知道?知道之后能不能快速定位、快速恢复?

这就是本阶段要解决的问题。很多工程师把「部署」理解成「把 jar
包拷到服务器上用 java -jar
启动」,这在开发环境没问题,但在生产环境远远不够。生产的真实诉求是三件事:

  1. 能监控——应用的内存、GC、接口耗时、连接池,都要变成可见的指标,出了问题主动告警,而不是等用户来投诉。
  2. 能部署——用 Docker 把应用和环境打包成镜像,用
    Kubernetes
    做副本管理、滚动更新、健康检查和自动扩缩容,让「发布」变成一条可重复、可回滚的流水线。
  3. 能排障——日志集中采集到 ELK,JVM 参数调好,OOM 时有
    heapdump 留现场,能顺着线索定位到是内存泄漏还是内存不够。

学完本阶段,你能独立完成一条完整的「上线链路」:把订单服务打成优化过的
Docker 镜像 → 推送到镜像仓库 → 用 Deployment + Service + Ingress 部署到
Kubernetes → 用 Actuator + Prometheus + Grafana 监控它的每一项指标 →
出问题时用 heapdump + MAT 定位 OOM。

本阶段前置依赖阶段 2(Spring Boot 基础),不需要你会 Docker 或
K8s,但需要你有基本的 Linux 命令行经验(会
lscdps、看日志)。如果你对
Linux 还不熟,建议先花半天熟悉一下常用命令再看本篇。


2. 学习目标与前置要求

学完本阶段,你能:

  1. 独立配置 Spring Boot Actuator,暴露 health / metrics / prometheus
    端点,并写出自定义业务指标(Counter / Gauge / Timer)。
  2. 写出生产级的多阶段构建 Dockerfile,能把一个 Spring Boot
    应用镜像从几百 MB 优化到一百 MB 以内,并以非 root 用户运行。
  3. 独立编写 Deployment + Service + Ingress + ConfigMap + Secret 完整
    YAML,正确配置 readiness / liveness / startup 探针和 resources
    资源限制。
  4. 用 GitHub Actions 搭一条「构建 → 测试 → 打镜像 → 推送 → 部署」的完整
    CI/CD 流水线。
  5. 用 Filebeat → Logstash → Elasticsearch → Kibana
    把容器日志集中采集,实现日志不落地。
  6. 配置 G1 垃圾回收参数与优雅停机,并独立走完一次 OOM
    排查流程(heapdump + MAT 定位)。

前置依赖:

  • 阶段 2《Spring Boot 核心》——掌握依赖注入、配置绑定、分层架构。
  • 基础 Linux 命令行操作。
  • 若未掌握,建议先补:Docker 官方「Get Started」教程前 3 节;K8s
    官方「Kubernetes Basics」前 4 个模块。

3. 环境准备

本阶段需要以下工具,版本尽量与本篇一致,避免踩版本坑:

工具 版本 用途
JDK 17(Temurin) 编译运行
Spring Boot 3.2.x 应用框架
Maven 3.9.x 构建
Docker 24+ 镜像构建
kubectl 1.28+ 操作 K8s
kind / minikube 最新 本地 K8s 集群
Prometheus + Grafana 最新 监控

初始化一个可运行的项目骨架(后续所有章节都基于它):

# 用 Spring Initializr 生成项目,或手动创建
curl https://start.spring.io/starter.zip 
  -d dependencies=web,actuator,lombok 
  -d type=maven-project 
  -d language=java 
  -d javaVersion=17 
  -d bootVersion=3.2.5 
  -d groupId=com.example 
  -d artifactId=order-service 
  -d name=order-service 
  -o order-service.zip
unzip order-service.zip && cd order-service

pom.xml 中需要额外加入 Prometheus 指标注册器(Actuator
本身不包含 Prometheus 格式输出):

<dependency>
    <!-- Micrometer 的 Prometheus 实现:让 /actuator/prometheus 输出 Prometheus 格式指标 -->
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-registry-prometheus</artifactId>
</dependency>

项目结构(本阶段贯穿使用):

order-service
├── pom.xml
├── Dockerfile
├── .dockerignore
├── k8s/
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── configmap.yaml
│   ├── secret.yaml
│   ├── ingress.yaml
│   └── hpa.yaml
├── .github/workflows/
│   └── ci.yml
├── filebeat/filebeat.yml
└── src/main/
    ├── java/com/example/order/
    │   ├── OrderServiceApplication.java
    │   ├── controller/OrderController.java
    │   ├── service/OrderService.java
    │   ├── config/MetricsConfig.java
    │   └── health/DependencyHealthIndicator.java
    └── resources/
        ├── application.yml
        ├── application-prod.yml
        └── logback-spring.xml

说明:本阶段重点在「运维」而非业务,业务代码只保留一个极简的订单接口,作为被监控、被打包、被部署的「靶子」。


4. 正文章节

第 1 章:Actuator 与监控指标

1.1 为什么生产必须有监控

开发环境里,你调试问题靠
IDE、靠断点、靠重启。生产环境里,这些都不可用:你不能在生产服务器上打断点,重启一次可能就是一次线上事故。所以生产排障必须依赖「运行时可观测的数据」——也就是监控指标。

监控解决三个递进的问题:

  1. 发生了什么——接口每秒请求多少、耗时多少、内存用了多少、GC
    停顿多久。
  2. 是否健康——应用还能不能正常处理请求,依赖的下游(数据库、Redis)还通不通。
  3. 如何定位——某个指标异常时,能顺着指标找到对应的日志、堆转储,缩小排查范围。

Spring Boot 官方给出的答案是
Actuator(应用状态监控)+
Micrometer(指标门面)。Actuator
负责把应用内部状态暴露成 HTTP 端点,Micrometer 负责把指标统一成一套
API,再适配到不同的监控后端(Prometheus、Grafana、InfluxDB、云厂商监控)。

1.2 最小可用:暴露三个核心端点

先给一个最小可用配置,暴露
healthinfometricsprometheus
四个端点:

# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus   # 只暴露这 4 个,避免把 env/beans 等敏感端点暴露出去
  endpoint:
    health:
      show-details: always                        # 健康详情始终返回(生产建议 when_authorized)

启动应用后访问下面几个地址验证:

# 1. 健康检查:返回 UP / DOWN
curl http://localhost:8080/actuator/health
# {"status":"UP"}

# 2. 应用信息:来自 application.yml 里的 info.*
curl http://localhost:8080/actuator/info

# 3. 指标列表:返回所有可用指标的名字
curl http://localhost:8080/actuator/metrics | head -30

# 4. 单个指标:返回该指标的当前值
curl http://localhost:8080/actuator/metrics/jvm.memory.used

# 5. Prometheus 格式:供 Prometheus 抓取
curl http://localhost:8080/actuator/prometheus

关键点exposure.include
不要
envconfigpropsbeansheapdumpthreaddump,这些会泄露环境变量(含数据库密码)、配置项和堆内存数据。除非有严格的内网访问控制和鉴权,否则只暴露
healthinfometricsprometheus

1.3
生产级:生产环境必须看的指标

Micrometer 会自动采集 JVM、线程、连接池、HTTP
等大量指标。下面这张表是生产必看的核心指标,你不需要背全,但要知道「出问题先看哪几个」:

指标名 含义 告警阈值参考
jvm.memory.used / jvm.memory.max JVM 堆/非堆已用与上限 used/max > 80% 告警
jvm.gc.pause GC 停顿时长(Timer) p99 持续 > 1s 告警
jvm.gc.memory.allocated 每秒新分配内存,衡量分配速率 突增说明有大量临时对象
http.server.requests 接口请求次数、耗时(按 uri/status 分桶) 慢接口 p99 > 1s 告警
hikaricp.connections.active 连接池活跃连接数 接近 max 告警
hikaricp.connections.pending 等待获取连接的线程数 > 0 持续说明连接池不够
tomcat.threads.busy Tomcat 忙线程数 持续接近 max 告警
system.cpu.usage 进程 CPU 使用率 > 80% 持续告警
process.uptime 进程运行时长 突降说明刚重启过
logback.events 各日志级别的事件计数 error 突增告警

看单个指标的细节,用 tag
参数按维度过滤,比如看某个接口的请求耗时:

# 看 /api/orders 接口的请求耗时(Timer 类型指标返回统计值)
curl 'http://localhost:8080/actuator/metrics/http.server.requests?tag=uri:/api/orders'
# {"name":"http.server.requests",...,"measurements":[
#   {"statistic":"COUNT","value":1234},
#   {"statistic":"TOTAL_TIME","value":98.7},
#   {"statistic":"MAX","value":0.45}]}

关键点http.server.requests 是按
urimethodstatusoutcome
等 tag 分桶的,一个接口一个桶,方便你精确定位「哪个接口慢」。

1.4
自定义指标:业务指标才是指标的灵魂

框架自带的指标只能告诉你「JVM 健康」「HTTP
正常」,但你的业务健不健康——比如「每秒创建多少订单」「下单成功率多少」「库存查询耗时多少」——框架一无所知。生产监控的真正价值,在于把你的业务也变成指标

Micrometer 提供三种最常用的度量类型,先讲概念,再给生产级代码:

  • Counter(计数器):只增不减,适合统计「订单创建总数」「失败次数」。
  • Gauge(仪表盘):实时反映一个瞬时值,适合统计「当前待处理订单数」。
  • Timer(计时器):统计一段操作的耗时分布(count、sum、max、p99),适合统计「下单接口耗时」。

最小可用示例——注入 MeterRegistry
手动打点:

@Slf4j
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {

    // 构造器注入 MeterRegistry:Micrometer 的核心入口,所有指标都通过它注册
    private final MeterRegistry meterRegistry;

    @PostMapping
    public String createOrder() {
        // 每调用一次,order.create.total 计数 +1,标签 method=createOrder
        meterRegistry.counter("order.create.total", "method", "createOrder").increment();
        // 业务逻辑……
        return "ok";
    }
}

这个写法能跑,但有两个问题:一是把打点逻辑和业务逻辑混在一起,二是每处都手写指标名和
tag,容易拼错。生产级做法是把指标封装到独立的 Service
里,统一管理指标名和 tag。下面是完整版:

// service/OrderService.java
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {

    private final MeterRegistry meterRegistry;
    // 用 AtomicInteger 保存「当前待处理订单数」,Gauge 需要引用一个可变的值源
    private final AtomicInteger pendingOrders = new AtomicInteger(0);

    /**
     * 创建订单:演示三类指标的组合使用。
     * 为什么把打点放在 Service 而不是 Controller:指标属于业务语义,
     * 放在 Service 层能被所有调用方(Controller、定时任务、MQ 消费者)复用。
     */
    public OrderVO createOrder(CreateOrderDTO dto) {
        // Timer:记录下单总耗时,自动统计 count/sum/max/p99
        return Timer.builder("order.create.duration")
                .description("下单接口耗时")          // 指标描述,进 /actuator/metrics 详情
                .tag("channel", dto.getChannel())     // 按渠道分桶,方便看哪个渠道慢
                .register(meterRegistry)
                .record(() -> {
                    // 下单开始,待处理 +1
                    pendingOrders.incrementAndGet();
                    try {
                        // 模拟业务耗时(真实场景这里是查库存、写库、发 MQ)
                        Thread.sleep(50);
                        // Counter:下单成功 +1
                        meterRegistry.counter("order.create.success", "channel", dto.getChannel()).increment();
                        return new OrderVO("ORDER-" + System.currentTimeMillis(), "SUCCESS");
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();   // 恢复中断标记,不吞中断
                        // Counter:下单失败 +1
                        meterRegistry.counter("order.create.failure", "channel", dto.getChannel()).increment();
                        throw new BizException(ErrorCode.SYSTEM_ERROR);
                    } finally {
                        // 无论成败,待处理 -1
                        pendingOrders.decrementAndGet();
                    }
                });
    }

    /** 当前待处理订单数:Gauge 实时反映 pendingOrders 的值 */
    @PostConstruct
    public void registerGauge() {
        // Gauge 不主动存储值,而是每次采集时回调这个 AtomicInteger 读当前值
        Gauge.builder("order.pending.count", pendingOrders, AtomicInteger::get)
                .description("当前待处理订单数")
                .register(meterRegistry);
    }
}

Timer.record(Supplier)
的写法把「计时」和「业务」解耦,finally 保证 Gauge
不会因为异常而永远 +1 不回退。

更优雅的方式:用 @Timed 注解,配合 AOP
自动计时。需要开启一个配置类:

// config/MetricsConfig.java
@Configuration
@RequiredArgsConstructor
public class MetricsConfig {

    private final MeterRegistry meterRegistry;

    /**
     * 开启 @Timed 注解支持。
     * 为什么需要这个 Bean:@Timed 依赖 AOP 拦截,而 Actuator 默认不注册 TimedAspect
     * 没有它 @Timed 注解会静默失效——这是新手最容易踩的坑。
     */
    @Bean
    public TimedAspect timedAspect() {
        return new TimedAspect(meterRegistry);
    }
}
// 方法上加 @Timed,自动计时并生成 http 之外的业务指标
@Timed(value = "order.query.duration", description = "订单查询耗时", percentiles = {0.5, 0.95, 0.99})
public OrderVO queryOrder(String orderId) {
    // 业务逻辑……
    return new OrderVO(orderId, "SUCCESS");
}

percentiles = {0.5, 0.95, 0.99} 让 Prometheus 直接输出
p50/p95/p99 分位值,这是判断「慢接口」的关键数据。

1.5 自定义健康检查:让 health
说真话

默认的 /actuator/health
只检查「应用进程还活着」,它不知道你的数据库、Redis
还通不通。如果数据库挂了,health 仍然返回
UP,K8s
探针仍然认为你健康,流量仍然打进来,然后每个请求都报错——这比直接宕机更糟。

生产级做法:自定义
HealthIndicator,把关键依赖的真实可用性纳入健康检查:

// health/DependencyHealthIndicator.java
@Component
@RequiredArgsConstructor
@Slf4j
public class DependencyHealthIndicator implements HealthIndicator {

    // 注入 DataSource,探测数据库是否真的可用
    private final DataSource dataSource;

    @Override
    public Health health() {
        try (Connection conn = dataSource.getConnection()) {
            // 真的执行一条 SQL,而不是只看连接对象非空
            boolean ok = conn.isValid(2);
            if (ok) {
                return Health.up().withDetail("database", "UP").build();
            }
            return Health.down().withDetail("database", "connection invalid").build();
        } catch (Exception e) {
            log.error("数据库健康检查失败", e);
            // 关键点:返回 down 而不是抛异常;抛异常会让整个 health 端点 500
            return Health.down(e).withDetail("database", "DOWN").build();
        }
    }
}

关键点HealthIndicator
内部不要抛异常,捕获后返回
Health.down()。否则健康检查自身报错,探针拿到的是 500,K8s
会误判。

1.6
健康检查分组:为 liveness / readiness 做准备

Spring Boot 把健康状态拆成两个维度,正好对应 K8s 的两种探针(第 3
章会用到):

  • livenessState(存活状态):进程是否还活着,一旦变成
    BROKEN 就应该重启。
  • readinessState(就绪状态):是否准备好接流量,未就绪就摘除流量但不重启。

开启双探针端点最简单的方式:

# application.yml
management:
  endpoint:
    health:
      probes:
        enabled: true     # 自动生成 /actuator/health/liveness 和 /actuator/health/readiness
      show-details: always
curl http://localhost:8080/actuator/health/liveness
# {"status":"UP"}
curl http://localhost:8080/actuator/health/readiness
# {"status":"UP"}

把数据库依赖挂进 readiness(就绪)而非
liveness(存活),语义是:数据库挂了,应用进程还活着,不该重启,但应该停止接流量。用分组实现:

management:
  endpoint:
    health:
      group:
        readiness:
          include: readinessState,db          # 就绪 = 进程就绪 + 数据库可用
        liveness:
          include: livenessState              # 存活 = 只要进程活着

这里 db 是 DataSource 自动注册的 HealthIndicator
名称。readinessState / livenessState 是 Spring
Boot 内置的可用性状态指示器。

1.7 接入 Prometheus + Grafana

应用侧指标已经就绪,接下来用 Prometheus 抓取、Grafana
展示。这是监控体系的标准三件套:

# prometheus/prometheus.yml —— Prometheus 抓取配置
global:
  scrape_interval: 15s        # 每 15 秒抓一次

scrape_configs:
  - job_name: 'order-service'
    metrics_path: '/actuator/prometheus'     # 抓 Prometheus 格式的指标
    static_configs:
      - targets: ['order-service:8080']      # 应用地址

Grafana 里添加 Prometheus 数据源后,直接导入社区现成的 Spring Boot
仪表盘(Dashboard ID:12900 或 10280),即可看到 JVM
内存、GC、线程池、HTTP 请求的全景图。告警规则示例:

# prometheus/rules.yml —— 告警规则
groups:
  - name: order-service-alerts
    rules:
      - alert: HighMemoryUsage
        expr: sum(jvm_memory_used_bytes{area="heap"}) / sum(jvm_memory_max_bytes{area="heap"}) > 0.8
        for: 5m                       # 持续 5 分钟才告警,避免毛刺
        labels: { severity: warning }
        annotations: { summary: "堆内存使用率超过 80%" }
      - alert: SlowEndpoint
        expr: histogram_quantile(0.99, http_server_requests_seconds_bucket) > 1
        for: 5m
        labels: { severity: warning }
        annotations: { summary: "接口 p99 耗时超过 1s" }

关键点:告警规则加
for: 5m,让指标异常持续一段时间才触发,避免瞬时抖动造成告警风暴。

1.8 Micrometer
指标命名规范与标签陷阱

指标多了之后,命名混乱会导致「指标库变成垃圾场」。Micrometer
的命名规范(也适用于 Prometheus 迁移):

  1. 用点号分层order.create.duration
    orderCreateDuration 清晰,Prometheus
    抓取时会自动把点转成下划线(order_create_duration)。
  2. 带上单位后缀:时长用
    ...seconds,大小用 ...bytes,计数用
    ...count 或直接 ...total。不要用
    ...millis 这种隐含单位。
  3. Timer 的命名http.server.requests
    这种是「名词 + 动作」,业务侧 order.create.duration
    同理。
  4. Counter
    命名
    order.create.success,Prometheus 里自动加
    _total 后缀。

标签(tag)是高危区。tag
会让一个指标拆成多个时间序列,tag 的取值组合数 = 时间序列数。如果 tag
取值无限增长,时间序列就无限增长,最后把 Prometheus
的内存和存储撑爆——这叫高基数(high
cardinality)问题

// 反例:用 orderId 做 tag —— 每个订单一个序列,Prometheus 内存爆炸
meterRegistry.counter("order.create", "orderId", orderId).increment();

// 正例:只用低基数的维度(渠道、结果、状态),订单号放日志不进指标
meterRegistry.counter("order.create.success", "channel", channel).increment();

判断 tag 是否安全的标准:这个 tag
的取值是不是「有限且可枚举」的集合。channel(web/app/h5)、status(success/failure)、region(华东/华南)都安全;userIdorderIdtraceId
都不安全,这些信息应该进日志,而不是进指标。

1.9 用 loggers
端点动态调整日志级别

生产排障经常遇到「线上日志级别是 INFO,看不到 DEBUG
细节,但重启改配置代价太大」。Actuator 的 loggers
端点能运行时动态改日志级别,不用重启

# 查看某个包的当前日志级别
curl http://localhost:8080/actuator/loggers/com.example.order

# 临时把某个包的日志级别调到 DEBUG
curl -X POST http://localhost:8080/actuator/loggers/com.example.order 
  -H "Content-Type: application/json" 
  -d '{"configuredLevel":"DEBUG"}'

# 排查完调回 INFO(或调成 null 恢复默认)
curl -X POST http://localhost:8080/actuator/loggers/com.example.order 
  -H "Content-Type: application/json" 
  -d '{"configuredLevel":"INFO"}'

需要把 loggers 加进
exposure.include关键点loggers
端点能改日志级别,属于敏感操作,生产环境务必配合 Spring Security
做鉴权,只对内网/运维账号开放。

本章小结:Actuator 暴露状态、Micrometer
统一指标、Prometheus 抓取、Grafana
展示,四者串起来就是一套完整的监控链路;而真正让监控「有用」的,是你自定义的业务指标、说真话的健康检查,以及「低基数
tag」这个让指标库不爆炸的纪律。


第 2 章:Docker
镜像构建与优化

2.1 为什么是 Docker

直接在生产服务器上 java -jar 有三大隐患:

  1. 环境不一致——开发机是 JDK 17,服务器可能是 JDK
    8;你本地装了一堆依赖,服务器没有。经典的「我本地能跑」问题。
  2. 配置漂移——服务器被不同的人手动改过,谁也说不清现在的状态,新机器部署要重新踩一遍坑。
  3. 不可迁移——换个服务器、扩容一台机器,都要重新配一遍环境。

Docker 把「应用 + 运行时 +
依赖」打包成一个镜像(Image),镜像在任何装了 Docker
的机器上运行结果一致。镜像跑起来就是容器(Container)。你把镜像推到镜像仓库,任何环境都能拉下来用同一份「环境
+ 代码」。

Docker 的核心三要素:

概念 说明 类比
Dockerfile 描述「如何构建镜像」的文本文件 菜谱
Image 按 Dockerfile 构建出的只读文件系统快照 做好的菜(冷冻)
Container 镜像的运行实例,有自己的进程、网络、文件系统 热好的菜(上桌)

2.2
最小可用:一个能跑起来的 Dockerfile

先看最朴素、能跑但不推荐上生产的写法,理解每个指令的作用:

# 最小可用 Dockerfile(能跑,但镜像巨大,仅用于理解指令)
FROM maven:3.9-eclipse-temurin-17            # 基础镜像:带 Maven + JDK 17,约 600MB
WORKDIR /app                                 # 后续指令的工作目录
COPY . .                                    # 把整个项目(含 src、pom.xml)拷进镜像
RUN mvn -B package -DskipTests              # 在镜像里执行 Maven 打包
EXPOSE 8080                                 # 声明容器监听 8080(仅文档作用,不真正发布端口)
ENTRYPOINT ["java", "-jar", "target/order-service-0.0.1-SNAPSHOT.jar"]
docker build -t order-service:naive .
docker images order-service   # 看镜像大小:通常 700MB+

这个镜像为什么大?因为它把
Maven、JDK、源码、编译产物全塞进了最终镜像,运行时根本不需要
Maven 和源码,也不需要完整的 JDK(只需要
JRE)。这就是「多阶段构建」要解决的问题。

2.3
多阶段构建:把「构建环境」和「运行环境」拆开

多阶段构建的核心思想:用一个大镜像构建,用一个小镜像运行,中间只拷贝最终产物。一个
FROM 就是一个阶段,后面的阶段用
COPY --from=阶段名 从前面阶段取文件:

# ============ 阶段 1:构建(builder)============
# 用带 Maven + JDK 的大镜像做编译,这个镜像只存在于构建过程,不进最终镜像
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
# 先只拷 pom.xml,单独执行依赖下载,利用 Docker 分层缓存(详见 2.4)
COPY pom.xml .
RUN mvn -B dependency:go-offline
# 再拷源码编译。改代码时,上面的依赖层不变,这步才重新执行
COPY src ./src
RUN mvn -B package -DskipTests

# ============ 阶段 2:运行(runtime)============
# 用精简的 JRE 镜像(只有运行时,无编译工具),约 250MB,是 JDK 镜像的一半不到
FROM eclipse-temurin:17-jre
WORKDIR /app
# 只从 builder 阶段拷贝最终的 jar,Maven、源码、依赖缓存统统不带走
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
# 用 exec 形式 ENTRYPOINT,让 java 成为 PID 1,能正确接收 SIGTERM 优雅停机
ENTRYPOINT ["java", "-jar", "app.jar"]
docker build -t order-service:1.0 .
docker images order-service   # 对比:naive 700MB+,多阶段后约 300MB 左右

关键点:为什么
COPY --from=builder /app/target/*.jar app.jar
能显著减小镜像——最终镜像只包含 JRE + 一个 jar,构建用的 Maven 和 JDK
都留在了 builder 阶段的临时层里,被直接丢弃。

2.4
分层缓存:让二次构建快到秒级

Docker 镜像是分层的,Dockerfile
里每条指令生成一层。构建时,如果某一层的输入没变,Docker
直接复用缓存,不重新执行。所以 Dockerfile
的顺序直接决定构建速度

  • 把最不易变的放前面(基础镜像、依赖),最易变的放后面(源码、产物)。
  • COPY pom.xml
    RUN mvn dependency:go-offline,是为了:只要
    pom.xml 不变,依赖层就命中缓存,改 Java
    代码后重构建只需重新编译源码,几秒钟完成;如果反过来先
    COPY . .,改任何一行代码都会让依赖层失效,每次都要重新下载几百
    MB 依赖。

.dockerignore
也很关键——它决定哪些文件进构建上下文。不写它,COPY . .
会把 target/(可能几百 MB
的历史产物)、.git/*.iml
全拷进去,构建又慢又脏:

# .dockerignore —— 排除不进镜像的文件
target/          # 构建产物,镜像里会重新生成,无需拷入
.git/            # 版本库,与运行无关
.idea/           # IDE 配置
*.iml
*.log

2.5 非 root 运行:安全性的底线

默认情况下,容器里的进程以 root
身份运行。一旦应用有漏洞被攻破,攻击者就拿到了容器内的 root
权限。生产铁律:容器进程必须降权为普通用户运行

FROM eclipse-temurin:17-jre
# 创建普通用户 appuser(-r 表示系统用户,无登录 shell)
RUN groupadd -r app && useradd -r -g app appuser
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
# 关键:先拷贝文件再 chown,最后才 USER 切换;顺序反了会导致 appuser 无写权限
RUN chown -R appuser:app /app
USER appuser
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

关键点USER appuser 必须放在所有需要
root
权限的操作(RUNchown)之后。切换用户后,后续指令都以
appuser 执行,无法再改系统文件。验证是否生效:

docker run -d --name check order-service:1.0
docker exec check whoami   # 输出应为 appuser 而不是 root

eclipse-temurin:17-jre
250MB,还可以再优化两个档次:

方案一:换 alpine 精简版 JRE。Alpine 是超轻量 Linux
发行版,JRE 的 alpine 版本能再小约 60MB:

FROM eclipse-temurin:17-jre-alpine
# alpine 没有 groupadd/useradd,用 addgroup/adduser
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN chown -R app:app /app
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

注意:alpine 使用 musl libc 而非 glibc,绝大多数 Spring Boot
应用无感,但少数依赖原生库(如某些加解密库)可能出现兼容问题,上线前必须完整回归测试。

方案二:用 jlink 定制最小 JRE。jlink 是 JDK 9+
自带的工具,能只打包应用真正用到的 JDK 模块,把运行时压到几十 MB:

# 阶段 1:构建(同上,略)
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B package -DskipTests

# 阶段 2:用 jlink 裁剪定制 JRE
# jdeps 扫描 jar 依赖哪些 JDK 模块,jlink 只保留这些模块
FROM eclipse-temurin:17-jre AS jre-builder
RUN jlink --add-modules java.base,java.logging,java.sql,java.naming,java.desktop,java.management,java.security.jgss,java.instrument 
          --strip-debug --no-header-files --no-man-pages 
          --output /custom-jre

# 阶段 3:运行(alpine + 定制 JRE,最终镜像可压到 100MB 以内)
FROM alpine:3.19
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
COPY --from=jre-builder /custom-jre /opt/jre
RUN chown -R app:app /app
USER app
EXPOSE 8080
ENTRYPOINT ["/opt/jre/bin/java", "-jar", "app.jar"]

镜像大小对比(同一订单服务实测的近似值,随依赖略有浮动):

构建方式 基础镜像 镜像大小
单阶段(maven + JDK) maven:3.9-eclipse-temurin-17 700 MB+
多阶段(JRE) eclipse-temurin:17-jre 约 300 MB
多阶段(alpine JRE) eclipse-temurin:17-jre-alpine 约 200 MB
多阶段(jlink 定制 JRE) alpine + 定制 JRE 100 MB 以内

--add-modules 的模块列表要根据你的应用实际依赖用
jdeps --print-module-deps app.jar
扫描得到,这里给出的是一个 Spring Boot Web
应用常见的模块集合,直接照抄可能有缺漏,以 jdeps
输出为准。

2.7 构建与运行命令

# 构建并打 tag
docker build -t order-service:1.0 .

# 本地运行:-d 后台,-p 端口映射,-e 传环境变量,--name 命名
docker run -d 
  -p 8080:8080 
  --name order-service 
  -e SPRING_PROFILES_ACTIVE=prod 
  --memory=1g --cpus=1               # 容器资源限制(对应 K8s 的 limits)
  order-service:1.0

# 看日志(容器 stdout)
docker logs -f order-service

# 进入容器排查
docker exec -it order-service sh

# 停止/删除
docker stop order-service && docker rm order-service

2.8
镜像仓库:把镜像推到能拉的地方

本地 docker build 出来的镜像只能在本机用,要让 K8s
集群、CI
流水线、其他同事都能拉到,必须推到镜像仓库。常见选择:

仓库 特点 适用
Docker Hub 官方公共仓库,免费额度有限 个人/开源
GitHub Container Registry (ghcr.io) 与 GitHub 集成,CI 里免额外登录 GitHub 托管代码
私有 Harbor 自建、有漏洞扫描和权限管理 企业内网

推送流程:

# 1. 登录仓库
docker login ghcr.io -u <用户名>

# 2. 打上「仓库前缀」的 tag(仓库名是 tag 的一部分)
docker tag order-service:1.0 ghcr.io/yourname/order-service:1.0

# 3. 推送
docker push ghcr.io/yourname/order-service:1.0

# 4. 别的机器拉取
docker pull ghcr.io/yourname/order-service:1.0

关键点:生产用不可变 tag(如 commit
SHA、语义化版本 1.0.0)而不是
latestlatest
是「最新」的别名,你无法确定它具体是哪个提交,回滚时也说不清「上一个版本」是什么。这也是第
4 章 CI 里用 ${{ github.sha }} 打 tag 的原因。

2.9
镜像安全扫描:上线前最后一道闸

镜像里可能带着有漏洞的依赖(比如某个 log4j
版本)。上线前用扫描工具过一遍:

# Docker 自带的 Scout 扫描
docker scout quickview order-service:1.0

# 或用开源 Trivy
trivy image order-service:1.0

扫描结果会列出**高危漏洞(Critical/High)**及对应 CVE
编号。处理优先级:Critical/High
必须修(升级依赖版本或基础镜像),Medium/Low
评估影响后再定。把扫描步骤加进 CI(第 4
章),让「有高危漏洞的镜像推不上去」变成自动闸门。

2.10 Docker
Compose:本地编排多个容器

一个应用往往带数据库、Redis 等依赖。本地开发时手动
docker run 每个容器太繁琐,用 Docker Compose 一键编排:

# docker-compose.yml
services:
  mysql:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: order_db
    ports: ["3306:3306"]
    volumes:
      - mysql-data:/var/lib/mysql    # 数据持久化,容器删了数据还在
    healthcheck:                     # 等 MySQL 就绪再启动应用
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 5s
      timeout: 3s
      retries: 10

  order-service:
    build: .                         # 直接用 Dockerfile 构建
    ports: ["8080:8080"]
    environment:
      SPRING_PROFILES_ACTIVE: prod
      DB_URL: jdbc:mysql://mysql:3306/order_db
      DB_USERNAME: root
      DB_PASSWORD: password
    depends_on:
      mysql:
        condition: service_healthy   # 依赖 MySQL 健康检查通过
    mem_limit: 1g                    # 本地也设资源限制

volumes:
  mysql-data:
docker compose up -d       # 一键启动所有服务
docker compose logs -f order-service
docker compose down        # 停止并删除(加 -v 连数据卷一起删)

关键点depends_on.condition: service_healthy
只有在 MySQL 通过自身 healthcheck
后才启动应用,避免「应用起来了数据库还没好」的竞态。这是本地编排和 K8s
readiness 探针共同的思想。

2.11 常用运维命令速查

# 镜像
docker images                                   # 列出本地镜像
docker rmi <镜像id>                              # 删除镜像
docker image prune                              # 清理悬空镜像

# 容器
docker ps -a                                    # 列出所有容器(含已停止)
docker start/stop/restart <容器名>
docker rm -f <容器名>                            # 强制删除

# 日志与排查
docker logs -f --tail 200 <容器名>               # 跟读最近 200 行
docker exec -it <容器名> sh                      # 进入容器
docker inspect <容器名>                          # 看容器详细配置(含 IP、资源限制)

# 资源占用
docker stats                                    # 实时看每个容器 CPU/内存

# 进入一个正在运行的容器排查,但不安装任何工具(用宿主机命令)
docker cp <容器名>:/app/app.jar ./               # 从容器拷文件出来

2.12 Dockerfile 指令速查

写 Dockerfile
不用背所有指令,但核心几个必须烂熟。下面这张表覆盖了生产 Dockerfile
用到的全部指令:

指令 作用 常见坑
FROM 指定基础镜像,多阶段构建里出现多次 用具体版本 tag,别用 latest
WORKDIR 设置工作目录,后续相对路径基于它 目录不存在会自动创建
COPY 从构建上下文拷文件进镜像 ADD 更可控,无自动解压/下载
RUN 构建时执行命令(结果是新的一层) 多条 RUN 合并成一条,减少层数
EXPOSE 声明监听端口(仅文档作用) 不真正发布端口,发布靠 -p
ENV 设置环境变量 敏感信息别写 ENV,用运行时注入
ARG 构建参数,构建时可用 --build-arg 仅构建期可见,运行期拿不到
USER 指定运行用户(非 root 运行的关键) 必须放在 chown 等 root 操作之后
ENTRYPOINT 容器启动的固定命令 用 exec 形式 ["java","-jar",...] 才能正确接收信号
CMD 默认参数/命令,可被 docker run 覆盖 与 ENTRYPOINT 配合:ENTRYPOINT 定命令,CMD 定默认参数

关键点ENTRYPOINTexec
形式
(JSON 数组)而不是 shell 形式(字符串)。shell 形式会在
java 外面包一层 /bin/sh -c,导致 java 不是 PID 1,收不到
SIGTERM,优雅停机失效。这是容器里优雅停机失效最常见的原因之一。

2.13 镜像优化的完整清单

把本阶段所有优化手段汇总成一张检查清单,构建前逐项过:

# 构建前自查
[ ] 用了多阶段构建(构建 JDK、运行 JRE 分离)
[ ]  COPY pom.xml 再 COPY src(利用依赖缓存)
[ ]  .dockerignore,排除了 target/.git/IDE 文件
[ ] 运行阶段用 JRE/alpine 而非 JDK
[ ]  USER 切换到非 root 用户
[ ] ENTRYPOINT 用 exec 形式
[ ] 镜像 tag 用版本号/SHA 而非 latest
[ ] 构建后跑过 docker scout/trivy 扫描

关键点:优化是有优先级的——「多阶段 + JRE」能减掉 60%
以上的体积,是性价比最高的两步;「alpine + jlink」是进阶,收益递减;「非
root +
安全扫描」不减小体积但直接影响安全,优先级同样靠前。先做大头,再抠细节。

2.14 实测对比:普通构建 vs
多阶段

拿同一个订单服务,实际跑一遍对比,感受差异:

# 方案 A:普通单阶段构建
docker build -f Dockerfile.naive -t order-service:naive .
docker images order-service:naive
# REPOSITORY          TAG     IMAGE ID       SIZE
# order-service       naive   xxxxxxxxxxxx   742MB

# 方案 B:多阶段构建(JRE)
docker build -f Dockerfile -t order-service:jre .
docker images order-service:jre
# REPOSITORY          TAG     IMAGE ID       SIZE
# order-service       jre     xxxxxxxxxxxx   312MB

# 方案 C:多阶段 + alpine JRE
docker build -f Dockerfile.alpine -t order-service:alpine .
docker images order-service:alpine
# REPOSITORY          TAG     IMAGE ID       SIZE
# order-service       alpine  xxxxxxxxxxxx   208MB

三个版本功能完全一样,体积差了 3.5
倍。体积的差异直接体现在:

影响 742MB 镜像 208MB 镜像
首次拉取时间(10MB/s) 约 74 秒 约 21 秒
K8s 节点磁盘占用 3 副本 ≈ 2.2GB 3 副本 ≈ 620MB
攻击面(含 JDK 编译工具)

关键点:镜像体积不是「洁癖」,而是真金白银的成本——更快的发布(拉取快)、更省的存储、更小的攻击面。多阶段构建是唯一成本为零的优化(只改
Dockerfile 写法,不改任何业务代码)。

2.15
把测试也放进构建阶段:一个 Dockerfile 走完 CI

多阶段构建不仅能分离「构建/运行」,还能加一个测试阶段,让
Docker 构建本身就把测试跑了,保证「打出来的镜像一定是测过的」:

# 阶段 0:测试(test)—— 跑单元测试,失败则整个构建失败
FROM maven:3.9-eclipse-temurin-17 AS test
WORKDIR /app
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B test                          # 测试失败,docker build 直接报错中断

# 阶段 1:构建(builder)
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B package -DskipTests           # 测试已在 test 阶段跑过

# 阶段 2:运行(runtime)
FROM eclipse-temurin:17-jre-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN chown -R app:app /app
USER app
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

关键点RUN mvn test 失败会让
docker build 返回非零退出码,CI 里
docker build
这步就红了,镜像推不上去——「没测过的代码进不了镜像」变成一条硬约束。这与第
4 章 CI 里单独跑 mvn test 是互补的:CI
里跑一次拿到测试报告,Dockerfile 里再兜底一次保证镜像质量。

本章小结:生产级 Dockerfile = 多阶段构建(构建用
JDK、运行用 JRE)+ 分层缓存(先拷依赖再拷源码)+ 非 root 运行 +
精简基础镜像 + 不可变 tag;这五个技巧能把镜像从 700MB 压到 100MB
以内,同时显著提升构建速度、安全性和可追溯性。


第 3 章:Kubernetes 部署

3.1 为什么需要 Kubernetes

Docker
解决了「单个容器怎么跑」,但没有解决「一群容器怎么管」。生产环境里,你的应用通常要跑
3
个副本(高可用)、要滚动更新(不停机发版)、要故障自愈(挂了自动拉起)、要自动扩缩容(流量翻倍时加机器)。这些靠手敲
docker run 是管不过来的。

Kubernetes(K8s)就是干这件事的:它是容器编排平台,你告诉它「我要 3
个副本、每个给 512M
内存、健康检查走这个接口、更新时一个个替换」,它来保证这个状态一直成立。

3.2 核心对象一张表

对象 作用 类比
Pod 最小调度单位,一个或多个容器的集合 一个「工位」
Deployment 管理 Pod 副本数、滚动更新、回滚 排班表(管多少人上班)
Service 稳定的访问入口,给 Pod 做负载均衡 前台(固定电话转接)
Ingress 对外暴露 HTTP 服务 + 路由规则 门禁(按 URL 分流)
ConfigMap 非敏感配置(明文) 公告栏
Secret 敏感配置(base64 编码存储) 保险箱
HPA 根据 CPU/内存等指标自动扩缩容 自动排班(忙时加人)

3.3 最小可用:一个 Pod

先理解 Pod 长什么样,再学 Deployment 怎么管它:

# pod.yaml —— 单个 Pod(一般不用,仅供理解,生产用 Deployment 管理)
apiVersion: v1
kind: Pod
metadata:
  name: order-service-pod
  labels:
    app: order-service            # label 是 Service 选择 Pod 的依据
spec:
  containers:
    - name: order-service
      image: order-service:1.0
      ports:
        - containerPort: 8080
kubectl apply -f pod.yaml
kubectl get pods            # 看 Pod 状态
kubectl logs order-service-pod   # 看日志
kubectl delete pod order-service-pod   # Pod 删了就没了,不会自动重建

Pod 最大的特点是「临时性」:删了就没了,IP 也会变。所以生产从不直接管
Pod,而是用 Deployment 声明「我要几个 Pod」,Pod 挂了
Deployment 自动补一个。

3.4 生产级
Deployment:副本、滚动更新、资源限制

# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
  labels:
    app: order-service
spec:
  replicas: 3                       # 3 副本高可用
  selector:
    matchLabels:
      app: order-service            # 必须与 template.metadata.labels 一致,否则报错
  strategy:                         # 滚动更新策略
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1                   # 更新时最多多出 1 个新 Pod
      maxUnavailable: 0             # 更新时不允许可用副本数减少(保证零停机)
  template:                         # Pod 模板,下面才是真正的 Pod 定义
    metadata:
      labels:
        app: order-service
    spec:
      containers:
        - name: order-service
          image: order-service:1.0
          imagePullPolicy: IfNotPresent   # 本地镜像不重复拉取;生产配 Always 或带 digest
          ports:
            - containerPort: 8080
          envFrom:                  # 从 ConfigMap 批量注入环境变量
            - configMapRef:
                name: order-service-config
            - secretRef:
                name: order-service-secret
          # ---- 健康检查:本章 3.5 重点 ----
          startupProbe:             # 启动探针:给慢启动留足时间
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            failureThreshold: 30    # 最多等 30 * 10s = 300s 启动
            periodSeconds: 10
          readinessProbe:           # 就绪探针:就绪才接流量
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 5
            failureThreshold: 3     # 连续 3 次失败摘除流量
          livenessProbe:            # 存活探针:挂了就重启
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
            failureThreshold: 3     # 连续 3 次失败才重启,避免误杀
          # ---- 资源限制:必须设置(本章 3.6 重点)----
          resources:
            requests:               # 调度保证量:向集群申请的最小资源
              memory: "512Mi"
              cpu: "500m"           # 500m = 0.5 核
            limits:                 # 硬上限:超出会被限流/OOM 杀死
              memory: "1Gi"
              cpu: "1"

关键点

  • selector.matchLabels 必须精确匹配
    template.metadata.labels,这是 Deployment 找到「它管的
    Pod」的依据,对不上会报
    selector does not match template labels
  • maxUnavailable: 0 + maxSurge: 1
    保证滚动更新时可用副本数只增不减,实现真正的零停机发版。
  • imagePullPolicy: IfNotPresent
    用于本地测试(kind/minikube 加载本地镜像);生产仓库里应改为
    Always 或用带 digest 的不可变 tag。

3.5 readiness /
liveness / startup 探针:生产必知

这是本阶段最重要的概念之一,也是面试和线上事故的高发区。三种探针各管一件事,职责不能混:

探针 问的问题 失败后的动作 典型用途
liveness(存活) 进程死了吗? 重启容器 死锁、内存耗尽、进程卡死
readiness(就绪) 准备好接流量了吗? 从 Service 摘除,不重启 依赖未就绪、预热中、连接池满
startup(启动) 启动完成了吗? 未完成前暂停 liveness/readiness 判定 保护慢启动应用

为什么三者缺一不可,看两个真实场景:

场景一:只配 liveness,不配 readiness。 应用启动需要
60 秒(加载配置、建连接池)。如果 liveness 的
initialDelaySeconds: 30failureThreshold: 3,那么
30 秒后开始探测,连续 3 次失败(约 30
秒后)就把还没启动完的容器杀了重启——然后无限循环,应用永远起不来。这就是
liveness 配得太激进导致的启动死循环。解法就是加
startupProbe,让它先等应用真正启动完。

场景二:liveness 和 readiness 用同一个探针。
数据库抖动 10 秒,readiness 失败 → 流量被摘除,这是对的;但如果 liveness
也指向同一个依赖检查,失败 3
次后容器被重启——重启解决不了数据库问题,反而让应用雪上加霜地反复重启。所以铁律:liveness
只检查「进程本身是否活着」(越轻越好),readiness
才检查「依赖是否可用」

对应到第 1 章的配置,liveness 指向
/actuator/health/liveness(只含
livenessState,永远只要进程活着就
UP),readiness 指向
/actuator/health/readiness(含
readinessState + db,数据库挂了就 DOWN 摘流量)。

探针通用参数:

probes:
  httpGet:
    path: /actuator/health/liveness
    port: 8080
  initialDelaySeconds: 30   # 容器启动后等多久才开始第一次探测
  periodSeconds: 10         # 每隔多久探测一次
  timeoutSeconds: 3         # 单次探测超时时间
  successThreshold: 1       # 连续成功几次算就绪(readiness 默认 1)
  failureThreshold: 3       # 连续失败几次算失败(liveness 失败则重启)

关键点timeoutSeconds 必须小于
periodSeconds,否则上一次探测还没超时,下一次就开始了,探针会互相叠加。

3.6 资源限制:不设限制 =
给集群埋雷

为什么容器必须设 resources:K8s
里,一个不设资源限制的容器会被当作「可以用任意多资源」。一旦它内存泄漏,会一直吃内存,直到把同节点的其他
Pod 挤到 OOM 被杀——这就是「一个坏邻居拖垮一栋楼」。设了
limits 后,容器内存超限会被内核 OOM Killer
直接杀掉,只影响它自己。

resources:
  requests:            # 声明「我需要这么多」——调度器按它找节点,也是 HPA 计算的基准
    memory: "512Mi"
    cpu: "500m"
  limits:              # 声明「我最多用这么多」——超了就被限流(CPU)或杀死(内存)
    memory: "1Gi"
    cpu: "1"

关键点

  • requests 决定 Pod 被调度到哪个节点(节点剩余资源必须 ≥
    requests 总和);limits 决定 Pod 最多用多少。
  • 内存的 requests 和 limits 建议设成相等(如都是
    1Gi)。原因:内存超限会被杀,如果 requests 是 512M 而 limits 是
    1G,节点可能按 512M 计算「还有空间」,结果实际用到 1G
    时节点内存爆了,反而误杀别的 Pod。
  • CPU
    是「可压缩」资源,超限只被限流不会死;内存是「不可压缩」资源,超限直接
    OOM Kill。

3.7
Service、ConfigMap、Secret、Ingress、HPA

Service:Pod 的 IP 会变,Service 提供一个稳定的虚拟
IP(ClusterIP)和 DNS 名,把流量负载均衡到后端 Pod:

# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: order-service
spec:
  selector:
    app: order-service          # 通过 label 选择后端 Pod
  ports:
    - port: 8080                # Service 对外端口
      targetPort: 8080          # 后端 Pod 的端口
      protocol: TCP
  type: ClusterIP               # 集群内部访问;对外暴露用 LoadBalancer 或 NodePort

ConfigMap / Secret:把配置从镜像里解耦出来,同一镜像
+ 不同配置就能跑不同环境:

# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: order-service-config
data:
  SPRING_PROFILES_ACTIVE: "prod"
  SERVER_PORT: "8080"
  DB_URL: "jdbc:mysql://mysql:3306/order_db"
---
# k8s/secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: order-service-secret
type: Opaque
# 注意:secret 的值必须 base64 编码;这里为演示写的原文,实际先 echo -n 'xxx' | base64
data:
  DB_USERNAME: cm9vdA==          # base64("root")
  DB_PASSWORD: cGFzc3dvcmQ=      # base64("password")
# 生成 base64 的正确姿势
echo -n 'root' | base64        # cm9vdA==
kubectl apply -f configmap.yaml -f secret.yaml

关键点:Secret 只是 base64
编码,不是加密,任何能读 Secret
的人都能解出来。生产应配合 RBAC 严格限制 Secret
的读取权限,或用外部密钥管理系统(Vault、云厂商 KMS)注入。

Ingress:把集群内的 Service
暴露到集群外,并按域名/路径路由:

# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: order-service
spec:
  rules:
    - host: order.example.com
      http:
        paths:
          - path: /api
            pathType: Prefix
            backend:
              service:
                name: order-service
                port:
                  number: 8080

HPA:根据 CPU/内存指标自动增减副本数:

# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: order-service
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-service
  minReplicas: 3
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70   # 平均 CPU 使用率超 70% 就扩容

3.8 部署与常用命令

# 依次应用所有资源
kubectl apply -f k8s/configmap.yaml -f k8s/secret.yaml
kubectl apply -f k8s/deployment.yaml -f k8s/service.yaml
kubectl apply -f k8s/ingress.yaml -f k8s/hpa.yaml

# 查看状态
kubectl get pods -w                    # 持续观察 Pod 状态变化
kubectl get deployment order-service
kubectl describe pod <pod-name>        # 排障首选:看事件、探针、资源

# 滚动更新:改镜像 tag 后 apply,或直接 set image
kubectl set image deployment/order-service order-service=order-service:2.0
kubectl rollout status deployment/order-service   # 等待更新完成

# 回滚:发版出问题时的救命稻草
kubectl rollout undo deployment/order-service     # 回滚到上一个版本
kubectl rollout history deployment/order-service  # 看历史版本

# 查看探针与资源是否生效
kubectl get pod <pod-name> -o yaml | grep -A5 -E 'probe|resources'

3.9 K8s
架构速览:先有地图再走路

理解 K8s 的「谁在干什么」,能帮你少踩一半坑。集群分两大部分:

组件 属于 职责
kube-apiserver 控制面(Master) 集群唯一入口,所有操作都经过它
etcd 控制面 存储集群所有状态(键值库)
kube-scheduler 控制面 决定 Pod 调度到哪个节点
kube-controller-manager 控制面 运行各种控制器,维持「期望状态」
kubelet 每个节点 接收指令,真正创建/管理容器
kube-proxy 每个节点 维护网络规则,实现 Service 负载均衡

K8s 的核心是声明式 + 调和循环:你在 YAML
里声明「我要 3 个副本」,controller
不断对比「当前状态」和「期望状态」,不一致就调整。所以
kubectl apply
不是「执行命令」,而是「提交期望」,集群自己会朝那个状态收敛。

# 查看集群组件状态
kubectl get nodes            # 看节点
kubectl get namespaces       # 看命名空间
kubectl cluster-info         # 看控制面地址

3.10
滚动更新策略详解:四种发版方式对比

Deployment 的 strategy
支持两种内置策略,加上两种更高级的模式:

策略 原理 优点 缺点
Recreate(重建) 先全删旧 Pod,再建新 Pod 简单 有停机窗口
RollingUpdate(滚动) 一个个替换 Pod 零停机 新旧版本短暂共存
蓝绿部署 另起一套新版本,切换流量 回滚快 需要双倍资源
金丝雀发布 少量流量切到新版本,逐步放量 风险最小 需要网关配合

滚动更新(RollingUpdate) 是内置默认策略,参数含义见
3.4。核心公式:更新过程中,可用副本数 =
replicas - maxUnavailable
replicas + maxSurge 之间。想要零停机,就设
maxUnavailable: 0

strategy:
  type: RollingUpdate
  rollingUpdate:
    maxSurge: 1              # 更新时可多创建的新 Pod 上限
    maxUnavailable: 0        # 更新时不可用 Pod 上限(0 = 始终满员服务)

回滚是滚动更新最大的福利——发版出问题,一条命令回到上一版:

kubectl rollout undo deployment/order-service            # 回滚上一版
kubectl rollout undo deployment/order-service --to-revision=2   # 回滚到指定版本
kubectl rollout history deployment/order-service         # 看所有历史版本

注意:rollout undo 回滚的是 Pod
模板,配置类资源(ConfigMap/Secret)的改动需要单独回滚,所以 ConfigMap
变更也要走版本管理。

3.11
命名空间:环境隔离的第一道墙

命名空间(Namespace)把集群逻辑上切成多个独立区域,dev/staging/prod
各自一套,互不干扰:

# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: prod
kubectl create namespace prod
kubectl apply -f k8s/deployment.yaml -n prod      # 部署到 prod 命名空间
kubectl get pods -n prod

关键点:Service 的 DNS
名是「命名空间内可见」的——order-service.prod.svc.cluster.local
只在 prod 命名空间内解析,天然隔离。跨环境访问要写全限定名。配合 RBAC
限制不同团队只能操作自己的命名空间,就是最基本的权限隔离。

3.12 ConfigMap
挂载为文件 + PodDisruptionBudget

ConfigMap
作为环境变量
适合少量配置,但配置一多(比如整个
application.yml),环境变量就不好维护了。这时把 ConfigMap
挂载成文件更干净:

# 把整个 application.yml 塞进 ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
  name: order-service-config-file
data:
  application.yml: |
    server:
      port: 8080
    management:
      endpoints:
        web:
          exposure:
            include: health,info,metrics,prometheus
---
# Deployment 里挂载成文件,覆盖 jar 内的 application.yml
spec:
  containers:
    - name: order-service
      volumeMounts:
        - name: config
          mountPath: /app/config
  volumes:
    - name: config
      configMap:
        name: order-service-config-file

配合启动参数
java -jar app.jar --spring.config.additional-location=/app/config/,让外部配置覆盖
jar 内配置。关键点:ConfigMap
挂载进容器后是「最终快照」,改了 ConfigMap,Pod
里的文件不会自动更新(除非用 kubelet 的自动刷新 + 应用重载,或直接重启
Pod)。所以改配置要「改 ConfigMap + 滚动重启」。

PodDisruptionBudget(PDB):防止「主动驱逐」(节点维护、缩容)把副本一次性全干掉。它声明「最少要保持几个可用」:

# k8s/pdb.yaml
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: order-service-pdb
spec:
  minAvailable: 2              # 任何时刻至少 2 个副本可用
  selector:
    matchLabels:
      app: order-service

关键点:PDB
只约束「主动驱逐」,不约束「被动故障」(Pod
自己挂了、节点宕机)。它是节点维护/自动缩容时的保险丝,避免一次维护把所有副本都赶下线。

3.13 排障常用命令:Pod
挂了第一眼看什么

线上 Pod 出问题,按下面的顺序排查,90% 的问题在前三步就能定位:

# 1. 看 Pod 状态(CrashLoopBackOff / Pending / OOMKilled 都是关键信号)
kubectl get pods -o wide

# 2. 看事件:describe 会告诉你探针失败、资源不足、镜像拉取失败等直接原因
kubectl describe pod <pod-name>
# 关键字段:Events 段、Last State(上次退出原因)、探针状态

# 3. 看日志:容器 stdout
kubectl logs <pod-name>            # 当前容器
kubectl logs <pod-name> --previous # 上次崩溃的容器(CrashLoop 时必看)

# 4. 看资源使用(确认是不是 OOMKilled)
kubectl top pod <pod-name>

# 5. 进入容器内排查
kubectl exec -it <pod-name> -- sh

常见状态解读

状态 含义 常见原因
Pending 调度不上去 资源不足、节点无可用资源
CrashLoopBackOff 反复启动崩溃 启动报错、liveness 太激进
OOMKilled 内存超限被杀 limits 太小或内存泄漏
ImagePullBackOff 镜像拉不下来 镜像名错、仓库未授权
Terminating 卡住 删不掉 优雅停机超时、finalizer 阻塞

关键点kubectl describe pod 的 Events
段是排障第一现场——探针失败次数、OOM
原因、调度失败原因都会记在这里。别跳过它直接看日志,先看事件能少走很多弯路。

本章小结:Deployment 管副本和滚动更新,Service
提供稳定入口,Ingress 对外暴露,ConfigMap/Secret 注入配置,HPA
自动扩缩容;而 readiness/liveness/startup 三探针、resources
限制、PDB,共同构成「应用能稳、出问题能自愈、维护不停机」的基石。


第 4 章:CI/CD

4.1 什么是
CI/CD,为什么手工发布不可靠

手工发布的流程通常是:本地 mvn package → scp 上传服务器
→ 重启服务。这套流程有三个致命问题:

  1. 不可重复——每个人的本地环境不同,你本地能打包,别人不一定;这次能成功,下次可能漏了步骤。
  2. 没有测试把关——赶时间的时候会「跳过测试直接发」,一个没跑测试的提交可能直接打挂生产。
  3. 没有回滚记录——发出去的版本没有留痕,出问题想回滚都不知道上一个版本是什么。

CI(持续集成):每次代码提交都自动触发「构建 +
测试」,快速发现集成错误。 CD(持续交付/部署):在 CI
通过的基础上,自动打包镜像、推送到仓库、部署到环境。

一条标准的 CI/CD 流水线是:

代码提交 → 构建 → 单元测试 → 打包 → 构建镜像 → 推送镜像仓库 → 部署 K8s → 验证

4.2
工具选型:Jenkins vs GitHub Actions vs GitLab CI

工具 特点 适用场景
Jenkins 老牌、插件丰富、自建部署 私有环境、复杂的自定义流程
GitHub Actions 与 GitHub 深度集成、配置即 YAML、免费额度 代码托管在 GitHub
GitLab CI 与 GitLab 集成、内置容器仓库 代码托管在 GitLab / 自建 GitLab

本篇以 GitHub Actions
为例,它的配置最简洁、最贴近现代开发流,且概念(workflow / job /
step)能直接迁移到其他工具。

4.3 最小可用:一条能跑通的 CI

先看最小版本,理解 workflow 的结构:

# .github/workflows/ci.yml
name: CI                          # workflow 名字
on:                               # 触发条件:push 到任何分支
  push:
jobs:
  build:                          # 一个 job
    runs-on: ubuntu-latest        # 跑在哪个操作系统
    steps:
      - uses: actions/checkout@v4         # 检出代码
      - uses: actions/setup-java@v4       # 装 JDK
        with:
          distribution: 'temurin'
          java-version: '17'
      - run: mvn -B test          # 跑测试
      - run: mvn -B package -DskipTests   # 打包(测试已跑过,这里跳过)

这个 pipeline
能自动跑测试和打包,但没有镜像、没有部署,离「上线」还差得远。

4.4 生产级:构建 → 测试 → 镜像
→ 部署

下面是完整的生产级 pipeline,串起第 2、3 章的所有产物:

# .github/workflows/ci.yml —— 生产级 CI/CD
name: CI/CD
on:
  push:
    branches: [ main ]           # 只有 main 分支触发,避免 feature 分支频繁构建
  pull_request:
    branches: [ main ]           # PR 也触发,但只跑测试不部署(见下方条件)

env:
  IMAGE_NAME: ghcr.io/${{ github.repository }}   # 用 GitHub 容器仓库,免额外配置
  K8S_NAMESPACE: prod

jobs:
  build-test:                    # Job 1:构建 + 测试
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          distribution: 'temurin'
          java-version: '17'
          cache: 'maven'         # 自动缓存 ~/.m2,加速依赖下载

      - name: Run unit tests
        run: mvn -B test

      - name: Package application
        run: mvn -B package -DskipTests

      # 上传构建产物,供下一个 job 使用(job 之间文件不共享)
      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: order-service-jar
          path: target/*.jar

  build-push:                    # Job 2:构建镜像 + 推送
    needs: build-test            # 依赖 Job 1 成功才执行
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write            # 允许推送容器镜像
    steps:
      - uses: actions/checkout@v4

      # 登录 GitHub 容器仓库
      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      # 构建并推送(利用 Docker 分层缓存加速)
      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          # 用 commit SHA 打 tag,保证镜像不可变、可追溯
          tags: |
            ${{ env.IMAGE_NAME }}:${{ github.sha }}
            ${{ env.IMAGE_NAME }}:latest
          cache-from: type=gha      # 从 GitHub Actions 缓存读取构建层
          cache-to: type=gha,mode=max

  deploy:                        # Job 3:部署到 K8s
    needs: build-push
    if: github.ref == 'refs/heads/main'   # 只有 main 分支才部署,PR 只跑前两个 job
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Configure kubectl
        uses: azure/k8s-set-context@v4
        with:
          method: kubeconfig
          kubeconfig: ${{ secrets.KUBECONFIG }}   # 集群凭据存 Secret,不进代码

      # 用 sed 把镜像 tag 替换成当前 commit SHA,实现「每次提交一个可追溯版本」
      - name: Update image tag
        run: sed -i "s|IMAGE_TAG_PLACEHOLDER|${{ github.sha }}|g" k8s/deployment.yaml

      - name: Deploy to Kubernetes
        run: |
          kubectl apply -f k8s/configmap.yaml -f k8s/secret.yaml
          kubectl apply -f k8s/deployment.yaml -f k8s/service.yaml -f k8s/ingress.yaml

      - name: Wait for rollout
        run: kubectl rollout status deployment/order-service --namespace ${{ env.K8S_NAMESPACE }} --timeout=300s

      - name: Rollback on failure
        if: failure()               # 部署失败自动回滚
        run: kubectl rollout undo deployment/order-service --namespace ${{ env.K8S_NAMESPACE }}

配合上面的 pipeline,deployment.yaml 里镜像 tag
写占位符,由 pipeline 替换:

# k8s/deployment.yaml 里的 image 字段
containers:
  - name: order-service
    image: IMAGE_TAG_PLACEHOLDER     # 由 CI 替换成 ghcr.io/xxx/order-service:<sha>

关键点

  • job 之间文件不共享。Job 1 打包的 jar 不会自动出现在
    Job 2,必须用 actions/upload-artifact /
    download-artifact 传递,或者让 Job 2 重新
    checkout + 重新构建(本例选择后者,更简单,且
    docker/build-push-action 直接在 Job 2 里用源码构建)。
  • 凭据不进代码。K8s 集群凭据放在 GitHub
    Secrets(KUBECONFIG),Docker 登录用内置的
    GITHUB_TOKEN,代码里不出现任何明文密码。
  • 镜像 tag 用 commit SHA,而不是
    latestlatest
    是可变标签,出了事无法确定线上跑的是哪个提交;SHA 不可变,配合
    rollout undo 可以精确回滚。
  • if: failure()
    自动回滚
    :部署失败时触发回滚步骤,把「人肉救火」变成「自动兜底」。

4.5 加速技巧与多环境

缓存:CI 最耗时的两处是 Maven 下载依赖和 Docker
拉基础镜像。上面已经用了两个缓存:setup-java
cache: 'maven'(缓存 ~/.m2)和
docker/build-push-action
cache-from/to: type=gha(缓存 Docker 层)。

多环境部署:dev / staging / prod 三套环境,用
environment 和分支区分:

deploy-prod:
  needs: build-push
  if: github.ref == 'refs/heads/main'
  environment: prod               # 关联 GitHub Environment,可配审批和密钥隔离
  runs-on: ubuntu-latest
  steps:
    # ... 同上面的部署步骤,namespace 换成 prod

GitHub 的 environment 还支持人工审批(required
reviewers)环境专属
Secret
,生产发布前可以要求指定人员点「通过」。

4.6 GitHub Actions
核心概念:看懂任何 pipeline

GitHub Actions 的四层结构,理解了就能看懂任何一份 CI 配置:

概念 说明 类比
Workflow 一个 .yml 文件,一次自动化流程 一份「流程文档」
Job Workflow 里的一组步骤,跑在独立 runner 上,默认并行 一个「工位」
Step Job 里的单个动作,可以跑命令或引用 action 一道「工序」
Runner 执行 Job 的机器(GitHub 托管或自建) 「工人」
on:                      # 触发器:什么事件启动 workflow
  push:
    branches: [ main ]
  pull_request:
  workflow_dispatch:      # 允许手动触发

jobs:
  job1:                  # job 之间默认并行,用 needs 声明依赖才串行
    runs-on: ubuntu-latest
    steps: [ ... ]
  job2:
    needs: job1          # job2 等 job1 成功后才跑
    runs-on: ubuntu-latest
    steps: [ ... ]

关键点:Job
之间默认并行,需要顺序时用 needs
声明依赖。每个 Job 跑在独立的干净环境里,所以 Job 之间要传文件必须用
artifact,环境变量用 env、密钥用 secrets

4.7 多环境 pipeline:dev
自动、prod 审批

真实项目至少三套环境。完整的多环境 pipeline 长这样:

name: Multi-Env CI/CD
on:
  push:
    branches: [ main ]

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-java@v4
        with: { distribution: 'temurin', java-version: '17', cache: 'maven' }
      - run: mvn -B test
      - run: mvn -B package -DskipTests

  build-push:
    needs: build-test
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v4
      - uses: docker/login-action@v3
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.sha }}

  deploy-dev:
    needs: build-push
    runs-on: ubuntu-latest
    environment: dev                       # 关联 dev 环境
    steps:
      - name: Deploy to dev
        run: |
          kubectl set image deployment/order-service order-service=ghcr.io/${{ github.repository }}:${{ github.sha }} -n dev
          kubectl rollout status deployment/order-service -n dev --timeout=180s

  deploy-prod:
    needs: build-push
    runs-on: ubuntu-latest
    environment: prod                      # prod 环境配了「required reviewers」
    steps:
      - name: Deploy to prod
        run: |
          kubectl set image deployment/order-service order-service=ghcr.io/${{ github.repository }}:${{ github.sha }} -n prod
          kubectl rollout status deployment/order-service -n prod --timeout=300s

关键点environment: prod 关联 GitHub
的 Environment
对象,可以在仓库设置里为它配置必要审批人(required
reviewers)保护规则
——代码合并到 main
后自动部署 dev,而部署 prod
必须有人点「批准」。这样「谁、何时、把什么版本发到生产」全程有记录、有人把关。

4.8
蓝绿部署与金丝雀发布:风险可控的发版

滚动更新虽然零停机,但新旧版本会短暂共存,如果新版本有隐藏
bug,流量还是会打到它。两种更稳的策略:

蓝绿部署:同时跑两套(蓝=旧、绿=新),验证绿没问题后,Service
的 selector 一刀切到绿:

# 1. 部署绿色版本(用不同的 label 区分)
kubectl apply -f deployment-green.yaml     # labels: version=green

# 2. 验证绿色版本正常(单独开一个测试 Service 或直接 curl Pod)

# 3. 切换流量:改 Service 的 selector 指向 version=green
kubectl patch service order-service -p '{"spec":{"selector":{"app":"order-service","version":"green"}}}'

# 4. 观察一段时间没问题后,删掉蓝色版本
kubectl delete deployment order-service-blue

金丝雀发布:只把一小部分流量导到新版本(比如
10%),观察指标没问题再逐步放量。这需要 Ingress 网关(如 Nginx Ingress /
Istio)支持按权重分流:

# 用 Istio 的 VirtualService 做金丝雀(10% 流量到 v2)
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: order-service
spec:
  hosts: ["order-service"]
  http:
    - route:
        - destination:
            host: order-service
            subset: v1
          weight: 90            # 90% 流量到旧版本
        - destination:
            host: order-service
            subset: v2
          weight: 10            # 10% 流量到新版本

关键点:蓝绿和金丝雀都依赖「可观测」——你得能在
Grafana
上对比新旧版本的错误率、耗时指标,才能判断「新版本到底行不行」。这也是为什么第
1 章的监控是第 4 章发版策略的前提。

4.9 镜像 tag
策略:让每个版本都可追溯、可回滚

第 4.4 里用 ${{ github.sha }}
tag,这只是起点。一套完整的 tag
策略应该同时满足「可追溯」「可回滚」「可读」三个诉求:

tag 类型 示例 用途 是否可变
commit SHA abc1234 精确对应一次提交,排障/回滚的锚点 不可变
语义化版本 1.2.0 人类可读的发布版本 不可变
分支名 main 追踪分支最新构建 可变
latest latest 仅本地开发方便 可变(生产禁用)

推荐的组合:每次构建打两个
tag
——<semver>(正式发布)+
<sha>(追溯)。回滚时用 SHA 精确定位,沟通时用
semver。

# 在 docker/build-push-action 里打多个 tag
tags: |
  ghcr.io/${{ github.repository }}:${{ github.sha }}
  ghcr.io/${{ github.repository }}:1.2.0

关键点:可变
tag(latestmain)在生产是定时炸弹——同一份
YAML 里写 image: app:latest,不同时间
kubectl apply
拉到的是不同镜像,出了事说不清线上跑的是哪一版。生产 YAML 里的 image
必须指向不可变 tag。

4.10
回滚策略:出问题怎么快速止血

发版出问题,按「影响范围」分三级止血:

  1. 最快:rollout undo。镜像已推送,只是新版本有问题,一条命令回到上一版
    Pod 模板(秒级)。
kubectl rollout undo deployment/order-service
  1. 配置也错了:连 ConfigMap
    一起回
    rollout undo 只回 Pod
    模板,ConfigMap/Secret 的改动要单独回滚(用
    kubectl apply -f 重新应用旧版配置)。

  2. 镜像本身有安全漏洞:回退镜像 +
    重新构建
    。把上一个安全版本的 tag
    重新部署,同时修复漏洞重新构建新版本。

关键点:止血和排查要分开——先
rollout undo
恢复服务,再慢慢查根因,别在线上「边查边改」。这就是为什么不可变 tag +
版本历史(rollout history)是回滚能力的基础。

本章小结:CI/CD
的价值在于把「人肉发布」变成「代码化、可重复、可回滚」的流水线;生产级
pipeline 必须包含测试把关、不可变镜像
tag、凭据隔离、失败自动回滚,以及「dev 自动 + prod
审批」的环境分级。


第 5 章:日志采集(ELK)

5.1 为什么日志要集中采集

单机时代,排障就是
tail -f app.log。但到了容器时代,问题来了:

  1. 容器是临时的——Pod
    被调度、重建、扩容后,旧容器连同它的日志文件一起消失。日志写在容器文件系统里
    = 写在沙滩上。
  2. 副本是分散的——3 个副本的日志分散在 3 个 Pod
    里,一个用户请求可能被路由到任意一个,查一条日志要翻 3 台机器。
  3. 格式不统一——每个系统各写各的,出了跨服务的链路问题,日志拼不起来。

解决之道:日志不落地、统一采集、集中检索。标准方案就是
ELK 三件套:

组件 作用
Filebeat 轻量采集器,读容器 stdout/日志文件,转发出去(占资源极小)
Logstash 日志处理管道:解析、过滤、转换格式
Elasticsearch 日志存储与全文检索
Kibana 可视化检索界面

规模不大时,Logstash 可以省掉,Filebeat 直接写 Elasticsearch;但
Logstash
的解析能力(grok)在处理复杂日志格式时更灵活,本篇保留完整链路。

5.2 第一步:把日志输出成 JSON

Filebeat/Logstash 解析文本日志要写复杂的 grok
正则,最省事的方式是让应用直接输出 JSON 日志。用
logstash-logback-encoder 给 logback 加 JSON 格式:

<!-- pom.xml 增加 -->
<dependency>
    <groupId>net.logstash.logback</groupId>
    <artifactId>logstash-logback-encoder</artifactId>
    <version>7.4</version>
</dependency>
<!-- src/main/resources/logback-spring.xml -->
<configuration>
    <!-- 控制台输出:JSON 格式,直接打到 stdout,由容器/DaemonSet 采集 -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <!-- 附加应用名和环境,方便在 ES 里按维度过滤 -->
            <customFields>{"app":"order-service","env":"prod"}</customFields>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>

关键点:日志只打到
stdout,不写文件。这正好契合 K8s 的日志模型——容器
stdout 会被 kubelet 收集,Filebeat
从节点上统一读取,实现「日志不落地」。

5.3 第二步:Filebeat
采集并转发

Filebeat 以 DaemonSet 方式跑在每个节点上,读取该节点所有容器的
stdout:

# filebeat/filebeat.yml —— Filebeat 配置
filebeat.inputs:
  - type: container              # 采集所有容器的 stdout
    paths:
      - /var/log/containers/*.log
    # 从容器日志里提取 namespace/pod/container 元数据,方便检索
    processors:
      - add_kubernetes_metadata:
          host: ${NODE_NAME}
          matchers:
            - logs_path:
                logs_path: "/var/log/containers/"

output.logstash:
  hosts: ["logstash:5044"]       # 转发给 Logstash 处理

5.4
第三步:Logstash 解析 + Elasticsearch 存储 + Kibana 展示

# logstash/pipeline.conf —— Logstash 处理管道
input {
  beats { port => 5044 }         # 接收 Filebeat 传来的日志
}

filter {
  # 应用已经输出 JSON,这里直接解析即可,无需写 grok 正则
  json {
    source => "message"
    target => "parsed"
  }
}

output {
  elasticsearch {
    hosts => ["elasticsearch:9200"]
    index => "order-service-%{+YYYY.MM.dd}"   # 按天建索引,方便按天清理
  }
}

部署好之后,在 Kibana 的 Discover 里就能检索:

# 查所有 ERROR 日志
level:ERROR

# 查某个 traceId 的完整链路
traceId:"b3f4a1c2d3e4f5a6"

# 查某个接口的所有日志
uri:"/api/orders"

关键点

  • 按天建索引index => "xxx-%{+YYYY.MM.dd}")配合
    ILM(Index Lifecycle Management)自动清理过期日志,否则 ES
    磁盘会被撑爆。
  • 日志里带 traceId(第 1 章 ApiResult
    里的字段),一条请求链路的所有日志都能用 traceId
    串起来,这是分布式排障的核心能力。

5.5 traceId
全链路串联:一条请求的完整轨迹

日志集中之后,下一个痛点是「怎么把一次请求的日志串起来」。一个下单请求可能跨了订单服务、库存服务、支付服务,每个服务日志都零散地躺在
ES 里。解法是给每个请求分配一个全局唯一的
traceId
,让它贯穿整个调用链。

实现:用一个 Servlet Filter 在请求入口生成/透传 traceId,放进日志
MDC:

// config/TraceIdFilter.java
@Component
@Slf4j
public class TraceIdFilter extends OncePerRequestFilter {

    private static final String TRACE_ID_HEADER = "X-Trace-Id";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws IOException, ServletException {
        // 优先取上游传来的 traceId(透传),没有就生成一个,保证跨服务链路是同一个 id
        String traceId = request.getHeader(TRACE_ID_HEADER);
        if (traceId == null || traceId.isBlank()) {
            traceId = UUID.randomUUID().toString().replace("-", "");
        }
        // 放进 MDC:logback 的 JSON encoder 会自动把它加进每条日志
        MDC.put("traceId", traceId);
        // 回写响应头,方便前端/客户端报障时带上 traceId
        response.setHeader(TRACE_ID_HEADER, traceId);
        try {
            chain.doFilter(request, response);
        } finally {
            // 关键:请求结束必须清理 MDC,否则线程池复用线程会串号
            MDC.remove("traceId");
        }
    }
}

这样每一条日志(包括第 1 章 ApiResult 里的
traceId 字段)都带上同一个 id,Kibana 里一条
traceId:"xxx" 就能捞出整条链路的所有日志。

关键点MDC 是 ThreadLocal 实现,Tomcat
线程池会复用线程,所以 finallyMDC.remove
必须的,否则下一个请求会串到上一个请求的
traceId,日志直接乱套。

5.6 Filebeat DaemonSet
完整部署

Filebeat 要以 DaemonSet
方式跑,保证每个节点都有一个采集器,读取该节点上所有容器的 stdout:

# filebeat-daemonset.yaml(精简版,完整镜像名略)
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: filebeat
  namespace: kube-system
spec:
  selector:
    matchLabels: { app: filebeat }
  template:
    metadata:
      labels: { app: filebeat }
    spec:
      containers:
        - name: filebeat
          image: docker.elastic.co/beats/filebeat:8.12.0
          args: ["-c", "/etc/filebeat.yml", "-e"]
          env:
            - name: NODE_NAME
              valueFrom:
                fieldRef:
                  fieldPath: spec.nodeName    # 注入节点名,用于日志元数据
          volumeMounts:
            - name: varlog
              mountPath: /var/log/containers   # 容器日志所在目录(宿主机)
              readOnly: true
            - name: config
              mountPath: /etc/filebeat.yml
              subPath: filebeat.yml
      volumes:
        - name: varlog
          hostPath:
            path: /var/log/containers          # 挂载宿主机日志目录
        - name: config
          configMap:
            name: filebeat-config

关键点:DaemonSet 保证「每节点一个 Filebeat」,比
Deployment 更适合日志采集——因为日志是「按节点产生」的,新节点加入集群时
DaemonSet 会自动补一个采集器。这就是「日志不落地」的落地方式:应用写
stdout,kubelet 收集到节点目录,Filebeat 从节点目录统一转发。

5.7 ES 索引管理与 ILM
自动清理

日志是海量且有时效性的,ES 的索引不能只增不删。用 ILM(Index
Lifecycle Management)
自动滚动和清理:

// ILM 策略:索引 7 天后转冷,30 天后删除
PUT _ilm/policy/order-service-logs
{
  "policy": {
    "phases": {
      "hot": { "actions": { "rollover": { "max_age": "1d", "max_size": "50gb" } } },
      "delete": { "min_age": "30d", "actions": { "delete": {} } }
    }
  }
}

配合按天建索引(第 5.4 的
index => "order-service-%{+YYYY.MM.dd}"),日志保留 30
天、超期自动删除,磁盘占用可控。关键点:日志保留周期要结合合规要求(某些行业要求保留
90 天甚至 180 天),别为了省磁盘把该留的删了。

5.8 日志字段规范:让 ES
检索更高效

一条结构化日志的价值,取决于它有哪些标准字段LogstashEncoder
默认输出这些字段,你还可以按需补充:

{
  "@timestamp": "2026-08-14T10:30:00.000Z",   // 日志时间(ES  @timestamp)
  "level": "INFO",                              // 日志级别,按级别过滤
  "logger_name": "com.example.order.service.OrderService",  // 来源类
  "thread_name": "http-nio-8080-exec-3",       // 线程名
  "message": "下单成功, orderId=ORDER-123",     // 日志正文
  "app": "order-service",                       // 应用名(customFields)
  "env": "prod",                                // 环境(customFields)
  "traceId": "b3f4a1c2d3e4f5a6"                 // 链路 id(MDC)
}

字段规范建议

  1. 必带appenvtraceIdlevel。这四者是检索和链路串联的基础。
  2. 时间用统一格式:ES 的 @timestamp
    要能被解析成时间类型,才能做时间范围查询和按时序排序。
  3. 别把整个对象塞进 message:结构化字段比正则解析
    message
    快得多,也更可靠。需要检索的字段(userId、orderId)单独提出来,而不是埋在
    message 字符串里。

关键点:Kibana 里「按字段过滤」比「全文搜
message」快一个数量级。设计日志时就想清楚「未来要按什么维度查」,把这些维度变成独立字段,而不是事后用
grok 正则去抠。

本章小结:日志采集的本质是「应用输出 JSON 到 stdout
→ Filebeat(DaemonSet)统一采集 → Logstash 解析 → ES 存储 → Kibana
检索」,核心原则是日志不落地、按天建索引、ILM 自动清理、带 traceId
全链路串联、关键字段结构化。


第 6 章:JVM 调优与 OOM 排查

6.1 JVM
内存结构:先搞清楚堆在哪

Java 应用的内存分两大块,OOM
排查的第一步就是分清是「哪块内存」出了问题:

区域 内容 OOM 时的表现
堆(Heap) 新生代(Eden + 两个 Survivor)+ 老年代,存所有对象实例 java.lang.OutOfMemoryError: Java heap space
元空间(Metaspace) 类元数据(JDK 8+ 取代永久代) OutOfMemoryError: Metaspace
线程栈 每个线程的栈帧 StackOverflowError
直接内存(Direct Memory) NIO 的 ByteBuffer、Netty 等 OutOfMemoryError: Direct buffer memory

生产里 90% 的 OOM
堆内存问题,所以本节的排查流程聚焦堆。

6.2 GC 算法与 G1 参数

GC 算法演进

算法 特点 适用
Parallel GC 吞吐优先,但停顿时间长 大堆、对停顿不敏感的批处理
G1 JDK 9+ 默认,Region 化,可控停顿 绝大多数服务(推荐)
ZGC 亚毫秒级停顿,超大堆 低延迟要求极高的大内存场景

G1 是生产默认推荐。核心思想:把堆分成大小相等的
Region,优先回收「垃圾最多的 Region」,用 MaxGCPauseMillis
控制目标停顿时间。

生产级 JVM 参数(Spring Boot 3.2 / JDK 17):

java 
  -Xms2g -Xmx2g                           # 堆初始/最大设为相等,避免运行中动态扩缩容带来的抖动
  -XX:+UseG1GC                            # 使用 G1
  -XX:MaxGCPauseMillis=200                # 目标最大 GC 停顿 200ms
  -XX:InitiatingHeapOccupancyPercent=45   # 堆占用 45% 就开始并发标记,避免 full GC
  -XX:+HeapDumpOnOutOfMemoryError         # OOM 时自动 dump 堆(排障的救命稻草)
  -XX:HeapDumpPath=/tmp/heapdump.hprof    # dump 文件路径(容器里挂到持久卷,否则重启丢失)
  -XX:+ExitOnOutOfMemoryError             # OOM 后主动退出,让 K8s 重启,而不是僵死
  -jar app.jar

关键点

  • -Xms-Xmx
    设成相等
    :如果不等,JVM
    在运行中动态扩堆,扩堆本身要时间,还会引发 GC
    波动,服务吞吐随之抖动。
  • -XX:+ExitOnOutOfMemoryError 配合
    K8s
    :OOM 后进程主动退出,K8s 的 liveness 探针或 restartPolicy
    会重启容器,比让一个半死的进程继续接流量更好。

6.3 优雅停机:别让发版丢请求

滚动更新时,K8s 会给旧 Pod 发
SIGTERM,让它自己退出。如果不配置优雅停机,Spring Boot 收到 SIGTERM
立刻杀死正在处理的请求——用户就收到了一个莫名失败。配置优雅停机:

# application-prod.yml
server:
  shutdown: graceful                 # 开启优雅停机
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s  # 最多等 30 秒让在途请求处理完

关键点timeout-per-shutdown-phase
要小于 K8s 的 terminationGracePeriodSeconds(默认 30
秒),否则应用还没优雅退完,K8s 就强杀了。建议应用侧 25s、K8s 侧
30s,留出 5 秒缓冲。

6.4 OOM 排查完整流程(重点)

这是本阶段最重要的实战能力。OOM
排查不是「加内存」三个字能解决的——加内存可能只是把泄漏的爆发时间推迟了几个小时。正确流程分四步,步步留证据。

第 0 步:确认 OOM
类型
。先看日志里的异常类型,确定是哪块内存:

# 堆内存不足
java.lang.OutOfMemoryError: Java heap space
# 元空间不足
java.lang.OutOfMemoryError: Metaspace
# 直接内存不足
java.lang.OutOfMemoryError: Direct buffer memory

第 1 步:留现场。如果启动参数没加
HeapDumpOnOutOfMemoryError,OOM 发生时没有
dump,事后只能靠猜。补参数重启,等它下次 OOM 自动 dump;如果已经 OOM
但进程还活着,用 jcmd 手动 dump:

# 找到 Java 进程 PID
jps -l
# 或容器内:ps aux | grep java

# 手动 dump 堆到文件
jcmd <PID> GC.heap_dump /tmp/heapdump.hprof

# 查看 GC 情况,判断是「回收不过来」还是「对象真的多」
jstat -gcutil <PID> 1000 10   # 每秒打印一次 GC 占比,共 10 次

第 2 步:分析 heapdump。把 dump 文件下载下来,用
MAT(Eclipse Memory Analyzer) 打开。MAT
会给出三个关键视图:

  1. Leak Suspects(泄漏嫌疑报告)——MAT
    自动分析,直接列出「最可能是泄漏」的对象和引用链。这是最高效的入口。
  2. Dominator
    Tree(支配树)
    ——按「谁占的内存最多」排序,快速找到内存大户。
  3. Histogram(对象直方图)——按类统计对象数量和大小。
# 也可以先用 jmap 看个粗略的类占用排行(无需 MAT)
jmap -histo:live <PID> | head -20

第 3
步:定位是泄漏还是不够
。这是整个流程的「分水岭」,判断错了方向就错了:

判断 特征 处理
内存泄漏 dump 里某个业务对象(如
Order、缓存对象)数量异常巨大、持续只增不减
找引用链,修代码(见 6.5)
内存不够 对象分布正常,只是业务量大了堆不够用 -Xmx 或扩容、或优化对象

判断技巧:结合监控看 jvm.memory.used
曲线。如果每次 GC
后内存都回落到基线
,说明是「不够」;如果GC
后内存仍持续爬升、永不回落
,说明是「泄漏」。

6.5 内存泄漏实战:模拟 + 定位 +
修复

用一个真实的内存泄漏例子串起整个流程。下面的代码有个典型的泄漏:静态集合持有对象引用,永不释放:

// service/MemoryLeakDemo.java —— 故意制造内存泄漏的代码(用于演示排查)
@Slf4j
@Service
public class MemoryLeakDemo {

    // 致命问题:static 集合,持有所有订单对象引用,GC 永远无法回收
    // static 字段的生命周期 = JVM 生命周期,这里的 List 会无限增长直到 OOM
    private static final List<OrderVO> CACHE = new ArrayList<>();

    @Scheduled(fixedRate = 100)
    public void leak() {
        // 每 100ms 往静态集合里塞一个对象,模拟「缓存忘记清理」
        CACHE.add(new OrderVO("ORDER-" + System.currentTimeMillis(), "SUCCESS"));
    }
}

复现并排查:

# 1. 用受限堆内存启动,加速 OOM 复现
java -Xmx256m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heapdump.hprof -jar app.jar

# 2. 观察内存曲线(另一个终端)
curl http://localhost:8080/actuator/metrics/jvm.memory.used
# 可以看到 used 持续上升,GC 后也不回落 —— 典型的泄漏特征

# 3. OOM 后,dump 文件已自动生成
ls -lh /tmp/heapdump.hprof

用 MAT 打开 dump,Leak Suspects 会直接指向
com.example.order.vo.OrderVO,展开引用链能看到
MemoryLeakDemo.CACHE(static ArrayList)→ 海量 OrderVO
对象。Dominator TreeArrayList 占用了
90% 以上的堆。

修复:把无限增长的静态集合换成有界的缓存,或用
Caffeine 这类带过期/淘汰策略的缓存:

@Slf4j
@Service
public class MemoryLeakDemo {

    // 修复:用有界缓存,超过 1000 条自动淘汰最旧的,内存有上限
    private final Queue<OrderVO> cache = new ArrayDeque<>(1000);

    @Scheduled(fixedRate = 100)
    public void leak() {
        cache.offer(new OrderVO("ORDER-" + System.currentTimeMillis(), "SUCCESS"));
        if (cache.size() > 1000) {
            cache.poll();   // 淘汰最旧,控制内存上界
        }
    }
}

6.6 堆的详细分区与对象流转

理解堆的分区,才能看懂 GC 日志和 OOM 的成因。堆分两大块:

区域 作用 特点
新生代(Young) 存放新创建的对象 大部分对象「朝生夕死」
├ Eden 对象诞生地 新对象先在这里
├ Survivor0 / Survivor1 经历过 GC 仍存活的对象 两个区来回倒腾
老年代(Old) 长期存活的对象 放「熬过多次 GC」的对象

对象流转过程:新对象进 Eden → 一次 Minor GC 后存活的进 Survivor → 在
Survivor 之间倒腾若干次(默认 15 次)仍存活 → 晋升老年代。Minor
GC 只回收新生代,速度快、频率高;Full GC
回收整个堆,停顿时间长、要尽量避免。

关键点:如果新生代设得太小,对象频繁晋升老年代,老年代很快满,触发
Full GC,接口开始抖动。所以「频繁 Full
GC」通常是新生代配置不合理或对象泄漏的信号,而不是单纯「堆太小」。

6.7 GC 日志解读:让 GC
自己说话

调优不能靠猜,要开 GC 日志看数据。JDK 17 用统一日志框架:

java -Xlog:gc*:file=/tmp/gc.log:time,uptime,level,tags -jar app.jar
# 用 jstat 实时看 GC 占用比例(最实用的一个命令)
jstat -gcutil <PID> 1000
# 输出列:S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# E=新生代占用、O=老年代占用、YGC=年轻代GC次数、FGC=Full GC次数、GCT=总停顿时间
# 或用 jcmd 打印详细 GC 统计
jcmd <PID> GC.heap_info

看 GC 日志的三个判断

  1. FGC 次数持续增长
    老年代有问题,要么泄漏、要么晋升太快。
  2. 每次 YGC 后 O(老年代占用)不回落
    有对象长期存活,疑似泄漏。
  3. GCT(总停顿时间)占比高 → GC
    过于频繁,检查堆大小和目标停顿参数。

6.8 常见内存泄漏场景清单

heapdump + MAT
能帮你找到「哪个对象占内存」,但「为什么会泄漏」还得靠对常见泄漏模式的认识。生产里最高频的几类泄漏:

泄漏场景 原因 修复
静态集合无限增长 static List 塞对象不清 用有界缓存(Caffeine/Guava Cache)
ThreadLocal 未清理 线程池复用线程,ThreadLocal 值不 remove finallyremove()
监听器/回调未注销 注册了 listener 没反注册 用 WeakReference 或显式注销
连接/流未关闭 数据库连接、IO 流没 close try-with-resources
缓存无过期策略 手写 Map 当缓存,永不过期 用带 TTL 的缓存库
定时任务堆积对象 @Scheduled 里不断 new 对象塞进长生命周期容器 控制对象生命周期

关键点ThreadLocal
泄漏是并发场景的重灾区——线程池里的线程几乎不会销毁,ThreadLocal
里存的对象只要不 remove,就一直被这个线程强引用,GC 永远回收不了。这是
MAT 里经常看到的「ThreadLocal → 大对象」引用链。

6.9
线程问题排查:jstack 定位 CPU 高与死锁

OOM 之外,另一类高频线上问题是「CPU
飙高」和「死锁」。这两个靠堆分析解决不了,要用线程栈。

CPU 飙高排查

# 1. 找到 CPU 占用最高的线程
top -H -p <PID>              # 看进程内各线程的 CPU 占用,记下最高的线程号(TID)

# 2. TID 转十六进制(jstack 里线程号是十六进制)
printf '%xn' <TID>          # 得到 nid

# 3. dump 线程栈,搜这个 nid
jstack <PID> > thread.txt
grep -A 20 'nid=0x<十六进制>' thread.txt

找到的线程栈会告诉你这个线程在跑哪段代码——CPU
高通常是死循环、正则回溯、大量计算或频繁 Full GC。

死锁排查

jstack <PID> | grep -A 20 'Found one Java-level deadlock'
# 会打印出互相等待的两个线程及其持有的锁

关键点jstack
要在问题发生时抓,问题过了线程栈就变了。生产建议配合监控(CPU
告警触发自动 jstack),把「事发现场」自动留下来。

6.10 MAT 分析 heapdump
的详细步骤

第 6.4 提到用 MAT
分析,这里把具体操作步骤补全,让流程可复现。假设你已经拿到了
heapdump.hprof

第一步:打开并解析。启动
MAT,File → Open Heap Dump 选择文件。大文件(几
GB)解析需要较长时间,且 MAT 本身需要的内存约为 dump 文件的 1.5
倍,所以建议在内存充足的分析机上做,或先用 -XX:HeapDumpPath
生成到有大磁盘的目录。

第二步:看 Leak Suspects(泄漏嫌疑报告)。MAT
解析完会弹出引导页,点 Leak Suspects。报告会给出:

  • 一个「嫌疑对象」列表,按占用内存从大到小排序;
  • 每个嫌疑对象占堆内存的百分比(超过 30% 基本可以锁定);
  • 指向它的最短引用链(Shortest Paths to GC
    Roots),这条链告诉你「是谁引用着它、让它无法回收」。
# 引用链的典型形态(读懂它就能定位泄漏)
# class java.util.ArrayList
#   └── static field: com.example.order.service.MemoryLeakDemo.CACHE
#         └── ...
# 含义:MemoryLeakDemo.CACHE 这个 static 字段持有一个 ArrayList,
#       它又持有了海量 OrderVO 对象,导致无法 GC

第三步:用 Dominator Tree 确认。点 Dominator
Tree
,按「Retained Heap(保留堆)」排序。保留堆 =
「如果回收这个对象,能连带释放多少内存」。排第一的对象通常就是元凶。

第四步:用 Histogram 查漏补缺。点
Histogram,按类统计对象数量和浅堆大小。如果某个类的实例数量异常巨大(比如
OrderVO 有 100 万个),结合业务判断是不是该有这么多。

关键点先看 Leak Suspects,再看 Dominator
Tree
。MAT 的自动分析通常直接命中答案,Dominator Tree
用于交叉验证,Histogram 用于兜底排查。不要一上来就翻
Histogram,那是大海捞针。

6.11 GC 参数速查表

参数 含义 生产建议
-Xms / -Xmx 堆初始/最大值 设为相等,避免动态扩缩抖动
-XX:+UseG1GC 使用 G1 JDK 17 默认,显式声明更清晰
-XX:MaxGCPauseMillis 目标最大停顿 200ms 是常见起点
-XX:InitiatingHeapOccupancyPercent 触发并发标记的堆占用阈值 45(默认),调小可减少 Full GC
-XX:+HeapDumpOnOutOfMemoryError OOM 时自动 dump 必开
-XX:HeapDumpPath dump 文件路径 指向持久卷,否则容器重启丢失
-XX:+ExitOnOutOfMemoryError OOM 后主动退出 配合 K8s 自动重启
-Xlog:gc*:file=... 输出 GC 日志 必开,排障依据
-XX:MetaspaceSize /
-XX:MaxMetaspaceSize
元空间初始/上限 动态代理多的应用要调大上限

关键点:G1 的 MaxGCPauseMillis
是「目标」不是「硬保证」,设得太小(比如 10ms)会导致 G1
频繁做小规模回收,反而吞吐下降。200ms 是服务型应用的经验起点,调优时结合
GC 日志里的实际停顿分布调整。

6.12 区分泄漏 vs
不够:用监控曲线判断

第 6.4 讲过「泄漏 vs
不够」的判断表,这里再深入一步——怎么用监控曲线在 OOM
之前就发现苗头


jvm.memory.used(堆已用内存)随时间的变化曲线,两种典型形态:

形态一:锯齿状(正常)。曲线上升 → 突然跌落 →
再上升。每次跌落就是一次 GC,GC
后内存回落到基线。这说明对象能被正常回收,只是当前堆大小撑不住业务峰值,属于「不够」,加大
-Xmx 或扩容即可。

形态二:台阶状(泄漏)。曲线上升 → 小幅回落(GC
清了少量)→ 继续上升到更高点,整体趋势单调上升、基线不断抬高。这说明每次
GC 后仍有对象残留、无法回收,属于「泄漏」,加大 -Xmx
只会推迟崩溃。

# 用 Actuator 指标手动观察(配合 Grafana 更直观)
watch -n 2 'curl -s http://localhost:8080/actuator/metrics/jvm.memory.used'

关键点:这个判断要在 OOM
发生之前做——监控曲线持续抬升就是「泄漏预警」,这时就该抓
heapdump 分析,而不是等 OOM 崩溃了才手忙脚乱。这也是第 1
章监控告警(jvm.memory.used > 80% 持续 5
分钟)的意义:它给你留出了「提前介入」的窗口。

6.13
ZGC:低延迟场景的另一个选项

G1
是默认推荐,但如果你的服务对延迟极其敏感(比如交易撮合、实时风控),且堆很大(几十
GB),可以评估 ZGC

java -XX:+UseZGC -Xms8g -Xmx8g -jar app.jar
对比 G1 ZGC
目标停顿 亚秒级(可配置,通常 200ms) 亚毫秒级(<1ms)
适用堆大小 几 GB ~ 几十 GB 几十 GB ~ TB 级
吞吐 较高 略低于 G1
JDK 支持 JDK 9+(默认) JDK 15+(生产可用)

关键点:ZGC 用「低停顿」换「略低的吞吐」和「更高的
CPU 开销」,不是万能药。绝大多数 Spring Boot 服务用 G1
就够了,只有当「GC 停顿导致接口 p99 抖动」成为真实痛点、且 G1
调参无效时,才考虑 ZGC。选型依据永远是实测数据,不是「越新越好」。

6.14 没有 MAT
时的兜底:jmap -histo 快速定位

生产环境经常没法立刻装 MAT 分析 dump,这时可以用 JDK 自带的
jmap 先做一个粗略定位,确认方向后再上 MAT
做精确分析:

# 1. 列出存活对象的类统计(按占用内存排序)
jmap -histo:live <PID> | head -30
#  num     #instances         #bytes  class name
#   1:       1200000       57600000  com.example.order.vo.OrderVO
#   2:        500000       24000000  java.lang.String
#   3:         80000        8000000  [Ljava.lang.Object;

看到 OrderVO 有 120 万个实例、占了
57MB,基本就能锁定「某个集合持有大量 OrderVO 对象」。

# 2. 直接生成 dump(进程会短暂停顿,低峰期执行)
jmap -dump:live,format=b,file=/tmp/heapdump.hprof <PID>

# 3. 看类加载与元空间占用(排查 Metaspace OOM)
jstat -gc <PID> 1000 5

关键点

  • jmap -histo:live 会触发一次 Full GC(带
    :live 参数),对生产有轻微影响,低峰期用。
  • jmap -histo
    只能告诉你「哪些类多」,不能告诉你「谁引用着它们」——引用链分析还得靠
    MAT 的 Leak Suspects。所以 jmap 是「快速定性」,MAT
    是「精确定位」,两者配合。

本章小结:JVM 调优 = 分清内存结构 + 选对 GC +
配好参数 + 开 GC 日志 + 开优雅停机;OOM 排查 = 留 dump 现场 → MAT
分析(Leak Suspects → Dominator Tree → Histogram)→ 判断泄漏还是不够 →
对症下药;CPU 高/死锁则用 jstack
抓线程栈定位。调优和排障都依赖「现场证据」,参数里留好 dump 和 GC
日志是第一步。


5.
生产级实战项目:订单服务全链路上线

这一节把前 6 章的知识串成一个完整项目:一个带监控、能打镜像、能上
K8s、能采集日志、OOM
可排查的订单服务。你可以照着一路跑通,作为你自己的生产模板。

5.1 完整目录结构

order-service
├── pom.xml
├── Dockerfile
├── .dockerignore
├── docker-compose-monitor.yml          # 本地一键拉起 Prometheus + Grafana
├── k8s/
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── configmap.yaml
│   ├── secret.yaml
│   ├── ingress.yaml
│   └── hpa.yaml
├── .github/workflows/ci.yml
├── filebeat/filebeat.yml
├── logstash/pipeline.conf
└── src/main/
    ├── java/com/example/order/
    │   ├── OrderServiceApplication.java
    │   ├── common/ApiResult.java
    │   ├── common/ErrorCode.java
    │   ├── exception/BizException.java
    │   ├── exception/GlobalExceptionHandler.java
    │   ├── dto/CreateOrderDTO.java
    │   ├── vo/OrderVO.java
    │   ├── controller/OrderController.java
    │   ├── service/OrderService.java
    │   ├── config/MetricsConfig.java
    │   └── health/DependencyHealthIndicator.java
    └── resources/
        ├── application.yml
        ├── application-prod.yml
        └── logback-spring.xml

5.2 业务代码(含监控打点)

启动类

// OrderServiceApplication.java
@SpringBootApplication
@EnableScheduling          // 开启定时任务(本阶段用于模拟泄漏排查,生产按需开启)
public class OrderServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderServiceApplication.class, args);
    }
}

公共响应体与错误码(贯穿全系列,逐字复用):

import lombok.Data;
import org.slf4j.MDC;

@Data
public class ApiResult<T> {
    private int code;        // 0=成功,非 0=错误码
    private String message;  // 提示信息
    private T data;          // 业务数据
    private String traceId;  // 链路追踪 id

    public static <T> ApiResult<T> ok(T data) {
        ApiResult<T> r = new ApiResult<>();
        r.setCode(ErrorCode.SUCCESS.getCode());
        r.setMessage(ErrorCode.SUCCESS.getMessage());
        r.setData(data);
        r.setTraceId(MDC.get("traceId"));
        return r;
    }
    public static <T> ApiResult<T> ok() {
        return ok(null);
    }
    public static <T> ApiResult<T> fail(int code, String message) {
        ApiResult<T> r = new ApiResult<>();
        r.setCode(code);
        r.setMessage(message);
        r.setTraceId(MDC.get("traceId"));
        return r;
    }
    public static <T> ApiResult<T> fail(ErrorCode ec) {
        return fail(ec.getCode(), ec.getMessage());
    }
}
// common/ErrorCode.java —— 统一错误码
public enum ErrorCode {
    SUCCESS(0, "success"),
    PARAM_ERROR(40001, "参数错误"),
    UNAUTHORIZED(40101, "未登录或登录已过期"),
    FORBIDDEN(40301, "无权限访问"),
    USER_NOT_FOUND(40401, "用户不存在"),
    ORDER_NOT_FOUND(40403, "订单不存在"),          // 本篇按需扩展
    SYSTEM_ERROR(50000, "系统繁忙,请稍后重试"),
    ;
    private final int code;
    private final String message;

    ErrorCode(int code, String message) {
        this.code = code;
        this.message = message;
    }

    public int getCode() { return code; }
    public String getMessage() { return message; }
}
// exception/BizException.java —— 统一业务异常
public class BizException extends RuntimeException {
    private final int code;

    public BizException(ErrorCode ec) {
        super(ec.getMessage());
        this.code = ec.getCode();
    }

    public BizException(int code, String message) {
        super(message);
        this.code = code;
    }

    public int getCode() { return code; }
}
// exception/GlobalExceptionHandler.java —— 全局异常处理
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 业务异常:返回具体错误码和提示
    @ExceptionHandler(BizException.class)
    public ApiResult<Void> handleBiz(BizException e) {
        log.warn("业务异常, code={}, message={}", e.getCode(), e.getMessage());
        return ApiResult.fail(e.getCode(), e.getMessage());
    }

    // 参数校验异常:JSR-303 校验失败
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ApiResult<Void> handleValid(MethodArgumentNotValidException e) {
        // 取第一个校验失败的字段提示
        String msg = e.getBindingResult().getFieldErrors().stream()
                .findFirst()
                .map(f -> f.getField() + " " + f.getDefaultMessage())
                .orElse(ErrorCode.PARAM_ERROR.getMessage());
        log.warn("参数校验失败: {}", msg);
        return ApiResult.fail(ErrorCode.PARAM_ERROR.getCode(), msg);
    }

    // 兜底异常:对外只给模糊提示,详细堆栈只进日志,避免泄露内部信息
    @ExceptionHandler(Exception.class)
    public ApiResult<Void> handleOther(Exception e) {
        log.error("系统异常", e);   // 完整堆栈只写日志
        return ApiResult.fail(ErrorCode.SYSTEM_ERROR);
    }
}

DTO / VO

// dto/CreateOrderDTO.java —— 入参,带校验
@Data
public class CreateOrderDTO {
    @NotBlank(message = "用户ID不能为空")
    private String userId;

    @NotBlank(message = "商品ID不能为空")
    private String productId;

    @Min(value = 1, message = "数量至少为1")
    private Integer quantity;

    private String channel = "web";   // 下单渠道,用作指标 tag
}
// vo/OrderVO.java —— 出参
@Data
@AllArgsConstructor
@NoArgsConstructor
public class OrderVO {
    private String orderId;
    private String status;
}

Controller

// controller/OrderController.java
@Slf4j
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {

    private final OrderService orderService;

    @PostMapping
    public ApiResult<OrderVO> create(@Valid @RequestBody CreateOrderDTO dto) {
        return ApiResult.ok(orderService.createOrder(dto));
    }

    @GetMapping("/{orderId}")
    public ApiResult<OrderVO> query(@PathVariable String orderId) {
        return ApiResult.ok(orderService.queryOrder(orderId));
    }
}

Service(核心:含三类自定义指标)

// service/OrderService.java
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderService {

    private final MeterRegistry meterRegistry;
    // 当前待处理订单数,用 AtomicInteger 作为 Gauge 的可变值源
    private final AtomicInteger pendingOrders = new AtomicInteger(0);

    public OrderVO createOrder(CreateOrderDTO dto) {
        // Timer:计时下单耗时,按渠道分桶
        return Timer.builder("order.create.duration")
                .description("下单接口耗时")
                .tag("channel", dto.getChannel())
                .register(meterRegistry)
                .record(() -> doCreateOrder(dto));
    }

    private OrderVO doCreateOrder(CreateOrderDTO dto) {
        pendingOrders.incrementAndGet();
        try {
            // 模拟下单:真实场景是查库存、写库、发 MQ
            Thread.sleep(50);
            OrderVO order = new OrderVO("ORDER-" + System.currentTimeMillis(), "SUCCESS");
            meterRegistry.counter("order.create.success", "channel", dto.getChannel()).increment();
            log.info("下单成功, orderId={}, userId={}", order.getOrderId(), dto.getUserId());
            return order;
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();   // 恢复中断标记
            meterRegistry.counter("order.create.failure", "channel", dto.getChannel()).increment();
            throw new BizException(ErrorCode.SYSTEM_ERROR);
        } finally {
            pendingOrders.decrementAndGet();
        }
    }

    @Timed(value = "order.query.duration", description = "订单查询耗时", percentiles = {0.5, 0.95, 0.99})
    public OrderVO queryOrder(String orderId) {
        if (orderId == null || orderId.isBlank()) {
            throw new BizException(ErrorCode.PARAM_ERROR);
        }
        // 演示:简单回显
        return new OrderVO(orderId, "SUCCESS");
    }

    @PostConstruct
    public void registerGauge() {
        Gauge.builder("order.pending.count", pendingOrders, AtomicInteger::get)
                .description("当前待处理订单数")
                .register(meterRegistry);
    }
}

MetricsConfig(@Timed 支持)与健康检查

// config/MetricsConfig.java
@Configuration
@RequiredArgsConstructor
public class MetricsConfig {

    private final MeterRegistry meterRegistry;

    // 必须显式注册 TimedAspect,否则 @Timed 注解静默失效
    @Bean
    public TimedAspect timedAspect() {
        return new TimedAspect(meterRegistry);
    }
}
// health/DependencyHealthIndicator.java
@Component
@RequiredArgsConstructor
@Slf4j
public class DependencyHealthIndicator implements HealthIndicator {

    private final DataSource dataSource;

    @Override
    public Health health() {
        try (Connection conn = dataSource.getConnection()) {
            return conn.isValid(2)
                    ? Health.up().withDetail("database", "UP").build()
                    : Health.down().withDetail("database", "connection invalid").build();
        } catch (Exception e) {
            log.error("数据库健康检查失败", e);
            return Health.down(e).withDetail("database", "DOWN").build();
        }
    }
}

5.3 配置文件

# application.yml —— 基础配置
server:
  port: 8080
  shutdown: graceful                      # 优雅停机
spring:
  lifecycle:
    timeout-per-shutdown-phase: 25s       # 小于 K8s 的 30s 宽限期
  application:
    name: order-service
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus   # 只暴露安全端点
  endpoint:
    health:
      probes:
        enabled: true                     # 自动生成 liveness / readiness 探针端点
      show-details: always
      group:
        readiness:
          include: readinessState,db      # 就绪 = 进程就绪 + 数据库可用
        liveness:
          include: livenessState          # 存活 = 进程活着即可
info:
  app:
    name: order-service
    version: 1.0.0
# application-prod.yml —— 生产配置(通过 SPRING_PROFILES_ACTIVE=prod 激活)
spring:
  datasource:
    url: ${DB_URL}                        # 从 ConfigMap/Secret 注入,不硬编码
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 3000
<!-- logback-spring.xml —— JSON 日志输出 -->
<configuration>
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <customFields>{"app":"order-service","env":"prod"}</customFields>
        </encoder>
    </appender>
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
    </root>
</configuration>

5.4 Dockerfile(多阶段 + 非
root)

# 阶段 1:构建
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn -B dependency:go-offline          # 先拉依赖,利用分层缓存
COPY src ./src
RUN mvn -B package -DskipTests

# 阶段 2:运行(alpine JRE + 非 root)
FROM eclipse-temurin:17-jre-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN chown -R app:app /app
USER app
EXPOSE 8080
# 生产 JVM 参数:堆 512m 起步(按实际调整),OOM 留现场并主动退出
ENTRYPOINT ["java", 
    "-Xms512m", "-Xmx512m", 
    "-XX:+UseG1GC", 
    "-XX:MaxGCPauseMillis=200", 
    "-XX:+HeapDumpOnOutOfMemoryError", 
    "-XX:HeapDumpPath=/tmp/heapdump.hprof", 
    "-XX:+ExitOnOutOfMemoryError", 
    "-jar", "app.jar"]
target/
.git/
.idea/
*.iml
*.log

5.5 Kubernetes 资源(完整)

# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: order-service-config
data:
  SPRING_PROFILES_ACTIVE: "prod"
  DB_URL: "jdbc:mysql://mysql:3306/order_db"
---
# k8s/secret.yaml(值需先 base64 编码)
apiVersion: v1
kind: Secret
metadata:
  name: order-service-secret
type: Opaque
data:
  DB_USERNAME: cm9vdA==
  DB_PASSWORD: cGFzc3dvcmQ=
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
  labels:
    app: order-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-service
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: order-service
    spec:
      terminationGracePeriodSeconds: 30    # 与优雅停机 25s 配合
      containers:
        - name: order-service
          image: IMAGE_TAG_PLACEHOLDER     # 由 CI 替换成 ghcr.io/xxx/order-service:<sha>
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 8080
          envFrom:
            - configMapRef:
                name: order-service-config
            - secretRef:
                name: order-service-secret
          startupProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            failureThreshold: 30
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 5
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
            failureThreshold: 3
          resources:
            requests:
              memory: "512Mi"
              cpu: "500m"
            limits:
              memory: "1Gi"
              cpu: "1"
# k8s/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: order-service
spec:
  selector:
    app: order-service
  ports:
    - port: 8080
      targetPort: 8080
  type: ClusterIP
# k8s/ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: order-service
spec:
  rules:
    - host: order.example.com
      http:
        paths:
          - path: /api
            pathType: Prefix
            backend:
              service:
                name: order-service
                port:
                  number: 8080
# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: order-service
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-service
  minReplicas: 3
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

5.6 运行步骤

第一步:本地跑通 + 验证监控

cd order-service
mvn spring-boot:run

# 验证健康检查
curl http://localhost:8080/actuator/health
curl http://localhost:8080/actuator/health/readiness
curl http://localhost:8080/actuator/health/liveness

# 造几条下单请求,触发业务指标
curl -X POST http://localhost:8080/api/orders 
  -H "Content-Type: application/json" 
  -d '{"userId":"1001","productId":"P001","quantity":2,"channel":"web"}'

# 查看业务指标是否产生
curl http://localhost:8080/actuator/metrics/order.create.duration
curl http://localhost:8080/actuator/metrics/order.pending.count

第二步:打镜像 + 对比大小

docker build -t order-service:1.0 .
docker images order-service   # 应看到约 200MB(alpine JRE 多阶段构建)

第三步:部署到 K8s(本地用 kind/minikube)

# 加载本地镜像到 kind
kind load docker-image order-service:1.0

kubectl apply -f k8s/configmap.yaml -f k8s/secret.yaml
kubectl apply -f k8s/deployment.yaml -f k8s/service.yaml -f k8s/ingress.yaml -f k8s/hpa.yaml

kubectl rollout status deployment/order-service
kubectl get pods   # 应看到 3 个 READY 1/1 的 Pod

第四步:接 Prometheus + Grafana(用 docker-compose
本地拉起)。

# docker-compose-monitor.yml
version: '3.8'
services:
  prometheus:
    image: prom/prometheus
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    ports: ["9090:9090"]
  grafana:
    image: grafana/grafana
    ports: ["3000:3000"]
docker compose -f docker-compose-monitor.yml up -d
# 浏览器打开 http://localhost:9090 看指标,http://localhost:3000 导入仪表盘 12900

第五步:模拟 OOM 排查(复用 6.5
的泄漏代码,临时启用后验证整个排查流程)。


6. 常见坑与排错指南

坑/现象 原因 解决方案
镜像几百 MB、拉取很慢 用完整 JDK 镜像单阶段构建,把 Maven 和源码都打进了镜像 多阶段构建 + JRE/alpine 基础镜像,产物只拷 jar
改了代码重新构建还是很慢 Dockerfile 先 COPY . . 再拉依赖,依赖层失效 COPY pom.xml +
dependency:go-offline,再拷源码
容器里 whoami 是 root 未配置 USER,进程以 root 运行,有安全风险 创建普通用户 + USER app,放在 chown 之后
应用启动时被反复重启,永远起不来 liveness 的 initialDelay 太短、failureThreshold
太小,启动没完成就被杀
加 startupProbe 给慢启动留足时间;liveness 别指向依赖检查
数据库抖动导致 Pod 反复重启 liveness 和 readiness 指向了同一个依赖检查 liveness 只查进程存活,readiness 才查依赖可用
一个内存泄漏的 Pod 拖垮整个节点 容器未设 resources 限制,无限吃内存 设置 requests + limits,内存的 requests 与 limits 相等
Pod 重启后日志找不到了 日志写在容器文件系统,容器销毁即丢失 日志只打 stdout,用 Filebeat 采集到 ELK 集中存储
发版瞬间有请求报 502/连接失败 未配优雅停机,SIGTERM 直接杀在途请求 server.shutdown: graceful + 合理的 timeout
应用 OOM 了却没有任何现场可查 没加 HeapDumpOnOutOfMemoryError,OOM 后无 dump 启动参数加 dump 参数,OOM 自动留 heapdump
OOM 后进程僵死、不退出 OOM 异常未捕获,进程半死还在接流量 -XX:+ExitOnOutOfMemoryError,让 K8s 重启
@Timed 注解没产生指标 未注册 TimedAspect Bean,注解静默失效 配置类里显式 @Bean TimedAspect
ES 磁盘被日志撑爆 索引只增不删 按天建索引 + ILM 自动清理过期索引
kubectl apply 报 selector 不匹配 Deployment 的 selector.matchLabels 与模板 labels
不一致
两处 labels 保持一致

8. 总结与延伸阅读

总结

本阶段补齐了从「写代码」到「稳定运行」的最后一块拼图:用
Actuator + Micrometer + Prometheus + Grafana
把应用状态和业务指标变成可观测的数据;用多阶段构建 + 分层缓存 +
非 root + 精简基础镜像
把镜像从 700MB 优化到 200MB 以内;用
Deployment + Service + Ingress + 双探针 + 资源限制
让应用在 K8s 上高可用、可自愈、可扩缩容;用 GitHub
Actions
把发布变成可重复、可回滚的流水线;用
ELK 让分散的容器日志集中可检索;最后用 G1 参数
+ 优雅停机 + heapdump/MAT
让 JVM
既能稳定运行、出问题又能快速定位。这六件事合起来,才是一个「能上线稳定运行」的服务该有的样子。

延伸阅读

正文完
 0
评论(没有评论)