在实际的前后端分离项目里,开发环境跑通只是第一步,真正让人头疼的往往是把整个系统交付到服务器上运行:前端要构建、后端要装 JDK、数据库要初始化、端口要互不冲突、服务要开机自启、日志出问题时要能快速定位。这一连串需求,非常适合用 Docker 来统一管理和部署。
“双栈工坊”可以理解为一个典型的前后端双技术栈应用:前端是一套静态页面工程,运行时由 Nginx 托管;后端是一套 Java Web 服务,对外提供 API。再加上 MySQL 作为数据层,整个系统就构成了一个多容器组合。Docker 负责把这三部分分别封装成镜像,用 Docker Compose 定义它们之间的网络、依赖、数据卷和环境变量,最终通过一组命令完成构建、启动、扩容、日志查看和故障恢复。
这篇文章以“双栈工坊”为示例项目,完整走一遍从环境检查、Dockerfile 编写、Compose 编排、镜像构建、容器启动到问题排查的过程。读完以后,你可以把同样的思路迁移到自己的前后端项目中,无论是本机开发、测试环境部署,还是生产容器化改造,都能有一套可复用的操作路径。
1. 先想清楚双栈项目为什么需要容器化部署
很多人最初接触 Docker 时只把它当成“轻量虚拟机”,觉得把项目打进去能跑就行。但双栈项目里真正的问题不是“能不能跑”,而是“每个环境能不能保持一致地跑”。理解容器化解决的核心问题,比记住命令更重要。
1.1 “双栈”在这里指什么
“双栈”并没有一个唯一的技术定义。在本文的示例项目里,它指前后端两套技术栈同时存在:
- 前端技术栈:使用 Node.js 环境进行依赖安装和构建,产物是纯静态文件,运行时交给 Nginx。
- 后端技术栈:使用 Java 生态,代码经过 Maven 编译打包成 JAR 包,运行在 JDK 环境中。
- 数据层:MySQL 提供数据持久化,但它在后端服务内部并非独立技术栈,而是独立容器。
这种结构的典型特征是一个系统里同时存在“构建环境”和“运行环境”,而且两种环境对底层软件版本的要求完全不同。比如前端需要 Node.js 18 才能完成构建,但运行时只需要 Nginx;后端需要 JDK 17 编译,但运行时只需要 JRE。如果直接在宿主机上部署,你需要手动安装 Node、Nginx、JDK、MySQL,并且处理版本冲突和环境污染。
Dockerfile 多阶段构建可以把“构建环境”和“运行环境”分开,最终镜像里只保留运行所需的内容。这正是容器化特别适合双栈项目的原因。
1.2 不容器化的典型痛点
在没有 Docker 时,双栈项目的部署过程通常是这样:
- 在服务器上安装 Node.js,执行
npm install和npm run build。 - 安装 Nginx,把前端构建产物拷贝到指定目录,再修改配置文件。
- 安装 JDK,设置
JAVA_HOME,把后端 JAR 包放到某个目录。 - 安装 MySQL,创建数据库和账号,执行初始化 SQL。
- 配置后端连接数据库的地址、账号、密码。
- 手工启动后端进程,再启动 Nginx。
- 下一次换一台服务器,以上过程全部重来。
这个流程存在几个非常现实的问题。第一是环境差异,本地能跑、服务器跑不起来,经常是因为 Node 或 JDK 版本不一致。第二是进程管理,后端一旦异常退出,如果没有 systemd 或 supervisor,服务就悄悄没了。第三是环境污染,为了一个项目在服务器上装一堆运行时,其他项目可能会踩到依赖冲突。第四是回滚困难,代码更新后如果新版本有问题,需要手工把旧包找回来重新替换,操作路径很长。
容器化之后,镜像里已经固定了 Node、JDK、Nginx 的版本和配置。同一套镜像在笔记本、测试服务器、生产服务器上跑出来的结果是一致的,进程退出后可以由restart策略拉起,回滚时只需要切换镜像标签。
1.3 容器化部署的基本构成
在开始操作前,先统一三个概念:
| 概念 | 通俗含义 | 在示例项目中的作用 |
|---|---|---|
| 镜像 | 只读模板,包含运行环境和应用文件 | 前端镜像、后端镜像、MySQL 镜像 |
| 容器 | 镜像的运行实例,可启动、停止、删除 | 每个服务跑在一个容器里 |
| 编排 | 定义多个容器如何协作 | Docker Compose 管理网络、依赖、数据卷 |
本文的核心主线是:先用 Dockerfile 把前端和后端做成镜像,再用 Docker Compose 把前端、后端、MySQL 三个服务编排起来。整个过程会覆盖镜像构建、容器管理、网络通信、数据持久化和日志排查,这正好对应“双栈工坊 Docker 管理部署容器”这个主题。
2. 环境准备:宿主机装好 Docker,后面所有操作才可复现
容器化操作的起点不是写代码,而是确认宿主机上的 Docker 环境可用。这里区分两类常见场景:一类是 Linux 服务器,另一类是本地 Windows 或 Mac 开发机。
2.1 Linux 服务器环境
在 Ubuntu 或 Debian 系列系统上,安装 Docker 的标准路径是配置官方软件源,然后安装docker-ce。CentOS 7 及类似系统的安装命令稍有区别,但思路相同。
# Ubuntu / Debian 示例 sudo apt-get update sudo apt-get install -y ca-certificates curl gnupg sudo install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg sudo chmod a+r /etc/apt/keyrings/docker.gpg echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null sudo apt-get update sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin这里有三点需要说明。第一,安装的是 Docker 引擎本身,而不是 Docker Desktop;服务器场景不需要图形界面。第二,docker-compose-plugin是 Docker Compose 的现代安装方式,安装后系统里存在docker compose子命令,而不是独立二进制docker-compose。第三,不同发行版的官方源地址不同,命令里的系统代号也随版本变化,生产环境应以 Docker 官方文档为准。
安装完成后,把当前用户加入docker用户组,避免每次执行命令都加sudo:
sudo usermod -aG docker $USER newgrp dockerusermod只是修改用户组,需要重新登录或者执行newgrp让用户组生效。这里要提醒一下:加入docker用户组相当于把宿主机 root 权限的一部分交给了该用户,在多人服务器上要谨慎管理成员。
2.2 Windows 和 Mac 本地环境
本地开发机通常使用 Docker Desktop。这个工具在 Windows 上依赖 WSL2 或 Hyper-V,在 Mac 上依赖系统虚拟化框架。
Windows 安装 Docker Desktop 前,先打开“启用或关闭 Windows 功能”,确认以下项已启用:
- “适用于 Linux 的 Windows 子系统”
- “虚拟机平台”
然后执行:
wsl --install安装完 WSL2 后重启系统,再安装 Docker Desktop。启动后如果出现 “Docker Desktop failed to start because virtualisation support wasn't detected” 这类报错,说明虚拟化功能没有启用,或者在 BIOS 中没有开启 CPU 虚拟化。检查路径如下:
- 在 Windows 的“任务管理器 -> 性能 -> CPU”中查看“虚拟化”状态是否为“已启用”。
- 如果显示已禁用,需要进入 BIOS,找到 “Intel Virtualization Technology” 或 “SVM Mode” 选项,设置为 Enabled。
- 如果已经启用但 Docker Desktop 仍报错,检查是否安装了旧版本 WSL,执行
wsl --update更新。
在本地使用 Docker Desktop 的优点是自带图形面板、日志查看和 Docker Compose 支持,适合学习和调试。但它默认会占用一定内存,WSL2 模式下还需要留意.wslconfig中内存上限的设置。
2.3 镜像源与版本核对
Docker 拉取镜像默认从 Docker Hub 获取。网络环境不同,拉取速度差异很大。针对“镜像下载慢”的问题,最可靠的做法是在服务器上配置镜像加速或使用内部私有 Registry。
镜像加速地址因服务商而异,通常可以在云厂商的容器镜像服务控制台找到。修改方式是在/etc/docker/daemon.json中写入:
{ "registry-mirrors": [ "https://your-registry-mirror.example.com" ] }然后重启 Docker:
sudo systemctl daemon-reload sudo systemctl restart docker这里不要照抄任何固定的加速地址,因为第三方地址可能随时失效,也可能存在内容安全和合规风险。最稳妥的方案是使用云平台提供的镜像服务,或者在内网搭建自建 Registry。
配置完成后,用以下命令检查镜像源是否生效:
docker info在输出中搜索Registry Mirrors,能看到配置的地址就说明生效了。
2.4 安装完成后的环境检查清单
环境是否准备好,不要只凭“docker 命令能找到”来判断。建议按这份清单逐项检查:
| 检查项 | 命令 | 预期结果 |
|---|---|---|
| Docker 客户端版本 | docker version | 正常输出版本号 |
| Docker 服务状态 | docker info | 服务正常运行,无错误提示 |
| 当前用户权限 | docker ps | 能列出容器列表,无权限报错 |
| Compose 插件 | docker compose version | 输出 Compose 版本 |
| 镜像源生效 | docker info | 能看到 Registry Mirrors 配置 |
如果docker info提示无法连接到 Docker daemon,先确认服务是否启动:
sudo systemctl status docker sudo systemctl enable --now docker在本地 Windows 上如果出现连接失败,优先检查 Docker Desktop 是否处于运行状态,而不是先怀疑配置。
3. 为双栈工坊编写基础 Dockerfile
环境准备好之后,开始构造应用镜像。这一章先把项目结构定下来,再分别写前端和后端的 Dockerfile,最后说明.dockerignore的重要性。
3.1 项目结构说明
示例项目采用标准的前后端分离结构,目录如下:
shuangzhan-gongfang/ ├── frontend/ # 前端工程 │ ├── Dockerfile │ ├── .dockerignore │ ├── package.json │ ├── vite.config.js │ └── src/ ├── backend/ # 后端工程 │ ├── Dockerfile │ ├── .dockerignore │ ├── pom.xml │ └── src/ ├── mysql/ │ ├── init.sql # 数据库初始化脚本 │ └── my.cnf # 自定义 MySQL 配置 ├── nginx/ │ └── default.conf # 前端容器内 Nginx 配置 ├── docker-compose.yml └── .env在真实的项目中,目录名和构建方式会有差异,但核心思想一致:每个部署单元对应一个目录,每个目录里有自己的 Dockerfile。Docker Compose 在根目录统一管理这些镜像的构建和启动。
3.2 前端镜像:多阶段构建与 Nginx 托管
前端工程不能直接扔进 Nginx 镜像,因为运行静态文件之前必须先完成依赖安装和构建。如果直接把开发态文件复制进镜像,镜像里必须带着 Node.js,运行时会白白多出几百兆体积。
更好的方式是使用多阶段构建。第一阶段使用 Node.js 镜像完成构建,第二阶段使用 Nginx 镜像只保留构建结果,也就是dist目录。
# frontend/Dockerfile # 第一阶段:构建前端静态资源 FROM node:18-alpine AS build WORKDIR /app COPY package.json package-lock.json ./ RUN npm install COPY . . RUN npm run build # 第二阶段:使用 Nginx 提供静态服务 FROM nginx:1.25-alpine COPY --from=build /app/dist /usr/share/nginx/html COPY nginx/default.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]这个 Dockerfile 有四个关键点。
第一,node:18-alpine作为构建阶段的基础镜像,相比完整版 Ubuntu 镜像更小,且 Node 版本固定。如果原始项目没有明确 Node 版本,落地前要确认package.json里的engines字段或 CI 脚本实际使用的版本。
第二,先复制package.json和package-lock.json,再执行npm install,最后才复制源码。这样做可以利用 Docker 的层缓存:只要依赖文件不变,每次修改源码时npm install层就不会重新执行,构建速度会快很多。
第三,CMD ["nginx", "-g", "daemon off;"]是 Nginx 官方镜像的默认启动方式,以前台进程运行 Nginx。不要在容器里使用systemctl start nginx,容器没有 systemd,进程结束后容器就退出了。
第四,nginx/default.conf会被覆盖进容器的/etc/nginx/conf.d/,用于配置前端页面和 API 反向代理。后面第 5 章会具体看这个配置。
3.3 后端镜像:JDK 基础镜像与 JAR 包
后端 Java 项目的目标是产出一个可运行 JAR 包。构建过程依赖 Maven 和 JDK,运行过程只需要 JRE。同样使用多阶段构建:
# backend/Dockerfile # 第一阶段:编译打包 FROM maven:3.9-eclipse-temurin-17 AS build WORKDIR /app COPY pom.xml . RUN mvn dependency:go-offline COPY src ./src RUN mvn package -DskipTests # 第二阶段:运行 FROM eclipse-temurin:17-jre-alpine WORKDIR /app COPY --from=build /app/target/*.jar app.jar EXPOSE 8080 ENTRYPOINT ["java", "-jar", "app.jar"]这段配置中的mvn dependency:go-offline会在源码复制之前下载依赖,目的是缓存依赖层。如果先复制pom.xml再执行这个命令,之后源码变化时不会重新下载依赖。
运行时使用eclipse-temurin:17-jre-alpine,它只包含运行 Java 程序所需的 JRE,不包含编译器,体积比 JDK 镜像小很多。
后端服务启动时要注意时区和内存参数。JVM 默认时区可能不是Asia/Shanghai,生产环境建议在启动参数里指定。修改后的启动命令:
ENTRYPOINT ["java", "-Duser.timezone=Asia/Shanghai", "-Xms512m", "-Xmx1024m", "-jar", "app.jar"]-Xms和-Xmx是 JVM 堆内存的初始值和最大值。值要结合宿主机内存决定,不能无脑设大。在容器环境里,堆上限最好低于容器内存限制,否则容器会因内存超额被系统杀掉。
3.4 .dockerignore 与镜像瘦身要点
COPY . .会复制整个上下文目录。如果目录里有node_modules、target、.git、log等目录,它们会被一并发送到 Docker 构建上下文,导致构建慢,甚至让镜像变大。必须在每个构建目录里创建.dockerignore。
前端工程的.dockerignore:
node_modules dist .git *.log .DS_Store后端工程的.dockerignore:
target .git *.log .idea *.iml .DS_Store镜像瘦身的常见手段不只是选小基础镜像。经常被忽略的是清理构建阶段留下的临时文件,比如 npm 缓存、Maven 本地仓库。这些内容只存在于第一阶段,第二阶段没有复制它们,所以不会进最终镜像。这也是多阶段构建的优势:最终镜像只包含最小运行文件。
检查镜像大小的方法:
docker images观察SIZE列,如果发现镜像体积异常大,可以进入容器查看/usr/share/nginx/html或/app目录里是否多了无关文件。
4. 用 Docker Compose 把容器编排在一起
有了镜像之后,下一步是定义多个容器的协作关系。前端容器需要访问后端 API,后端容器需要连接 MySQL,MySQL 需要持久化数据。这些关联如果靠手工docker run一个个启动,命令会越来越长,也容易漏配置。Docker Compose 用一份 YAML 文件把所有服务关系记录下来。
4.1 服务规划与端口分配表
在写 Compose 文件之前,先规划三个服务的运行方式:
| 服务名 | 镜像来源 | 容器内端口 | 宿主机端口 | 作用 |
|---|---|---|---|---|
| frontend | 本地构建 | 80 | 80 | 提供前端页面,反向代理 API |
| backend | 本地构建 | 8080 | 8080 | 提供业务 API |
| mysql | mysql:8.0 | 3306 | 3306 | 存储业务数据 |
这里有一个原则:容器内端口由应用监听,宿主机端口决定外部如何访问。如果宿主机 3306 端口已被占用,可以把宿主机端口改成 33060,写成33060:3306。前端的80端口在服务器上默认 HTTP 端口,生产环境如果还有 Web 防火墙或负载均衡,一般不会直接暴露 80,而是由上层网关转发。
4.2 docker-compose.yml 完整示例
在项目根目录创建docker-compose.yml:
services: mysql: image: mysql:8.0 container_name: shuangzhan-mysql restart: always environment: MYSQL_ROOT_PASSWORD: root_pass_2024 MYSQL_DATABASE: shuangzhan_db MYSQL_USER: shuangzhan MYSQL_PASSWORD: app_pass_2024 TZ: Asia/Shanghai ports: - "3306:3306" volumes: - mysql-data:/var/lib/mysql - ./mysql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro - ./mysql/my.cnf:/etc/mysql/conf.d/my.cnf:ro healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-uroot", "-proot_pass_2024"] interval: 10s timeout: 5s retries: 5 backend: build: context: ./backend container_name: shuangzhan-backend restart: always environment: MYSQL_HOST: mysql MYSQL_PORT: 3306 MYSQL_DATABASE: shuangzhan_db MYSQL_USER: shuangzhan MYSQL_PASSWORD: app_pass_2024 TZ: Asia/Shanghai ports: - "8080:8080" depends_on: mysql: condition: service_healthy frontend: build: context: ./frontend container_name: shuangzhan-frontend restart: always ports: - "80:80" depends_on: - backend volumes: mysql-data:这个文件比常见的入门示例多加了几个关键配置,逐一说明。
restart: always表示容器异常退出后由 Docker 自动重启。这对进程守护非常有用,后端 Java 进程如果因为内存溢出崩溃,容器会被自动拉起。
healthcheck用于 MySQL 健康检查。MySQL 容器启动后并不会立刻可用,它需要初始化数据目录和用户。depends_on如果只写服务名,Docker Compose 只保证启动顺序,不保证服务真正可用。加上condition: service_healthy后,backend 会等待 MySQL 通过健康检查再启动,避免后端启动时数据库还没就绪。
MYSQL_USER和MYSQL_PASSWORD会在 MySQL 容器首次初始化时自动创建对应的普通用户,并授权访问MYSQL_DATABASE指定的数据库。这比直接用 root 连接安全。
后端环境变量中的MYSQL_HOST: mysql是 Compose 网络内的服务名,不是 IP。这个值会由 Docker 内置 DNS 解析到 MySQL 容器的 IP。
4.3 网络:服务间为什么用服务名访问
在 Compose 文件中,所有服务默认加入同一个网络,服务名就是主机名。因此 backend 里的 JDBC 地址可以写成:
spring.datasource.url=jdbc:mysql://mysql:3306/shuangzhan_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai如果后端代码里把数据库地址写成localhost,容器启动后就会出现Connection refused。原因很简单:在容器内部,localhost指的是当前容器自己,而不是宿主机,更不是 MySQL 容器。
这一点是双栈项目容器化部署里最常见的错误之一。以下表格总结了连接地址的三种情况:
| 连接方式 | MySQL 地址 | 适用场景 |
|---|---|---|
| 相同 Compose 网络 | mysql:3306 | 后端容器连 MySQL 容器 |
| 宿主机进程连容器 | 127.0.0.1:3306 | 本地调试时用 Navicat 连容器 MySQL |
| 跨服务器连数据库 | 服务器 IP:端口 | 外部应用访问数据库 |
如果后端代码的数据库地址是写死的,可以通过环境变量覆盖 Spring Boot 配置。例如在application.yml中使用占位符:
spring: datasource: url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${MYSQL_DATABASE} username: ${MYSQL_USER} password: ${MYSQL_PASSWORD}这样同一份代码,在本地不设置环境变量时可以用默认值,在容器里通过 Compose 注入环境变量,成本和风险都更低。
4.4 数据卷:MySQL 数据与后端日志的持久化
容器的文件系统是临时的。容器删除后,写入容器内部的数据也会消失。MySQL 的数据必须放在宿主机持久化目录里。
volumes: - mysql-data:/var/lib/mysqlmysql-data是 Docker 命名卷。它和./mysql/init.sql这种直接挂载宿主目录的方式不同。命名卷由 Docker 管理,不关心具体路径,适合数据库这类需要权限和性能较高的数据。直接挂载目录适合配置文件和日志,因为运维人员需要直接查看和修改。
初始化 SQL 脚本通过只读方式挂载到/docker-entrypoint-initdb.d/。MySQL 官方镜像会在初次启动数据目录时按文件名顺序执行这个目录下的.sql脚本。要注意:只有数据目录为空时才会执行,如果 MySQL 卷已经有数据,修改init.sql不会重新初始化。
后端日志如果重要,建议把容器的日志目录挂载到宿主机:
volumes: - ./logs/backend:/app/logs不过,更推荐的方式是在 Compose 层统一配置日志驱动,生产环境通常把容器标准输出收集到集中日志系统。这一点在第 7 章展开。
5. 构建、启动与验证一条龙
Compose 文件写完后,整个项目的启动流程变得很简短。但“能启动”不等于“部署成功”,还需要按顺序验证容器状态、页面访问、API 通断、日志是否正常。
5.1 构建与启动
在项目根目录执行:
docker compose build这个命令会按照build指令逐个构建frontend和backend镜像。构建过程中如果看到某一步报错,优先查看报错输出而不是急于调整代码。常见问题包括 npm 下载依赖超时、Maven 依赖拉取失败、源代码在构建环境中缺失等。
构建完成后,直接启动全部服务:
docker compose up -d-d表示后台模式,容器会在后台运行,不在终端里打印日志。首次启动时 Docker 会自动拉取mysql:8.0镜像,如果本地没有,这个步骤会花费一定时间。
如果要重新构建并启动,可以合并执行:
docker compose up -d --build这在代码更新后非常常用。--build会先重新构建镜像,再启动容器,省去手工执行两个命令的过程。
5.2 验证容器状态与服务可用性
启动后先看容器状态:
docker compose ps正常输出中,每一个服务的STATUS列应该是Up,MySQL 服务在健康检查通过后可能显示Up (healthy)。如果某一行的状态是Restarting或Exited,说明启动过程有问题。
再看端口监听情况:
ss -tlnp | grep -E '80|8080|3306'在本地浏览器打开http://localhost应该看到前端页面。如果页面能打开但接口报错,打开浏览器开发者工具,查看网络请求的失败状态。
如果服务器有防火墙,需要确认 80 和 8080 端口在安全组里已经放行。容器内部端口开得再正确,宿主机防火墙没放行,外部网络一样访问不到。
5.3 查看日志与进入容器
日志是排查容器问题的第一入口。查看所有服务的日志:
docker compose logs只看某个服务的最新日志:
docker compose logs -f backend-f类似于tail -f,会持续输出新日志,适合启动过程和接口调试。如果后端启动失败,日志里会直接出现异常堆栈,比如数据库连接失败、端口被占用、配置文件读取不到等。
需要进入容器内部排查时:
docker exec -it shuangzhan-backend sh前端 Nginx 容器里可能没有 shell 之外的编辑器,可以用docker exec检查配置文件是否生效:
docker exec -it shuangzhan-frontend cat /etc/nginx/conf.d/default.conf检查容器内是否能连通 MySQL:
docker exec -it shuangzhan-backend ping mysql注意:有些精简镜像里没有ping命令。可以改用getent hosts mysql或直接让 Java 程序去连接,看日志结果。
5.4 常用管理命令速查表
容器化管理的过程会反复用到下面这些命令,整理成表格方便查阅:
| 操作 | 命令 | 说明 |
|---|---|---|
| 查看所有服务状态 | docker compose ps | 看容器是否在运行 |
| 查看日志 | docker compose logs -f backend | 实时跟踪后端日志 |
| 停止并删除容器 | docker compose down | 不删除镜像和数据卷 |
| 停止并删除卷 | docker compose down -v | 谨慎使用,会删除 MySQL 数据 |
| 重启单个服务 | docker compose restart backend | 快速重启服务 |
| 重新构建某个服务 | docker compose build backend | 只构建后端镜像 |
| 进入容器 | docker exec -it 容器名 sh | 交互式进入 shell |
| 查看容器资源占用 | docker stats | 查看 CPU、内存、网络状况 |
| 查看镜像列表 | docker images | 确认镜像大小和构建时间 |
其中docker compose down -v必须特别小心。-v会删除 Compose 文件里定义的普通卷,MySQL 数据目录如果没有额外备份,将无法恢复。恢复数据的难度比修复代码大得多,操作前先确认数据有没有备份。
6. 实战中最高频的 5 个坑及排查链路
容器化部署的很多报错现象都很类似,但根因差异很大。下面整理 5 个双栈项目中最常见的问题,每条都给出现象、原因、检查方式和处理办法。
6.1 镜像拉取慢或拉取失败
现象:执行docker compose up时卡在Pulling mysql,或者直接提示failed to pull image。
可能原因:默认 Docker Hub 访问受限或速度慢;本地没有目标镜像;网络 DNS 解析异常。
排查链路:
- 先看具体报错,是超时还是 404。404 通常是镜像名写错,超时通常是网络问题。
- 执行
docker search mysql测试 Docker Hub 是否可访问。 - 检查
/etc/docker/daemon.json中是否配置了有效的镜像源。 - 确认镜像源地址是否可用,可以用
curl -I请求镜像源地址检查连通性。
处理建议:优先使用云厂商容器镜像服务提供的镜像加速地址,或者内网私有 Registry。不要依赖来路不明的公共加速地址,服务质量和合规性都无法保证。同时,在服务器第一次拉取大镜像时,尽量选择网络空闲时段。
6.2 容器启动后立即退出
现象:docker compose ps显示某个服务处于Exited状态,或一直Restarting。
可能原因:应用启动时抛出异常;启动命令错误;容器没有前台进程;内存不足被系统杀掉。
排查链路:
- 查看容器日志,这是最直接的线索。
- 如果是后端容器,日志里通常有完整堆栈。
- 如果是前端 Nginx 容器,检查
nginx -t校验配置文件语法。 - 用
docker inspect 容器名查看State.ExitCode和Error字段。
docker compose logs backend --tail 100 docker inspect shuangzhan-backend | grep -A 10 '"State"'处理建议:前端容器启动后立刻退出,优先查 Nginx 配置是否有语法错误;后端容器退出,优先查 JVM 启动参数、数据库连接和配置文件。不要直接删容器重启,日志会丢。
6.3 前端页面能打开,但接口报 502 或 504
现象:浏览器能加载前端页面,但请求 API 时返回 502 Bad Gateway 或 504。
可能原因:前端 Nginx 的proxy_pass地址写错;后端服务没有启动;后端监听端口与 Nginx 转发端口不一致。
假设前端容器里的 Nginx 配置如下:
server { listen 80; server_name _; location / { root /usr/share/nginx/html; index index.html; } location /api/ { proxy_pass http://backend:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }这个配置的关键在于proxy_pass http://backend:8080。backend是 Compose 网络中的服务名,如果写成http://localhost:8080,Nginx 容器会把请求发给自己,而后端并不在同一个容器里,结果必然是 502。
排查链路:
docker compose ps确认后端容器是否在运行。- 在后端日志中看是否有请求进来。
- 在 frontend 容器里执行
wget http://backend:8080/actuator/health或类似命令,验证 Nginx 能否访问后端。 - 检查
proxy_pass后面是否带了路径,路径拼接不一致会导致 404,而不是 502。
6.4 MySQL 连接失败:Host、时区、认证
现象:后端启动日志提示java.net.UnknownHostException: mysql或Access denied for user,又或者Communications link failure。
可能原因有三类,表现不同。
第一类:UnknownHostException,表示后端容器无法解析mysql这个主机名。原因通常是后端没加入 Compose 网络,或者数据库地址不是服务名。
第二类:Access denied for user 'shuangzhan'@'...',表示账号密码错误,或者用户没有目标数据库的权限。检查 Compose 文件里MYSQL_USER、MYSQL_PASSWORD是否和后端环境变量一致。
第三类:Public Key Retrieval is not allowed,通常出现在 MySQL 8.0 连接时。解决方案是在 JDBC URL 中加上:
allowPublicKeyRetrieval=true&useSSL=false但要注意,useSSL=false只适合开发环境,生产环境数据库连接必须走加密连接,不能无脑关闭。
时区问题也很常见。后端连接 MySQL 后,发现时间差 8 小时,可以在 JDBC URL 中指定serverTimezone=Asia/Shanghai,同时把容器时区设置为TZ=Asia/Shanghai,并在 JVM 启动参数中加上-Duser.timezone=Asia/Shanghai。三层配合才能保证时间一致。
6.5 容器时间不对与日志乱码
现象:容器内执行date显示 UTC 时间;后端日志里的中文变成乱码。
时间不对的原因是基础镜像默认时区是 UTC。在 Compose 的environment里加:
environment: TZ: Asia/Shanghai后端服务如果依赖 JVM 时区,还要加上 JVM 参数:
ENTRYPOINT ["java", "-Duser.timezone=Asia/Shanghai", "-jar", "app.jar"]日志乱码多数是因为容器默认 locale 不支持中文,或者日志文件本身没有按 UTF-8 写入。首先确认应用日志配置编码为 UTF-8,比如 Logback 里<charset>UTF-8</charset>。其次确认挂载到宿主机的日志文件编码,尽可能统一为 UTF-8。
7. 学习环境与生产环境的差异化配置
同一个 Compose 文件在笔记本上能跑通,直接搬到生产环境可能会踩不少问题。学习环境的目的是快速验证功能,生产环境的核心要求是稳定性、可观测性和可回滚。
7.1 学习环境怎么快速跑通
学习环境不需要引入太多复杂配置。可以用以下最小原则:
- 镜像源能拉取就行,不必追求极致速度。
- 环境变量直接写在 Compose 文件里,不引入外部保管。
- 不设置健康检查也能跑通,但建议保留,因为它是排查启动顺序问题的好工具。
- 把宿主机端口直接映射到 80、8080、3306,方便访问。
如果在本地反复试验,建议每改一次 Compose 配置后执行:
docker compose up -dup会自动对比配置变化,并重建需要变更的容器。不要先down再up,那样会增加不必要的容器重建时间。
7.2 生产环境还要补哪些能力
生产环境至少需要补齐以下能力:
| 能力 | 说明 | 建议方案 |
|---|---|---|
| 配置外置 | 数据库密码不能明文写死在 Compose 文件里 | 使用环境变量文件.env,或接入配置中心 |
| 日志采集 | 容器销毁后日志不能丢 | 使用 Docker 日志驱动或 filebeat 采集 |
| 健康检查 | 应用是否就绪要能被自动化判断 | 后端暴露健康检查端点,Compose 配置 healthcheck |
| 资源限制 | 防止单个容器耗尽宿主机内存 | 在 Compose 中配置mem_limit、cpus |
| 安全加固 | 镜像漏洞、容器权限、网络暴露面 | 定期扫描镜像,最小化基础镜像,只暴露必要端口 |
| 数据备份 | MySQL 数据定期备份 | 定期mysqldump,并验证恢复流程 |
一个带内存限制的后端服务示例:
backend: build: context: ./backend restart: always mem_limit: 1g cpus: 1.0 environment: JAVA_OPTS: "-Xms512m -Xmx768m"这里要注意 JVM 堆上限和容器内存限制的关系。容器限制 1GB,JVM 最大堆最好设置在 768MB 以下,因为 JVM 除了堆之外还有元空间、线程栈、JIT 编译等内存开销。如果堆上限设置接近容器限制,容器很容易触发 OOM 被杀。
7.3 发布与回滚思路
在生产环境,不要直接使用docker compose build后立即up的方式发布。更稳妥的做法是把构建和部署分离:
- 在 CI 流水线中构建镜像,打上不可变的版本标签。
- 将镜像推送到私有 Registry。
- 在部署机上修改 Compose 文件中的镜像版本。
- 执行
docker compose pull拉取新镜像。 - 执行
docker compose up -d更换容器。
这样发布的镜像内容是可追溯的。如果新版本有问题,只需要改回旧镜像标签,执行:
docker compose up -d容器会基于旧镜像重新创建,回滚操作很直接。关键前提是 Compose 文件里写的是image,而不是build:
backend: image: registry.example.com/shuangzhan-backend:2024.06.01-r1采用镜像仓库方式后,部署机上不再需要源代码,也不需要 Node 和 Maven 环境,这进一步降低了部署机的复杂度。
8. 最佳实践与下一步方向
容器化部署不是“写个 Dockerfile 就能上线”这么简单。真正可维护的部署方案,依赖一套稳定的规范。
8.1 镜像构建规范
以下几点是长期实践中值得坚持的规范:
- 固定基础镜像版本。不要使用
node:latest、openjdk:latest,同一份 Dockerfile 在不同时间构建可能得到不同结果。应使用node:18-alpine、eclipse-temurin:17-jre-alpine这类精确版本。 - 把依赖安装放在源码复制之前,充分利用 Docker 构建缓存。
- 构建阶段不保留无用的包管理工具缓存,多阶段构建能有效减小最终镜像体积。
- 每个镜像只做一件事。不要在一个容器里同时运行 Nginx 和 Java 进程,进程管理会变得复杂。
- 非 root 运行。默认情况下容器内使用 root 用户运行进程,攻击者一旦进入容器,能控制的文件范围很大。后端镜像可以创建普通用户:
FROM eclipse-temurin:17-jre-alpine RUN addgroup -S app && adduser -S app -G app WORKDIR /app COPY --from=build /app/target/*.jar app.jar USER app ENTRYPOINT ["java", "-jar", "app.jar"]8.2 容器化项目的可复用清单
下面这份清单适合在每次发布前过一遍:
| 检查项 | 操作 |
|---|---|
| 镜像构建是否成功 | docker compose build是否能顺利执行 |
| 镜像版本是否固定 | 生产环境不使用 latest |
| 数据库数据是否持久化 | MySQL 是否有命名卷挂载 |
| 敏感信息是否外置 | 密码是否写死在镜像或 Compose 文件中 |
| 服务依赖顺序是否正确 | 后端是否等待 MySQL 健康检查 |
| 端口是否冲突 | ss -tlnp确认宿主端口没有被占用 |
| JVM 内存是否受限 | mem_limit是否大于 JVM 堆上限 |
| 日志是否能持久化 | 容器日志是否有统一采集方案 |
| 回滚路径是否存在 | 上一版本镜像是否还能拉取 |
这份清单不是一次性核对完就结束。每当项目依赖、版本、运行环境发生变化时,都要重新过一遍。
8.3 值得继续深入的方向
双栈工坊项目的容器化完成了第一步,后面还有多个方向可以继续深入。
第一个方向是容器编排升级。Docker Compose 适合单机场景,当服务规模增长到多台服务器时,可以学习 Kubernetes 或轻量级 K3s,理解 Pod、Service、Deployment 与 Compose 概念的对应关系。
第二个方向是镜像安全。可以从docker scan或trivy开始,定期扫描基础镜像和应用依赖的漏洞,理解镜像安全与容器安全是两个不同层面。
第三个方向是监控与日志链路。为后端接入 Actuator、Prometheus、Grafana,学习如何用指标判断容器健康状态;为日志接入 Elasticsearch 或 Loki,解决容器随时销毁后日志不可见的问题。
第四个方向是 CI/CD 整合。把镜像构建、推送到私有仓库、远程部署这三个步骤接入 GitLab CI、Jenkins 或 GitHub Actions,形成提交代码后自动部署的流水线。
从实践角度来看,这篇文章最重要的判断是:Docker 解决的不是“代码能不能跑”,而是“代码在每个环境里能不能一致地跑、出问题时能不能快速查”。双栈项目因为涉及前端、后端、数据库三种运行时,容器化的收益尤其明显。建议先在本机把这个最小示例完整跑通,再把“多阶段构建、Compose 编排、依赖健康检查、日志排查”这组能力迁移到真实项目中。对你手里的双栈工坊,这才是最有价值的落地点。