1. 项目概述:一个“顺利”部署背后的连环坑
如果你以为 Docker 部署就是把镜像 build 出来、run 一下就万事大吉,那这篇文章可能会让你有点“幻灭”。我这次负责一个内部演示系统的容器化部署,项目本身不复杂,就是一个前后端分离的小应用,数据库用 MySQL,后端是 Spring Boot,前端是 Nginx 托管的静态页面。按说这种组合是 Docker 最经典的玩法,教程满网都是,但我还是从早到晚踩了一整天的坑,从镜像构建到容器互通再到数据持久化,几乎每个环节都没让我省心。
这篇复盘不是给你重复官方文档,而是把我在 Day2 实战中真实遇到的报错、异常、诡异现象,以及最终的解决路径记录下来。内容包括镜像构建时的依赖缓存问题、容器启动时的时区偏移、数据库连接失败的网络排查、挂载目录权限导致的启动崩溃,还有日志暴涨撑爆磁盘的隐患。每个问题我都会还原当时的错误现场、解释为什么会出现、以及最终怎么处理,顺便把那些文档里不会写、但确实能救命的排查命令和配置技巧一并放进来。
适合谁看?如果你正准备把第一个项目用 Docker 部署到服务器上,或者已经部署了但时不时遇到“莫名其妙”的问题,这篇文章就是给你写的。即便是有点经验的人,里面那几个排查思路和参数配置也值得一看——有些坑,遇到了才知道疼。
2. 内容整体设计与思路拆解:为什么 Docker 部署处处是坑
2.1 Docker 部署的“理想模型”与“现实落差”
理论上,Docker 部署应该是这样的:写个 Dockerfile,把应用和依赖一起打包成镜像;写个 docker-compose.yml,把应用和中间件编排起来;然后一条docker-compose up -d,整个世界清静了。
但在实际操作中,这个理想模型成立的前提是:你的开发环境、测试环境和生产环境高度一致,而且你对自己构建的镜像内容了如指掌。而现实是,我这次的部署经历了下面这些“意外”:
- 同一个 Dockerfile,在公司电脑上构建只要 3 分钟,到了服务器上构建了 20 分钟还没完——因为基础镜像拉取慢、依赖缓存失效;
- 镜像构建成功后,启动容器,前端页面倒是出来了,但后端接口全部超时——因为容器之间的网络配置不对;
- 排查网络问题时发现数据库容器根本没起来——因为数据目录的权限和挂载方式有问题;
- 好不容易全部跑通了,第二天早上收到磁盘告警——因为日志文件在容器里无限增长。
这些问题单独拆开看,每一个都有成熟的解决方案,但当它们在同一天内连环出现时,考验的就不再是你会不会敲命令,而是你有没有一套系统化的排查思路。我会在这篇复盘里把整个部署流程拆成“准备阶段”“构建阶段”“运行阶段”“维护阶段”四个环节,分别记录问题和解法,这样你后续遇到类似场景时可以直接对照。
2.2 我选择的部署方案与备选权衡
先交代一下这次部署的技术选型,这部分决定了后续坑的方向和形态。
应用架构很简单:后端是一个 Spring Boot 服务,跑在 8080 端口;前端是静态资源,用 Nginx 托管;数据库是 MySQL 8.x。我不打算直接用 Docker 命令行去一个个起容器,而是选择编写 docker-compose.yml 来统一编排。原因很现实:这个系统包含三个服务,它们之间有依赖关系,用 Compose 可以定义启动顺序、网络别名和卷挂载,一条命令搞定整个生命周期,后续维护也只需要改一个文件。
但也有一个选择是直接在每个服务里手动docker run,好处是灵活、好调试,坏处是当你需要重建环境时,你要记得每一个参数——端口映射、环境变量、卷路径、网络模式,一旦漏掉,又是一个新坑。我最终选择 Compose,还有一个原因是它可以比较清晰地管理环境变量和配置差异,同一个编排文件通过.env文件切换不同环境,这对后续部署到其他服务器是刚需。
提示:如果你只有单个服务要部署,而且不需要频繁重建,直接用
docker run问题不大。但一旦超过两个服务,强烈建议上 Compose,别嫌它“绕”,它省下的是你未来的记忆负担。
2.3 部署环境的基本情况说明
这次部署的目标机器是某云服务商的一台轻量级服务器,2 核 2G 内存,操作系统是 Ubuntu 22.04,Docker 版本是 24.x,Docker Compose 是 v2 插件版。这个配置在今天的应用里算是比较“紧巴”的,但跑这么一个小系统绰绰有余——前提是你别在构建阶段把内存干爆。
整个部署的核心内容就三个部分:后端服务的容器化、前端静态资源的容器化、MySQL 的容器化。我逐个构建、逐个部署、逐个排查,本来预计两小时收工,最后硬生生搞到深夜。下面这些坑,基本是按时间顺序出现的。
3. 核心细节解析与实操要点:构建与配置阶段的那些雷
3.1 Dockerfile 的细节差异:从“能跑”到“高效跑”
先看后端服务的 Dockerfile。很多新手写 Dockerfile 就是:
FROM openjdk:17 COPY target/app.jar /app.jar ENTRYPOINT ["java", "-jar", "/app.jar"]这个写法的确能跑,但有几个隐患。第一,基础镜像没有明确指定版本 tag,openjdk:17会跟随仓库的最新改动,下次构建可能拉到一个和你本地不同的镜像,这就引入了不确定性。第二,它把整个 jar 分成一层,如果你改了代码重新打包,这一层就会完全失效,Docker 的层缓存机制完全帮不上忙,每次都要重新传一次 jar 包。
我这次就用了一种更好的方式:把依赖层和代码层分开。因为 Spring Boot 的 jar 包结构是固定的,依赖都在BOOT-INF/lib下,业务代码在BOOT-INF/classes下,所以可以先单独拷依赖,再拷业务代码:
FROM eclipse-temurin:17-jre WORKDIR /app COPY target/*.jar /tmp/app.jar RUN cd /tmp && java -Djarmode=tools -jar app.jar extract --layers COPY --from=0 /tmp/app/layers/dependencies/ ./ COPY --from=0 /tmp/app/layers/spring-boot-loader/ ./ COPY --from=0 /tmp/app/layers/snapshot-dependencies/ ./ COPY --from=0 /tmp/app/layers/application/ ./ ENTRYPOINT ["java", "-jar", "app.jar"]这个写法利用了 Spring Boot 2.3+ 内置的层提取工具,把 jar 拆成依赖层和应用层。好处是:当你只改了业务代码时,构建时依赖层完全命中缓存,构建时间从十几分钟压缩到几十秒。
注意:如果你还在用较老的 Spring Boot 版本,不支持层提取,那至少把构建拆成两步——先复制 pom.xml 并运行依赖解析,再复制源码打包。这样也能最大程度利用构建缓存。
但这里就是我的第一个坑。我当时没有认真对待基础镜像拉取的问题,直接用了openjdk:17,结果服务器上拉取这个镜像花了快十分钟。因为openjdk这个官方仓库本身就是个大块头,几百 MB 的镜像在带宽一般的服务器上非常痛苦。
实操建议:选基础镜像优先考虑瘦身版,比如
eclipse-temurin是官方推荐的开源 JDK 镜像,有完整的-jre精简版本;或者用alpine系列的镜像配合 glibc 补丁。别嫌瘦身版“不带常用工具”,生产环境本来就该尽量减少攻击面和体积。
3.2 docker-compose.yml 的配置逻辑:别名、依赖和网络的协同
写完 Dockerfile,自然就到了 docker-compose.yml。这个文件的坑比 Dockerfile 更多,因为它在“调度”三个容器,任何一环配置得不对,整个系统就跑不起来。
我最初的 Compose 配置长这样:
services: backend: build: ./backend ports: - "8080:8080" depends_on: - mysql frontend: build: ./frontend ports: - "80:80" depends_on: - backend mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root看起来没毛病,对吧?实际上至少有四个问题被埋在了里面。
第一个问题:depends_on只控制启动顺序,不保证服务可用。也就是说,backend容器启动了,不代表 MySQL 已经就绪,Spring Boot 如果此时尝试建立数据库连接,会直接报连接失败并退出。第二个问题:我没有给容器设置网络别名,虽然 Compose 会自动创建默认网络,但服务名本身就是 DNS 名称,所以 Spring Boot 的数据库地址可以直接写mysql,这一点问题不大。第三个问题:MySQL 的 root 密码用明文写的,而且没有挂载数据卷,容器一旦被删除,数据全没了。第四个问题:我没有指定restart策略,服务器一重启,容器不会自动恢复。
以上任何一条,都足以让你在部署当天多花几小时。我特别把“服务可用性”的问题拿出来说,因为在 Compose 里加入一个健康检查机制是完全可以解决的:
services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: demo healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot"] interval: 10s timeout: 5s retries: 5 backend: build: ./backend ports: - "8080:8080" depends_on: mysql: condition: service_healthy这样 Spring Boot 容器只会在 MySQL 健康检查通过之后才启动,从根上避免了连接被拒的报错。这是 Compose 进阶用法里最实用的一条,强烈建议所有人都用起来。
3.3 环境变量的传递与管理:默认值陷阱与 .env 文件
Compose 里的environment和环境变量传递方式,也是这次踩坑的一个点。
我后端服务有个配置是数据库密码,我一开始直接写死在代码的 application.yml 里。这样做的坏处很明显:镜像里存了明文密码,而且换个环境就要重新 build 镜像。更好的做法是用环境变量覆盖配置,Spring Boot 天然支持这种机制,你只需要在 application.yml 里写:
spring: datasource: url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME} username: ${DB_USER} password: ${DB_PASSWORD}然后在 Compose 里通过environment传入,或者在同级目录维护一个.env文件,Compose 会自动读取它来填充变量:
backend: build: ./backend environment: DB_HOST: mysql DB_PORT: "3306" DB_NAME: demo DB_USER: demo DB_PASSWORD: ${DB_PASSWORD}这里有个容易翻车的细节:.env文件里的值默认是字符串,如果你要传数字类型,需要用引号包起来。而且.env文件不要提交到代码仓库,它属于“环境专属配置”,不该被版本化。我这次就是因为图省事把密码写死在 Dockerfile 里,导致每次改密码都要重新构建镜像,白白浪费了构建缓存。
4. 实操过程与核心环节实现:连环坑的现场复盘与解决
4.1 坑一:镜像构建时依赖下载慢得离谱,而且一直失败
这是当天的第一个拦路虎。事情是这样的:我写好 Dockerfile 后,在服务器上执行docker compose build,结果进度条走到依赖下载阶段就开始“龟速”,一个几十 MB 的依赖包下载了五分钟还没完,然后直接报了Connection timed out。这是典型的网络访问不稳定问题,而 Maven 默认中央仓库的服务器,在云服务器上访问速度并没有你想象的那么快。
解决方案有两个方向。第一个是给 Maven 配置国内镜像源,方法是在镜像构建阶段往容器里写入一个 settings.xml,把中央仓库地址替换成镜像站的地址。我的 Dockerfile 里加了一段:
FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline COPY src/ ./src/ RUN mvn package -DskipTests然后在构建环境里配置镜像源,就是在pom.xml同级的~/.m2/settings.xml里,把仓库 URL 换成镜像站地址。这一招立竿见影,构建速度快了五倍不止。
第二个方向是直接换基础镜像,把maven:3.9-eclipse-temurin-17换成国内镜像仓库里已有的构建镜像,这样至少拉取基础镜像的时候不会卡太久。
实操经验:在服务器上构建镜像时,先执行
docker pull把基础镜像提前拉下来,再去执行复合构建,这样可以把网络超时的风险前置,避免构建中途失败后还得重来。分步执行还能让你更清楚地判断问题出在哪一层。
4.2 坑二:容器内时区偏移,前端时间显示差了 8 小时
系统跑通之后,我马上发现一个诡异的问题:前端页面上显示的时间比本地时间晚了八个小时。这问题说大不大,但出现在演示系统里非常尴尬。
原因是容器默认使用 UTC 时区,而我们的业务在国内,需要的是东八区时间。这个问题的排查其实不难:进入容器看/etc/timezone或者执行date命令就能确认。但难在“预防”,因为很多人部署完根本不会去检查时间,直到用户反馈时间不对才知道出了问题。
修复方法有两种。一种是在 Compose 文件里给每个服务加上时间卷挂载:
volumes: - /etc/localtime:/etc/localtime:ro另一种是在 Dockerfile 里设置时区:
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo "Asia/Shanghai" > /etc/timezone注意,第一种方案依赖宿主机时区设置正确,如果宿主机本身时区不对,挂载也白搭;第二种方案是把时区固化到镜像里,可移植性更好。我这次是两种都用上了,因为后端接口返回的时间由 JVM 决定,前端 Nginx 容器主要是静态资源不涉及时间逻辑,但保险起见还是统一处理了。
提示:如果你用的是基于 Alpine 的镜像,容器里可能没有
tzdata这个包,直接执行ln -sf会失败。你需要先apk add tzdata,然后再做符号链接。这也算是个不起眼的小坑。
4.3 坑三:数据库容器反复重启,日志里报权限错误
时间问题刚解决,数据库容器又开始“作妖”。表现为:docker compose ps里 MySQL 容器的状态是restarting,日志里不断刷:
mysqld: Can't create/write to file '/var/lib/mysql/'这个错误的意思是 MySQL 没有权限写入挂载的数据目录。我当时的 Compose 配置里给 MySQL 加了卷:
volumes: - ./mysql-data:/var/lib/mysql问题就出在这个./mysql-data目录的权限上。宿主机上这个目录是 root 用户创建的,而 MySQL 容器内部运行进程的用户是mysql(UID 999),它没有权限往 root 拥有的目录里写文件。
这不是 Docker 的 bug,而是 Linux 文件权限与容器用户机制的交汇点,几乎每个用卷挂载跑 MySQL 的人都会撞上。解决方案有几种:
第一种,宿主机上直接把目录权限改掉,最粗暴但有效:
chown -R 999:999 ./mysql-data这里的 999 是 MySQL 容器内 mysql 用户的 UID。你不需要进入容器确认,MySQL 官方镜像的 Dockerfile 里写死了这个 UID。
第二种,更优雅的做法,用一个初始化容器把目录权限纠正过来,但这需要你多写一个服务定义,对新手不够友好。
第三种,直接在 Compose 里给 MySQL 加环境变量让它以 root 用户初始化再降权,但这样会引入额外的安全风险和配置复杂度。
我最终选择了第一种,简单直接,一行命令解决问题。但这里有个后续影响:如果接下来你还要挂载其他目录到 MySQL 容器,都要记得检查 UID 是否匹配,否则同样的问题会换个姿势再来一次。
4.4 坑四:前端容器能访问到页面,但后端接口全部 502
这个坑排除了数据库权限问题之后出现的。现象是:Nginx 容器起来了,首页也能打开,但所有需要调用后端接口的请求全部返回 502 Bad Gateway。
当时我的 Compose 里 frontend 容器的配置是:
frontend: image: nginx:alpine ports: - "80:80"而 Nginx 的配置里代理转发到后端的地址写的是http://localhost:8080。这就是 502 的根源:在 Nginx 容器内部,localhost指的是 Nginx 容器自己,不是宿主机,也不是后端容器。你必须把代理地址改成后端服务的容器名,也就是 Compose 网络中的 DNS 名称,也就是http://backend:8080。
这个问题的本质是对 Docker 网络模型的误解。Compose 会创建一个默认的桥接网络,在这个网络里的所有容器都可以通过服务名(service name)互相访问,但这仅限于同一个网络内。而localhost永远只属于容器自身,你访问不到别的容器。
修正后的 Nginx 配置关键片段:
location /api/ { proxy_pass http://backend:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }改完之后需要重建前端容器才能生效:
docker compose up -d --force-recreate frontend注意:如果你在 Compose 中手动给某些容器指定了
network_mode: host,那它们就不在默认的桥接网络里,也就不能通过服务名访问了。混用网络模式是大忌,除非你非常清楚自己在做什么,否则就全部走默认网络。
4.5 坑五:磁盘告警,日志文件无限膨胀
当所有服务都稳定运行了一夜之后,第二天早上我收到服务器的磁盘使用率告警邮件。检查发现/var/lib/docker/containers目录占了几个 GB 的空间,罪魁祸首是每个容器的完整日志文件。
Docker 默认会记录容器内所有输出到 stdout/stderr 的数据,而且是完整保留,没有大小限制。如果你的应用里有堆栈打印、SQL 日志、或者像 Nginx 这种访问日志输出特别多的服务,磁盘被撑爆是迟早的事。
这个问题有两种解法:一种是在运行容器时限制日志大小,一种是全局配置 Docker daemon 的日志参数。我选择在 Compose 文件里针对每个服务单独配置:
services: backend: logging: driver: json-file options: max-size: "10m" max-file: "3"这样单个容器的日志文件被限制在 30 MB 以内,超过就轮转,不会无限增长。还有一种更彻底的方式,是用local日志驱动,性能更好,但不支持 Docker 日志 API 的一些高级查询功能。对于绝大多数场景,json-file加上大小限制已经足够了。
实操经验:部署完成后马上把日志轮转配置加进去,不要等告警了才处理。磁盘告警是所有服务器问题里最麻烦的一类,因为一旦空间耗尽,很多服务会进入不可用的状态,而且清理时要花双倍时间。
4.6 坑六:重启后服务丢了,everything is gone
这个问题是在我修复完日志问题,顺手重启了一下服务器之后发现的。重启完登录服务器,执行docker ps,发现所有容器都不在了,而镜像还在。
原因很简单,我的容器启动参数里没有restart策略。Docker 默认的restart是no,意思是无论容器退出还是 Docker 守护进程重启,都不会自动启动任何容器。
解决方案是在 Compose 文件里给每个服务添加:
restart: unless-stopped这个策略表示:除非管理员手动停止容器,否则在 Docker 守护进程启动时自动拉起容器。这个配置非常关键,尤其是数据库这类无状态服务之外的“有状态中间件”,你绝对不希望 MySQL 因为服务器重启就从此失联。
但也要注意,unless-stopped不是万能药。如果你的容器因为应用本身崩溃而反复重启,光靠重启策略是不够的,你要检查日志找到根因。不然容器就会在“崩溃-重启-崩溃”的循环里一直耗资源,完全失去意义。
5. 常见问题与排查技巧实录:越早知道越省事的 Docker 排查法
5.1 快查手册:用这些命令快速定位问题
踩了一天的坑,我最大的体会是:Docker 部署出错不可怕,可怕的是不知道怎么系统地排查。下面这几个命令是我这次用得最频繁的,每一个都是“救命级”的,建议直接收藏。
查看容器整体状态:
docker compose ps查看单个容器的实时日志,-f表示持续跟随输出:
docker compose logs -f backend查看容器内部运行了什么进程:
docker exec -it backend ps aux查看容器 IP 和网络信息:
docker inspect backend | grep -A 10 "Networks"测试容器之间网络连通性:
docker exec -it backend ping mysql进入容器内部交互式排查:
docker exec -it backend /bin/sh查看宿主机上目录的 UID/GID,用于解决权限挂载问题:
ls -n ./mysql-data我特别推荐docker compose ps作为第一步,因为它一眼就能看出哪个容器状态异常。如果某个容器显示restarting,多半是启动时崩溃,此时docker compose logs就是诊断的重头戏。
5.2 实战中的网络排查套路与思路
网络问题在 Docker 部署里出现频率极高,而且表现形式多样,可能是 502、连接超时、拒绝连接、DNS 解析失败……不同的报错指向不同的原因,但排查路径是共通的。
我的排查顺序是:先确认目标容器的通信地址对不对,再从部署的容器内部尝试解析服务名,然后通过 ping 或测试连接来判断网络是否可达,最后检查端口映射是否正确。
举个例子,如果前端代理到后端时报 502,你要按这个顺序想:
- 后端容器在不在运行?状态是不是 healthy?
- 前端容器能不能通过
http://backend:8080访问到后端? - 后端容器里 Spring Boot 实际监听的端口是 8080 吗?会不会是 8081?
- 有没有防火墙或安全组规则挡在中间?
大多数网络问题在走完这个流程之后都能定位,因为 Docker 网络本身的设计是简单的,它只是在容器之间创建了一系列虚拟网桥,但只要你在配置里搞混了端口、服务名、网络模式中的任何一个,表现就会变得非常“玄学”。
5.3 让我少走弯路的三个检查习惯
这次部署之后,我养成了三个固定的检查习惯,每个都不费时间,但确实帮我少踩很多坑。
第一,每一次修改 Compose 配置后,先执行docker compose config验证一下配置语法,它会打印出最终的解析结果,任何缩进错误、类型错误、环境变量没定义的问题都会在这里暴露。
第二,启动完成后立刻执行docker compose ps,并配合docker compose logs --tail 50挨个容器过一遍,确认启动过程中没有任何异常输出。别等服务“看起来启动了”就放一边,很多后台错误是慢慢浮现的。
第三,所有密码和敏感信息绝不写在 Compose 文件或 Dockerfile 里,一律用环境变量注入,而且.env文件不进版本库。这个习惯一开始会觉得麻烦,但当你需要把项目交付给同事或迁移到新环境时,它的价值就会成倍体现。
5.4 部署完成后必做的四件小事
部署完成不代表工作结束,我每次都会花几分钟做这四件事,能避免大量的后续麻烦:
- 配置日志轮转,具体方法上面已经提过,这里不再赘述。
- 设置容器的重启策略为
unless-stopped,确保服务器重启后服务能自愈。 - 验证数据卷挂载是否真实生效,方法是在宿主机上写入一个测试文件,然后进入容器查看,能看到就说明挂载成功。
- 检查镜像体积,用
docker images看看有没有可以精简的镜像,删掉悬空镜像和构建缓存,保持环境干净。
这些事单独拿出来都很简单,但它们组合起来的价值在于:让整个部署环境变得更可维护、更不容易出幺蛾子。一天下来,我最深的感觉就是,部署过程的操作倒是其次,真正决定当天效率的,是入坑前你是否准备好了一整套检查预案。这跟开车一样,好司机从来不靠撞了之后才想起踩刹车。
说回我自己的体会:以前我也是那种“能跑就行”的风格,部署完看一眼页面能开就收工。但从 Day1 到 Day2,连续两天被各种小问题折磨之后,我才真正意识到容器化部署这件事,绝不是“把程序装进盒子里”这么简单。它要求你对 Linux 权限模型、网络协议、进程管理、存储机制都有基本的理解,否则排障就是拆东墙补西墙。这篇复盘里的每一个坑,我都亲手踩过、修过、验证过,写出来就是想让你绕开这些我已经走过的弯路。如果你正在做类似的事情,希望这篇记录能帮你省下那整整一天。