简介这份文档面向需要在 Windows 环境下将 Spring Boot 项目容器化、并迁移到 Linux 运行的中级 Java 开发者与运维人员围绕 Dockerfile 编写、镜像构建、容器启动、网络连通性排查、MySQL 容器化及跨平台镜像打包展开。包内仅含 1 个 docx 文件压缩包约 2.48MB以图文步骤与命令示例为主便于按目录顺序对照实践。已有 135 人学习说明内容对实际部署有一定参考价值。读者可从中获得从 jar 包复制、端口暴露、后台运行到宿主机数据库访问的完整思路也能看到桥接网络下 ping/telnet 排查、MySQL 挂载目录与端口映射、Docker Compose 多容器管理的操作要点适合作为从本地开发走向 Linux 部署的流程笔记与排错参考。1. 一个 jar 包在 Windows 上跑得好好的进了容器就连不上数据库在 Windows 上把 SpringBoot 项目打成 jar写个 Dockerfiledocker build一遍过再docker run把容器拉起来最后把镜像 save 成 tar 搬到 Linux 上 load听起来是条很顺的路。我第一次走的时候每一步都显示成功构建成功、容器启动成功、docker ps里状态是 Up。然后浏览器一直转圈docker logs里一行Communications link failure底下跟着Connection refused。这类故障恶心的地方在于它不在某一个点上出错而是构建层、运行层、网络层、数据库权限层各有一套自己的排错入口你在错误的层里查查一整天也查不出所以然。这篇笔记按我自己翻车的顺序拆先修构建再修启动再修连不上数据库最后是镜像搬到 Linux 之后集中爆发的那几个坑。2. 构建就翻车COPY failed、构建上下文和那个不能省的点2.1 COPY failed: no source files were specified 到底在说什么这个报错几乎每个人第一次写 Dockerfile 都会撞上完整长这样COPY failed: file not found in build context or excluded by .dockerignore: stat target/app.jar: file not found它跟 Docker 本身没关系说的是我在构建上下文里找不到你写的那个路径。排查方向只有三个jar 根本没生成、路径写错、被.dockerignore排除了。新手最容易忽略的是第一条——先mvn package再docker build很多人是先 build 后打包target目录还是空的。我一般会按这个顺序确认# 1. 先确认 jar 真的生成了看清楚文件名和大小 mvn -q clean package -DskipTests ls -l target/*.jar # 2. 确认 Docker 收到的上下文里确实有这个文件 docker build --progressplain -t demo-app:1.0.0 . 21 | grep -i copy第一条命令里的-DskipTests是跳过测试打包阶段跑单测会顺带拉起 Spring 上下文连不上数据库就又报一堆无关的错先把构建和测试分开。第二条的--progressplain是让构建过程逐层打印默认的 BuildKit 输出会折叠步骤COPY 失败时你根本看不到它到底在哪个目录下找文件。路径这块还有一个隐蔽的坑COPY的源路径是相对构建上下文的不是相对 Dockerfile 所在目录。你把 Dockerfile 放在docker/子目录里写COPY target/app.jar app.jar一样能找到只要构建命令末尾那个点指的是项目根目录。反过来如果你cd进docker/再执行 build上下文就变成了docker/target自然不存在。2.2 那个孤零零的点和一份能直接抄的 Dockerfiledocker build -t 镜像名 .末尾的点是构建上下文目录.表示当前目录。这个参数被省略或者写错位置报错会非常迷惑比如docker build requires exactly 1 argument或者更常见的——上下文传成了/构建卡在Sending build context上半天不动。我习惯显式写清楚# 在项目根目录执行最后的点就是构建上下文 docker build -t demo-app:1.0.0 . # Dockerfile 改名或放到子目录时用 -f 指定上下文仍然由最后那个点决定 docker build -f docker/Dockerfile -t demo-app:1.0.0 . # 改完 Dockerfile 一定要加 --no-cache否则命中的是旧层改了跟没改一样 docker build --no-cache -t demo-app:1.0.0 .参数说明-t是镜像名:标签标签不写默认latest生产上尽量写明确版本号后面docker save搬机器时才不会因为latest被覆盖而搞混-f只在 Dockerfile 不叫这个名字或者不在上下文根目录时才需要--no-cache是排错时的高频后悔药缓存层导致的改了没生效十有八九是它。一份能直接用的 Dockerfile 长这样# 基础镜像JDK8 alpine体积小但默认不带时区数据 FROM openjdk:8-jdk-alpine # alpine 不带 tzdata不加这行容器里的时间和日志会差 8 小时 RUN apk add --no-cache tzdata \ cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ echo Asia/Shanghai /etc/timezone WORKDIR /app # 源路径相对构建上下文目标路径相对 WORKDIR 里的绝对路径最稳妥 COPY target/app.jar /app/app.jar # 只是声明不发布端口真正的映射在 docker run -p EXPOSE 8080 # 数组形式启动java 进程才是 PID 1docker stop 才能收到 SIGTERM 优雅退出 ENTRYPOINT [java, -Duser.timezoneAsia/Shanghai, -Dfile.encodingUTF-8, -Xms256m, -Xmx512m, -jar, /app/app.jar]ENTRYPOINT这里值得多说两句。写成数组形式exec formPID 1 就是 java 进程docker stop发 SIGTERM 它能收到Spring 会走优雅停机写成 shell 形式ENTRYPOINT java -jar app.jarPID 1 变成/bin/sh -cSIGTERM 被 shell 吞掉docker stop要等满 10 秒超时才 SIGKILL日志里看不到任何关闭动作排查时特别容易误判成应用卡死。这个差别在开发机上无所谓上了生产就是停机时丢请求。2.3 基础镜像选 alpine 的三个代价openjdk:8-jdk-alpine体积只有一百多兆比完整版小一大截但代价得提前知道没有 tzdata上面已经补了、没有常用字体导出 PDF、生成验证码图片会报FontConfiguration相关错误、DNS 解析行为和 glibc 版本略有差异。最后一条在容器里ping域名时表现最明显——宿主机能 ping 通容器里报bad address换成 IP 就正常这是 musl 的解析策略问题不是网络不通。3. 容器起来了又倒下docker run 之后的三分钟3.1 Exited (0) 和 Exited (1) 是两条完全不同的路docker ps -a看到Exited (0)的时候别急着查代码退出码 0 表示进程正常结束——对一个 SpringBoot 应用来说正常结束通常意味着它压根没进到启动逻辑或者启动完立刻被关掉了。Exited (1)才是异常退出要看日志。区分方法很简单# 先看状态、退出码和启动命令 docker ps -a # 再看容器的标准输出SpringBoot 的启动日志全在这 docker logs --tail 200 demo-app # 容器起来就挂、日志都来不及打的时候看 Docker 记录的退出原因 docker inspect --format {{.State.ExitCode}} {{.State.Error}} {{.State.OOMKilled}} demo-appdocker inspect那条命令里的三个字段各有含义ExitCode是进程退出码137 一般是 OOM 或 SIGKILL1 是应用自己抛异常退的0 是正常结束Error记录的是 Docker 层面的启动失败原因比如executable file not foundOOMKilled为 true 就是内存超了被内核杀掉-Xmx设得比容器内存限制还大是常见诱因。Exited (0)我踩过两次。一次是ENTRYPOINT里 jar 路径写错但写成了 shell 形式sh -c执行失败返回 0容器成功退出另一次是把ENTRYPOINT写成了CMD而docker run后面跟了个sh参数CMD 被整个覆盖容器跑完 shell 就退出了。记住一条ENTRYPOINT不会被docker run后面的参数覆盖只追加CMD会被完全覆盖镜像的启动命令是固定的就用前者。3.2 端口映射写了却访问不到三层绑定要分别确认-p 8080:8080写对了还访问不到问题在三个层面上得从里往外查# 第一层容器里应用有没有在监听监听的是 0.0.0.0 还是 127.0.0.1 docker exec -it demo-app sh -c netstat -tlnp 2/dev/null || ss -tlnp # 第二层从宿主机直接打容器 IP绕开端口映射 docker inspect -f {{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}} demo-app curl -v http://172.17.0.2:8080/actuator/health # 第三层确认端口真的被映射出来了看 PORTS 那一列 docker port demo-app第一层最常见的翻车是应用只监听了127.0.0.1容器里能curl localhost:8080通宿主机怎么都访问不到。SpringBoot 默认绑0.0.0.0但如果 yml 里手写了server.address: 127.0.0.1或者用了某些内嵌容器配置就会出现这个现象。第二层能通、第三层不通那问题在-p参数或者防火墙第二层就不通说明应用根本没起在预期的地址上。alpine 镜像里不一定有netstatss也没有的话就退回到curl localhost:8080加日志判断。注意-p 8080:8080前面那个是宿主机端口后面那个是容器端口。写反了容器能起来但外部完全访问不到而且docker ps的 PORTS 列会显示0.0.0.0:8080-8080/tcp看起来毫无异常。这属于典型的看十遍看不出来的错误。3.3 容器名冲突和 --restart别让老容器在你看不见的地方活着改完 Dockerfile 重新构建镜像docker run --name demo-app直接报docker: Error response from daemon: Conflict. The container name /demo-app is already in use by container 3f2a....原因很直白——上一个同名的容器还在只是停掉了。docker run不会自动复用或替换同名容器。我一般这么处理# 停掉并删除旧的同名容器再起新的 docker stop demo-app docker rm demo-app docker run -d --name demo-app -p 8080:8080 demo-app:1.0.0 # 或者一步到位--rm 让容器退出后自动删除适合开发机反复调试 docker run --rm -d --name demo-app -p 8080:8080 demo-app:1.0.0--rm在开发机上非常好用但它和--restartalways互斥两个一起写会直接报错。--restartalways适合数据库这类要长期活的容器它会在 Docker 守护进程重启、容器异常退出时自动拉起。不过排错阶段我建议先不要加容器崩了自动重启、重启完又崩docker ps里看到的是刚启动的状态日志被反复覆盖真正的第一现场就丢了。等排查干净再加回去。4. 连不上数据库ping 得通3306 还是 refused4.1 容器里的 localhost 不是你的 Windows 宿主机这是整个流程里最反直觉的一条也几乎是所有容器里连不上数据库问题的源头。SpringBoot 项目在 Windows 上直接跑yml 里写jdbc:mysql://localhost:3306/demo一切正常因为进程就在宿主机上。打成镜像塞进容器之后localhost指的是容器自己容器里没有 MySQL自然Connection refused。典型报错长这样com.mysql.cj.jdbc.exceptions.CommunicationsException: Communications link failure Caused by: java.net.ConnectException: Connection refused (Connection refused)只要看到Connection refused先别怀疑密码那说明 TCP 连接根本没建立起来还没走到认证那一步。如果看到的是Access denied for user才是账号权限问题。这两个错误区分开能省掉一半排查时间。改法就一条yml 里的数据库地址不能写localhost或127.0.0.1。要么写宿主机的真实内网 IP容器走 bridge 网络时可以通过宿主机 IP 访问宿主机的端口要么把数据库也容器化然后用容器名当主机名。4.2 先分网络层和端口层别一上来就改配置数据库地址改成宿主机 IP 之后还是连不上下一步不是继续改配置是验证。我习惯在业务容器里做两步探测# 进容器注意 alpine 镜像里没有 bash用 sh docker exec -it demo-app sh # 第一层网络层通不通能不能解析并到达目标 ping -c 2 192.168.1.100 # 第二层端口层通不通这个才是决定 JDBC 能不能连上的关键 nc -zv 192.168.1.100 3306两条命令的结论组合起来能直接定位问题ping通、nc不通说明网络可达但 3306 端口没开或者被防火墙拦了去查数据库有没有监听0.0.0.0、宿主机防火墙有没有放行ping都不通是网段或者路由问题业务容器和数据库容器压根不在一个网络里。nc在 busybox 里是自带的如果没有用cat /dev/tcp/192.168.1.100/3306也能测能连上会立刻返回连不上报Connection refused或timed out。数据库容器侧也要确认监听地址# 看 MySQL 容器里 3306 监听在哪个地址0.0.0.0 才对容器外可见 docker exec -it mysqldoc netstat -tlnp | grep 3306 # 看容器的端口映射规则 docker port mysqldoc-p 3307:3306这种写法宿主机 3306 被已有实例占用时很常见意味着从宿主机连是3307但容器之间互连仍然用 3306因为容器网络里走的是容器端口不经过宿主机映射。这一点混淆过很多人宿主机上工具连192.168.1.100:3307yml 里如果写宿主机 IP 也得是3307只有直接用容器名互访时才是3306。4.3 MySQL 的 host 匹配rootlocalhost 和 root% 不是一回事ping通、nc通报错变成Access denied for user root172.17.0.2。这不是密码错是 MySQL 的 host 匹配规则在起作用——用户身份是用户名来源地址的二元组默认的root只允许从localhost登录从容器网段172.17.x.x过来一律拒绝。-- 登录 MySQL 容器 -- docker exec -it mysqldoc mysql -uroot -p -- 先看清楚现有用户允许从哪来 SELECT user, host FROM mysql.user WHERE user root; -- 给业务单独建账号不要图省事放开 root CREATE USER app% IDENTIFIED BY App123456; GRANT ALL PRIVILEGES ON demo.* TO app%; FLUSH PRIVILEGES; -- 确认生效 SELECT user, host FROM mysql.user WHERE user app;app%里的%是任意来源开发环境图方便可以这么写生产上建议收窄成具体网段比如app172.17.%。另一个反直觉的点MySQL 的 host 匹配是最长优先如果同时存在app%和applocalhost从容器过来会命中%从容器内部自己连会命中localhost两条记录的密码可以不一样排错时用SELECT CURRENT_USER();看实际生效的是哪一条比猜快得多。4.4 建一张自定义网络用容器名代替 IP用容器 IP 互连是能通的但容器重启后 IP 会变yml 里的地址跟着失效这种昨天还好今天就不行的玄学故障十有八九就是 IP 变了。正确做法是建一张自定义 bridge 网络把数据库、缓存、应用都连上去之后直接用容器名当主机名。# 创建一张自定义网络 docker network create dev_net # 把已经跑起来的数据库和缓存容器接入 docker network connect dev_net mysqldoc docker network connect dev_net redis-doc # 应用容器启动时直接指定网络比事后 connect 更干净 docker run -d --name demo-app --network dev_net -p 8080:8080 demo-app:1.0.0 # 确认三个容器都在同一张网络里 docker network inspect dev_net --format {{range .Containers}}{{.Name}} {{end}}自定义 bridge 网络自带 DNS 解析容器名就是主机名。yml 里对应改成spring: datasource: # 主机名 mysqldoc 就是数据库容器的 --name端口用容器内端口 3306 url: jdbc:mysql://mysqldoc:3306/demo?useUnicodetruecharacterEncodingutf8useSSLfalseallowPublicKeyRetrievaltrueserverTimezoneAsia/Shanghai username: app password: App123456 driver-class-name: com.mysql.cj.jdbc.Driver redis: host: redis-doc port: 6379 password: redis参数里有两个是 MySQL 8 必须加的allowPublicKeyRetrievaltrue不加会报Public Key Retrieval is not allowed因为 MySQL 8 默认认证插件是caching_sha2_password老版本驱动拿不到公钥就直接拒连serverTimezoneAsia/Shanghai不加在跨时区或者容器没配时区时会报The server time zone value is unrecognized。如果驱动版本实在太老不支持退一步在数据库里把账号改成传统插件ALTER USER app% IDENTIFIED WITH mysql_native_password BY App123456; FLUSH PRIVILEGES;4.5 数据库也容器化时的四个挂载点把 MySQL 也放进容器之后最怕的是删容器连带删数据。docker run时不挂卷数据全在容器可写层里docker rm一下库就没了没有后悔药。启动命令里至少要挂四个目录# 先在宿主机建好目录Docker 不会帮你创建多级不存在的路径 mkdir -p /data/docker/mysql/log /data/docker/mysql/data /data/docker/mysql/conf /data/docker/mysql/mysql-files docker run -d \ --name mysqldoc \ --restartalways \ --network dev_net \ -p 3307:3306 \ -v /data/docker/mysql/log:/var/log/mysql \ -v /data/docker/mysql/data:/var/lib/mysql \ -v /data/docker/mysql/conf:/etc/mysql \ -v /data/docker/mysql/mysql-files:/var/lib/mysql-files \ -e MYSQL_ROOT_PASSWORDroot \ mysql:8.0.24逐个说明-p 3307:3306把容器 3306 映射到宿主机 3307宿主机上已有实例占着 3306 时必须这么错开--restartalways让容器跟着 Docker 守护进程自启/var/lib/mysql是数据目录这个卷是保命的/etc/mysql挂出来放my.cnf改配置不用进容器/var/lib/mysql-files是导入导出文件的落地目录secure_file_priv指向它不挂的话LOAD DATA会失败。注意Windows 上用 PowerShell 写-v D:\xxx\data:/var/lib/mysql是可以的但如果你在 Git Bash 里执行同样的命令路径会被自动转义成D:/xxx/data甚至加上盘符前缀容器挂载会失败或挂到空目录。跨终端复制命令时这个坑特别常见。5. 常见问题镜像搬到 Linux 后集中翻车的五个坑5.1 load 完镜像变成 none或者容器起来报 exec format errorWindows 上docker save出来的 tar 传到 Linuxdocker load之后docker images里出现一行none:none。原因是save时用的是镜像 ID 而不是名称:标签# 错误写法load 出来没有名字 docker save -o app.tar 3f2a1b9c # 正确写法把名称和标签一起打进去 docker save -o app-1.0.0.tar demo-app:1.0.0 docker save -o mysql-8.0.24.tar mysql:8.0.24 docker save -o redis-7.tar redis:7 # Linux 侧加载并核对 docker load -i app-1.0.0.tar docker images | grep demo-app另一个更要命的情况是exec format error。开发机是 x86目标服务器如果是 ARM 架构镜像搬过去启动直接报这个错日志里连 Java 的版本信息都看不到。搬之前查一下两边的架构# Windows 和 Linux 上都跑一遍输出的 os/architecture 要一致 docker inspect --format {{.Os}}/{{.Architecture}} demo-app:1.0.0不一致的话要么在目标架构上用docker build --platform linux/amd64重新构建要么老老实实拿到对应架构的机器上打。这个错误最难查的地方在于它报得非常靠前你会以为是 Dockerfile 写错了其实是镜像本身跑不起来。5.2 容器 Up、端口映射也对外网就是访问不了现象docker ps显示0.0.0.0:8080-8080/tcp服务器上curl localhost:8080通从自己电脑访问超时。原因基本锁定在防火墙Linux 上默认开着的防火墙会拦掉未放行的端口。测试环境有人图省事直接systemctl stop firewalld能通但这不是能带进生产的做法。# 只放行需要的端口不要整个关掉防火墙 firewall-cmd --zonepublic --add-port8080/tcp --permanent firewall-cmd --zonepublic --add-port3307/tcp --permanent firewall-cmd --reload # 确认规则生效 firewall-cmd --list-ports--permanent是写进配置文件不加的话重启就失效--reload是让配置立刻生效两步缺一不可。云服务器上还有一层安全组firewall-cmd --list-ports里明明有 8080 但外部还是不通就得去控制台看安全组入方向规则这层在机器内部是查不出来的。5.3 容器一删数据全没或者改的配置下次启动又变回去现象调试时docker rm了 MySQL 容器重起一个库表全空或者进了容器改了my.cnf重启容器配置复原。原因都是没做卷映射——容器可写层随容器生命周期存在删容器就删数据容器内改的文件同样不落到宿主机。# 起容器前先确认挂载点Mounts 里能看到宿主机路径和容器路径的对应关系 docker inspect --format {{range .Mounts}}{{.Source}} - {{.Destination}}{{\n}}{{end}} mysqldoc输出里Destination覆盖了/var/lib/mysql才算数据安全。已经跑起来但没挂卷的容器补救办法是先docker cp把数据导出来再用带-v的命令重建容器导回去别直接删。5.4 日志时间差 8 小时对时对到怀疑人生现象应用日志时间比实际时间早 8 小时数据库里create_time也是错的但宿主机date命令显示正常。原因是容器默认用 UTCalpine 基础镜像连时区文件都没有。三个层面都要处理镜像里装 tzdata、JVM 参数指定时区、JDBC 连接串带上serverTimezone。前两个在第 2 章的 Dockerfile 里已经写了第三个是连接串里的serverTimezoneAsia/Shanghai。数据库容器这边可以在启动时加环境变量或者挂一个my.cnf进去# /data/docker/mysql/conf/my.cnf [mysqld] default-time-zone 08:00 character-set-server utf8mb4 collation-server utf8mb4_general_ci改完docker restart mysqldoc用SELECT NOW();验证。三处都对上了时间才不会在链路上某一环莫名其妙偏掉。5.5 宿主机上已经有 MySQL 和 Redis端口直接撞车现象docker run -p 3306:3306报Bind for 0.0.0.0:3306 failed: port is already allocated。原因很直白宿主机上已经有一个实例占着这个端口。要么停掉宿主机的实例要么把容器端口映射到别的宿主端口也就是前面命令里的3307和6380。# 先查清楚端口被谁占了再决定是停服务还是换映射 ss -tlnp | grep -E 3306|6379 # 换映射后从宿主机连要用新端口容器之间互连仍然用原端口 docker run -d --name redis-doc --network dev_net -p 6380:6379 \ -v /data/docker/redis/data:/data \ redis redis-server --appendonly yes --requirepass redis--appendonly yes开 AOF 持久化配合/data的卷映射容器重启数据不丢--requirepass设密码不设的话默认无密码暴露在网络上很危险。连的时候注意区分宿主机上的客户端连127.0.0.1:6380应用容器里连redis-doc:6379。6. 快速定位三步固定动作别再从日志第一行读起故障出现时最浪费时间的动作是打开docker logs从头往下读几千行启动日志里真正的错误往往在最后二十行或者在更前面的某一行里一闪而过。我现在的固定顺序是三步从外往里收。第一步看容器的生死状态。docker ps -a拿到状态和退出码docker inspect拿到退出原因和是否 OOM。这一步决定后面往哪个方向查Exited (0)查启动命令Exited (1)查应用日志Up但访问不了就跳第二步。第二步验证端口和网络用命令代替猜测。下表的组合能覆盖绝大多数访问不到的场景现象直接执行的命令看什么浏览器访问超时docker port demo-app宿主机端口有没有映射出来容器内 curl 通、外部不通ss -tlnp | grep 8080、firewall-cmd --list-ports监听地址和防火墙规则数据库连接被拒docker exec -it demo-app nc -zv mysqldoc 3306网络层通不通、端口层通不通容器间互访失败docker network inspect dev_net --format {{range .Containers}}{{.Name}} {{end}}两个容器是否在同一张网络数据疑似丢失docker inspect --format {{range .Mounts}}{{.Source}}-{{.Destination}}{{\n}}{{end}} mysqldoc卷映射有没有覆盖数据目录时间不对docker exec -it mysqldoc date容器时区是否正确第三步只在这时候看日志而且带着关键词看。docker logs --tail 200 demo-app 21 | grep -iE error|exception|caused by把Caused by那几行挑出来根因通常在最后一个Caused by里前面的都是包装。Connection refused往网络查Access denied往权限查Public Key Retrieval往驱动参数查exec format error往架构查——这几个关键词几乎能一一对应到具体动作比通读日志快得多。从那以后我每次把镜像从开发机搬到另一台机器都强制走一遍docker inspect看架构、docker port看映射、进容器nc测一次数据库端口这三下花不到一分钟能挡掉后面几小时的瞎猜。希望帮到你。本文还有配套的精品资源点击获取