一个 Dockerfile 打包前端 + Go 后端:用 ShiyuAdmin 讲透真实项目容器化

很多 Docker 教程都会从下面这几条命令开始:
docker build
docker run
docker ps
这些命令当然要会。
但真正到了项目里,问题通常会变成:
- 前端和后端怎么一起打包?
- 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:15
shiyu-redis:
image: redis:7
shiyu-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.tsx
Docker 没必要重新下载所有 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=1 GOOS=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 Backend
Nginx 负责:
前端静态资源
+
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
用户直接刷新:
/dashboard
Nginx 会尝试找:
/usr/share/nginx/html/dashboard
当然找不到。
于是就会出现:
404 Not Found
通过:
try_files $uri /index.html;
找不到真实文件的时候返回:
index.html
然后让 React Router 自己处理路由。
这是前端 SPA 部署到 Nginx 时非常常见的配置。
九、一个容器跑两个进程,谁负责管理?
这里又出现一个东西:
Supervisor
Dockerfile 最后不是:
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=true
Supervisor 可以重新启动它。
这是这个项目里一个比较有意思的 Docker 设计。
十、Docker Compose 才是整个项目真正的启动入口
单独有 Dockerfile 还不够。
因为我们的系统还需要:
PostgreSQL
Redis
于是使用:
docker-compose.yml
统一编排。
PostgreSQL:
shiyu-postgres:
image: postgres:15
environment:
POSTGRES_DB: shiyu_admin_scaffold
POSTGRES_USER: shiyu
POSTGRES_PASSWORD: shiyu123
ports:
- "15432:5432"
Redis:
shiyu-redis:
image: redis:7
ports:
- "16379:6379"
应用:
shiyu-app:
image: ${SHIYU_IMAGE:-ghcr.io/rodert/shiyuadmin:latest}
ports:
- "18000:80"
最终我们只需要:
docker compose up -d
整个环境就可以启动。
十一、15432:5432 到底是什么意思?
Docker 新手最容易混淆的就是:
ports:
- "15432:5432"
记一个规则:
宿主机端口:容器端口
也就是:
localhost:15432
↓
PostgreSQL 容器 5432
Redis:
- "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:5432
Redis 同理:
shiyu-redis:6379
Compose 中还定义了:
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_healthy
shiyu-redis:
condition: service_healthy
什么意思?
应用启动之前:
PostgreSQL
Redis
必须先通过健康检查。
PostgreSQL:
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U shiyu -d shiyu_admin_scaffold"
]
interval: 10s
timeout: 5s
retries: 5
Redis:
healthcheck:
test:
[
"CMD",
"redis-cli",
"ping"
]
interval: 10s
timeout: 5s
retries: 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: 30s
timeout: 10s
retries: 3
start_period: 40s
它不是简单判断:
进程还活着
而是真正访问:
/api/v1/system/health
只有 API 正常返回,才算:
healthy
这个思路在:
Docker Compose
Kubernetes
CI/CD
负载均衡
自动部署
里都非常重要。
十五、数据库容器删了,数据怎么办?
数据库最不能接受的事情就是:
docker rm
然后:
数据没了
所以 PostgreSQL 使用了 Volume:
volumes:
- shiyu-pg-data:/var/lib/postgresql/data
Redis:
volumes:
- shiyu-redis-data:/data
底部定义:
volumes:
shiyu-pg-data:
driver: local
shiyu-redis-data:
driver: local
于是:
Container 生命周期
≠
Data 生命周期
即使容器被重新创建:
docker compose down
docker compose up -d
Volume 仍然存在。
数据也就还在。
但注意:
docker compose down -v
这里多了:
-v
就代表连 Volume 一起删除。
数据库数据也会被清理。
生产环境执行这个命令之前一定要确认清楚。
十六、restart: unless-stopped 有什么用?
三个核心服务都配置了:
restart: unless-stopped
意思可以简单理解成:
容器异常退出或者 Docker 重启之后,自动尝试重新运行。
比如服务器重启:
Linux 重启
↓
Docker 启动
↓
PostgreSQL
Redis
ShiyuAdmin
自动恢复
但是如果你明确手工把它停止:
docker stop shiyu-app
Docker 会尊重这个行为。
这就是:
unless-stopped
这个名字的来源。
十七、开发环境和生产环境怎么共用 Compose?
这个项目还有一个很实用的设计:
docker-compose.yml
docker-compose.local.yml
生产环境:
image: ${SHIYU_IMAGE:-ghcr.io/rodert/shiyuadmin:latest}
直接拉已经构建好的镜像。
而本地开发:
services:
shiyu-app:
build:
context: .
dockerfile: Dockerfile
image: shiyu-admin:local
volumes:
- ./backend/shiyu-admin-backend/configs:/app/configs:ro
然后:
docker compose \
-f docker-compose.yml \
-f docker-compose.local.yml \
up -d --build
Docker 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
三份大量重复配置要舒服很多。
十八、实际把项目跑起来
首先克隆项目:
git clone https://github.com/Rodert/ShiyuAdmin.git
cd ShiyuAdmin
如果直接使用已经发布的镜像:
docker compose up -d
查看:
docker compose ps
如果需要从当前源码重新构建:
docker compose \
-f docker-compose.yml \
-f docker-compose.local.yml \
up -d \
--build
启动完成以后:
http://localhost:18000
即可访问应用。
十九、Docker 项目排错,先学会这几个命令
Docker 出问题以后,我最常用的并不是重装 Docker,而是先看:
docker compose ps
如果发现:
shiyu-app
异常:
docker compose logs shiyu-app
实时查看:
docker compose logs -f shiyu-app
查看 PostgreSQL:
docker compose logs -f shiyu-postgres
查看 Redis:
docker compose logs -f shiyu-redis
进入应用容器:
docker exec -it shiyu-app sh
进入 PostgreSQL:
docker exec -it shiyu-postgres \
psql \
-U shiyu \
-d shiyu_admin_scaffold
查看 Docker 网络:
docker network ls
查看 Volume:
docker volume ls
这几个命令基本已经可以覆盖相当一部分 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 真正难的地方,从来不是记住:
docker ps
docker images
docker run
而是理解:
程序
↓
镜像
↓
容器
↓
网络
↓
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,与其继续背几十条命令,不如直接找一个这样的完整项目:
git clone https://github.com/Rodert/ShiyuAdmin.git
cd ShiyuAdmin
docker compose up -d
先把它跑起来。
然后一层一层把 Dockerfile 和 docker-compose.yml 拆开。
你会发现:
Docker 真正值得学的,不是怎么启动一个容器,而是怎么把一个完整的软件系统装进容器里,并且稳定地跑起来。

浙公网安备 33010602011771号