部署方案最终定为四个容器的 Docker Compose 编排:前端 Nginx 容器、后端 Spring Boot 容器、MySQL 容器、Redis 容器,外加一个自定义 bridge 网络和一个独立数据卷。后端和前端镜像都用多阶段构建,运行镜像里不含 Maven、Node 和源码,后端镜像从 800MB 级压到 200MB 出头,前端镜像只有 25MB 左右。SSE 长连接在容器网络里天然可用,不需要额外开端口,唯一要处理的是心跳与健康检查的配合。下面把镜像构建、编排文件、网络与数据卷设计逐块展开。
一、整体架构
| 容器 | 镜像来源 | 暴露端口 | 职责 |
|---|---|---|---|
| frontend | 多阶段构建(node → nginx) | 80 | 托管 dist + 反代 /api 与 /sse |
| backend | 多阶段构建(maven → jre) | 仅内网 | Spring Boot 多阶段生成 + SSE |
| mysql | mysql:8.0 官方镜像 | 仅内网 | 文章与任务数据 |
| redis | redis:7-alpine 官方镜像 | 仅内网 | 缓存、任务进度、限流计数 |
对外只暴露 Nginx 的 80 端口,MySQL、Redis、后端全部收在容器网络内部。这样安全组只需开 80,公网扫不到 3306 和 6379。
二、后端镜像:多阶段构建
后端是 Spring Boot + JDK 17,用 Maven 构建。第一阶段用 maven:3.9-eclipse-temurin-17 编译,第二阶段切到 eclipse-temurin:17-jre-alpine 只放运行环境:
#build stage
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn package -DskipTests -B
#runtime stage
FROM eclipse-temurin:17-jre-alpine AS runtimeWORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENV TZ=Asia/Shanghai \
JAVA_OPTS="-Xms512m -Xmx1024m -XX:MaxMetaspaceSize=256m"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
两个细节:COPY pom.xml 和 mvn dependency:go-offline 先跑,依赖层被 Docker 缓存,改源码后重新构建只重编译业务代码,构建时间从 5 分钟降到 30 秒;JVM 参数走环境变量 JAVA_OPTS,不同机器内存不同,改 compose 里的 environment 即可,不用重打镜像。
三、前端镜像:多阶段构建
前端是 Vue 3 + Vite。第一阶段用 node 构建出 dist,第二阶段用 nginx 托管:
#build stage
FROM node:20-alpine AS builder
WORKDIR /app
RUN npm config set registry https://registry.npmmirror.com
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
#runtime stage
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
npm ci 按 lock 文件精确安装,比 npm install 快且可复现。nginx.conf 里最关键的两行:try_files 兜 SPA 路由,location /sse/ 关缓冲开长连接。
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /sse/ {
proxy_pass http://backend:8080/sse/;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
add_header X-Accel-Buffering no;
gzip off;
}
}
backend 是 compose 里的服务名,Docker 内置 DNS 会自动解析成容器 IP,容器重建 IP 变化也不影响。
四、Docker Compose 编排
services:
mysql:
image: mysql:8.0
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ai_writer
TZ: Asia/Shanghai
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: always
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5
backend:
build: ./backend
restart: always
environment:
SPRING_PROFILES_ACTIVE: prod
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}
REDIS_PASSWORD: ${REDIS_PASSWORD}
LLM_API_KEY: ${LLM_API_KEY}
JAVA_OPTS: "-Xms512m -Xmx1024m"
TZ: Asia/Shanghai
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
frontend:
build: ./frontend
restart: always
ports:
- "80:80"
depends_on:
- backend
volumes:
mysql_data:
redis_data:
4.1 启动顺序:depends_on 的坑
depends_on 默认只等容器”启动”,不等”就绪”。MySQL 容器起来了,但 mysqld 可能还在初始化,后端去连直接 Connection refused,然后容器崩溃、compose 拉起、再失败,循环崩溃。解法是给 MySQL 和 Redis 加 healthcheck,后端依赖写 condition: service_healthy,等健康检查通过再启动后端。后端侧再加一层保险:数据库连接配置 fail-fast: false,短时不可用先启动再重连。
4.2 数据持久化
MySQL 的 /var/lib/mysql 和 Redis 的 /data 用命名卷挂载。容器随时可以删了重建,数据留在卷里不丢。环境变量统一走 .env 文件,MYSQL_ROOT_PASSWORD 这类敏感值不写进 compose 和镜像。
五、SSE 长连接在容器网络里的注意点
SSE 走 HTTP,容器间通信没有额外限制,proxy_read_timeout 3600s 在容器网络里依然生效。三个注意点:后端容器里必须设 TZ=Asia/Shanghai,否则定时心跳与日志时区漂移;ulimit 与文件描述符按并发连接数调,SSE 长连接吃文件描述符,/etc/security/limits.conf 和 Docker 默认的 1024 连接数对高并发不够;日志挂载到宿主机目录,docker compose logs -f 之外,容器删了日志不丢。
六、构建与发布流程
- 改代码后,后端
docker compose build backend,前端docker compose build frontend; docker compose up -d增量拉起变化的服务;docker compose ps看健康状态,docker compose logs -f backend盯启动日志;- 验证 SSE:
curl -N http://服务器IP/sse/generate?topic=docker,确认流式输出; - 回滚:镜像打 tag(
backend:1.2.0),出问题docker compose up -d --no-deps backend:1.1.0秒级切换。
这套方案在「企业级 AI 网关」项目上原样复用,网关只是后端镜像换了构建源,compose 的网络、卷、健康检查模板直接拷贝,新项目从零到上线一套 compose 搞定。
七、安全与资源限制
容器化不代表放任不管,三处加固是上线前必须做的。资源限制用 deploy.resources.limits 给每个容器划上限,防止单个服务把整台机器吃满——多阶段生成是内存敏感型任务,后端镜像的堆上限和容器内存上限要留出缓冲区,容器限 2G、JVM 堆 1G,避免 OOM 被杀后无限重启。端口最小化前面已说过,MySQL 和 Redis 不映射到宿主机。镜像层面,运行镜像只保留 JRE 和运行时文件,不包含 Maven、Node、源码,安全扫描的攻击面小得多。
backend:
build: ./backend
restart: always
deploy:
resources:
limits:
cpus: "2.0"
memory: 2g
.env 文件不进版本库,敏感配置在 CI 里通过密钥注入。compose 里引用的 LLM_API_KEY 只存在于容器环境变量,镜像和源码里搜不到明文。每天定时 docker compose down 前先确认数据卷正常,容器重建流程已经跑过多次,数据无一丢失。
常见问题(FAQ)
Q1:compose 里后端连不上 MySQL 怎么办?
确认 MySQL 健康检查通过后后端才启动,且连接串主机名用服务名 mysql 而不是 IP。
Q2:前端镜像为什么这么小?
多阶段构建只把 dist 静态文件和 nginx 配置文件拷进运行镜像,没有 Node 和源码。
Q3:容器里 SSE 频繁断连是什么原因?
多半是后端时区或心跳问题导致超时误判,检查 TZ 环境变量并确认 proxy_read_timeout 配置生效。