很多 Docker 教程都会从下面这几条命令开始:
dockerbuilddockerrundockerps这些命令当然要会。
但真正到了项目里,问题通常会变成:
- 前端和后端怎么一起打包?
- PostgreSQL、Redis 怎么启动?
- 数据库还没启动,后端就启动了怎么办?
- 前端怎么访问后端 API?
- 数据库容器删除以后,数据会不会丢?
- 开发环境和生产环境怎么共用一套 Docker 配置?
这些问题,单独背 Docker 命令解决不了。
最近我维护的一个开源后台项目ShiyuAdmin,正好包含一套比较完整的 Docker 实践。
项目地址:
https://github.com/Rodert/ShiyuAdmin今天就不背概念了,直接拿这个真实项目拆 Docker。
一、整个 Docker 架构是什么样的?
ShiyuAdmin 最终运行时主要有三个容器:
┌──────────────────┐ │ Browser │ └────────┬─────────┘ │ localhost:18000 │ ┌────────▼─────────┐ │ shiyu-app │ │ │ │ Nginx │ │ ┌────────┐ │ │ │ React │ │ │ └────────┘ │ │ │ │ │ /api ▼ │ │ Go Backend │ └──────┬────┬──────┘ │ │ ┌────────┘ └────────┐ ▼ ▼ ┌──────────────┐ ┌──────────────┐ │ PostgreSQL │ │ Redis │ │ 5432 │ │ 6379 │ └──────────────┘ └──────────────┘对应 Docker Compose 中:
services:shiyu-postgres:image:postgres:15shiyu-redis:image:redis:7shiyu-app:image:ghcr.io/rodert/shiyuadmin:latest这里已经体现了 Docker 一个非常重要的思想:
一个完整系统,不等于一个容器。
数据库是一个服务,Redis 是一个服务,业务系统也是一个服务。
Docker Compose 就负责把它们组织起来。
二、Dockerfile 最值得学的:多阶段构建
ShiyuAdmin 的 Dockerfile 不是简单地:
FROM ubuntu COPY . . RUN xxx而是使用了Multi-stage Build,多阶段构建。
整个过程分成三个阶段:
Node ↓ 构建 React 前端 Go ↓ 编译 Go 后端 Nginx ↓ 最终运行镜像先看第一阶段。
FROM node:20-alpine AS frontend-builder WORKDIR /app COPY frontend/shiyu-admin-web/package.json ./ RUN npm install \ --legacy-peer-deps \ --no-audit \ --loglevel=error COPY frontend/shiyu-admin-web/ ./ RUN npm run build它的任务非常单纯:
把前端项目编译成静态文件。
最终会得到:
dist/这里有一个很容易被忽略的小细节:
COPY package.json ./ RUN npm install COPY frontend/ ./为什么不直接:
COPY frontend/ ./ RUN npm install因为 Docker 有Layer Cache。
package.json 没变化的时候:
npm install这一层可以直接使用缓存。
你只是改了:
src/App.tsxDocker 没必要重新下载所有 npm 依赖。
这就是 Dockerfile 优化中非常经典的一种方式:
先复制依赖描述文件 ↓ 安装依赖 ↓ 再复制业务代码三、第二阶段:编译 Go 后端
接下来是 Go:
FROM golang:1.23-alpine AS backend-builder WORKDIR /app ENV GOPROXY=https://proxy.golang.org,direct RUN apk add --no-cache gcc musl-dev sqlite-dev COPY backend/shiyu-admin-backend/go.mod \ backend/shiyu-admin-backend/go.sum ./ RUN go mod download COPY backend/shiyu-admin-backend/ ./ RUN CGO_ENABLED=1 GOOS=linux \ go build \ -trimpath \ -ldflags='-s -w' \ -o /server \ ./cmd/server这里其实和前端是同样的思路。
先:
COPY go.mod go.sum ./ RUN go mod download再:
COPY backend/ ./原因还是 Docker 缓存。
只要:
go.mod go.sum没发生变化:
go mod download这一层就有机会直接复用。
对于大型 Go 项目来说,重新下载依赖浪费的时间还是很多的。
四、为什么 Go 编译要写 CGO_ENABLED=1?
这里还有一句:
CGO_ENABLED=1GOOS=linux go build很多 Go 项目里更常见的是:
CGO_ENABLED=0因为这样容易编译成完全静态的二进制文件。
但是 ShiyuAdmin 同时考虑了 SQLite 场景,所以 Dockerfile 安装了:
gcc musl-dev sqlite-dev运行环境里也安装了:
sqlite-libs这就是一个非常典型的例子:
Dockerfile 怎么写,不是看网上模板,而是看项目真正依赖什么。
如果项目依赖 CGO,那么简单复制:
CGO_ENABLED=0反而可能直接编译失败或者运行异常。
五、第三阶段才是真正的生产镜像
前面的 Node 和 Go 镜像都只是:
builder真正运行程序的是:
FROM nginx:alpine然后把前两个阶段产生的结果复制进来。
前端:
COPY --from=frontend-builder \ /app/dist \ /usr/share/nginx/html后端:
COPY --from=backend-builder \ /server \ /app/server配置文件:
COPY --from=backend-builder \ /app/configs \ /app/configs这样最终镜像里根本不需要:
完整 Node.js 开发环境 完整 Go 编译器 项目全部源码 npm 编译工具链 Go 编译工具链它真正需要的只是:
Nginx 前端 dist Go 二进制程序 配置文件 运行库这就是多阶段构建最大的价值之一。
六、为什么不把 Node 和 Go 都塞进最终镜像?
假设不用多阶段构建,直接:
FROM node RUN 安装 Go RUN 安装 Nginx RUN npm install RUN go build COPY 所有源码虽然也可能跑起来,但是最终镜像会非常臃肿。
因为生产环境根本不需要 Go 编译器。
也不需要:
node_modules npm 源码 编译工具Multi-stage Build 的思想其实非常简单:
构建环境 ≠ 运行环境构建的时候可以很重。
运行的时候应该尽可能简单。
七、一个容器里面为什么同时有 Nginx 和 Go?
ShiyuAdmin 的最终容器里面有两个进程:
Nginx Go BackendNginx 负责:
前端静态资源 + API 反向代理Go 负责业务 API。
Nginx 配置核心就是:
location /api { proxy_pass http://127.0.0.1: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; proxy_set_header X-Forwarded-Proto $scheme; }浏览器请求:
http://localhost:18000/api/v1/user首先进入 Docker:
宿主机 18000 ↓ 容器 80 ↓ Nginx ↓ 127.0.0.1:8080 ↓ Go Backend这样前端和后端就可以统一使用一个入口。
用户根本不需要知道 Go 实际运行在:
8080八、React 刷新 404 怎么解决?
Nginx 里还有一句非常重要:
location / { try_files $uri /index.html; }如果 React 使用前端路由:
/user/list /system/config /dashboard用户直接刷新:
/dashboardNginx 会尝试找:
/usr/share/nginx/html/dashboard当然找不到。
于是就会出现:
404 Not Found通过:
try_files $uri /index.html;找不到真实文件的时候返回:
index.html然后让 React Router 自己处理路由。
这是前端 SPA 部署到 Nginx 时非常常见的配置。
九、一个容器跑两个进程,谁负责管理?
这里又出现一个东西:
SupervisorDockerfile 最后不是:
CMD ["nginx"]也不是:
CMD ["/app/server"]而是:
CMD [ "/usr/bin/supervisord", "-c", "/etc/supervisord.conf" ]Supervisor 再分别启动:
[program:backend] command=/app/server autostart=true autorestart=true以及:
[program:nginx] command=/usr/sbin/nginx -g "daemon off;" autostart=true autorestart=true于是变成:
Docker │ ▼ Supervisor ├── Go Backend └── Nginx如果 Go 后端异常退出:
autorestart=trueSupervisor 可以重新启动它。
这是这个项目里一个比较有意思的 Docker 设计。
十、Docker Compose 才是整个项目真正的启动入口
单独有 Dockerfile 还不够。
因为我们的系统还需要:
PostgreSQL Redis于是使用:
docker-compose.yml统一编排。
PostgreSQL:
shiyu-postgres:image:postgres:15environment:POSTGRES_DB:shiyu_admin_scaffoldPOSTGRES_USER:shiyuPOSTGRES_PASSWORD:shiyu123ports:-"15432:5432"Redis:
shiyu-redis:image:redis:7ports:-"16379:6379"应用:
shiyu-app:image:${SHIYU_IMAGE:-ghcr.io/rodert/shiyuadmin:latest}ports:-"18000:80"最终我们只需要:
dockercompose up-d整个环境就可以启动。
十一、15432:5432 到底是什么意思?
Docker 新手最容易混淆的就是:
ports:-"15432:5432"记一个规则:
宿主机端口:容器端口也就是:
localhost:15432 ↓ PostgreSQL 容器 5432Redis:
-"16379:6379"表示:
localhost:16379 ↓ Redis 6379应用:
-"18000:80"表示:
localhost:18000 ↓ Nginx 80因此浏览器最终访问:
http://localhost:18000十二、为什么容器之间不应该使用 localhost?
这是另一个非常容易踩坑的问题。
假设 Go 后端要连接 PostgreSQL。
很多人会写:
localhost:5432但是在 Docker 里面:
localhost代表的是:
当前这个容器自己Go 在:
shiyu-app数据库却在:
shiyu-postgres它们不是一个容器。
所以应该通过 Docker Compose 的 Service Name 通信:
shiyu-postgres:5432Redis 同理:
shiyu-redis:6379Compose 中还定义了:
networks:shiyu-network:driver:bridge三个容器加入同一个 Docker Network 后,就可以通过服务名称互相发现。
可以把它理解成 Docker 自带了一个内部 DNS。
shiyu-app │ ├── shiyu-postgres │ └── shiyu-redis这也是为什么在容器里一般不需要记数据库 IP 地址。
十三、depends_on 并不只是控制启动顺序
ShiyuAdmin 的 Compose 还有一个很值得学习的配置:
depends_on:shiyu-postgres:condition:service_healthyshiyu-redis:condition:service_healthy什么意思?
应用启动之前:
PostgreSQL Redis必须先通过健康检查。
PostgreSQL:
healthcheck:test:["CMD-SHELL","pg_isready -U shiyu -d shiyu_admin_scaffold"]interval:10stimeout:5sretries:5Redis:
healthcheck:test:["CMD","redis-cli","ping"]interval:10stimeout:5sretries:5这解决了一个很经典的问题:
Docker:数据库容器已经启动了 实际: PostgreSQL:我还在初始化……如果后端马上连接数据库:
connection refused就可能出现了。
所以真正重要的不是:
容器启动而是:
服务 Ready十四、应用本身也有健康检查
应用容器同样配置:
healthcheck:test:["CMD-SHELL","wget--quiet--tries=1--spider \ http://127.0.0.1/api/v1/system/health \||exit 1"]interval:30stimeout:10sretries:3start_period:40s它不是简单判断:
进程还活着而是真正访问:
/api/v1/system/health只有 API 正常返回,才算:
healthy这个思路在:
Docker Compose Kubernetes CI/CD 负载均衡 自动部署里都非常重要。
十五、数据库容器删了,数据怎么办?
数据库最不能接受的事情就是:
dockerrm然后:
数据没了所以 PostgreSQL 使用了 Volume:
volumes:-shiyu-pg-data:/var/lib/postgresql/dataRedis:
volumes:-shiyu-redis-data:/data底部定义:
volumes:shiyu-pg-data:driver:localshiyu-redis-data:driver:local于是:
Container 生命周期 ≠ Data 生命周期即使容器被重新创建:
dockercompose downdockercompose up-dVolume 仍然存在。
数据也就还在。
但注意:
dockercompose down-v这里多了:
-v就代表连 Volume 一起删除。
数据库数据也会被清理。
生产环境执行这个命令之前一定要确认清楚。
十六、restart: unless-stopped 有什么用?
三个核心服务都配置了:
restart:unless-stopped意思可以简单理解成:
容器异常退出或者 Docker 重启之后,自动尝试重新运行。
比如服务器重启:
Linux 重启 ↓ Docker 启动 ↓ PostgreSQL Redis ShiyuAdmin 自动恢复但是如果你明确手工把它停止:
dockerstop shiyu-appDocker 会尊重这个行为。
这就是:
unless-stopped这个名字的来源。
十七、开发环境和生产环境怎么共用 Compose?
这个项目还有一个很实用的设计:
docker-compose.yml docker-compose.local.yml生产环境:
image:${SHIYU_IMAGE:-ghcr.io/rodert/shiyuadmin:latest}直接拉已经构建好的镜像。
而本地开发:
services:shiyu-app:build:context:.dockerfile:Dockerfileimage:shiyu-admin:localvolumes:-./backend/shiyu-admin-backend/configs:/app/configs:ro然后:
dockercompose\-fdocker-compose.yml\-fdocker-compose.local.yml\up-d--buildDocker Compose 会把两个文件合并。
可以理解成:
docker-compose.yml + docker-compose.local.yml ↓ 最终配置主配置负责:
PostgreSQL Redis 网络 Volume 端口 健康检查local 文件只覆盖:
shiyu-app 镜像来源从:
GHCR变成:
本地 Dockerfile 构建这种方式比维护:
docker-compose.dev.yml docker-compose.test.yml docker-compose.prod.yml三份大量重复配置要舒服很多。
十八、实际把项目跑起来
首先克隆项目:
gitclone https://github.com/Rodert/ShiyuAdmin.gitcdShiyuAdmin如果直接使用已经发布的镜像:
dockercompose up-d查看:
dockercomposeps如果需要从当前源码重新构建:
dockercompose\-fdocker-compose.yml\-fdocker-compose.local.yml\up-d\--build启动完成以后:
http://localhost:18000即可访问应用。
十九、Docker 项目排错,先学会这几个命令
Docker 出问题以后,我最常用的并不是重装 Docker,而是先看:
dockercomposeps如果发现:
shiyu-app异常:
dockercompose logs shiyu-app实时查看:
dockercompose logs-fshiyu-app查看 PostgreSQL:
dockercompose logs-fshiyu-postgres查看 Redis:
dockercompose logs-fshiyu-redis进入应用容器:
dockerexec-itshiyu-appsh进入 PostgreSQL:
dockerexec-itshiyu-postgres\psql\-Ushiyu\-dshiyu_admin_scaffold查看 Docker 网络:
dockernetworkls查看 Volume:
dockervolumels这几个命令基本已经可以覆盖相当一部分 Docker 项目的日常排查。
二十、这套 Docker 配置还能继续优化什么?
目前这套配置作为开源项目和快速部署方案已经比较完整了。
如果真正用于生产环境,我还会继续做几件事。
首先,数据库密码不要直接硬编码:
POSTGRES_PASSWORD:shiyu123可以改为:
POSTGRES_PASSWORD:${POSTGRES_PASSWORD}然后放到:
.env或者更专业的 Secret 管理系统里。
其次,生产环境可以不把 PostgreSQL:
5432映射到宿主机。
如果只有:
shiyu-app需要访问数据库,完全可以只通过 Docker 内网通信。
Redis 同理。
另外还可以继续增加:
CPU / Memory 限制 日志轮转 HTTPS 数据库备份 监控 告警 CI/CD 镜像版本固定 自动发布再往后发展,其实就会慢慢进入:
Docker Compose ↓ CI/CD ↓ Kubernetes这一整套工程化体系。
最后
Docker 真正难的地方,从来不是记住:
dockerpsdockerimagesdockerrun而是理解:
程序 ↓ 镜像 ↓ 容器 ↓ 网络 ↓ Volume ↓ 服务编排 ↓ 健康检查 ↓ 部署ShiyuAdmin 这个项目虽然规模不算特别庞大,但里面已经包含了不少非常典型的 Docker 实战知识:
Multi-stage Build Docker Layer Cache Node 前端构建 Go 二进制构建 Nginx 静态资源服务 Nginx API 反向代理 Supervisor 多进程管理 Docker Compose PostgreSQL Redis Docker Network Volume Healthcheck depends_on restart policy Compose override所以如果你正在学 Docker,与其继续背几十条命令,不如直接找一个这样的完整项目:
gitclone https://github.com/Rodert/ShiyuAdmin.gitcdShiyuAdmindockercompose up-d先把它跑起来。
然后一层一层把 Dockerfile 和 docker-compose.yml 拆开。
你会发现:
Docker 真正值得学的,不是怎么启动一个容器,而是怎么把一个完整的软件系统装进容器里,并且稳定地跑起来。