一、一个"反面教材"
很多团队的第一个 Dockerfile 长这样:
FROM openjdk:17
COPY target/app.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
这个镜像打包后约 500MB,每次构建都需要重新传输,部署缓慢。接下来我们一步步优化。
二、分层构建 —— 把依赖和业务代码分开
Spring Boot 的 fat jar 由三部分组成:
- 内嵌容器(Tomcat / Undertow)—— 极少变化
- 三方依赖(Spring、Jackson 等)—— 很少变化
- 业务代码 —— 频繁变化
利用这个特性做分层:
<!-- pom.xml -->
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<layers>
<enabled>true</enabled>
</layers>
</configuration>
</plugin>
</plugins>
</build>
# Dockerfile —— 多阶段构建 + 分层
FROM eclipse-temurin:21-jre-alpine AS builder
WORKDIR /app
COPY target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
# 按变化频率倒序 COPY —— 越不容易变的越靠前
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
# 创建非 root 用户
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
# 优雅关闭
STOPSIGNAL SIGTERM
EXPOSE 8080
ENTRYPOINT ["java", "-XX:+UseG1GC", "-XX:MaxRAMPercentage=75.0", "-Djava.security.egd=file:/dev/./urandom", "org.springframework.boot.loader.launch.JarLauncher"]
为什么这样写?
| 改进 | 好处 |
|---|---|
| JRE 替代 JDK | 镜像从 ~400MB 降到 ~180MB |
| Alpine 基础镜像 | 再减 50% → ~90MB |
| 分层 COPY | 业务代码变更时只重建最后一层 |
| 非 root 运行 | 安全性 |
MaxRAMPercentage |
容器场景下自动适配内存限制 |
| SIGTERM | 优雅关闭而不是 kill -9 |
三、优雅关闭与健康检查
Spring Boot 优雅关闭
# application.yml
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
Docker 健康检查
HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=40s CMD wget -qO- http://localhost:8080/actuator/health || exit 1
四、Docker Compose 编排
version: '3.8'
services:
app:
build: .
image: myapp:${APP_VERSION:-latest}
container_name: myapp
ports:
- "8080:8080"
environment:
- SPRING_DATASOURCE_URL=jdbc:mysql://mysql:3306/myapp
- SPRING_REDIS_HOST=redis
- SERVER_TOMCAT_THREADS_MAX=200
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_started
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 60s
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
cpus: '0.5'
memory: 512M
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "50m"
max-file: "3"
mysql:
image: mysql:8.0
container_name: mysql
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: myapp
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: redis
volumes:
- redis_data:/data
volumes:
mysql_data:
redis_data:
五、JVM 容器感知 —— 别被 OOM Killer 杀掉
JDK 10+ 引入了 UseContainerSupport(默认开启),JVM 能感知容器的 cgroup 限制。
# ✅ 推荐:用百分比,跟随容器限制自动适配
ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
# ❌ 避免:固定值,容器 2G 限制时堆 4G → OOM Kill
ENTRYPOINT ["java", "-Xmx4g", "-jar", "app.jar"]
内存计算
| 容器限制 | MaxRAMPercentage | 堆大小 | 剩余给 OS/Metaspace |
|---|---|---|---|
| 512M | 75% | 384M | 128M(偏紧) |
| 1G | 75% | 768M | 256M |
| 2G | 75% | 1.5G | 512M |
| 4G | 75% | 3G | 1G |
经验法则:
MaxRAMPercentage设为 75%,留 25% 给 Metaspace、线程栈和 OS 缓存。
六、CI/CD 构建流水线
# .github/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches: [main]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up JDK 21
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: maven
- name: Build with Maven
run: mvn -B clean package -DskipTests
- name: Build Docker image
run: |
docker build -t $REGISTRY/$IMAGE_NAME:${{ github.sha }} .
docker tag $REGISTRY/$IMAGE_NAME:${{ github.sha }} $REGISTRY/$IMAGE_NAME:latest
- name: Push to registry
run: |
echo ${{ secrets.GITHUB_TOKEN }} | docker login $REGISTRY -u ${{ github.actor }} --password-stdin
docker push $REGISTRY/$IMAGE_NAME:${{ github.sha }}
docker push $REGISTRY/$IMAGE_NAME:latest
- name: Deploy to server
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.SERVER_HOST }}
username: deploy
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /opt/myapp
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
七、镜像瘦身终极方案
如果你对镜像大小有极致追求,可以尝试 jlink + 自定义 JRE:
# 第一步:用 jlink 生成最小化 JRE
FROM eclipse-temurin:21-jdk-alpine AS jre-builder
RUN jlink --add-modules java.base,java.logging,java.sql,java.naming,java.management,java.net.http,jdk.unsupported --strip-debug --no-man-pages --no-header-files --compress=zip-6 --output /custom-jre
# 第二步:最终镜像
FROM alpine:3.20
COPY --from=jre-builder /custom-jre /opt/jre
# ... 其他层
ENV PATH="/opt/jre/bin:$PATH"
这样做能把基础镜像压缩到 40MB 以内,整个应用镜像 ~80MB。
八、总结
| 层次 | 最佳实践 |
|---|---|
| 基础镜像 | JRE + Alpine,不用 JDK |
| 分层 | 开启 Spring Boot layers,依赖与代码分离 |
| 安全 | 非 root 用户运行 |
| 资源 | MaxRAMPercentage 而非 -Xmx 固定值 |
| 关闭 | server.shutdown=graceful + STOPSIGNAL SIGTERM |
| 健康 | HEALTHCHECK + /actuator/health |
| 日志 | Docker json-file + 大小轮转 |
| CI/CD | 多阶段构建 + 镜像缓存 + 自动部署 |
一个好的 Docker 镜像就像一段好代码——简洁、内聚、安全、易于维护。你的 Dockerfile 本身也应该被 Code Review。
