部署时需要注意选型对比(AI 大模型评测平台的 Docker 化与生产部署)

部署靠手工的年代,本地打包、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. 先记录当前运行镜像版本,作为失败时的旧版本快照;
  2. 拉取新版本镜像并滚动更新,等待健康检查通过;
  3. 健康检查失败则自动切回旧版本,并立刻通知值班人员排查。

十二、安全加固

部署的安全底线是”镜像干净、权限最小、网络隔离”。

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:自建机房还是云?

云。云的优势是灾备、弹性、运维工具链。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
赵其鑫的头像赵其鑫管理团队

相关推荐

返回顶部