一个 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 真正值得学的,不是怎么启动一个容器,而是怎么把一个完整的软件系统装进容器里,并且稳定地跑起来。

posted @ 2026-09-08 14:29  JavaPub  阅读(11)  评论(0)    收藏  举报