文章

Docker 学习笔记:容器化与 Java 应用部署

牛耕田

暂存笔记,持续补充中。容器化是现代部署的起点,「在我电脑上能跑」的终结者。

一、核心概念:镜像、容器、仓库

概念 类比 说明
镜像 Image 类 / 光盘 只读模板,分层存储
容器 Container 对象 / 运行中的程序 镜像的运行实例,可读写层
仓库 Registry Maven 中央仓库 存放镜像,如 Docker Hub、Harbor
Dockerfile 源码 构建镜像的「配方」
Volume 移动硬盘 数据持久化
Network 局域网 容器间通信

镜像分层:每条 Dockerfile 指令生成一层,层可被多个镜像共享,这是 Docker 省空间的关键。

┌─────────────────┐  ← 可写层(容器运行时新增/修改)
├─────────────────┤  ← COPY app.jar
├─────────────────┤  ← RUN mvn package
├─────────────────┤  ← COPY pom.xml
├─────────────────┤  ← FROM eclipse-temurin:21-jre
└─────────────────┘

层缓存:某一层变化,其之后的所有层都会失效重建。因此变化频率低的指令放前面(如先 COPY pom.xmlCOPY src)。

二、Docker vs 虚拟机

维度 虚拟机 Docker
隔离级别 硬件虚拟化,独立内核 进程级隔离,共享宿主内核
启动速度 分钟级 秒级 / 毫秒级
资源占用 每台 GB 级 MB 级
镜像大小 GB 级 MB ~ 几百 MB
隔离性 较弱(共享内核)

原理:Docker 依赖 Linux 的 Namespace(隔离视图:PID、NET、MNT、UTS、IPC、USER)+ Cgroups(限制资源:CPU、内存)+ UnionFS(分层文件系统)。

三、Dockerfile 指令速查

指令 作用 备注
FROM 指定基础镜像 必须是第一条
WORKDIR 设置工作目录 优先用它,别用 RUN cd
COPY 复制本地文件进镜像 推荐,比 ADD 语义清晰
ADD 复制 + 自动解压 + 支持 URL 非必要不用
RUN 构建时执行命令 多层合并可减小体积
ENV 设置环境变量 构建和运行时都生效
ARG 构建参数 仅构建期有效
EXPOSE 声明端口 仅文档作用,不实际映射
CMD 容器启动默认命令 可被 docker run 覆盖,只生效最后一条
ENTRYPOINT 容器入口 不易被覆盖,常配 CMD 传参
VOLUME 声明匿名卷 数据持久化
USER 指定运行用户 安全实践:不要用 root
HEALTHCHECK 健康检查 配合编排系统

CMD vs ENTRYPOINT

# 组合用法:ENTRYPOINT 定命令,CMD 给默认参数
ENTRYPOINT ["java", "-jar", "/app.jar"]
CMD ["--spring.profiles.active=prod"]

# docker run myapp                        → java -jar /app.jar --spring.profiles.active=prod
# docker run myapp --server.port=9090     → java -jar /app.jar --server.port=9090

ENTRYPOINT / CMD 一定用 exec 形式(JSON 数组)

  • CMD ["java","-jar","app.jar"] → 进程 PID 为 1,能正确接收 SIGTERM 优雅停机
  • CMD java -jar app.jar → shell 形式,会包一层 /bin/sh -c信号转发给 shell 而非 Java 进程,导致 docker stop 无法优雅关闭。

四、Java 应用 Dockerfile 最佳实践

方案 A:多阶段构建(推荐)

单阶段会把 Maven、源码、依赖缓存全打进镜像,动辄 1GB+。多阶段构建只把产物复制进运行镜像:

# ---------- 构建阶段 ----------
FROM maven:3.9-eclipse-temurin-21 AS builder
WORKDIR /build

# 先只复制 pom,利用层缓存避免每次改代码都重新拉依赖
COPY pom.xml .
RUN mvn dependency:go-offline -B

COPY src ./src
RUN mvn clean package -DskipTests -B

# ---------- 运行阶段 ----------
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app

# 时区(日志时间对得上)
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

