把 Java 应用容器化,是迈向现代化部署的关键一步。它不仅解决了经典的"在我机器上能运行"的问题,更能让应用在开发、测试、生产环境表现完全一致。这篇文章将带你详细拆解,如何将一个(或多个)Java 应用 Jar 包部署到 Docker 容器中,从基础到生产环境的最佳实践,涵盖单服务、多服务以及 CI/CD 自动化部署。
一、项目打包:一切的基础
在开始编写 Dockerfile 之前,首先要确保你的 Java 项目已经构建为可执行的 JAR 包。
-
Maven 项目:在项目根目录执行
bashmvn clean package -
Gradle 项目:执行
bashgradle build
构建成功后,你会在 target/(Maven)或 build/libs/(Gradle)目录下找到生成的 .jar 文件。记住这个文件的位置,它是后续 Docker 镜像的"原材料"。
二、场景一:单个 JAR 包部署------最核心的场景
这是最常见、也是最基础的部署场景。目标是将一个 JAR 包和 JRE 环境打包成一个独立的镜像。
2.1 编写 Dockerfile
在项目根目录下创建一个名为 Dockerfile 的文件,内容如下:
dockerfile
# 选择精简的基础镜像,显著缩小体积
FROM openjdk:11-jre-slim
# 设置工作目录
WORKDIR /app
# 复制 JAR 包到镜像内(使用通配符,避免写死版本号)
COPY target/*.jar app.jar
# 声明容器运行时监听的端口(文档说明,不会自动映射)
EXPOSE 8080
# 使用环境变量注入 JVM 参数,更灵活
ENV JAVA_OPTS="-Xms512m -Xmx1024m"
# 定义容器启动后执行的命令(Exec 模式)
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"]
关键点解读:
- 基础镜像选择 :
openjdk:11-jre-slim是体积较小的 JRE 镜像,能有效减少最终镜像的体积,加快下载和部署速度。如果需要更小,可以考虑alpine版本。 - WORKDIR :设置工作目录后,后续的
COPY、ENTRYPOINT等指令都会在该目录下执行,避免使用根目录造成混乱。 - ENV 与 ENTRYPOINT 组合 :将 JVM 参数通过环境变量
JAVA_OPTS注入,可以在不修改 Dockerfile 的情况下,通过docker run -e灵活调整不同环境的堆内存大小。 - ENTRYPOINT 形式 :这里使用了 Shell 模式的变体
["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"],目的是让JAVA_OPTS环境变量能够被正确解析。
2.2 构建镜像与运行容器
bash
# 构建镜像,-t 命名,最后的 . 代表构建上下文为当前目录
docker build -t my-single-app:v1 .
# 后台运行容器,将宿主机 8080 端口映射到容器 8080 端口
docker run -d -p 8080:8080 --name my-app-container my-single-app:v1
# 查看日志,确认启动是否成功
docker logs -f my-app-container
三、场景二:多个 JAR 包部署到同一个容器------非常规但实用的方案
有时出于资源利用最大化或过渡期架构的考虑,需要将多个 JAR 包(比如多个微服务)部署到同一个容器中。这种方式在一定程度上违背了 Docker"一个容器一个进程"的最佳实践,会带来日志管理复杂、资源隔离差等问题,请谨慎使用。如果条件允许,更推荐使用 Docker Compose 或 Kubernetes 进行多服务编排。
这里提供两种实现方式,重点推荐方式一(脚本控制)。
3.1 方式一:使用启动脚本(推荐)
这是管理多进程更可控的方式。通过一个 Shell 脚本,将所有 JAR 包启动为后台进程。
3.1.1 编写启动脚本 start.sh
在项目目录下创建 start.sh:
bash
#!/bin/bash
# 启动服务A,放到后台执行
java -jar /app/service-a.jar &
# 启动服务B,放到后台执行
java -jar /app/service-b.jar &
# 关键:让容器保持前台运行,否则容器会立即退出
# tail -f /dev/null 是一个阻塞命令,能让容器一直存活
tail -f /dev/null
核心要点 :每个 JAR 包启动命令末尾的 & 是关键 ,它让进程在后台运行,脚本可以继续执行下一个命令。而 tail -f /dev/null 是一个"取巧"的阻塞命令,让容器至少有一个前台进程而保持存活。
3.1.2 编写 Dockerfile
dockerfile
FROM openjdk:11-jre-slim
WORKDIR /app
# 复制所有 JAR 包和启动脚本
COPY *.jar /app/
COPY start.sh /app/
# 给脚本添加执行权限
RUN chmod +x /app/start.sh
# 声明所有端口(仅文档说明)
EXPOSE 8080 8081
# 使用脚本作为入口
ENTRYPOINT ["/app/start.sh"]
3.1.3 构建与运行
bash
docker build -t my-multi-app:v1 .
docker run -d -p 8080:8080 -p 8081:8081 --name my-multi-container my-multi-app:v1
进阶优化 :可以使用 supervisor 这类进程管理工具来替代脚本,它能提供更完善的进程管理和日志聚合功能。
3.2 方式二:使用 CMD 连接命令(不推荐)
另一种不推荐的方式是直接在一个 CMD 或 ENTRYPOINT 中用 && 或 ; 连接多个命令。这种方式的问题是,如果第一个命令是长期运行的服务,第二个命令永远不会被执行到。如果强行都放到后台,又回到了进程管理的问题。
dockerfile
# 错误示范:永远启动不了第二个服务
CMD ["sh", "-c", "java -jar service-a.jar && java -jar service-b.jar"]
这种方式无法正确处理多长期运行进程的情况,仅适合初始化脚本后跟一个前台主进程的模式。
四、生产环境部署的进阶优化
将应用跑起来只是第一步,在生产环境中,以下最佳实践能让你事半功倍。
4.1 多阶段构建(Multi-stage Build)
多阶段构建是优化 Docker 镜像体积的利器。它允许你在一个 Dockerfile 中使用多个 FROM 语句,最终只将需要的产物(如 JAR 包)复制到最终的运行镜像中,抛弃构建过程中产生的大量依赖和临时文件。
dockerfile
# 第一阶段:构建阶段
FROM maven:3.8-openjdk-17-slim AS build
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline # 预先下载依赖,利用缓存
COPY src ./src
RUN mvn clean package -DskipTests
# 第二阶段:运行阶段
FROM openjdk:17-jre-slim
WORKDIR /app
# 仅从构建阶段复制 JAR 包
COPY --from=build /build/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
4.2 利用构建缓存加速
Docker 在构建镜像时,会按顺序执行指令,并检查每一条指令是否发生变化。将变化不频繁 的指令(如 FROM、ENV、RUN 下载依赖)放在 Dockerfile 的前面,将变化频繁 的指令(如 COPY 源代码或 JAR 包)放在后面,可以最大化利用缓存,极大加速构建过程。
4.3 健康检查(HEALTHCHECK)
HEALTHCHECK 指令让 Docker 能够感知容器的健康状态,而不仅仅是进程是否存活。这对于服务编排(如 Kubernetes)进行故障转移和滚动更新至关重要。
dockerfile
# 示例:假设应用有 /health 健康检查端点
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
4.4 非 Root 用户运行
出于安全考虑,容器内应避免以 Root 权限运行应用,防止容器逃逸等风险。
dockerfile
FROM openjdk:11-jre-slim
# 创建非 root 用户和组
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
COPY target/*.jar app.jar
# 切换用户
USER appuser
ENTRYPOINT ["java", "-jar", "app.jar"]
4.5 日志与数据持久化
容器是无状态的,容器删除后,内部的数据和日志都会丢失。通过挂载卷(Volume)将日志和应用数据存储到宿主机或外部存储,是生产环境的刚需。
bash
# 运行容器时,将宿主机目录映射到容器内日志目录
docker run -d -v /host/logs:/app/logs my-single-app:v1
4.6 CMD 与 ENTRYPOINT 的深入选择
| 指令 | 特点 | 适用场景 |
|---|---|---|
| CMD | 提供默认命令或参数,可被 docker run 命令覆盖。 |
适用于镜像可以被用作其他用途,或需要灵活传递启动参数的情况。 |
| ENTRYPOINT | 定义容器的主进程,不可被 docker run 命令覆盖 (除非使用 --entrypoint)。 |
适用于将容器当作一个独立的可执行程序,比如运行一个特定的服务,命令固定。 |
| ENTRYPOINT + CMD | ENTRYPOINT 定义固定命令,CMD 为其提供默认参数,且 CMD 可被覆盖。 |
最灵活的方式。例如:ENTRYPOINT ["curl"] + CMD ["https://example.com"],运行时可追加新 URL。 |
五、从手动到自动化:CI/CD 集成
为了进一步提升效率,可以结合 Jenkins 等 CI/CD 工具,实现代码提交后自动构建镜像并部署。
一个典型的 Jenkins Pipeline 包含以下阶段:
- Checkout:从 Git 仓库拉取代码。
- Build:使用 Maven/Gradle 构建 JAR 包。
- Docker Build:基于项目中的 Dockerfile 构建镜像。
- Docker Push:将镜像推送到 Harbor 或阿里云镜像仓库等私有仓库。
- Deploy :在目标服务器上执行
docker run拉取新镜像并启动容器。
groovy
// Jenkinsfile 核心片段
pipeline {
agent any
stages {
stage('Build JAR') {
steps {
sh 'mvn clean package -DskipTests'
}
}
stage('Build and Push Image') {
steps {
sh 'docker build -t my-registry/my-app:$BUILD_NUMBER .'
sh 'docker push my-registry/my-app:$BUILD_NUMBER'
}
}
stage('Deploy') {
steps {
sh 'docker stop my-app || true'
sh 'docker rm my-app || true'
sh 'docker run -d --name my-app -p 8080:8080 my-registry/my-app:$BUILD_NUMBER'
}
}
}
}
六、常见问题排查
- 容器启动后立即退出 :通常是因为主进程不是前台进程。检查
ENTRYPOINT或CMD,确保命令是持续运行的,而不是一个后台命令(如service start)。多进程场景下,需要脚本配合tail -f /dev/null或supervisor来保持前台进程。 - 端口冲突 :容器运行失败,
docker logs显示Address already in use。检查宿主机端口是否被占用,或容器内应用配置的端口与EXPOSE/-p映射的端口是否一致。 - JVM 内存溢出(OOM) :在容器中运行 Java 应用,需要同时设置 JVM 的
-Xmx和 Docker 的--memory限制,且建议 JVM 堆内存值小于容器内存限制,为 JVM 的非堆内存和系统预留空间。
小结
Docker 部署 JAR 包的核心在于编写一个正确的 Dockerfile 以及理解容器的进程模型。
- 对于单 JAR 包,流程清晰直接,是标准化的首选。
- 对于多 JAR 包,核心是编写一个能管理多个后台进程的启动脚本,但必须清醒认识其局限性。
- 面向生产环境,务必应用多阶段构建、健康检查、非 root 用户、日志持久化等最佳实践。