先讲个很常见的事:你花了两天把 Spring Boot 项目写好,本地 mvn spring-boot:run 一切正常,于是高高兴兴把 jar 包扔到服务器上。然后你会发现服务器上要么 JDK 版本对不上,要么环境变量被之前遗留的 Java 8 项目搞乱了,要么时区差了 8 个小时。折腾两天终于跑起来了,结果运维同事补一句:"这个服务器上还有别的服务,你下次部署前记得先通知我们。"这时候你才会意识到,部署这件事最危险的从来不是"跑不起来",而是"今天能跑、明天不一定能跑"。
我用 Docker 部署 Spring Boot 项目有三年多了,从最开始只会 docker run 一个 jar,到现在把 MySQL、Redis、日志采集全部容器化,中间踩过的坑不算少。这篇文章不是把官方文档复述一遍,而是把我从零把一个 Spring Boot 项目推到生产环境的完整过程、每个关键选择的理由,以及翻车记录都摊开来讲。适合两类人看:一是刚接触 Docker 的 Java 开发者,想知道 Dockerfile 到底该怎么写、为什么这么写;二是已经用 Docker 部署过 Spring Boot 项目,但总觉得哪里还不够"生产级"的朋友,可以对照检查一下自己的方案还有哪些漏洞。
1. 先想清楚:Spring Boot 项目容器化到底解决了什么问题
1.1 传统部署方式的三个老大难
Java 项目部署这件事,说实话本身不算难,难点在于"稳定地重复"。
以前我部署 Spring Boot 项目,流程是:本地 mvn package 打 jar,scp 到服务器,然后 nohup java -jar app.jar > app.log 2>&1 &。听起来很简单,但坑全藏在细节里。
第一是环境差异。服务器上可能已经有老项目用的是 JDK 1.8,/etc/profile 里的 JAVA_HOME 指向 jdk8,而你手头这个新项目基于 Spring Boot 3.x,最低要求 JDK 17。直接在命令行里跑 java -jar,大概率起来的是 jdk8,然后喜提 UnsupportedClassVersionError。改全局 JAVA_HOME 又会让老项目出问题,这种拉扯我见过太多次。
第二是依赖冲突。同一个服务器跑多个 Java 应用,大家共用一套系统库。A 项目依赖 openssl 1.0,B 项目因为新功能装了依赖 openssl 1.1 的组件,装来装去系统状态变得不可预测,谁也不知道改了什么会导致哪个服务挂掉。这种"因为装了个东西,另一个服务崩了"的诡异事故,在传统部署方式下几乎无解。
第三是配置漂移。新服务器第一次部署一切顺利,第二次第三次开始出幺蛾子——"上次我明明配过时区,怎么这次又不对了"。生产服务器经过长期手动修改,最终变成一台"只有创始人敢碰的机器",出问题了靠着聊天记录和肌肉记忆去猜,这个过程消耗的精力远超容器化改造本身。
1.2 镜像不可变:一次构建、处处运行
Docker 解决这些问题的思路其实很朴素:把运行环境连同应用一起打进镜像。镜像里的 JDK 版本、系统依赖、时区、配置文件,在构建那一刻就固定下来了。同一份镜像,在开发笔记本上怎么跑,到测试服务器、生产服务器就怎么跑,不存在"我这儿明明是好的"这种话。
打个比方,镜像像是一张已经调好配料的预制菜包装,你不关心它在哪个厨房做、锅是什么牌子,只需要按说明加热,出来的菜一定一致。容器只是这个镜像的一个运行实例,镜像不变,行为就不会因宿主机差异而变化。
1.3 不是所有项目都要 Docker
不过我得泼一点冷水。Docker 不是银弹,如果你的情况是:小团队内部管理系统、单台服务器、JDK 环境已经装好、没有多环境迁移需求——那直接用 java -jar 跑,运维成本反而更低。引入 Docker 之后,你多了镜像构建、仓库管理、磁盘清理这些新的维护负担。
判断标准很简单:你需不需要频繁换环境部署?需不需要保证多套环境一致?有没有多人协作的部署需求? 如果三个答案都是"否",那容器化省下的成本可能还不够学 Docker 花的时间。反过来说,只要有一个答案是"是",这篇文章接下来的内容就值得你读完。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 镜像构建前的准备:基础镜像选型与构建上下文
2.1 基础镜像选型:为什么我弃用了 openjdk 官方镜像
现在还能在网上搜到大量老教程让你用 openjdk:8-jdk-alpine,如果你还在照着做,建议停下来。
OpenJDK 官方在 Docker Hub 上的 openjdk 仓库已经停止维护了,版本不再更新,更关键的是存在安全漏洞得不到修复。现在 Java 社区推荐用的是 Eclipse Temurin(eclipse-temurin 镜像)或 Amazon Corretto(amazoncorretto 镜像),两者都是 LTS 版本、长期维护、持续推送安全更新。
| 镜像 | 基础系统 | 大致压缩后大小 | 适用场景 |
|---|---|---|---|
| eclipse-temurin:17-jre | Ubuntu Jammy | 约 80MB | Spring Boot 3.x 首选 |
| eclipse-temurin:17-jdk | Ubuntu Jammy | 约 180MB | 多阶段构建的编译阶段使用 |
| amazoncorretto:17-alpine | Alpine | 约 60MB | 追求极致体积且无 JNI 依赖 |
| openjdk:8-jdk-alpine | Alpine | 约 50MB | 老项目遗留,不推荐新用 |
这里有个很多人不知道的坑:Temurin 官方已经不提供 Alpine 变体了。原因是 Alpine 用的是 musl libc 而不是标准 glibc,Java 应用本身没问题,但一旦你的应用或第三方库通过 JNI 调用本地代码(比如某些加密库、OCR 组件、网络工具库),在 Alpine 下有概率出现诡异崩溃。我踩过一次:一个项目用了某个底层 JNI 库,在 alpine 镜像上偶尔抛出 SIGSEGV,进程直接死掉,换回 Ubuntu 基础镜像之后问题消失。从那以后我的默认选择就是 eclipse-temurin,Alpine 只在纯 IO 类、无复杂依赖的服务上考虑。
2.2 多阶段构建:把构建工具从生产镜像里踢出去
新手写 Dockerfile 最常见的做法是:
dockerfile复制FROM maven:3.9-eclipse-temurin-17
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn clean package -DskipTests
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/target/app.jar"]
这样能跑,但生产镜像里混进了完整的 Maven 和 JDK,体积轻松超过 300MB,而且把构建工具暴露在生产环境,等于给攻击者留了一扇门。
多阶段构建的思路是:第一阶段用包含 Maven + JDK 的镜像完成编译,第二阶段只用 JRE 镜像把编译好的 jar 拷过去。构建工具只在构建阶段存在,最终镜像只剩运行所需的最小环境,体积通常能砍掉一半以上。
2.3 .dockerignore:别再把 target 目录塞进构建上下文
这个文件我见很多人不写。如果项目根目录下没有 .dockerignore,docker build 默认会把整个目录发送给 Docker daemon 作为构建上下文。Spring Boot 项目里有 .git、target、.idea 这些目录,target 动辄几百 MB,每次构建都要传一遍,慢是小事,严重的是如果 Dockerfile 里用了 COPY . .,target 里旧的 jar 会被一起拷进镜像,覆盖掉你编译出来的新 jar,然后就出现"我改了代码但镜像没变"的经典灵异事件。
一个基础版的 .dockerignore:
code复制.git
.gitignore
target
.idea
*.iml
.DS_Store
*.log
3. Dockerfile 编写:从能跑到跑得稳
3.1 一份可复制的多阶段 Dockerfile
下面这份是我目前生产环境在用的模板,Spring Boot 2.7 和 3.x 都能用,JDK 版本按项目要求调整:
dockerfile复制# 构建阶段
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /build
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn clean package -DskipTests -B
# 运行阶段
FROM eclipse-temurin:17-jre
WORKDIR /app
# 创建非 root 用户
RUN groupadd -r app -g 1001 && useradd -r -g app -u 1001 appuser
# 安装 curl 用于健康检查(Ubuntu 基础镜像默认没有 curl)
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl tzdata \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /build/target/app.jar app.jar
ENV TZ=Asia/Shanghai \
JAVA_OPTS=""
USER appuser
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
几个细节解释一下为什么不这么写就不行。
RUN mvn dependency:go-offline -B 这行是整个构建提速的关键。它会先把 pom.xml 里的依赖全部下载到本地仓库。这样只要 pom.xml 不变,Docker 就会复用这一层缓存,之后你改业务代码重新构建,依赖下载这一步秒过。没有这一行的话,每次改一行代码,Maven 都要重新解析并下载依赖,慢到你怀疑人生。
ENTRYPOINT 我用了 sh -c 而不是直接 ["java", "-jar", "app.jar"],是为了能通过环境变量 JAVA_OPTS 从外部注入 JVM 参数。生产环境调整堆内存大小、加 GC 日志,不需要重新构建镜像,直接改 compose 文件里的环境变量就行,这是运维层面特别实用的设计。
3.2 为什么 HEALTHCHECK 要用 /actuator/health 而不是根路径
有人写健康检查是 curl -f http://localhost:8080/,根路径返回 200 就觉得完事了。但 Spring Boot 应用的根路径只能说明"HTTP 服务活着",说明不了数据库连接池、Redis、消息队列等依赖是否正常。加了 spring-boot-starter-actuator 之后,/actuator/health 会聚合各种健康指标,数据库连不上返回 503,Redis 超时返回 503,才能真正代表"这个容器是可以对外提供服务的"。
如果你的 Spring Boot 版本在 2.3 及以上,还可以在 application.yml 里开启可用性探针:
yaml复制management:
endpoint:
health:
probes:
enabled: true
这样会额外暴露 /actuator/health/liveness 和 /actuator/health/readiness 端点,配合 Docker HEALTHCHECK 或 Kubernetes 的存活/就绪探针,能更精细地控制容器的生命周期。生产环境用这个组合基本是标配了。
3.3 非 root 用户和时区不是小事
很多人在本地跑容器没感觉,但生产环境里容器内跑 root 是安全审计的重点关注项。一旦应用被攻破,攻击者拿到的是 root 权限,可以读写宿主机挂载的目录,甚至通过 Docker socket 逃逸到宿主机。我的做法是创建专用用户(上面 Dockerfile 里的 appuser),jar 文件归该用户所有。这一步成本极低,收益极大,建议成为默认习惯。
时区问题更常见。Temurin 镜像默认时区是 UTC,Spring Boot 应用里如果用了 LocalDateTime.now(),或者 MySQL 连接串里没指定 serverTimezone,日志和数据库记录会出现 8 小时的时差。上面 Dockerfile 里我设置了 ENV TZ=Asia/Shanghai,同时安装了 tzdata 数据包。注意:老版本镜像可能没带 tzdata,光设 TZ 环境变量不生效,apt-get install tzdata 一定不能省,否则你排查半天会发现环境变量没起作用。
提示:排查时区问题,先在容器里执行
date看当前时区,再执行ls -la /etc/localtime看是否指向Asia/Shanghai。这两条命令基本能定位 90% 的容器时区问题。
4. docker-compose 编排:把 MySQL、Redis 和服务串起来
4.1 为什么要从 docker run 升级到 compose
如果一个 Spring Boot 项目只依赖一个 MySQL,你还能用两条 docker run 硬扛。但实际项目往往还有 Redis、消息队列、Nginx。这时手动 docker run 的痛点会集中爆发:
- 网络:每个容器默认在独立的 bridge 网络里,服务之间要互相访问,得手动
docker network create,再给每个容器加网络,烦死了。 - 启动顺序:先起数据库,等它 Ready 再起应用,靠人肉等待不现实。
- 配置管理:一条 docker run 命令动辄十几行参数,记错一个端口、写错一个环境变量,排查半天。
docker-compose 把服务的拓扑结构、依赖关系、网络、数据卷、环境变量集中写在一个 YAML 文件里,一条 docker compose up -d 全部搞定。新版本 Docker Compose 已经作为 Docker 的原生命令集成,不需要单独安装。
4.2 一个带健康检查的完整 compose 示例
yaml复制services:
mysql:
image: mysql:8.0
container_name: app-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: appdb
MYSQL_USER: appuser
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
volumes:
- mysql-data:/var/lib/mysql
- ./init-sql:/docker-entrypoint-initdb.d:ro
networks:
- app-net
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root -p$$MYSQL_ROOT_PASSWORD"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
redis:
image: redis:7-alpine
container_name: app-redis
restart: unless-stopped
command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"]
volumes:
- redis-data:/data
networks:
- app-net
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 10
app:
build: .
image: myapp:${APP_VERSION}
container_name: app-springboot
restart: unless-stopped
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/appdb?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
SPRING_DATASOURCE_USERNAME: appuser
SPRING_DATASOURCE_PASSWORD: ${MYSQL_PASSWORD}
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PASSWORD: ${REDIS_PASSWORD}
TZ: Asia/Shanghai
ports:
- "8080:8080"
networks:
- app-net
volumes:
mysql-data:
redis-data:
networks:
app-net:
driver: bridge
注意,新版 docker compose 已经不需要写 version: '3.8' 了,这个旧字段反而可能触发弃用警告,直接写 services 就好。
4.3 depends_on 的 condition 才是真正的启动顺序控制
depends_on 很多人只知道写服务名,但默认行为只保证"先启动 MySQL 容器",不保证 MySQL 已经能接受连接。Spring Boot 启动时连不上数据库,进程直接退出,然后 restart 策略又把它拉起来,形成反复重启的循环。
解决方法是 condition: service_healthy:只有当 MySQL 的 healthcheck 通过后,compose 才会启动 app 服务。MySQL 官方镜像自带 mysqladmin ping,Redis 镜像自带 redis-cli ping,这两个中间件的健康检查写起来很简单。这个特性是 docker compose 相对于自己写启动脚本最大的优势——系统帮你把"依赖就绪"这个状态管理起来了。
4.4 环境变量和 .env:密码别写死在 YAML 里
compose 文件里我用了 ${MYSQL_ROOT_PASSWORD} 这种占位符,真正的值放在同目录的 .env 文件里。这样做的好处:
- 密码、密钥不进入 Git 仓库(.env 加入 .gitignore),避免安全事故。
- 不同环境(dev/staging/prod)只需要不同的 .env 文件,compose 文件本身不用改。
- 部署时切换环境,用
docker compose --env-file .env.prod up -d即可。
一个容易踩的小坑:healthcheck 里用 $$MYSQL_ROOT_PASSWORD 时,$$ 是为了让这个变量在容器内而不是在宿主机 shell 展开。如果写成 ${MYSQL_ROOT_PASSWORD},compose 会在宿主层展开一次,命令里的密码如果包含 $、空格等特殊字符,可能导致健康检查失败。这个问题很隐蔽,遇到 MySQL 总是 unhealthy 却查不出原因时,先看看是不是这个。
4.5 数据卷与初始化脚本:容器删了,数据不能没
MySQL 容器一旦删除重建,如果数据没做持久化,所有业务数据直接清零。上面的 compose 里用了 named volume:mysql-data:/var/lib/mysql。named volume 由 Docker 管理,删除容器不丢数据,只在显式 docker volume rm 时删除。比 bind mount 更省心,因为不需要关心宿主机目录权限。
初始化 SQL 的挂载也很关键。MySQL 官方镜像在数据目录为空时会执行 /docker-entrypoint-initdb.d/ 下所有 .sql 或 .sh 脚本,按文件名顺序执行。所以第一次启动时,建库建表脚本会自动执行。注意它只在数据目录为空时执行,如果 volume 里已有数据,脚本不会重复跑。这个机制用来做基础设施初始化可以,但别依赖它做每次启动都执行的数据库迁移——那种场景请用 Flyway 或 Liquibase。
5. 生产环境部署的几件大事
5.1 镜像仓库:构建机和生产机的桥梁
本地构建、生产拉取,中间需要一个镜像中转站。不搞私有仓库的话,最土的办法是 docker save 打成 tar 包,scp 到服务器再 docker load。但这样做没有版本管理,节点一多就乱了。
正规一点的做法是部署私有镜像仓库(Harbor、Nexus 都行,团队小用 Docker Registry 也够),或者用云厂商的容器镜像服务。CI 里构建完 push 上去,生产机上 pull 下来跑,tag 用 日期+commit,不要用 latest。为什么不用 latest?因为 latest 指向的是一个会变化的名字,你没法通过这个名字准确知道线上跑的是哪个版本、哪个 commit 的代码。出了问题回滚,你根本不知道要回滚到哪个 tag。
5.2 JVM 内存参数:容器内存限制和堆内存的博弈
这是生产环境最容易出问题的地方。很多人直接在 Dockerfile 里写:
dockerfile复制ENTRYPOINT ["java", "-Xmx512m", "-jar", "app.jar"]
如果容器内存限制是 1G,512m 堆看起来没问题,但 JVM 除了堆还有元空间、线程栈、JIT 编译产物,加起来可能超过 1G,然后容器被内核 OOM Kill,应用却连 OutOfMemoryError 都来不及打印。
更好的方式是让 JVM 感知容器限制:
dockerfile复制ENTRYPOINT ["sh", "-c", "java -XX:MaxRAMPercentage=75.0 $JAVA_OPTS -jar app.jar"]
-XX:MaxRAMPercentage=75.0 的意思是:不管容器限了 1G 还是 4G,JVM 自动把最大堆设置为容器可用内存的 75%,剩下 25% 留给元空间、线程栈和 JVM 自身开销。这样在开发环境(不限内存)和生产环境(限内存)之间迁移,不需要改 JVM 参数。这个参数在 JDK 8u191+ 的容器场景下才稳定可用,老旧 JDK 8 版本还是老老实实手动指定 -Xmx 并留足余量。
5.3 日志方案:容器日志别裸奔
Docker 默认把容器日志输出到 json-file 文件,默认不限大小,一个容器长期运行,日志文件能把磁盘撑爆。这是生产事故的常见原因,而且往往是深夜 2 点那种最不想接电话的时候发生。
最简单的缓解方案是给 Docker daemon 加限制,在 /etc/docker/daemon.json 里:
json复制{
"log-driver": "json-file",
"log-opts": {
"max-size": "50m",
"max-file": "3"
}
}
然后重启 Docker 服务。对于排查问题来说,容器日志只是"原始日志",最好让 Spring Boot 输出结构化 JSON 日志,再通过 filebeat 之类的采集器统一收集到 Elasticsearch 或其他日志平台。Spring Boot 里用 logback-spring.xml 的 LogstashEncoder 很容易输出 JSON:
xml复制<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>
监控层面,Spring Boot Actuator 暴露了 /actuator/prometheus 端点,配合 Prometheus 抓取指标,能直观看到 JVM 堆、GC 频率、线程池状态等数据。容器化之后监控这件事更重要,因为进程被调度到哪台机器、哪次发布导致性能下降,没有指标全靠猜就太被动了。
5.4 优雅停机与更新发布
docker stop 默认先发 SIGTERM,等 10 秒(可以用 -t 参数指定更长),再发 SIGKILL。Spring Boot 从 2.3 开始支持优雅停机,在 application.yml 里配置:
yaml复制server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 20s
这样容器收到 SIGTERM 后,会停止接收新请求,等待正在处理的请求完成后再退出,而不是直接被杀。发布新版本时,这个机制保证老容器在摘流期间能把存量请求处理完,对在线用户来说体验是无感的。
更新发布我一般用蓝绿思路的简化版:新镜像启动后确认健康检查通过,再让 Nginx 或负载均衡把流量切过去,最后停掉老容器。小项目没有 Nginx 做挡板的话,至少要用 compose 里的 restart: unless-stopped 保证意外退出能自动拉起。这套东西拉起来之后,发布就不再是"凌晨发版"的理由了——你的应用可以在任意时间平滑更新。
5.5 不可变基础设施的安全基线
生产容器里,除了时区和内存,还有几条安全基线建议一起做掉:
- 基础镜像定期更新:别一个镜像用一年不 pull 新版本,安全漏洞补丁都在新版本里。
- 容器内只装必要的工具:有 curl 就够了,别在镜像里塞 vim、bash-completion 之类的调试工具,减少攻击面。
- 密钥不进镜像:数据库密码、Redis 密码、JWT 密钥通过环境变量或挂载的 secret 文件注入,而不是直接写进 jar 包或 Dockerfile。
- 数据卷权限控制:bind mount 的宿主机目录属主和容器内运行用户要匹配,不然会出现权限拒绝的问题。
关于容器内以非 root 用户运行,我多说一句。Spring Boot 如果映射小于 1024 的端口(比如 80),非 root 用户没有权限 bind,解决办法是外层用 Nginx 做端口转发,或者宿主机映射成 8080 这种高位端口,别为了省事直接用 root 跑容器。
6. 踩坑实录:部署过程中最折磨人的几个问题
6.1 Maven 依赖反复下载,构建慢到怀疑人生
我第一次用 Docker 构建 Spring Boot 项目,构建一次要十分钟,一大半时间花在下载依赖上,因为每次构建都是全新的 Maven 本地仓库。
解决方法就是前面说的"先 COPY pom.xml,再 RUN dependency:go-offline"。但还有一个更彻底的思路:本地调试 Dockerfile 的时候,用 Maven 镜像挂一个本地仓库卷:
bash复制docker run --rm \
-v ~/.m2:/root/.m2 \
-v $(pwd):/build \
-w /build \
maven:3.9-eclipse-temurin-17 \
mvn clean package -DskipTests
把宿主机的 ~/.m2 挂载进容器,依赖只要下载过一次就一直复用。这个技巧在本地反复调试 Dockerfile 时特别好用。CI 环境不推荐这么干,CI 应该用干净构建保证可复现性。
6.2 MySQL 容器起来又崩,初始化 SQL 没生效
有一次同事反馈 MySQL 容器反复重启,docker logs 一看是 chown: changing ownership of '/var/lib/mysql/': Operation not permitted。原因是用了 bind mount 挂载宿主机目录作为数据目录,但目录属主不是 MySQL 镜像里的 mysql 用户,权限对不上。MySQL 官方镜像内 mysql 用户的 uid 是 999,如果你的宿主机目录是 root 所有,容器启动时 chown 就会失败。
解决方法是:要么用 named volume(Docker 自动管理属主),要么把宿主机目录 chown -R 999:999 /data/mysql。
初始化 SQL 不生效也常见,原因多半是数据目录不空。MySQL 官方镜像只在数据目录为空时执行 /docker-entrypoint-initdb.d。如果你先跑了一个容器生成了数据,后来才加初始化脚本,脚本不会执行。别浪费时间反复重启排查,确认数据目录没重要内容后,直接把 volume 删了重建一次。
6.3 容器里连不上"localhost"的数据库
新手最容易犯的错:Spring Boot 的 application.yml 里数据库地址写的是 jdbc:mysql://localhost:3306/appdb,在容器里跑,localhost 指的是容器自己,不是宿主机,更不是 MySQL 容器。
容器内访问数据库有几个正确的写法:
- 服务在同一个 compose 网络里,用服务名:
jdbc:mysql://mysql:3306/appdb。这是 compose 编排下最推荐的写法。 - 容器访问宿主机上的服务:Linux 下要加
--add-host=host.docker.internal:host-gateway,Docker Desktop 则默认支持host.docker.internal。 - 直接访问宿主机 IP 写在配置里(不推荐,IP 一变应用就挂,防火墙还可能拦截)。
6.4 容器 OOMKilled 但 Java 日志里什么都没有
症状很典型:应用突然挂掉,docker inspect 里 OomKilled: true,但翻 Java 日志找不到一个 OutOfMemoryError。原因是容器超过 cgroup 内存限制时,内核直接杀进程,JVM 根本来不及打印堆栈。
排查思路:
bash复制# 确认确实是被 OOM 杀掉的
docker inspect --format '{{.State.OOMKilled}}' 容器名
# 观察实时内存占用
docker stats
如果确认是 OOM,治本的方法是把内存限制调大,或者把 JVM 参数改成 -XX:MaxRAMPercentage=75.0,让 JVM 在容器预算内工作。需要现场堆栈做分析的话,可以加 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/,再配合 volume 把 dump 文件持久化到宿主机,事后用 MAT 分析。
6.5 Docker Desktop 在 Windows 上启动失败
这是开发阶段最常见的问题,群里经常有人问 Docker Desktop 报 virtualization support not detected 或 WSL 2 installation incomplete。这类问题大多出在 Windows 的虚拟化没有开启。
Windows 上 Docker Desktop 目前主流是走 WSL2 后端。如果报虚拟化未启用,依次检查:
- BIOS 里开启 Intel VT-x 或 AMD-V。
- 控制面板启用"适用于 Linux 的 Windows 子系统"和"虚拟机平台"两个 Windows 功能。
- 升级 WSL2 内核:
wsl --update。 - Docker Desktop 设置里确认 "Use the WSL 2 based engine" 已勾选。
另外,在 Windows 上用 Docker 挂载本地目录(bind mount)性能会很差,尤其项目代码在 Windows 文件系统(/mnt/c/...)下,文件监听、编译都很慢。最有效的办法是把项目放到 WSL2 的 Linux 文件系统里,比如 ~/projects,在 WSL2 终端里执行 docker 命令,性能会好一个量级。
6.6 Spring Boot 3.x / 4.x 对容器化部署的影响
现在新项目基本都是 Spring Boot 3.x(基于 Java 17+),容器化部署和 2.x 差别不大,主要注意:基础镜像用 Temurin 17/21;javax 包名要换成 jakarta;actuator 探针配置方式基本兼容。
Spring Boot 4.0 目前已经在推进,基于 Spring Framework 7,行业里也已经有人遇到"找不到 DataSourceAutoConfiguration"这类问题。这种问题通常是 Spring Boot 4 调整了自动配置类的包结构,你自研的 starter 或自定义自动配置需要重新编译、更新包引用。对容器化部署来说思路不变——镜像里的 JDK 版本可能要跟着升到 21 或更高,但 Dockerfile、compose、内存策略这些方法论全部照旧。基础能力的学习是长期的,框架版本升级只是把镜像里的运行时换一个。
6.7 构建上下文和 .dockerignore 的连锁问题
这个坑放在最后说,是因为它最常见但也最隐蔽。项目里如果用了 COPY . . 又没有 .dockerignore,构建时会发生两件事:
- 把几百 MB 的 target 目录打包发给 Docker daemon,本地构建慢到卡顿。
- 更隐蔽的是,如果你用 IDEA 打包完 jar 后没清理,Dockerfile 里
COPY . .会把 target 里的旧 jar 一起拷进构建阶段,导致镜像里塞进两份 jar,甚至覆盖掉多阶段拷贝的版本。
所以 .dockerignore 不是可选项,是必须项,而且要在项目一开始就写好。等出了问题再补,成本要高得多。
最后分享一个我自己的部署习惯:每次发布前,把 compose 文件、.env 和部署说明文档一起提交到 Git 仓库,镜像 tag 固定成日期加短 commit 号,比如 myapp:20250612-a1b2c3d。生产机上保留最近两到三个镜像 tag,出问题要回滚,就是改一行 tag 再 docker compose up -d app,十几秒搞定。容器化让我最受益的点,不是某个命令多酷炫,而是让"上生产"变成一套可重复、可回退的机械流程。希望这篇从零到生产的完整记录,能帮你少走几个弯路。