# 创建非 root 用户
RUN groupadd -r app && useradd -r -g app app
COPY --from=builder --chown=app:app /build/target/*.jar app.jar
USER app

EXPOSE 8080
ENTRYPOINT ["java", \
    "-XX:MaxRAMPercentage=75.0", \
    "-Djava.security.egd=file:/dev/./urandom", \
    "-jar", "/app/app.jar"]

方案 B:分层 JAR(进一步优化启动与拉取速度)

Spring Boot 3 支持把依赖分层,代码改动时只重建最后一层:

FROM eclipse-temurin:21-jre AS builder
WORKDIR /app
COPY target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=builder /app/dependencies/ ./
COPY --from=builder /app/spring-boot-loader/ ./
COPY --from=builder /app/snapshot-dependencies/ ./
COPY --from=builder /app/application/ ./
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

关键点依赖层变化少,业务代码层变化多。分层后,改一行代码只需重新推送最小的 application 层,镜像拉取从几百 MB 降到几 MB。

五、.dockerignore

必须配置,否则 node_modules.gittarget 等会被打进构建上下文,拖慢构建:

.git
.gitignore
.idea
*.iml
target/
node_modules/
dist/
*.log
Dockerfile*
docker-compose*
README.md

六、Docker Compose 编排

单机多容器编排的标准工具,用 YAML 声明式描述「服务 + 网络 + 卷」。

services:
  mysql:
    image: mysql:8.0
    container_name: blog-mysql
    environment:
      MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
      MYSQL_DATABASE: blog
      TZ: Asia/Shanghai
    command:
      - --character-set-server=utf8mb4
      - --collation-server=utf8mb4_unicode_ci
    volumes:
      - mysql-data:/var/lib/mysql
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    ports:
      - "3306:3306"
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis-data:/data
    ports:
      - "6379:6379"

  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: blog-app
    depends_on:
      mysql:
        condition: service_healthy      # 等 MySQL 真正就绪再启动
    environment:
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/blog?useSSL=false&serverTimezone=Asia/Shanghai
      SPRING_DATASOURCE_USERNAME: root
      SPRING_DATASOURCE_PASSWORD: ${MYSQL_ROOT_PASSWORD}
    ports:
      - "8080:8080"
    restart: unless-stopped

volumes:
  mysql-data:
  redis-data:

要点

  • 服务名即主机名:app 连 MySQL 用 mysql:3306,不是 localhost(每个容器有独立网络命名空间)。
  • depends_oncondition: service_healthy,否则 MySQL 还没初始化完 app 就启动会报连接失败。
  • restart: unless-stopped 保证宿主机重启后自动拉起。
  • 密码用 ${VAR}.env 读取.env 加入 .gitignore

常用命令

docker compose up -d              # 后台启动
docker compose ps                 # 查看状态
docker compose logs -f app        # 跟踪日志
docker compose restart app        # 重启单个服务
docker compose down               # 停止并移除容器(卷保留)
docker compose down -v            # 连卷一起删(危险!数据丢失)
docker compose up -d --build      # 重新构建并启动

七、数据持久化与管理

# 具名卷(推荐,Docker 管理,性能好)
docker run -v mysql-data:/var/lib/mysql mysql:8

# 绑定挂载(宿主机路径,便于直接改文件)
docker run -v /data/mysql:/var/lib/mysql mysql:8

# 只读挂载
docker run -v /data/config:/app/config:ro myapp

容器无状态原则:容器内不存重要数据,全部挂卷或存外部。这样才能随时删除重建。

八、镜像瘦身技巧

手段 效果
多阶段构建 去掉构建工具链,最有效
换 slim / alpine 基础镜像 数百 MB → 几十 MB
RUN 命令合并 + 清理缓存 减少层数与垃圾文件
.dockerignore 减小构建上下文
用 JRE 而非 JDK JRE 更小(除非需要 jcmd/jmap)
分层 JAR 提升拉取速度(体积不减)

⚠️ alpine 的坑:使用 musl libc,某些依赖 glibc 的库(如部分 Netty 本地库、字体库)会出问题。Java 应用优先用 eclipse-temurin:21-jre-jammy(Ubuntu 系)而非 alpine,稳定优先。

九、常用运维命令

# 镜像
docker images                       # 列出镜像
docker build -t myapp:1.0 .         # 构建
docker rmi myapp:1.0                # 删除镜像
docker image prune -a               # 清理无引用镜像

# 容器
docker ps                           # 运行中的容器
docker ps -a                        # 包括已停止
docker run -d -p 8080:8080 --name app myapp:1.0
docker exec -it app /bin/sh         # 进容器调试
docker logs -f --tail 100 app       # 看日志
docker stats                        # 实时资源占用
docker stop app                     # 优雅停止(发 SIGTERM,10s 超时后 SIGKILL)
docker rm app                       # 删除容器

# 清理(谨慎)
docker system df                    # 查看磁盘占用
docker system prune -a              # 清理所有未使用资源

排查「容器起不来」的标准套路

docker ps -a                        # 1. 看状态是不是 Exited
docker logs <container>             # 2. 看日志报什么错(90% 的答案在这)
docker inspect <container>          # 3. 看配置、网络、退出码
docker exec -it <container> sh      # 4. 进容器手动验证(能进说明没崩)

十、生产实践要点

  1. 不要用 latest 标签,用具体版本号或 Git commit hash,保证可追溯、可回滚。
  2. EXPOSE 只是文档,端口映射靠 -p 或 compose 的 ports
  3. 日志走 stdout/stderr,不要往容器内写文件(容器删除即丢)。用 docker logs 或接入 ELK/Loki。
  4. JVM 感知容器内存限制:JDK 10+ 默认开启 UseContainerSupport-XX:MaxRAMPercentage=75 比硬编码 -Xmx 更适合容器。
  5. 健康检查必须配,否则编排系统无法判断容器是否真的可用。
  6. 镜像安全扫描docker scout cves myapp:1.0 或 Trivy,定期修复基础镜像漏洞。
  7. .env 与密钥永不入镜像,通过环境变量或 Secret 注入。

十一、小结

  • 镜像分层 + 构建缓存 是理解 Dockerfile 优化的钥匙:不变的放前面。
  • 多阶段构建 是 Java 镜像瘦身的头号手段;分层 JAR 是拉取提速的利器。
  • exec 形式写 ENTRYPOINT,否则优雅停机失效。
  • Compose 用服务名当主机名,depends_on 要配 service_healthy
  • 容器无状态,数据一律挂卷;日志走 stdout
  • 生产纪律:版本标签明确、非 root 运行、健康检查齐全、密钥不落盘。