部署靠手工的年代,本地打包、scp 传到服务器、kill 掉旧进程再启动新的,每次发布都是一次豪赌。有一次我在本地构建好,传到生产才发现少了几个依赖,环境不一致直接导致服务起不来,凌晨两点在服务器上 debug 的滋味实在不好受。后来我下决心把整条部署链路 Docker 化,前端、后端、MySQL、Redis、RabbitMQ 全部容器化,配合 GitLab CI 自动构建、金丝雀发布、一键回滚,平台才算真正跑稳。下面把 Docker 化到生产部署这条”最后一公里”上的关键决策和踩过的坑讲清楚。
一、为什么必须 Docker 化
平台从”手动 scp 部署”到 Docker 化后收益巨大:
| 维度 | 传统部署 | Docker 化 |
|---|---|---|
| 部署时间 | 30 分钟 | 2 分钟 |
| 环境一致性 | 经常有差异 | 完全一致 |
| 回滚速度 | 慢 | 秒级 |
| 资源隔离 | 弱 | 强 |
| 横向扩展 | 手动 | 自动 |
这张表里的每一项,都是我们从事故里换来的。环境不一致那条最痛:开发、测试、生产三套环境,JDK 版本、系统库、配置文件各有各的样,经常”在我机器上能跑”到生产就崩。Docker 把运行时连同依赖一起打包,镜像在哪儿跑都是同一套,这个问题的根子就被拔掉了。回滚速度同样重要,出事故时每多等一分钟都多一分损失,容器回滚只要换镜像重启,秒级完成。
其实决定 Docker 化之前,我们认真考虑过两条别的路。一条是用 Ansible 写部署脚本,把环境准备、服务拉起全部编排好,机器上装的还是裸 JDK 和原生服务;另一条是直接上 Kubernetes,一步到位。Ansible 方案能解决手忙脚乱,但环境差异的根子还在,每台机器的系统版本、系统库依赖依然要靠脚本逐个兜底,脚本本身又会成为新的维护负担。K8s 对当时的四人小团队太重,光学习成本就够喝一壶。Docker Compose 正好卡在中间:镜像把环境差异关死在包里,编排能力对”单体加几个中间件”的规模绰绰有余。这个取舍后来被验证是对的,平台跑了大半年,部署相关的线上事故几乎绝迹。
平台所有服务(前端、后端、MySQL、Redis、RabbitMQ)都跑在 Docker 上。数据库也用容器,可能有朋友觉得生产数据库不该容器化,我们权衡后的结论是:对 4 人小团队来说,Compose 管理 MySQL/Redis 的运维成本远低于裸机维护,数据卷挂载好、备份定时跑,风险是可控的。
二、Dockerfile 规范
Dockerfile 是镜像的”配方”,写得潦草会直接体现在镜像大小和启动时间上。平台分前后端两套规范。
1. 后端 Dockerfile
# 多阶段构建
FROM eclipse-temurin:21-jdk-jammy AS builder
WORKDIR /build
COPY . .
RUN ./mvnw clean package -DskipTests
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY --from=builder /build/target/eval-*.jar app.jar
# 非 root 用户
RUN useradd -ms /bin/bash app
USER app
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s \
CMD curl -f http://localhost:8080/actuator/health || exit 1
ENTRYPOINT ["java", \
"-XX:+UseG1GC", \
"-XX:MaxRAMPercentage=75.0", \
"-Djava.security.egd=file:/dev/./urandom", \
"-jar", "app.jar"]
这段 Dockerfile 解决”镜像太大、启动太慢、权限过高”三个问题。多阶段构建把编译环境和运行环境分开,builder 阶段用 JDK 打包,运行阶段只带 JRE,镜像体积从 1.5GB 砍到 250MB。JVM 参数里 -XX:MaxRAMPercentage=75.0 尤其重要,容器内存限制 4G 时,JVM 默认只按宿主机内存算堆,不显式设置会被”饿死”。
关键点:
- 多阶段构建减小镜像(最终 250MB 而非 1.5GB);
- 用 JRE 不用 JDK 减小体积;
- 非 root 用户运行;
- HEALTHCHECK 探活。
非 root 用户这条是安全基线:容器里跑 root,一旦被攻破就是宿主机权限,换成低权限用户后攻击面小得多。HEALTHCHECK 则是 Docker 自动重启僵死实例的前提,没有它,进程活着但接口无响应时,Docker 根本不知道要拉起新实例。
2. 前端 Dockerfile
FROM node:20-alpine AS builder
WORKDIR /build
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm i --frozen-lockfile
COPY . .
RUN pnpm build:prod
FROM nginx:1.25-alpine
COPY --from=builder /build/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
前端镜像的思路一样:builder 阶段用 node 装依赖、构建产物,运行阶段只放一个 nginx 托管静态文件。两个细节值得注意:一是 pnpm i --frozen-lockfile 用锁文件装依赖,保证每次构建的依赖版本一致;二是构建产物直接 COPY 进 nginx 镜像,前端本身没有运行时逻辑,镜像小到几十 MB,拉取和启动都快。
三、Docker Compose 编排
单容器只是开始,平台所有服务要靠 Compose 编排成一个整体。
# docker-compose.yml
version: '3.8'
services:
app:
image: eval-app:${VERSION}
restart: unless-stopped
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: prod
SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/eval
SPRING_REDIS_HOST: redis
SPRING_RABBITMQ_HOST: rabbitmq
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
deploy:
replicas: 4
resources:
limits:
cpus: '2'
memory: 4G
mysql:
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping"]
interval: 10s
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis_data:/data
rabbitmq:
image: rabbitmq:3-management
restart: unless-stopped
volumes:
- rabbitmq_data:/var/lib/rabbitmq
nginx:
image: nginx:1.25-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./certs:/etc/nginx/certs
depends_on:
- app
volumes:
mysql_data:
redis_data:
rabbitmq_data:
这份编排文件回答”依赖怎么启动、配置怎么传、数据怎么存”三个问题。depends_on 加 condition: service_healthy 解决启动顺序——应用不再抢跑在 MySQL 和 Redis 就绪之前,当初没有这个条件时,应用起来就报连接拒绝,反复重启才稳定。deploy.resources.limits 给容器设了 CPU 和内存上限,防止某个容器吃光宿主机资源。三个具名 volume 挂载数据目录,容器重建数据不丢,这条如果不做,容器一重启等于生产数据库清空。
为什么不用裸 docker run 一条条启动?试过,上线头一个月就是那么干的,很快撞上三个问题:启动顺序要手动卡,MySQL 没就绪应用就报连接拒绝,全靠运气;容器默认走桥接网络,服务之间靠 IP 互联,IP 一变配置就得跟着改;restart 策略散落在每条命令里,容易漏配。Compose 把服务、网络、卷、重启策略收进一个文件,能进 git、能 review、能对比改动,一条 docker-compose up -d 拉起全部。对我们这个规模,这就是恰到好处的抽象,再多一层反而是负担。
四、Nginx 配置
Nginx 是流量入口,承担反代、TLS、静态资源三件事,配置上有个坑特别值得说。
# nginx.conf
upstream eval_backend {
server app:8080;
keepalive 32;
}
server {
listen 80;
server_name eval.example.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name eval.example.com;
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
# SSE 必须关缓冲
location /api/eval/stream {
proxy_pass http://eval_backend;
proxy_buffering off;
proxy_cache off;
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
}
# WebSocket 升级
location /ws/ {
proxy_pass http://eval_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
}
# 普通 API
location /api/ {
proxy_pass http://eval_backend;
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;
}
# 前端静态资源
location / {
root /usr/share/nginx/html;
try_files $uri $uri/ /index.html;
}
}
评测平台的流式接口走 SSE,Nginx 默认会缓冲响应,把流式输出攒到 4KB 才发出去,首字延迟被拉到几十秒。proxy_buffering off 就是为这个场景准备的,一次排查线上流式卡顿的半夜加班换来这行配置。WebSocket 的 Upgrade/Connection 头也要显式传,否则代理层不认识升级协议,长连接直接断。这三个 location 的分工,把”流量从哪进、怎么转”讲得一清二楚。
五、环境隔离
平台用三个环境严格隔离,从开发到生产逐级放行:
| 环境 | 用途 | 数据 |
|---|---|---|
| dev | 开发 | 测试数据 |
| staging | 预发 | 生产脱敏数据 |
| production | 生产 | 真实数据 |
每个环境:
- 独立域名(dev.example.com / staging.example.com / example.com);
- 独立数据库 + Redis + MQ;
- 独立部署 pipeline;
- 独立监控告警。
环境隔离的底线是”生产数据不进非生产环境”。staging 用脱敏数据,既能验证真实业务路径,又不用承担泄露风险。我们有同事图省事,在 dev 环境直接连了生产库,一次误操作删了线上数据,从此以后环境的访问权限收得很紧,账号隔离、网络隔离都补上了。
环境分几层也权衡过。最省事的是 dev 和 production 两层,开发测完直接上生产,但吃过亏之后就看懂了 staging 的不可替代:有些 bug 只在”数据规模接近生产、配置接近生产”时才暴露,dev 环境小数据量跑得欢,直接上生产就翻车。staging 卡在中间,跑脱敏数据、走完整发布流程,等于给生产又加了一道闸。现在平台任何改动先上 staging 观察,绿灯才轮到生产,过程多一步,但把大量低级问题挡在了上线之前。
六、CI/CD 流水线
部署动作全部交给流水线,人只负责合并代码和点发布按钮。
# .gitlab-ci.yml
stages:
- test
- build
- deploy
test-backend:
stage: test
script:
- cd server
- mvn test
- mvn verify
test-frontend:
stage: test
script:
- cd web
- pnpm install
- pnpm lint
- pnpm typecheck
- pnpm test
build:
stage: build
script:
- docker build -t eval-app:${CI_COMMIT_SHA} .
- docker push registry.example.com/eval-app:${CI_COMMIT_SHA}
deploy-staging:
stage: deploy
script:
- ssh deploy@staging "cd /app && docker-compose pull && docker-compose up -d"
only:
- develop
deploy-production:
stage: deploy
script:
- ssh deploy@prod "cd /app && ./deploy.sh ${CI_COMMIT_SHA}"
when: manual
only:
- main
流水线的核心设计是”镜像带版本、生产人工触发”。镜像用 CI_COMMIT_SHA 打标签,每个版本可追溯;staging 推到 develop 分支自动部署,生产必须人工点按钮,防止手滑把没验证的代码放上线。deploy.sh 里封装了拉镜像、滚动更新、健康检查、失败回滚,生产发布从”半小时手工操作”变成”一条命令 + 一个确认”。
七、灰度发布
全量发布风险太大,平台用”金丝雀发布”逐步放量。
v2.4 上线 25% 流量
↓ 30 分钟无异常
v2.4 上线 50% 流量
↓ 30 分钟无异常
v2.4 上线 100% 流量
# Kubernetes Canary Deployment
apiVersion: argoproj.io/v1alpha1
kind: Rollout
spec:
strategy:
canary:
steps:
- setWeight: 25
- pause: { duration: 30m }
- setWeight: 50
- pause: { duration: 30m }
- setWeight: 100
灰度发布的价值是”把事故范围控制在 25% 以内”。新版本只接 25% 流量,观察 30 分钟,看错误率、P99、业务成功率有没有异常,没问题再放量到 50%、100%。每个档位之间留观察窗口,等告警系统给出反馈,而不是一口气全量替换。我们有一次后端加了字段没处理好,灰度 25% 时就触发错误率告警,直接回滚,全程几乎没有用户感知。
发布策略我们对比过蓝绿和金丝雀两种。蓝绿的优势是切换快,新老版本各占一套环境,流量瞬间切过去,回退也快;代价是资源要翻倍,而且切换的瞬间,新版本要是有问题,影响面依然是整个用户群。金丝雀胜在渐进:从 25% 开始放量,异常只出现在四分之一的流量上,配合告警能及时踩刹车。平台流量还没到必须蓝绿那个量级,K8s 的 Rollout 配置也现成,就选了金丝雀。后来那次字段兼容问题,就是 25% 阶段被错误率告警拦住回滚的,这个选择算是经过了实战检验。
八、配置管理
配置和代码分开管理,是部署能秒级回滚的前提——配置写死在镜像里,改配置就得重新构建镜像,慢且危险。
1. 配置分层
application.yml # 基础配置
application-dev.yml # 开发环境
application-staging.yml # 预发
application-prod.yml # 生产
Spring 的 profile 机制天然适合分层:基础配置放公共文件,各环境覆盖差异项。环境相关的配置不写死任何账号密码,只写占位符,真实值在部署时注入。
2. 敏感配置
敏感信息(数据库密码、API Key)从环境变量或 Vault 读:
spring:
datasource:
password: ${DB_PASSWORD}
api:
openai:
key: ${OPENAI_API_KEY}
# 用 docker secrets 或 Kubernetes Secret 管理
kubectl create secret generic eval-secrets \
--from-literal=DB_PASSWORD=xxx \
--from-literal=OPENAI_API_KEY=xxx
敏感配置这条线我们吃过亏:早期把数据库密码直接写进 application-prod.yml,随镜像一起分发,等于把钥匙放在门口垫子下面。后来全部改成环境变量注入,镜像里不残留任何敏感信息,即使镜像被拉走也拿不到密码。
3. 配置中心
平台用 Nacos 做动态配置:
@NacosValue(value = "${llm.timeout:30000}", autoRefreshed = true)
private long llmTimeout;
修改 Nacos 配置后无需重启应用。动态配置在调优场景特别好用:线上 LLM 超时设置需要调整,改一下 Nacos 配置立即生效,不用走一次发布流程。要注意的是动态配置也要走变更记录,我们给 Nacos 加了操作审计,谁改了什么配置可追溯。
九、数据库迁移
数据库 schema 变更由 Flyway 管理,启动时自动执行迁移脚本。
@Configuration
@Profile("!test")
public class FlywayConfig {
@Bean
public Flyway flyway(DataSource ds) {
return Flyway.configure()
.dataSource(ds)
.locations("classpath:db/migration")
.baselineOnMigrate(true)
.load();
}
}
启动时自动跑迁移,禁止应用层 DDL。这条规则看起来严格,实则是把”生产库被谁改过”这件事彻底管住:所有变更都走 Flyway 脚本,进版本库、可 review、可回滚。baselineOnMigrate 让已存在的旧库也能平滑接入,不用清数据重来。当初我们也有人想绕过 Flyway 直接连数据库改表,被拦截过几次后都老实了。
十、监控与日志
部署上去只是开始,能观测才算真正掌控。
1. 应用指标
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: always
Prometheus 抓 /actuator/prometheus。应用侧把 health、metrics 端点全部暴露出来,Prometheus 定时抓取,Grafana 出看板。健康端点开启 show-details: always,告警时能直接看到是数据库连接断了还是磁盘满了,省得反复翻日志。
2. 日志收集
# docker-compose 加 filebeat
filebeat:
image: docker.elastic.co/beats/filebeat:8.0
volumes:
- ./filebeat.yml:/usr/share/filebeat/filebeat.yml:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
ELK 集中存储。容器日志如果不收集,容器一删日志就没了,排查事故时最痛苦。filebeat 直接读 Docker 容器日志目录,送进 ES,Kibana 里按关键字、时间、traceId 全文检索,排查效率提升明显。
3. 健康检查
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
interval: 30s
timeout: 5s
retries: 3
Docker 自动重启不健康实例。健康检查和”实例僵死但进程活着”的场景正好互补:进程在、端口通,但应用内部状态已经坏了,靠端口探测发现不了,必须探测健康端点。
十一、回滚策略
每次部署前保留上一版本镜像:
# deploy.sh
NEW_VERSION=$1
OLD_VERSION=$(docker inspect --format='{{.Image}}' eval-app | cut -d: -f2)
# 启动新版本
docker service update --image eval-app:$NEW_VERSION eval-app
# 健康检查
sleep 30
if ! curl -f http://api.example.com/actuator/health; then
echo "新版本异常,回滚"
docker service update --image eval-app:$OLD_VERSION eval-app
fi
手动回滚:5 分钟内可完成。脚本的思路是先记下当前镜像版本,启动新版后等 30 秒做健康检查,失败就切回旧镜像。这套”自动探测 + 手动确认”的组合,把回滚从”现场翻历史版本重新部署”变成”一条命令切回上一版”。回滚本身也要演练,我们每季度挑一次低峰期模拟发布事故做一次回滚演练,确保脚本在真出事时是好的。
回滚的完整操作按下面三步执行:
- 先记录当前运行镜像版本,作为失败时的旧版本快照;
- 拉取新版本镜像并滚动更新,等待健康检查通过;
- 健康检查失败则自动切回旧版本,并立刻通知值班人员排查。
十二、安全加固
部署的安全底线是”镜像干净、权限最小、网络隔离”。
1. 镜像扫描
trivy image eval-app:v2.5
CI 跑漏洞扫描,高危漏洞阻断发布。镜像里藏着已知漏洞是很常见的事,基础镜像升级、依赖更新不及时就会带上。我们把 trivy 扫描接进 CI,扫描不过不允许推送镜像,把漏洞挡在构建阶段。
2. 最小权限
- 容器非 root 运行;
- 数据库账号只授必要权限;
- API Key 用专用账号(不是 root)。
最小权限是安全领域的通用原则,落到部署上就是:应用容器不用 root、数据库账号只给业务需要的增删改查权限、调用外部 API 用独立账号。权限给大了,一次误操作或一次入侵就能波及全库。
3. 网络隔离
# Docker Network
networks:
backend:
internal: true # 后端网络不能访问外网
frontend:
# 前端网络可以访问后端
后端网络设成 internal: true,容器只有应用和数据库、Redis、MQ 之间的内网通信,外网不可达。评测平台的后端唯一需要出网的是调模型 API,我们也通过出网代理白名单控制,其余一律隔离。这样即使某个容器被攻破,横向扩散的范围也被限制住。
十三、灾备方案
生产系统的底线是”数据不丢、故障能恢复”。
1. 数据库备份
# 每天凌晨 3 点全量备份
0 3 * * * mysqldump -uroot -p$DB_PASS eval | gzip > /backup/eval_$(date +\%Y\%m\%d).sql.gz
# binlog 增量备份
mysqlbinlog --read-from-remote-server --host=mysql --raw \
--user=repl --password=$REPL_PASS mysql-bin.000001
保留 30 天本地 + 90 天 OSS。备份策略是”全量 + 增量”组合:每天凌晨全量 dump,binlog 实时增量,恢复时可以按时间点回到任意时刻。备份的有效性也要验证,我们每月做一次恢复演练,真到事故时能证明备份能拉起来。
2. Redis 持久化
redis:
command: redis-server --appendonly yes --appendfsync everysec
volumes:
- redis_data:/data
AOF 持久化,重启不丢数据。Redis 默认只存内存,重启全丢。评测平台的进度计数、限流状态都在 Redis 里,开了 AOF 后即使宕机,最多丢一秒的数据,可接受。
3. 多可用区
生产数据库、Redis 跨可用区部署:
# 同城多 AZ
mysql-primary: 可用区 A
mysql-replica-1: 可用区 B
mysql-replica-2: 可用区 C
主库放可用区 A,两个从库分别放 B 和 C。单可用区故障时,从库能顶上来,切换后有冗余备份可用。多可用区的成本比单机房高,但评测数据是用户的资产,这个钱不能省。
十四、踩过的坑
部署环节的坑几乎都踩过一遍,整理出来供参考:
- 镜像过大:JDK 镜像 1.5GB,pull 慢。用 JRE + alpine 减到 250MB。
- 时区不一致:容器默认 UTC,时间戳差 8 小时。在 Docker Compose 设
TZ: Asia/Shanghai。 - JVM 内存未限制:容器内存 4G 但 JVM 默认占 25%,实际 1G。显式设
-XX:MaxRAMPercentage。 - HEALTHCHECK 缺失:实例僵死但 Docker 不重启。加 healthcheck。
- 环境变量泄漏:敏感信息不能进镜像。用 secret。
- 数据卷未持久化:容器重启数据丢失。挂载 volume。
- 日志未收集:容器日志只在本地,宕机丢。配 ELK。
- Nginx 缓冲 SSE:SSE 延迟 30s。
proxy_buffering off。 - HTTPS 证书过期:用 certbot 自动续期。
- 依赖启动顺序:应用先于 MySQL 启动报错。
depends_on.condition: service_healthy。
时区那条很隐蔽:容器默认 UTC,评测记录的时间戳和北京时间差 8 小时,用户对账对不上,排查半天才发现是容器时区问题。一条 TZ: Asia/Shanghai 就解决了,但代价是一个下午。
十五、运维 Checklist
把整套运维动作收成一张可勾选的清单,每次发布前逐项过:
- [ ] 镜像多阶段构建
- [ ] 非 root 用户
- [ ] 健康检查
- [ ] 资源限制
- [ ] 日志收集
- [ ] 监控告警
- [ ] 自动备份
- [ ] 灰度发布
- [ ] 一键回滚
- [ ] 密钥管理
这张清单贴在发布流程里,任何一项没打勾就不允许上线。它看起来简单,但把”部署时需要注意哪些事项”压缩成了一组可执行的动作。到这里,评测平台从构建、编排、发布到灾备的部署链路就完整了。
常见问题(FAQ)
Q1:Docker 还是 Kubernetes?
小团队 Docker Compose,规模大了上 K8s。平台还在 Compose 阶段。
Q2:怎么升级到 K8s?
Helm Chart + ArgoCD,平滑迁移。半年内规划。
Q3:自建机房还是云?
云。云的优势是灾备、弹性、运维工具链。