1. 为什么把 jar 包塞进 Docker 是个值得聊的话题刚入行那会儿,我部署一个 Java 服务的标准动作是这样的:本地mvn package打出 jar,scp 传到服务器,写个nohup java -jar xxx.jar &,然后祈祷它别在半夜挂掉。挂了就挂了,第二天上班看日志,发现是环境变量没配、JDK 版本不对、或者某个依赖的 so 库缺失。这种“人肉运维”的日子,相信不少同行都经历过。后来容器化浪潮来了,大家都说“把 jar 包做成镜像,一键跑起来”。听起来很美,但真动手的时候,问题一个接一个:基础镜像选哪个?JDK 版本怎么对齐?时区怎么设?日志往哪写?配置文件是打进镜像还是挂载出来?镜像打出来 800MB,推送到仓库要等半天,这合理吗?这篇内容就是围绕jar 包 Docker 服务器部署这件事,把我这些年踩过的坑、试过的方案、以及最终沉淀下来的一套可复用的流程,完整地摊开来讲。不管你是刚接触容器化部署的后端开发,还是需要自己维护服务运行环境的全栈选手,或者是想把手头那堆“裸跑”的 Java 服务收编进容器的运维同学,这里面的内容都能直接拿去用。核心关键词就几个:jar 包、Docker 镜像、多阶段构建、容器部署、日志挂载、时区配置、健康检查。我会从方案选型的逻辑讲起,再到 Dockerfile 的逐行拆解,然后是完整的构建与部署实操,最后是问题排查的速查表。每一步都会说清楚“为什么这么做”,而不是只给一堆命令让你抄。2. 方案选型:别急着写 Dockerfile,先把这几件事想清楚2.1 基础镜像怎么选:JRE 还是 JDK,alpine 还是 slim这是第一个岔路口。很多人上来就FROM openjdk:8-jdk,能用是能用,但镜像体积直接飙到 600MB 以上。对于一个只需要跑 jar 包的生产环境来说,编译工具链、源码、文档这些东西全是累赘。我的选择逻辑是这样的:镜像类型体积量级适用场景注意事项openjdk:8-jdk600MB+需要在容器内编译代码生产环境跑 jar 包属于杀鸡用牛刀openjdk:8-jre300MB+纯运行环境比 JDK 小一半,但仍有优化空间openjdk:8-jre-slim200MB 左右生产环境首选去掉了文档和部分非必要组件openjdk:8-jre-alpine100MB 以内追求极致体积基于 musl libc,部分 native 依赖可能不兼容eclipse-temurin:8-jre200MB 左右替代 Oracle JDK社区维护,更新及时提示:alpine 镜像虽小,但如果你的项目依赖了某些基于 glibc 的 native 库(比如某些加密库、图像处理库),跑起来会报No such file or directory或者Error loading shared library。这种情况要么换 slim 镜像,要么在 alpine 里补装libc6-compat,但补装之后体积优势就没那么明显了。我个人的习惯是:优先用eclipse-temurin的 jre-slim 版本。它是社区维护的 OpenJDK 构建,更新频率有保障,体积也控制得不错。如果项目对 JDK 版本有特殊要求(比如必须用某个特定发行版),再按需调整。2.2 构建方式:单阶段还是多阶段单阶段构建就是在一个 Dockerfile 里从头写到尾:拉基础镜像、拷贝 jar、设置启动命令。简单直接,但有个问题——如果你需要在构建过程中做一些额外操作(比如解压 jar、分层处理、生成配置文件),这些操作产生的中间文件都会留在最终镜像里。多阶段构建则是把“构建”和“运行”分开。构建阶段可以用一个带完整工具链的镜像,运行阶段只拷贝最终产物。对于 jar 包部署来说,多阶段构建的典型用法是:# 构建阶段:解压 jar,做分层处理 FROM eclipse-temurin:8-jre AS builder WORKDIR /build COPY app.jar app.jar RUN java -Djarmode=layertools -jar app.jar extract # 运行阶段:只拷贝分层后的文件 FROM eclipse-temurin:8-jre WORKDIR /app COPY --from=builder /build/dependencies/ ./ COPY --from=builder /build/spring-boot-loader/ ./ COPY --from=builder /build/snapshot-dependencies/ ./ COPY --from=builder /build/application/ ./ ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]这种分层构建的好处是:当你的业务代码变更时,只有application层会变,dependencies层可以复用缓存。推送到镜像仓库时,也只推送变更的层,大大加快了部署速度。实测下来,一个 200MB 的镜像,分层之后日常更新只需要推送几 MB 的增量。注意:分层构建依赖 Spring Boot 的layertools,如果你的项目不是 Spring Boot 或者版本较老,这个方案不适用。普通项目用单阶段构建就够了,别为了分层而分层。2.3 配置管理:打进镜像还是外部挂载这个问题争论了很久。我的观点很明确:配置绝对不能打进镜像。原因很简单——镜像应该是不可变的构建产物,同一份镜像在测试环境、预发环境、生产环境都应该能跑。如果把数据库连接串、Redis 地址这些写死在镜像里,那每次换环境都要重新构建,镜像的“一次构建、到处运行”就成了一句空话。正确的做法是:镜像里只放一份默认配置(或者干脆不放),作为兜底实际配置通过环境变量注入,或者挂载外部配置文件敏感信息(密码、密钥)通过环境变量或者配置中心下发,绝不写进 Dockerfile# 通过环境变量覆盖配置 docker run -d \ -e SPRING_PROFILES_ACTIVE=prod \ -e DB_HOST=10.0.0.1 \ -e DB_PASSWORD=xxxx \ my-java-app:1.0.0如果配置项特别多,用环境变量会显得很臃肿,这时候可以挂载一个外部配置文件:docker run -d \ -v /opt/config/application-prod.yml:/app/config/application-prod.yml \ my-java-app:1.0.0然后在启动命令里指定配置路径:ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=/app/config/"]2.4 日志输出:别让日志烂在容器里容器里的日志如果只往文件写,会有两个问题:一是容器重启后日志丢失,二是要看日志得先docker exec进去,非常不方便。我的做法是:让 Java 应用把日志输出到标准输出(stdout)和标准错误(stderr),然后由 Docker 的日志驱动统一收集。这样用docker logs就能直接看,也可以配置日志驱动转发到集中式日志系统。在 Spring Boot 里,只需要在application.yml里配置:logging: file: name: # 留空,不写文件 pattern: console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"如果有些日志必须落盘(比如审计日志),那就挂载一个数据卷出来:docker run -d \ -v /opt/logs/my-app:/app/logs \ my-java-app:1.0.0实操心得:挂载日志目录时,宿主机目录的权限要和容器内运行用户的 UID 匹配。我遇到过好几次容器启动失败,就是因为宿主机目录是 root 创建的,容器内用非 root 用户跑,写不进去。解决办法是在 Dockerfile 里创建用户时指定 UID,然后宿主机目录也 chown 成同样的 UID。3. Dockerfile 逐行拆解:一份可以直接抄的模板3.1 完整 Dockerfile 示例先上模板,再逐段解释。这是一个经过多个生产项目验证的 Dockerfile,适用于大多数 Spring Boot 项目的 jar 包部署:# 基础镜像 FROM eclipse-temurin:8-jre-jammy # 维护者信息(可选) LABEL maintainer="dev-team" # 设置时区 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 创建非 root 用户 RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser # 设置工作目录 WORKDIR /app # 拷贝 jar 包 COPY target/my-app-1.0.0.jar app.jar # 创建日志目录并授权 RUN mkdir -p /app/logs && chown -R appuser:appuser /app # 切换用户 USER appuser # 暴露端口(根据实际项目调整) EXPOSE 8080 # JVM 参数 ENV JAVA_OPTS="-Xms512m -Xmx1024m -XX:+UseG1GC -XX:MaxGCPauseMillis=200" # 启动命令 ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar app.jar"]3.2 时区配置:为什么必须显式设置基础镜像默认用的是 UTC 时间。如果你的应用日志、数据库记录、定时任务都依赖本地时间,不设时区就会出现“日志时间比实际时间少 8 小时”的经典问题。设置时区的正确姿势是两行:ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone第一行设置环境变量,第二行创建软链接并写入时区文件。有些教程只写第一行,在某些基础镜像里不生效,所以两行都写上最稳妥。注意:如果你用的是 alpine 镜像,/usr/share/zoneinfo路径可能不存在,需要先apk add --no-cache tzdata安装时区数据。3.3 非 root 用户运行:安全底线容器默认以 root 用户运行,这在生产环境是个安全隐患。一旦容器被突破,攻击者就拥有了 root 权限。虽然容器本身有一定的隔离性,但多一层防护总归是好的。创建非 root 用户的步骤:RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser-r表示创建系统用户-g指定用户组-d指定家目录-s /sbin/nologin禁止登录 shell然后切换用户:USER appuser实操心得:切换用户之后,之前用 root 创建的文件和目录,appuser 可能没有写权限。所以要在USER appuser之前,把需要写的目录 chown 给 appuser。我一般会在拷贝完 jar 包之后,统一执行一次chown -R appuser:appuser /app。3.4 JVM 参数:容器里跑 Java 的特殊考量在容器里跑 Java,JVM 参数有几个坑:堆内存设置。以前大家习惯-Xms512m -Xmx1024m写死,但在容器环境下,如果容器内存限制是 1GB,JVM 堆设了 1GB,加上元空间、线程栈、直接内存,很容易触发 OOM Killer。更合理的做法是用-XX:MaxRAMPercentage:ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0"这样 JVM 会根据容器内存限制自动计算堆大小,容器给 1GB 就设 750MB 堆,给 2GB 就设 1.5GB 堆,不用改 Dockerfile。随机数生成。-Djava.security.egd=file:/dev/./urandom这个参数是为了加快随机数生成速度。默认的/dev/random在熵不足时会阻塞,导致应用启动慢。用/dev/urandom或者/dev/./urandom可以避免这个问题。GC 选择。G1 在大多数场景下表现均衡,-XX:+UseG1GC -XX:MaxGCPauseMillis=200是常见的组合。如果对吞吐量要求更高,可以考虑 Parallel GC;如果对延迟极其敏感,可以考虑 ZGC(需要 JDK 11+)。3.5 启动命令:ENTRYPOINT 还是 CMD这两个指令都能指定启动命令,区别在于:ENTRYPOINT:容器启动时固定执行的命令,docker run后面的参数会作为它的附加参数CMD:默认命令,如果docker run指定了命令,CMD 会被覆盖对于 jar 包部署,我推荐用ENTRYPOINT,并且用sh -c的形式,这样可以解析环境变量:ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]如果写成ENTRYPOINT ["java", "$JAVA_OPTS", "-jar", "app.jar"],$JAVA_OPTS不会被解析,会被当成一个普通的字符串参数传给 java,导致启动失败。这是个很常见的坑。4. 完整实操:从本地构建到服务器运行4.1 本地构建镜像假设项目结构是这样的:my-project/ ├── src/ ├── target/ │ └── my-app-1.0.0.jar ├── Dockerfile └── pom.xml构建步骤:# 1. 先打包 jar mvn clean package -DskipTests # 2. 构建镜像 docker build -t my-java-app:1.0.0 . # 3. 查看镜像体积 docker images | grep my-java-app如果构建过程中报错COPY failed: no source files were specified,检查一下target目录下 jar 包的名字是否和 Dockerfile 里写的一致。Maven 打包出来的 jar 名字通常包含版本号,比如my-app-1.0.0.jar,别写成my-app.jar。4.2 镜像体积优化实战一个未经优化的 Spring Boot 镜像,体积通常在 300MB 以上。经过下面几步优化,可以压到 150MB 左右:第一步:换基础镜像。从openjdk:8-jdk换成eclipse-temurin:8-jre-jammy,体积从 600MB+ 降到 200MB 左右。第二步:合并 RUN 指令。每个 RUN 指令都会产生一层,层数越多体积越大。把能合并的合并:# 不推荐 RUN apt-get update RUN apt-get install -y tzdata RUN rm -rf /var/lib/apt/lists/* # 推荐 RUN apt-get update && \ apt-get install -y --no-install-recommends tzdata && \ rm -rf /var/lib/apt/lists/*第三步:清理缓存。安装完软件后,把包管理器的缓存删掉。--no-install-recommends也很重要,避免安装一堆用不到的推荐包。第四步:使用 .dockerignore。在项目根目录创建.dockerignore文件,把不需要打进镜像的东西排除掉:.git .idea *.iml target/*.jar.original *.md实测数据:一个典型的 Spring Boot 项目,优化前镜像 380MB,优化后 145MB,推送时间从 3 分钟降到 40 秒。4.3 服务器上的部署流程镜像构建好之后,有两种方式放到服务器上:一是推送到镜像仓库,服务器拉取;二是直接导出镜像文件,传到服务器加载。方式一:镜像仓库# 本地打标签 docker tag my-java-app:1.0.0 registry.example.com/my-java-app:1.0.0 # 推送 docker push registry.example.com/my-java-app:1.0.0 # 服务器上拉取 docker pull registry.example.com/my-java-app:1.0.0方式二:导出导入# 本地导出 docker save my-java-app:1.0.0 -o my-java-app.tar # 传到服务器后导入 docker load -i my-java-app.tar方式一适合有私有仓库的团队,方式二适合临时部署或者没有仓库的情况。方式二的缺点是每次都要传完整镜像,体积大的时候很慢。启动容器:docker run -d \ --name my-java-app \ --restart unless-stopped \ -p 8080:8080 \ -e SPRING_PROFILES_ACTIVE=prod \ -e JAVA_OPTS="-Xms512m -Xmx1024m" \ -v /opt/logs/my-app:/app/logs \ -v /opt/config:/app/config \ --memory 1.5g \ --cpus 1.5 \ my-java-app:1.0.0参数说明:--restart unless-stopped:容器异常退出时自动重启,除非手动停止-p 8080:8080:端口映射,宿主机端口:容器端口-e:注入环境变量-v:挂载数据卷--memory:内存限制,要比 JVM 堆大一些,留出元空间和系统开销--cpus:CPU 限制注意:--memory设了 1.5g,JVM 的MaxRAMPercentage设了 75%,那堆最大就是 1.125g。剩下的 375MB 给元空间、线程栈、直接内存、GC 等。这个比例可以根据实际情况调整,如果应用创建大量线程,需要留更多内存给线程栈。4.4 用 Docker Compose 管理多服务如果项目还依赖了数据库、Redis 等中间件,用 Docker Compose 会更方便:version: '3.8' services: app: image: my-java-app:1.0.0 container_name: my-java-app restart: unless-stopped ports: - "8080:8080" environment: - SPRING_PROFILES_ACTIVE=prod - DB_HOST=mysql - REDIS_HOST=redis - JAVA_OPTS=-Xms512m -Xmx1024m volumes: - ./logs:/app/logs - ./config:/app/config depends_on: - mysql - redis networks: - app-network mysql: image: mysql:8.0 container_name: my-mysql restart: unless-stopped environment: - MYSQL_ROOT_PASSWORD=root123 - MYSQL_DATABASE=mydb volumes: - mysql-data:/var/lib/mysql networks: - app-network redis: image: redis:7-alpine container_name: my-redis restart: unless-stopped volumes: - redis-data:/data networks: - app-network volumes: mysql-data: redis-data: networks: app-network: driver: bridge启动命令:docker-compose up -d查看日志:docker-compose logs -f app实操心得:Compose 里的服务名就是容器间通信的主机名。比如 app 服务里配置DB_HOST=mysql,就能直接连到 mysql 容器,不用写 IP。这是 Docker 内置的 DNS 解析,非常方便。5. 常见问题与排查技巧实录5.1 容器启动就退出,日志也看不到这是最常见的问题。容器启动后立刻退出,docker logs显示的信息很少或者为空。排查思路:先看退出码:docker inspect <container_id> | grep ExitCode退出码 1:通常是应用启动报错,检查配置文件、数据库连接退出码 137:被 OOM Killer 杀了,内存不够,调大--memory或者调小 JVM 堆退出码 143:收到了 SIGTERM 信号,正常停止如果日志为空,可能是应用还没输出日志就挂了。可以临时覆盖启动命令,进容器看看:docker run -it --entrypoint /bin/bash my-java-app:1.0.0进去之后手动执行java -jar app.jar,看报什么错。5.2 时区不对,日志时间差 8 小时前面说了要设时区,但有时候设了还是不对。检查两个地方:一是 Dockerfile 里的时区设置是否生效:docker exec <container_id> date如果输出的不是北京时间,说明时区没设对。检查/etc/localtime软链接指向哪里。二是 JVM 的时区。有些 JDK 版本会缓存时区信息,需要在启动参数里显式指定:ENTRYPOINT ["java", "-Duser.timezone=Asia/Shanghai", "-jar", "app.jar"]5.3 中文乱码容器里默认的 locale 通常是 POSIX,不支持中文。如果应用需要处理中文,需要在 Dockerfile 里设置:ENV LANG=C.UTF-8 ENV LC_ALL=C.UTF-8如果基础镜像里没有 C.UTF-8 locale,可以安装:RUN apt-get update && apt-get install -y locales && \ locale-gen C.UTF-8 && \ rm -rf /var/lib/apt/lists/*5.4 健康检查配置Docker 的健康检查可以自动检测容器是否正常运行,配合--restart策略,能在应用假死时自动重启。HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1参数说明:--interval=30s:每 30 秒检查一次--timeout=5s:检查超时时间--start-period=60s:容器启动后 60 秒内不检查,给应用启动留时间--retries=3:连续失败 3 次才标记为 unhealthy注意:健康检查的接口要选轻量的,别选那种会查数据库、查缓存的接口,否则健康检查本身就成了负担。Spring Boot Actuator 的/actuator/health默认只返回应用状态,比较合适。5.5 常见问题速查表问题现象可能原因排查命令解决方案容器启动即退出应用启动报错docker logs <id>检查配置、依赖、端口占用退出码 137内存不足被 OOMdocker inspect <id>调大内存限制或调小 JVM 堆日志时间差 8 小时时区未设置docker exec <id> date设置 TZ 和 JVM 时区参数中文乱码locale 不支持docker exec <id> locale设置 LANG=C.UTF-8端口无法访问端口未映射或防火墙docker port <id>检查 -p 参数和防火墙规则配置文件不生效挂载路径错误docker exec <id> ls /app/config检查 -v 参数和文件权限镜像推送慢镜像层数多、体积大docker history <image>合并 RUN 指令、换基础镜像容器内无法解析域名DNS 配置问题docker exec <id> cat /etc/resolv.conf指定 --dns 或检查 Docker 网络5.6 几个容易忽略的细节文件句柄数限制。容器默认的文件句柄数可能比较小,高并发场景下会报Too many open files。可以在启动时加参数:docker run --ulimit nofile=65535:65535 ...优雅停机。Java 应用收到 SIGTERM 信号后,应该先处理完手头的请求再退出。Spring Boot 2.3+ 支持优雅停机:server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s然后在docker run时加--stop-timeout 30,给容器 30 秒的停止时间。镜像标签管理。别只用latest标签,每次构建都用版本号或者 git commit hash 作为标签,方便回滚:docker build -t my-java-app:$(git rev-parse --short HEAD) .资源限制要合理。--memory设得太小,应用跑不起来;设得太大,一台服务器上能跑的容器就少了。我的经验值是:JVM 堆大小设为容器内存的 50%-75%,剩下的留给系统。比如容器限制 2GB,JVM 堆设 1GB-1.5GB 比较合适。6. 进阶玩法:让部署更顺滑的几个技巧6.1 利用构建缓存加速镜像构建Docker 构建时会逐层缓存,如果某一层没变,就直接用缓存。所以 Dockerfile 里指令的顺序很重要:变化频率低的放前面,变化频率高的放后面。# 这些不常变,放前面 FROM eclipse-temurin:8-jre-jammy ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone RUN groupadd -r appuser && useradd -r -g appuser appuser # jar 包经常变,放后面 COPY target/my-app-1.0.0.jar app.jar这样改代码重新构建时,前面的层都命中缓存,只有 COPY 那一层重新执行,构建速度从几分钟降到几秒。6.2 多环境配置的优雅管理不同环境的配置差异,用 Spring Boot 的 profile 机制来管理:# 测试环境 docker run -e SPRING_PROFILES_ACTIVE=test ... # 生产环境 docker run -e SPRING_PROFILES_ACTIVE=prod ...然后在 jar 包里放application-test.yml、application-prod.yml,公共配置放application.yml。这样同一份镜像可以在不同环境跑,只需要改一个环境变量。如果配置项特别敏感,可以用配置中心(比如 Nacos、Apollo),应用启动时从配置中心拉取配置。这样连环境变量都不用改,完全靠配置中心区分环境。6.3 滚动更新与回滚用 Docker Compose 或者 Swarm 做滚动更新:# 构建新版本镜像 docker build -t my-java-app:1.0.1 . # 更新服务 docker-compose up -d --no-deps app--no-deps表示只更新 app 服务,不动依赖的数据库和 Redis。回滚就是换回旧版本镜像:docker-compose stop app docker-compose rm -f app # 修改 docker-compose.yml 里的镜像版本为 1.0.0 docker-compose up -d app实操心得:每次部署前,先把当前运行的镜像版本记录下来。出问题时能快速回滚。我习惯在服务器上建一个deploy-history.log,每次部署追加一行:时间、版本、操作人。简单但有效。6.4 监控与告警的接入容器跑起来只是第一步,还得知道它跑得好不好。几个关键的监控指标:容器级别:CPU 使用率、内存使用率、网络 IO、磁盘 IOJVM 级别:堆内存使用、GC 次数和耗时、线程数应用级别:接口响应时间、错误率、QPS容器级别的监控可以用 cAdvisor + Prometheus + Grafana 这套组合。JVM 级别的监控可以用 Micrometer + Prometheus,Spring Boot Actuator 已经内置了 Micrometer 支持,只需要加依赖和配置:management: endpoints: web: exposure: include: health,info,metrics,prometheus metrics: export: prometheus: enabled: true然后在 Prometheus 里配置抓取目标,Grafana 里导入 JVM 监控面板,就能看到堆内存、GC、线程等指标了。6.5 安全加固的几个建议除了前面说的非 root 用户运行,还有几个安全措施:只读文件系统。容器运行时不写文件系统,需要写的地方挂载数据卷:docker run --read-only --tmpfs /tmp -v /opt/logs:/app/logs ...禁用特权模式。除非必要,不要用--privileged。限制能力。默认容器拥有一堆 Linux capabilities,很多用不到,可以丢弃:docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE ...镜像扫描。定期扫描镜像里的漏洞:docker scan my-java-app:1.0.0或者用 Trivy 等工具:trivy image my-java-app:1.0.0这些措施看起来繁琐,但都是生产环境的基本要求。我见过太多团队因为容器安全没做好,被扫描出高危漏洞,临时加班整改。7. 我踩过的几个印象深刻的坑说几个真实经历,都是文档里不会写、但实际部署时大概率会遇到的问题。第一个坑:jar 包名字带版本号,Dockerfile 写死了。项目迭代时版本号从 1.0.0 升到 1.0.1,Dockerfile 里的COPY target/my-app-1.0.0.jar app.jar没改,构建时报文件不存在。后来改成用 ARG 参数:ARG JAR_FILE=target/*.jar COPY ${JAR_FILE} app.jar这样不管 jar 包叫什么名字,都能匹配到。第二个坑:容器内存限制和 JVM 堆不匹配。容器限制 1GB,JVM 堆设了 1GB,结果应用跑一会儿就被 OOM Killer 杀了。原因是 JVM 除了堆,还需要元空间、线程栈、直接内存、代码缓存等,这些加起来可能好几百 MB。后来改成-XX:MaxRAMPercentage=70,让 JVM 自己算,问题解决。第三个坑:日志挂载目录权限不对。宿主机目录是 root 创建的,容器里用 appuser 跑,写不进去,应用启动时报Permission denied。解决办法是在 Dockerfile 里创建用户时指定 UID,宿主机目录也 chown 成同样的 UID:RUN groupadd -r -g 1000 appuser && useradd -r -u 1000 -g appuser appuserchown -R 1000:1000 /opt/logs/my-app第四个坑:健康检查接口太重。一开始健康检查用的是业务接口,结果健康检查本身把数据库连接池占满了。后来换成 Actuator 的/actuator/health,并且配置了management.endpoint.health.show-details=never,只返回 UP 或 DOWN,不查具体组件。第五个坑:时区设了但没生效。Dockerfile 里写了ENV TZ=Asia/Shanghai,但容器里date命令输出的还是 UTC。原因是基础镜像里没有时区数据文件。后来加了RUN apt-get update && apt-get install -y tzdata,问题解决。所以设时区之前,先确认基础镜像里有没有 tzdata。这些坑说起来都不复杂,但没踩过就是不知道。希望看到这里的同行能少走点弯路。8. 写在最后的一些个人习惯部署这件事,工具和命令都是死的,关键是形成一套自己的流程和检查清单。我现在的习惯是:每次部署前,先本地构建镜像,跑一遍docker run确认能正常启动。然后检查 Dockerfile 有没有硬编码的配置、有没有用 root 用户、时区设了没有、健康检查配了没有。部署到服务器后,先看日志确认启动成功,再看健康检查状态,最后验证接口是否正常。这套流程看起来繁琐,但比出了问题再回头排查要省时间得多。尤其是生产环境,一次部署失败可能就是几十分钟的服务不可用,提前多花五分钟检查,能避免很多麻烦。另外,Dockerfile 和 docker-compose.yml 一定要纳入版本管理,和代码一起提交。我见过有团队把 Dockerfile 放在服务器上手动维护,结果换个人部署就出问题。基础设施即代码,这句话不是口号,是实打实的经验。后续如果项目规模变大,可以考虑上 Kubernetes,把部署、扩缩容、滚动更新这些事交给编排系统。但那是另一个话题了,对于大多数中小项目来说,Docker + Docker Compose 已经足够。先把这套玩熟,再考虑下一步。
1. 为什么把 jar 包塞进 Docker 是个值得聊的话题刚入行那会儿,我部署一个 Java 服务的标准动作是这样的:本地mvn package打出 jar,scp 传到服务器,写个nohup java -jar xxx.jar &,然后祈祷它别在半夜挂掉。挂了就挂了,第二天上班看日志,发现是环境变量没配、JDK 版本不对、或者某个依赖的 so 库缺失。这种“人肉运维”的日子,相信不少同行都经历过。后来容器化浪潮来了,大家都说“把 jar 包做成镜像,一键跑起来”。听起来很美,但真动手的时候,问题一个接一个:基础镜像选哪个?JDK 版本怎么对齐?时区怎么设?日志往哪写?配置文件是打进镜像还是挂载出来?镜像打出来 800MB,推送到仓库要等半天,这合理吗?这篇内容就是围绕jar 包 Docker 服务器部署这件事,把我这些年踩过的坑、试过的方案、以及最终沉淀下来的一套可复用的流程,完整地摊开来讲。不管你是刚接触容器化部署的后端开发,还是需要自己维护服务运行环境的全栈选手,或者是想把手头那堆“裸跑”的 Java 服务收编进容器的运维同学,这里面的内容都能直接拿去用。核心关键词就几个:jar 包、Docker 镜像、多阶段构建、容器部署、日志挂载、时区配置、健康检查。我会从方案选型的逻辑讲起,再到 Dockerfile 的逐行拆解,然后是完整的构建与部署实操,最后是问题排查的速查表。每一步都会说清楚“为什么这么做”,而不是只给一堆命令让你抄。2. 方案选型:别急着写 Dockerfile,先把这几件事想清楚2.1 基础镜像怎么选:JRE 还是 JDK,alpine 还是 slim这是第一个岔路口。很多人上来就FROM openjdk:8-jdk,能用是能用,但镜像体积直接飙到 600MB 以上。对于一个只需要跑 jar 包的生产环境来说,编译工具链、源码、文档这些东西全是累赘。我的选择逻辑是这样的:镜像类型体积量级适用场景注意事项openjdk:8-jdk600MB+需要在容器内编译代码生产环境跑 jar 包属于杀鸡用牛刀openjdk:8-jre300MB+纯运行环境比 JDK 小一半,但仍有优化空间openjdk:8-jre-slim200MB 左右生产环境首选去掉了文档和部分非必要组件openjdk:8-jre-alpine100MB 以内追求极致体积基于 musl libc,部分 native 依赖可能不兼容eclipse-temurin:8-jre200MB 左右替代 Oracle JDK社区维护,更新及时提示:alpine 镜像虽小,但如果你的项目依赖了某些基于 glibc 的 native 库(比如某些加密库、图像处理库),跑起来会报No such file or directory或者Error loading shared library。这种情况要么换 slim 镜像,要么在 alpine 里补装libc6-compat,但补装之后体积优势就没那么明显了。我个人的习惯是:优先用eclipse-temurin的 jre-slim 版本。它是社区维护的 OpenJDK 构建,更新频率有保障,体积也控制得不错。如果项目对 JDK 版本有特殊要求(比如必须用某个特定发行版),再按需调整。2.2 构建方式:单阶段还是多阶段单阶段构建就是在一个 Dockerfile 里从头写到尾:拉基础镜像、拷贝 jar、设置启动命令。简单直接,但有个问题——如果你需要在构建过程中做一些额外操作(比如解压 jar、分层处理、生成配置文件),这些操作产生的中间文件都会留在最终镜像里。多阶段构建则是把“构建”和“运行”分开。构建阶段可以用一个带完整工具链的镜像,运行阶段只拷贝最终产物。对于 jar 包部署来说,多阶段构建的典型用法是:# 构建阶段:解压 jar,做分层处理 FROM eclipse-temurin:8-jre AS builder WORKDIR /build COPY app.jar app.jar RUN java -Djarmode=layertools -jar app.jar extract # 运行阶段:只拷贝分层后的文件 FROM eclipse-temurin:8-jre WORKDIR /app COPY --from=builder /build/dependencies/ ./ COPY --from=builder /build/spring-boot-loader/ ./ COPY --from=builder /build/snapshot-dependencies/ ./ COPY --from=builder /build/application/ ./ ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"]这种分层构建的好处是:当你的业务代码变更时,只有application层会变,dependencies层可以复用缓存。推送到镜像仓库时,也只推送变更的层,大大加快了部署速度。实测下来,一个 200MB 的镜像,分层之后日常更新只需要推送几 MB 的增量。注意:分层构建依赖 Spring Boot 的layertools,如果你的项目不是 Spring Boot 或者版本较老,这个方案不适用。普通项目用单阶段构建就够了,别为了分层而分层。2.3 配置管理:打进镜像还是外部挂载这个问题争论了很久。我的观点很明确:配置绝对不能打进镜像。原因很简单——镜像应该是不可变的构建产物,同一份镜像在测试环境、预发环境、生产环境都应该能跑。如果把数据库连接串、Redis 地址这些写死在镜像里,那每次换环境都要重新构建,镜像的“一次构建、到处运行”就成了一句空话。正确的做法是:镜像里只放一份默认配置(或者干脆不放),作为兜底实际配置通过环境变量注入,或者挂载外部配置文件敏感信息(密码、密钥)通过环境变量或者配置中心下发,绝不写进 Dockerfile# 通过环境变量覆盖配置 docker run -d \ -e SPRING_PROFILES_ACTIVE=prod \ -e DB_HOST=10.0.0.1 \ -e DB_PASSWORD=xxxx \ my-java-app:1.0.0如果配置项特别多,用环境变量会显得很臃肿,这时候可以挂载一个外部配置文件:docker run -d \ -v /opt/config/application-prod.yml:/app/config/application-prod.yml \ my-java-app:1.0.0然后在启动命令里指定配置路径:ENTRYPOINT ["java", "-jar", "app.jar", "--spring.config.location=/app/config/"]2.4 日志输出:别让日志烂在容器里容器里的日志如果只往文件写,会有两个问题:一是容器重启后日志丢失,二是要看日志得先docker exec进去,非常不方便。我的做法是:让 Java 应用把日志输出到标准输出(stdout)和标准错误(stderr),然后由 Docker 的日志驱动统一收集。这样用docker logs就能直接看,也可以配置日志驱动转发到集中式日志系统。在 Spring Boot 里,只需要在application.yml里配置:logging: file: name: # 留空,不写文件 pattern: console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"如果有些日志必须落盘(比如审计日志),那就挂载一个数据卷出来:docker run -d \ -v /opt/logs/my-app:/app/logs \ my-java-app:1.0.0实操心得:挂载日志目录时,宿主机目录的权限要和容器内运行用户的 UID 匹配。我遇到过好几次容器启动失败,就是因为宿主机目录是 root 创建的,容器内用非 root 用户跑,写不进去。解决办法是在 Dockerfile 里创建用户时指定 UID,然后宿主机目录也 chown 成同样的 UID。3. Dockerfile 逐行拆解:一份可以直接抄的模板3.1 完整 Dockerfile 示例先上模板,再逐段解释。这是一个经过多个生产项目验证的 Dockerfile,适用于大多数 Spring Boot 项目的 jar 包部署:# 基础镜像 FROM eclipse-temurin:8-jre-jammy # 维护者信息(可选) LABEL maintainer="dev-team" # 设置时区 ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone # 创建非 root 用户 RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser # 设置工作目录 WORKDIR /app # 拷贝 jar 包 COPY target/my-app-1.0.0.jar app.jar # 创建日志目录并授权 RUN mkdir -p /app/logs && chown -R appuser:appuser /app # 切换用户 USER appuser # 暴露端口(根据实际项目调整) EXPOSE 8080 # JVM 参数 ENV JAVA_OPTS="-Xms512m -Xmx1024m -XX:+UseG1GC -XX:MaxGCPauseMillis=200" # 启动命令 ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -Djava.security.egd=file:/dev/./urandom -jar app.jar"]3.2 时区配置:为什么必须显式设置基础镜像默认用的是 UTC 时间。如果你的应用日志、数据库记录、定时任务都依赖本地时间,不设时区就会出现“日志时间比实际时间少 8 小时”的经典问题。设置时区的正确姿势是两行:ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone第一行设置环境变量,第二行创建软链接并写入时区文件。有些教程只写第一行,在某些基础镜像里不生效,所以两行都写上最稳妥。注意:如果你用的是 alpine 镜像,/usr/share/zoneinfo路径可能不存在,需要先apk add --no-cache tzdata安装时区数据。3.3 非 root 用户运行:安全底线容器默认以 root 用户运行,这在生产环境是个安全隐患。一旦容器被突破,攻击者就拥有了 root 权限。虽然容器本身有一定的隔离性,但多一层防护总归是好的。创建非 root 用户的步骤:RUN groupadd -r appuser && useradd -r -g appuser -d /app -s /sbin/nologin appuser-r表示创建系统用户-g指定用户组-d指定家目录-s /sbin/nologin禁止登录 shell然后切换用户:USER appuser实操心得:切换用户之后,之前用 root 创建的文件和目录,appuser 可能没有写权限。所以要在USER appuser之前,把需要写的目录 chown 给 appuser。我一般会在拷贝完 jar 包之后,统一执行一次chown -R appuser:appuser /app。3.4 JVM 参数:容器里跑 Java 的特殊考量在容器里跑 Java,JVM 参数有几个坑:堆内存设置。以前大家习惯-Xms512m -Xmx1024m写死,但在容器环境下,如果容器内存限制是 1GB,JVM 堆设了 1GB,加上元空间、线程栈、直接内存,很容易触发 OOM Killer。更合理的做法是用-XX:MaxRAMPercentage:ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0"这样 JVM 会根据容器内存限制自动计算堆大小,容器给 1GB 就设 750MB 堆,给 2GB 就设 1.5GB 堆,不用改 Dockerfile。随机数生成。-Djava.security.egd=file:/dev/./urandom这个参数是为了加快随机数生成速度。默认的/dev/random在熵不足时会阻塞,导致应用启动慢。用/dev/urandom或者/dev/./urandom可以避免这个问题。GC 选择。G1 在大多数场景下表现均衡,-XX:+UseG1GC -XX:MaxGCPauseMillis=200是常见的组合。如果对吞吐量要求更高,可以考虑 Parallel GC;如果对延迟极其敏感,可以考虑 ZGC(需要 JDK 11+)。3.5 启动命令:ENTRYPOINT 还是 CMD这两个指令都能指定启动命令,区别在于:ENTRYPOINT:容器启动时固定执行的命令,docker run后面的参数会作为它的附加参数CMD:默认命令,如果docker run指定了命令,CMD 会被覆盖对于 jar 包部署,我推荐用ENTRYPOINT,并且用sh -c的形式,这样可以解析环境变量:ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]如果写成ENTRYPOINT ["java", "$JAVA_OPTS", "-jar", "app.jar"],$JAVA_OPTS不会被解析,会被当成一个普通的字符串参数传给 java,导致启动失败。这是个很常见的坑。4. 完整实操:从本地构建到服务器运行4.1 本地构建镜像假设项目结构是这样的:my-project/ ├── src/ ├── target/ │ └── my-app-1.0.0.jar ├── Dockerfile └── pom.xml构建步骤:# 1. 先打包 jar mvn clean package -DskipTests # 2. 构建镜像 docker build -t my-java-app:1.0.0 . # 3. 查看镜像体积 docker images | grep my-java-app如果构建过程中报错COPY failed: no source files were specified,检查一下target目录下 jar 包的名字是否和 Dockerfile 里写的一致。Maven 打包出来的 jar 名字通常包含版本号,比如my-app-1.0.0.jar,别写成my-app.jar。4.2 镜像体积优化实战一个未经优化的 Spring Boot 镜像,体积通常在 300MB 以上。经过下面几步优化,可以压到 150MB 左右:第一步:换基础镜像。从openjdk:8-jdk换成eclipse-temurin:8-jre-jammy,体积从 600MB+ 降到 200MB 左右。第二步:合并 RUN 指令。每个 RUN 指令都会产生一层,层数越多体积越大。把能合并的合并:# 不推荐 RUN apt-get update RUN apt-get install -y tzdata RUN rm -rf /var/lib/apt/lists/* # 推荐 RUN apt-get update && \ apt-get install -y --no-install-recommends tzdata && \ rm -rf /var/lib/apt/lists/*第三步:清理缓存。安装完软件后,把包管理器的缓存删掉。--no-install-recommends也很重要,避免安装一堆用不到的推荐包。第四步:使用 .dockerignore。在项目根目录创建.dockerignore文件,把不需要打进镜像的东西排除掉:.git .idea *.iml target/*.jar.original *.md实测数据:一个典型的 Spring Boot 项目,优化前镜像 380MB,优化后 145MB,推送时间从 3 分钟降到 40 秒。4.3 服务器上的部署流程镜像构建好之后,有两种方式放到服务器上:一是推送到镜像仓库,服务器拉取;二是直接导出镜像文件,传到服务器加载。方式一:镜像仓库# 本地打标签 docker tag my-java-app:1.0.0 registry.example.com/my-java-app:1.0.0 # 推送 docker push registry.example.com/my-java-app:1.0.0 # 服务器上拉取 docker pull registry.example.com/my-java-app:1.0.0方式二:导出导入# 本地导出 docker save my-java-app:1.0.0 -o my-java-app.tar # 传到服务器后导入 docker load -i my-java-app.tar方式一适合有私有仓库的团队,方式二适合临时部署或者没有仓库的情况。方式二的缺点是每次都要传完整镜像,体积大的时候很慢。启动容器:docker run -d \ --name my-java-app \ --restart unless-stopped \ -p 8080:8080 \ -e SPRING_PROFILES_ACTIVE=prod \ -e JAVA_OPTS="-Xms512m -Xmx1024m" \ -v /opt/logs/my-app:/app/logs \ -v /opt/config:/app/config \ --memory 1.5g \ --cpus 1.5 \ my-java-app:1.0.0参数说明:--restart unless-stopped:容器异常退出时自动重启,除非手动停止-p 8080:8080:端口映射,宿主机端口:容器端口-e:注入环境变量-v:挂载数据卷--memory:内存限制,要比 JVM 堆大一些,留出元空间和系统开销--cpus:CPU 限制注意:--memory设了 1.5g,JVM 的MaxRAMPercentage设了 75%,那堆最大就是 1.125g。剩下的 375MB 给元空间、线程栈、直接内存、GC 等。这个比例可以根据实际情况调整,如果应用创建大量线程,需要留更多内存给线程栈。4.4 用 Docker Compose 管理多服务如果项目还依赖了数据库、Redis 等中间件,用 Docker Compose 会更方便:version: '3.8' services: app: image: my-java-app:1.0.0 container_name: my-java-app restart: unless-stopped ports: - "8080:8080" environment: - SPRING_PROFILES_ACTIVE=prod - DB_HOST=mysql - REDIS_HOST=redis - JAVA_OPTS=-Xms512m -Xmx1024m volumes: - ./logs:/app/logs - ./config:/app/config depends_on: - mysql - redis networks: - app-network mysql: image: mysql:8.0 container_name: my-mysql restart: unless-stopped environment: - MYSQL_ROOT_PASSWORD=root123 - MYSQL_DATABASE=mydb volumes: - mysql-data:/var/lib/mysql networks: - app-network redis: image: redis:7-alpine container_name: my-redis restart: unless-stopped volumes: - redis-data:/data networks: - app-network volumes: mysql-data: redis-data: networks: app-network: driver: bridge启动命令:docker-compose up -d查看日志:docker-compose logs -f app实操心得:Compose 里的服务名就是容器间通信的主机名。比如 app 服务里配置DB_HOST=mysql,就能直接连到 mysql 容器,不用写 IP。这是 Docker 内置的 DNS 解析,非常方便。5. 常见问题与排查技巧实录5.1 容器启动就退出,日志也看不到这是最常见的问题。容器启动后立刻退出,docker logs显示的信息很少或者为空。排查思路:先看退出码:docker inspect <container_id> | grep ExitCode退出码 1:通常是应用启动报错,检查配置文件、数据库连接退出码 137:被 OOM Killer 杀了,内存不够,调大--memory或者调小 JVM 堆退出码 143:收到了 SIGTERM 信号,正常停止如果日志为空,可能是应用还没输出日志就挂了。可以临时覆盖启动命令,进容器看看:docker run -it --entrypoint /bin/bash my-java-app:1.0.0进去之后手动执行java -jar app.jar,看报什么错。5.2 时区不对,日志时间差 8 小时前面说了要设时区,但有时候设了还是不对。检查两个地方:一是 Dockerfile 里的时区设置是否生效:docker exec <container_id> date如果输出的不是北京时间,说明时区没设对。检查/etc/localtime软链接指向哪里。二是 JVM 的时区。有些 JDK 版本会缓存时区信息,需要在启动参数里显式指定:ENTRYPOINT ["java", "-Duser.timezone=Asia/Shanghai", "-jar", "app.jar"]5.3 中文乱码容器里默认的 locale 通常是 POSIX,不支持中文。如果应用需要处理中文,需要在 Dockerfile 里设置:ENV LANG=C.UTF-8 ENV LC_ALL=C.UTF-8如果基础镜像里没有 C.UTF-8 locale,可以安装:RUN apt-get update && apt-get install -y locales && \ locale-gen C.UTF-8 && \ rm -rf /var/lib/apt/lists/*5.4 健康检查配置Docker 的健康检查可以自动检测容器是否正常运行,配合--restart策略,能在应用假死时自动重启。HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ CMD curl -f http://localhost:8080/actuator/health || exit 1参数说明:--interval=30s:每 30 秒检查一次--timeout=5s:检查超时时间--start-period=60s:容器启动后 60 秒内不检查,给应用启动留时间--retries=3:连续失败 3 次才标记为 unhealthy注意:健康检查的接口要选轻量的,别选那种会查数据库、查缓存的接口,否则健康检查本身就成了负担。Spring Boot Actuator 的/actuator/health默认只返回应用状态,比较合适。5.5 常见问题速查表问题现象可能原因排查命令解决方案容器启动即退出应用启动报错docker logs <id>检查配置、依赖、端口占用退出码 137内存不足被 OOMdocker inspect <id>调大内存限制或调小 JVM 堆日志时间差 8 小时时区未设置docker exec <id> date设置 TZ 和 JVM 时区参数中文乱码locale 不支持docker exec <id> locale设置 LANG=C.UTF-8端口无法访问端口未映射或防火墙docker port <id>检查 -p 参数和防火墙规则配置文件不生效挂载路径错误docker exec <id> ls /app/config检查 -v 参数和文件权限镜像推送慢镜像层数多、体积大docker history <image>合并 RUN 指令、换基础镜像容器内无法解析域名DNS 配置问题docker exec <id> cat /etc/resolv.conf指定 --dns 或检查 Docker 网络5.6 几个容易忽略的细节文件句柄数限制。容器默认的文件句柄数可能比较小,高并发场景下会报Too many open files。可以在启动时加参数:docker run --ulimit nofile=65535:65535 ...优雅停机。Java 应用收到 SIGTERM 信号后,应该先处理完手头的请求再退出。Spring Boot 2.3+ 支持优雅停机:server: shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s然后在docker run时加--stop-timeout 30,给容器 30 秒的停止时间。镜像标签管理。别只用latest标签,每次构建都用版本号或者 git commit hash 作为标签,方便回滚:docker build -t my-java-app:$(git rev-parse --short HEAD) .资源限制要合理。--memory设得太小,应用跑不起来;设得太大,一台服务器上能跑的容器就少了。我的经验值是:JVM 堆大小设为容器内存的 50%-75%,剩下的留给系统。比如容器限制 2GB,JVM 堆设 1GB-1.5GB 比较合适。6. 进阶玩法:让部署更顺滑的几个技巧6.1 利用构建缓存加速镜像构建Docker 构建时会逐层缓存,如果某一层没变,就直接用缓存。所以 Dockerfile 里指令的顺序很重要:变化频率低的放前面,变化频率高的放后面。# 这些不常变,放前面 FROM eclipse-temurin:8-jre-jammy ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone RUN groupadd -r appuser && useradd -r -g appuser appuser # jar 包经常变,放后面 COPY target/my-app-1.0.0.jar app.jar这样改代码重新构建时,前面的层都命中缓存,只有 COPY 那一层重新执行,构建速度从几分钟降到几秒。6.2 多环境配置的优雅管理不同环境的配置差异,用 Spring Boot 的 profile 机制来管理:# 测试环境 docker run -e SPRING_PROFILES_ACTIVE=test ... # 生产环境 docker run -e SPRING_PROFILES_ACTIVE=prod ...然后在 jar 包里放application-test.yml、application-prod.yml,公共配置放application.yml。这样同一份镜像可以在不同环境跑,只需要改一个环境变量。如果配置项特别敏感,可以用配置中心(比如 Nacos、Apollo),应用启动时从配置中心拉取配置。这样连环境变量都不用改,完全靠配置中心区分环境。6.3 滚动更新与回滚用 Docker Compose 或者 Swarm 做滚动更新:# 构建新版本镜像 docker build -t my-java-app:1.0.1 . # 更新服务 docker-compose up -d --no-deps app--no-deps表示只更新 app 服务,不动依赖的数据库和 Redis。回滚就是换回旧版本镜像:docker-compose stop app docker-compose rm -f app # 修改 docker-compose.yml 里的镜像版本为 1.0.0 docker-compose up -d app实操心得:每次部署前,先把当前运行的镜像版本记录下来。出问题时能快速回滚。我习惯在服务器上建一个deploy-history.log,每次部署追加一行:时间、版本、操作人。简单但有效。6.4 监控与告警的接入容器跑起来只是第一步,还得知道它跑得好不好。几个关键的监控指标:容器级别:CPU 使用率、内存使用率、网络 IO、磁盘 IOJVM 级别:堆内存使用、GC 次数和耗时、线程数应用级别:接口响应时间、错误率、QPS容器级别的监控可以用 cAdvisor + Prometheus + Grafana 这套组合。JVM 级别的监控可以用 Micrometer + Prometheus,Spring Boot Actuator 已经内置了 Micrometer 支持,只需要加依赖和配置:management: endpoints: web: exposure: include: health,info,metrics,prometheus metrics: export: prometheus: enabled: true然后在 Prometheus 里配置抓取目标,Grafana 里导入 JVM 监控面板,就能看到堆内存、GC、线程等指标了。6.5 安全加固的几个建议除了前面说的非 root 用户运行,还有几个安全措施:只读文件系统。容器运行时不写文件系统,需要写的地方挂载数据卷:docker run --read-only --tmpfs /tmp -v /opt/logs:/app/logs ...禁用特权模式。除非必要,不要用--privileged。限制能力。默认容器拥有一堆 Linux capabilities,很多用不到,可以丢弃:docker run --cap-drop=ALL --cap-add=NET_BIND_SERVICE ...镜像扫描。定期扫描镜像里的漏洞:docker scan my-java-app:1.0.0或者用 Trivy 等工具:trivy image my-java-app:1.0.0这些措施看起来繁琐,但都是生产环境的基本要求。我见过太多团队因为容器安全没做好,被扫描出高危漏洞,临时加班整改。7. 我踩过的几个印象深刻的坑说几个真实经历,都是文档里不会写、但实际部署时大概率会遇到的问题。第一个坑:jar 包名字带版本号,Dockerfile 写死了。项目迭代时版本号从 1.0.0 升到 1.0.1,Dockerfile 里的COPY target/my-app-1.0.0.jar app.jar没改,构建时报文件不存在。后来改成用 ARG 参数:ARG JAR_FILE=target/*.jar COPY ${JAR_FILE} app.jar这样不管 jar 包叫什么名字,都能匹配到。第二个坑:容器内存限制和 JVM 堆不匹配。容器限制 1GB,JVM 堆设了 1GB,结果应用跑一会儿就被 OOM Killer 杀了。原因是 JVM 除了堆,还需要元空间、线程栈、直接内存、代码缓存等,这些加起来可能好几百 MB。后来改成-XX:MaxRAMPercentage=70,让 JVM 自己算,问题解决。第三个坑:日志挂载目录权限不对。宿主机目录是 root 创建的,容器里用 appuser 跑,写不进去,应用启动时报Permission denied。解决办法是在 Dockerfile 里创建用户时指定 UID,宿主机目录也 chown 成同样的 UID:RUN groupadd -r -g 1000 appuser && useradd -r -u 1000 -g appuser appuserchown -R 1000:1000 /opt/logs/my-app第四个坑:健康检查接口太重。一开始健康检查用的是业务接口,结果健康检查本身把数据库连接池占满了。后来换成 Actuator 的/actuator/health,并且配置了management.endpoint.health.show-details=never,只返回 UP 或 DOWN,不查具体组件。第五个坑:时区设了但没生效。Dockerfile 里写了ENV TZ=Asia/Shanghai,但容器里date命令输出的还是 UTC。原因是基础镜像里没有时区数据文件。后来加了RUN apt-get update && apt-get install -y tzdata,问题解决。所以设时区之前,先确认基础镜像里有没有 tzdata。这些坑说起来都不复杂,但没踩过就是不知道。希望看到这里的同行能少走点弯路。8. 写在最后的一些个人习惯部署这件事,工具和命令都是死的,关键是形成一套自己的流程和检查清单。我现在的习惯是:每次部署前,先本地构建镜像,跑一遍docker run确认能正常启动。然后检查 Dockerfile 有没有硬编码的配置、有没有用 root 用户、时区设了没有、健康检查配了没有。部署到服务器后,先看日志确认启动成功,再看健康检查状态,最后验证接口是否正常。这套流程看起来繁琐,但比出了问题再回头排查要省时间得多。尤其是生产环境,一次部署失败可能就是几十分钟的服务不可用,提前多花五分钟检查,能避免很多麻烦。另外,Dockerfile 和 docker-compose.yml 一定要纳入版本管理,和代码一起提交。我见过有团队把 Dockerfile 放在服务器上手动维护,结果换个人部署就出问题。基础设施即代码,这句话不是口号,是实打实的经验。后续如果项目规模变大,可以考虑上 Kubernetes,把部署、扩缩容、滚动更新这些事交给编排系统。但那是另一个话题了,对于大多数中小项目来说,Docker + Docker Compose 已经足够。先把这套玩熟,再考虑下一步。