阶段 9 ·
从「能写代码」到「能上线稳定运行」。上线只是开始,能监控、能排障、能扩容才算闭环。
1. 导语
前 8 个阶段,你学会了用 Spring Boot 3.2.x
写接口、连数据库、做缓存、保证事务、设计安全。但你写的每一行代码,最终都要跑在一台真实的机器上、被真实的用户访问。代码写完只是「交付了一半」,剩下的一半是:它能不能稳定地跑下去?出问题的时候你能不能在一分钟内知道?知道之后能不能快速定位、快速恢复?
这就是本阶段要解决的问题。很多工程师把「部署」理解成「把 jar
包拷到服务器上用 java -jar
启动」,这在开发环境没问题,但在生产环境远远不够。生产的真实诉求是三件事:
- 能监控——应用的内存、GC、接口耗时、连接池,都要变成可见的指标,出了问题主动告警,而不是等用户来投诉。
- 能部署——用 Docker 把应用和环境打包成镜像,用
Kubernetes
做副本管理、滚动更新、健康检查和自动扩缩容,让「发布」变成一条可重复、可回滚的流水线。 - 能排障——日志集中采集到 ELK,JVM 参数调好,OOM 时有
heapdump 留现场,能顺着线索定位到是内存泄漏还是内存不够。
学完本阶段,你能独立完成一条完整的「上线链路」:把订单服务打成优化过的
Docker 镜像 → 推送到镜像仓库 → 用 Deployment + Service + Ingress 部署到
Kubernetes → 用 Actuator + Prometheus + Grafana 监控它的每一项指标 →
出问题时用 heapdump + MAT 定位 OOM。
本阶段前置依赖阶段 2(Spring Boot 基础),不需要你会 Docker 或
K8s,但需要你有基本的 Linux 命令行经验(会
ls、cd、ps、看日志)。如果你对
Linux 还不熟,建议先花半天熟悉一下常用命令再看本篇。
2. 学习目标与前置要求
学完本阶段,你能:
- 独立配置 Spring Boot Actuator,暴露 health / metrics / prometheus
端点,并写出自定义业务指标(Counter / Gauge / Timer)。 - 写出生产级的多阶段构建 Dockerfile,能把一个 Spring Boot
应用镜像从几百 MB 优化到一百 MB 以内,并以非 root 用户运行。 - 独立编写 Deployment + Service + Ingress + ConfigMap + Secret 完整
YAML,正确配置 readiness / liveness / startup 探针和 resources
资源限制。 - 用 GitHub Actions 搭一条「构建 → 测试 → 打镜像 → 推送 → 部署」的完整
CI/CD 流水线。 - 用 Filebeat → Logstash → Elasticsearch → Kibana
把容器日志集中采集,实现日志不落地。 - 配置 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、靠断点、靠重启。生产环境里,这些都不可用:你不能在生产服务器上打断点,重启一次可能就是一次线上事故。所以生产排障必须依赖「运行时可观测的数据」——也就是监控指标。
监控解决三个递进的问题:
- 发生了什么——接口每秒请求多少、耗时多少、内存用了多少、GC
停顿多久。 - 是否健康——应用还能不能正常处理请求,依赖的下游(数据库、Redis)还通不通。
- 如何定位——某个指标异常时,能顺着指标找到对应的日志、堆转储,缩小排查范围。
Spring Boot 官方给出的答案是
Actuator(应用状态监控)+
Micrometer(指标门面)。Actuator
负责把应用内部状态暴露成 HTTP 端点,Micrometer 负责把指标统一成一套
API,再适配到不同的监控后端(Prometheus、Grafana、InfluxDB、云厂商监控)。
1.2 最小可用:暴露三个核心端点
先给一个最小可用配置,暴露
health、info、metrics、prometheus
四个端点:
# 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
里不要放
env、configprops、beans、heapdump、threaddump,这些会泄露环境变量(含数据库密码)、配置项和堆内存数据。除非有严格的内网访问控制和鉴权,否则只暴露
health、info、metrics、prometheus。
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 是按
uri、method、status、outcome
等 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 迁移):
- 用点号分层:
order.create.duration比
orderCreateDuration清晰,Prometheus
抓取时会自动把点转成下划线(order_create_duration)。 - 带上单位后缀:时长用
...seconds,大小用...bytes,计数用
...count或直接...total。不要用
...millis这种隐含单位。 - Timer 的命名:
http.server.requests
这种是「名词 + 动作」,业务侧order.create.duration
同理。 - 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(华东/华南)都安全;userId、orderId、traceId
都不安全,这些信息应该进日志,而不是进指标。
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 有三大隐患:
- 环境不一致——开发机是 JDK 17,服务器可能是 JDK
8;你本地装了一堆依赖,服务器没有。经典的「我本地能跑」问题。 - 配置漂移——服务器被不同的人手动改过,谁也说不清现在的状态,新机器部署要重新踩一遍坑。
- 不可迁移——换个服务器、扩容一台机器,都要重新配一遍环境。
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
权限的操作(RUN、chown)之后。切换用户后,后续指令都以
appuser 执行,无法再改系统文件。验证是否生效:
docker run -d --name check order-service:1.0
docker exec check whoami # 输出应为 appuser 而不是 root
2.6 再瘦一圈:alpine 与
jlink 定制 JRE
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)而不是
latest。latest
是「最新」的别名,你无法确定它具体是哪个提交,回滚时也说不清「上一个版本」是什么。这也是第
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 定默认参数 |
关键点:ENTRYPOINT 用 exec
形式(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: 30、failureThreshold: 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 上传服务器
→ 重启服务。这套流程有三个致命问题:
- 不可重复——每个人的本地环境不同,你本地能打包,别人不一定;这次能成功,下次可能漏了步骤。
- 没有测试把关——赶时间的时候会「跳过测试直接发」,一个没跑测试的提交可能直接打挂生产。
- 没有回滚记录——发出去的版本没有留痕,出问题想回滚都不知道上一个版本是什么。
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,而不是
latest。latest
是可变标签,出了事无法确定线上跑的是哪个提交;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(latest、main)在生产是定时炸弹——同一份
YAML 里写 image: app:latest,不同时间
kubectl apply
拉到的是不同镜像,出了事说不清线上跑的是哪一版。生产 YAML 里的 image
必须指向不可变 tag。
4.10
回滚策略:出问题怎么快速止血
发版出问题,按「影响范围」分三级止血:
- 最快:
rollout undo。镜像已推送,只是新版本有问题,一条命令回到上一版
Pod 模板(秒级)。
kubectl rollout undo deployment/order-service
-
配置也错了:连 ConfigMap
一起回。rollout undo只回 Pod
模板,ConfigMap/Secret 的改动要单独回滚(用
kubectl apply -f重新应用旧版配置)。 -
镜像本身有安全漏洞:回退镜像 +
重新构建。把上一个安全版本的 tag
重新部署,同时修复漏洞重新构建新版本。
关键点:止血和排查要分开——先
rollout undo
恢复服务,再慢慢查根因,别在线上「边查边改」。这就是为什么不可变 tag +
版本历史(rollout history)是回滚能力的基础。
本章小结:CI/CD
的价值在于把「人肉发布」变成「代码化、可重复、可回滚」的流水线;生产级
pipeline 必须包含测试把关、不可变镜像
tag、凭据隔离、失败自动回滚,以及「dev 自动 + prod
审批」的环境分级。
第 5 章:日志采集(ELK)
5.1 为什么日志要集中采集
单机时代,排障就是
tail -f app.log。但到了容器时代,问题来了:
- 容器是临时的——Pod
被调度、重建、扩容后,旧容器连同它的日志文件一起消失。日志写在容器文件系统里
= 写在沙滩上。 - 副本是分散的——3 个副本的日志分散在 3 个 Pod
里,一个用户请求可能被路由到任意一个,查一条日志要翻 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
线程池会复用线程,所以 finally 里 MDC.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)
}
字段规范建议:
- 必带:
app、env、traceId、level。这四者是检索和链路串联的基础。 - 时间用统一格式:ES 的
@timestamp
要能被解析成时间类型,才能做时间范围查询和按时序排序。 - 别把整个对象塞进 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
会给出三个关键视图:
- Leak Suspects(泄漏嫌疑报告)——MAT
自动分析,直接列出「最可能是泄漏」的对象和引用链。这是最高效的入口。 - Dominator
Tree(支配树)——按「谁占的内存最多」排序,快速找到内存大户。 - 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 Tree 里 ArrayList 占用了
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 日志的三个判断:
- FGC 次数持续增长 →
老年代有问题,要么泄漏、要么晋升太快。 - 每次 YGC 后 O(老年代占用)不回落 →
有对象长期存活,疑似泄漏。 - GCT(总停顿时间)占比高 → GC
过于频繁,检查堆大小和目标停顿参数。
6.8 常见内存泄漏场景清单
heapdump + MAT
能帮你找到「哪个对象占内存」,但「为什么会泄漏」还得靠对常见泄漏模式的认识。生产里最高频的几类泄漏:
| 泄漏场景 | 原因 | 修复 |
|---|---|---|
| 静态集合无限增长 | static List 塞对象不清 |
用有界缓存(Caffeine/Guava Cache) |
| ThreadLocal 未清理 | 线程池复用线程,ThreadLocal 值不 remove | finally 里 remove() |
| 监听器/回调未注销 | 注册了 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
既能稳定运行、出问题又能快速定位。这六件事合起来,才是一个「能上线稳定运行」的服务该有的样子。
延伸阅读
- Spring
Boot Actuator 官方文档 —— 端点与指标的权威参考 - Docker 官方文档 —— Dockerfile
与多阶段构建 - Kubernetes
官方文档 —— Pod、探针、Deployment、HPA - Micrometer 官方文档 ——
指标类型与命名规范 - 《深入理解 Java 虚拟机(第 3 版)》周志明 —— GC 与内存结构章节(第
3、5 章) - 《Kubernetes in Action》—— K8s 从原理到实战的经典教材