欢迎光临
我们一直在努力

前端 Docker 实战:从构建到部署的 CI/CD 全流程

去年 KMS 知识管理平台做了一次大的架构升级,其中一个重要目标就是把前端部署彻底自动化。在那之前,每次上线都是人肉 scp、手工改 Nginx、提心吊胆地盯着屏幕等页面加载。有次周五晚上发版,配置文件传错了环境,整个周末都在救火。那次之后我们下定决定把 CI/CD 流水线搭起来,这篇文章就是整个实践过程的记录。


一、前端部署为什么需要 CI/CD?

先回顾一下"手动部署"年代我们都在干什么。KMS 是一个多页应用,包含 Web 端、Dashboard 后台和移动端三个入口,分别对应三套构建产物。16 年我刚接手的时候,部署流程是这样的:

  • 本地 npm run build,三种构建命令各跑一遍,大概 3 分钟;
  • 把 dist 目录下的文件用 scp 传到测试服务器;
  • SSH 上去,把旧文件备份一份(万一出问题还能回滚),然后覆盖;
  • nginx -s reload,刷新浏览器,确认页面正常;
  • 同样的操作在预发布和生产环境再执行一遍。
  • 看起来也就几步,但问题出在"人"这个变量上。谁也不能保证每次都记得备份;谁也不能保证 scp 的目标路径不会手滑写错;更不能保证多人协作时,一个人在上线,另一个人也在上线。有一次同事 A 正在部署 Dashboard,同事 B 也同时连上了生产机器,结果两个人的文件交叉覆盖,页面白屏了 20 分钟。

    KMS 平台面向金融科技场景,稳定性的要求放在第一位。我们内部分析过线上故障的根因分布,发现"部署操作失误"占了将近 30%。这些失误包括配置文件写错、静态资源路径不对、忘记刷新 CDN、甚至把 dev 环境的 API 地址打包进了生产镜像——每一个都很低级,但每一个都真实发生过。

    前端部署的真实需求,其实比很多人想的复杂:

    • 静态资源推 CDN:构建后的 JS/CSS 文件名带 hash,需要上传到对象存储或 CDN 节点。手动操作几乎不现实——你不可能每次发版都把几十个文件一个个上传。
    • SPA 路由:React Router / Vue Router 的 History 模式要求所有路径都返回 index.html,否则用户刷新页面就是一个 404。Nginx 配一行 try_files 就搞定,但如果忘了就翻车。
    • 环境变量注入:dev / staging / prod 三套环境的 API 地址、功能开关、上报 key 都不一样。构建时把变量打包进镜像是最常见的做法,但这也意味着换一个环境就要重新构建一次,违背"一次构建、多次部署"的原则。
    • 回滚能力:线上出问题后,能不能在 30 秒内切回到上一个稳定版本?手动部署的回滚速度取决于你备份文件的速度和心态——越急越容易出错。

    CI/CD 流水线解决的不是某个单一问题,而是把上面所有环节都标准化、自动化、可追溯。每次构建的历史、谁触发的、代码 diff 是什么、部署到了哪个节点,全都有记录。出了问题不是"我记得上次改了啥来着",而是直接去 GitLab Pipeline 页面看日志,一目了然。

    我们的目标很简单:代码 push 到 GitLab,剩下的全部自动完成。 接下来就一步步拆解是怎么做到的。


    二、多阶段构建:镜像从 1.2G 瘦到 120MB

    在正式搭建流水线之前,得先搞定 Dockerfile。如果镜像都打不好,后面的自动化都是空中楼阁。

    反面教材

    KMS 最初用的 Dockerfile 长这样——这也是很多前端项目第一版 Dockerfile 的真实写照:

    FROM node:18
    WORKDIR /app
    COPY . .
    RUN npm install
    RUN npm run build
    EXPOSE 3000
    CMD ["npx", "serve", "-s", "dist", "-l", "3000"]

    这个 Dockerfile 有几个致命问题:

    第一,基础镜像太大。 node:18 基于 Debian,镜像本身就有 950MB。加上 node_modules(KMS 项目全量安装大概 400MB)、源码和构建产物,最终镜像接近 1.2GB。每次 docker push 到镜像仓库要等将近 3 分钟,换个环境拉镜像又是 3 分钟,碰到网络不好的情况直接超时。

    第二,devDependencies 全打进去了。 生产环境只需要静态文件,不需要 webpack、eslint、jest、storybook 这些构建工具。但 npm install 默认会装全部依赖,白白多了 200 多 MB。

    第三,没有任何缓存层。 Docker 镜像构建是分层的,每一行指令产生一个 layer。COPY . . 之后哪怕只改了一行代码,后面所有 layer 的缓存都会失效,从头跑一遍 npm install。

    第四,用 serve 启动。 npx serve 是开发用的临时工具,不适合生产环境。没有 gzip、没有缓存策略、没有反向代理能力。

    多阶段构建方案

    Docker 的多阶段构建(multi-stage build)正好解决这些问题。核心思路:用一个镜像负责构建,另一个镜像负责运行,最终只保留运行所需的文件。

    KMS 最终采用的 Dockerfile:

    # ============================================
    # Stage 1: 构建阶段
    # ============================================
    FROM node:18-alpine AS builder

    WORKDIR /app

    # 利用 Docker layer cache,先复制依赖描述文件
    COPY package.json package-lock.json ./

    # npm ci 比 npm install 更快且更严格(要求 package-lock.json 一致)
    RUN npm ci –only=production && \\
    cp -R node_modules /tmp/node_modules

    # 然后安装全部依赖用于构建
    RUN npm ci

    # 复制源码
    COPY . .

    # 构建(以 KMS 的 web 端为例)
    ARG BUILD_ENV=production
    ENV NODE_ENV=$BUILD_ENV
    RUN npm run build:web

    # ============================================
    # Stage 2: 运行阶段
    # ============================================
    FROM nginx:1.25-alpine

    # 安装 curl 用于健康检查
    RUN apk add –no-cache curl

    # 复制构建产物
    COPY –from=builder /app/packages/web/dist /usr/share/nginx/html

    # 复制 Nginx 配置
    COPY nginx.conf /etc/nginx/conf.d/default.conf

    # 复制启动脚本(用于运行时注入环境变量)
    COPY docker-entrypoint.sh /docker-entrypoint.sh
    RUN chmod +x /docker-entrypoint.sh

    EXPOSE 80

    HEALTHCHECK –interval=30s –timeout=3s –start-period=5s –retries=3 \\
    CMD curl -f http://localhost/health || exit 1

    ENTRYPOINT ["/docker-entrypoint.sh"]
    CMD ["nginx", "-g", "daemon off;"]

    关键优化点拆解

    1. .dockerignore 先瘦身

    构建前先把不该进镜像的文件排除掉。KMS 的 .dockerignore:

    node_modules
    .git
    .gitlab-ci.yml
    Dockerfile
    docker-compose*.yml
    README.md
    .husky
    coverage
    dist
    *.log
    .env.local
    .env.*.local
    storybook-static

    最关键的是排除 node_modules——因为 Dockerfile 里会重新安装,不需要把本地的复制进去。另外 dist 目录也别带,确保每次构建的产物是干净的。

    2. npm ci vs npm install

    npm install 会根据 package.json 动态解析依赖版本,即使有 lock 文件也可能产生偏差。npm ci 严格按 package-lock.json 安装,并且在安装前会删除现有的 node_modules,保证每次结果一致。CI 场景下,npm ci 快 20%-30%。

    3. node_modules 分两层复制

    注意 Stage 1 里的这个细节:

    RUN npm ci –only=production && \\
    cp -R node_modules /tmp/node_modules
    RUN npm ci

    第一次 npm ci –only=production 是为了拿到生产依赖,存到 /tmp/node_modules。第二次完整 npm ci 是为了拿到 devDependencies(webpack、TypeScript 等构建工具)。为什么不直接 RUN npm ci && npm prune –production?因为 prune 操作不可靠,尤其是 monorepo 项目里 workspace 的依赖 link 关系复杂的时候。

    4. Alpine vs slim

    Node.js 官方提供了几个基础镜像变体:

    镜像大小说明
    node:18 950MB 基于 Debian,GLIBC 完整
    node:18-slim 240MB Debian 精简版
    node:18-alpine 115MB 基于 Alpine Linux,musl libc

    我们选了 Alpine 作为构建阶段的基础镜像。唯一需要注意的是 Alpine 用 musl libc 而不是 GLIBC,部分原生模块(比如 node-sass)需要额外处理。KMS 项目用的是 Dart Sass,不存在这个问题。如果你的项目有 native addon,要么换成 slim 版本,要么在 Alpine 里装编译工具链。

    5. 最终效果

    指标优化前优化后
    镜像大小 1.18 GB 121 MB
    构建时间(无缓存) 5 分 12 秒 3 分 08 秒
    构建时间(有缓存) 4 分 50 秒 1 分 15 秒
    docker push 时间 2 分 40 秒 18 秒
    生产依赖体积 包含 devDeps, 390MB 仅生产依赖, 42MB

    镜像缩小了将近 10 倍,push/pull 速度快了将近 9 倍。最关键的是有缓存的情况下,增量构建只需要 1 分钟出头——因为改了业务代码只会让最后几个 layer 重建,前面的 node_modules 层完全命中缓存。


    三、GitLab CI 流水线配置实战

    镜像准备好了,接下来把它接入 GitLab CI,实现代码提交后自动完成 lint、测试、构建、部署全流程。

    KMS 用的 GitLab 版本是 16.x,流水线配置文件放在项目根目录的 .gitlab-ci.yml。下面是一个完整可运行的配置,每个 stage 我会解释为什么这么写。

    # .gitlab-ci.yml
    # KMS Frontend CI/CD Pipeline

    # ============================================
    # 全局变量
    # ============================================
    variables:
    DOCKER_REGISTRY: registry.kms.example.com
    IMAGE_NAME: kmsfrontendweb
    # DOCKER_AUTH_CONFIG 在 GitLab CI Variables 中配置,不要写在这里

    # ============================================
    # Stage 定义
    # ============================================
    stages:
    lint
    test
    build
    deploy
    review
    cleanup

    整个流水线的执行顺序如下:

    在这里插入图片描述

    # ============================================
    # 全局缓存:node_modules
    # ============================================
    .node_cache: &node_cache
    cache:
    key:
    files:
    packagelock.json
    paths:
    node_modules/
    policy: pullpush

    # ============================================
    # Stage 1: Lint
    # ============================================
    lint:
    stage: lint
    image: node:18alpine
    <<: *node_cache
    before_script:
    npm ci
    script:
    npm run lint
    npm run typecheck
    rules:
    if: $CI_PIPELINE_SOURCE == "merge_request_event"
    if: $CI_COMMIT_BRANCH == "main"
    if: $CI_COMMIT_BRANCH =~ /^release\\//
    tags:
    docker

    # ============================================
    # Stage 2: Test
    # ============================================
    unit-test:
    stage: test
    image: node:18alpine
    <<: *node_cache
    before_script:
    npm ci
    script:
    npm run test coverage
    coverage: /All files[^|]*\\|[^|]*\\s+([\\d.]+)/
    artifacts:
    when: always
    reports:
    junit: junit.xml
    coverage_report:
    coverage_format: cobertura
    path: coverage/coberturacoverage.xml
    rules:
    if: $CI_PIPELINE_SOURCE == "merge_request_event"
    if: $CI_COMMIT_BRANCH == "main"
    tags:
    docker

    # ============================================
    # Stage 3: Build Docker Image
    # ============================================
    build:
    stage: build
    image: docker:24
    services:
    docker:24dind
    variables:
    DOCKER_TLS_CERTDIR: "/certs"
    before_script:
    echo "$CI_REGISTRY_PASSWORD" | docker login $DOCKER_REGISTRY u $CI_REGISTRY_USER passwordstdin
    script:
    # 用 CI_COMMIT_SHORT_SHA 作为 tag,保证可追溯
    docker build
    buildarg BUILD_ENV=$CI_ENVIRONMENT_NAME
    t $DOCKER_REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHORT_SHA
    t $DOCKER_REGISTRY/$IMAGE_NAME:latest
    .
    docker push $DOCKER_REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHORT_SHA
    docker push $DOCKER_REGISTRY/$IMAGE_NAME:latest
    rules:
    if: $CI_COMMIT_BRANCH == "main"
    if: $CI_COMMIT_BRANCH =~ /^release\\//
    tags:
    docker

    # ============================================
    # Stage 4: Deploy
    # ============================================
    deploy-staging:
    stage: deploy
    image: alpine:3.18
    before_script:
    apk add nocache opensshclient dockercompose
    mkdir p ~/.ssh
    echo "$SSH_PRIVATE_KEY" | base64 d > ~/.ssh/id_rsa
    chmod 600 ~/.ssh/id_rsa
    sshkeyscan H $STAGING_HOST >> ~/.ssh/known_hosts
    script:
    scp dockercompose.staging.yml $STAGING_USER@$STAGING_HOST:/opt/kms/dockercompose.yml
    scp nginx.conf $STAGING_USER@$STAGING_HOST:/opt/kms/nginx.conf
    ssh $STAGING_USER@$STAGING_HOST "
    cd /opt/kms &&
    echo '$CI_REGISTRY_PASSWORD' | docker login $DOCKER_REGISTRY u $CI_REGISTRY_USER passwordstdin &&
    export IMAGE_TAG=$CI_COMMIT_SHORT_SHA &&
    docker compose pull &&
    docker compose up d removeorphans
    "
    environment:
    name: staging
    url: https://staging.kms.example.com
    rules:
    if: $CI_COMMIT_BRANCH == "main"
    tags:
    docker

    deploy-production:
    stage: deploy
    image: alpine:3.18
    before_script:
    apk add nocache opensshclient dockercompose
    mkdir p ~/.ssh
    echo "$SSH_PRIVATE_KEY" | base64 d > ~/.ssh/id_rsa
    chmod 600 ~/.ssh/id_rsa
    sshkeyscan H $PRODUCTION_HOST >> ~/.ssh/known_hosts
    script:
    scp dockercompose.prod.yml $PRODUCTION_USER@$PRODUCTION_HOST:/opt/kms/dockercompose.yml
    scp nginx.conf $PRODUCTION_USER@$PRODUCTION_HOST:/opt/kms/nginx.conf
    ssh $PRODUCTION_USER@$PRODUCTION_HOST "
    cd /opt/kms &&
    echo '$CI_REGISTRY_PASSWORD' | docker login $DOCKER_REGISTRY u $CI_REGISTRY_USER passwordstdin &&
    export IMAGE_TAG=$CI_COMMIT_SHORT_SHA &&
    docker compose pull &&
    docker compose up d removeorphans
    "
    environment:
    name: production
    url: https://kms.example.com
    rules:
    if: $CI_COMMIT_BRANCH == "main"
    when: manual # 生产环境必须手动触发
    tags:
    docker

    # ============================================
    # Stage 5: MR Review 环境
    # ============================================
    review:
    stage: review
    image: alpine:3.18
    before_script:
    apk add nocache opensshclient
    mkdir p ~/.ssh
    echo "$SSH_PRIVATE_KEY" | base64 d > ~/.ssh/id_rsa
    chmod 600 ~/.ssh/id_rsa
    sshkeyscan H $REVIEW_HOST >> ~/.ssh/known_hosts
    script:
    ssh $REVIEW_USER@$REVIEW_HOST "
    docker run d rm
    name kmsreview$CI_MERGE_REQUEST_IID
    p 0:80
    $DOCKER_REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHORT_SHA
    "
    # 获取动态分配的端口
    REVIEW_PORT=$(ssh $REVIEW_USER@$REVIEW_HOST "docker port kms-review-$CI_MERGE_REQUEST_IID 80 | cut -d: f2")
    environment:
    name: review/$CI_MERGE_REQUEST_IID
    url: http://$REVIEW_HOST:$REVIEW_PORT
    on_stop: stopreview
    rules:
    if: $CI_PIPELINE_SOURCE == "merge_request_event"
    tags:
    docker

    stop-review:
    stage: cleanup
    image: alpine:3.18
    before_script:
    apk add nocache opensshclient
    mkdir p ~/.ssh
    echo "$SSH_PRIVATE_KEY" | base64 d > ~/.ssh/id_rsa
    chmod 600 ~/.ssh/id_rsa
    sshkeyscan H $REVIEW_HOST >> ~/.ssh/known_hosts
    script:
    ssh $REVIEW_USER@$REVIEW_HOST "docker stop kmsreview$CI_MERGE_REQUEST_IID || true"
    environment:
    name: review/$CI_MERGE_REQUEST_IID
    action: stop
    rules:
    if: $CI_PIPELINE_SOURCE == "merge_request_event"
    when: manual
    tags:
    docker

    几个容易忽略的细节

    缓存策略:按 lock 文件做 key

    注意这行:

    cache:
    key:
    files:
    packagelock.json

    默认的 GitLab cache key 是基于分支名的,这在分支很多的时候会导致每个分支各自一份缓存,浪费空间。改成按 package-lock.json 文件的 hash 做 key 后,只要依赖不变(绝大多数时候如此),所有分支共享同一份 node_modules 缓存。

    Secret 管理:CI Variables,不是 .env

    KMS 项目里曾经有人把 Docker Registry 的账号密码写在 .gitlab-ci.yml 里提交了。还好是内网仓库,但这件事足够让人警醒。GitLab 提供了 CI/CD Variables 功能(Settings > CI/CD > Variables),所有敏感信息都配在那里。

    需要配置的变量:

    变量名说明作用域
    CI_REGISTRY_USER Docker Registry 用户名 全局
    CI_REGISTRY_PASSWORD Docker Registry 密码(Masked) 全局
    SSH_PRIVATE_KEY 服务器 SSH 私钥(Base64, Masked) 全局
    STAGING_HOST 预发布服务器 IP 全局
    PRODUCTION_HOST 生产服务器 IP 生产环境专用
    REVIEW_HOST 预览环境服务器 IP 全局

    密码类型选择 “Masked” 后,Pipeline 日志里会自动打码。不过有个坑:Masked 变量有字符长度要求(至少 8 个字符),短密码是屏蔽不了的。

    MR 预览环境:每个 MR 一个临时实例

    Review 和 stop-review 这两个 Job 实现了一个非常实用的功能:每当有人提 MR,CI 会自动在单独的一台 Review 服务器上启动一个容器,分配随机端口。MR 页面右边会出现一个 “View App” 按钮,点进去就是这条 MR 改动后的实际效果。QA 和 PM 不需要拉代码到本地就能验收。

    MR 被合并或关闭后,GitLab 自动调用 stop-review 销毁容器释放端口。不过我们的 stop-review 设置的是 when: manual——因为有时候 MR 合并了大家还想再看一眼效果,给一个手动销毁的缓冲期。

    生产环境部署:必须手动触发

    注意 deploy-production 的 rules 里有一行 when: manual。这不是技术限制,是流程上的安全阀。即便代码合到了 main 分支,生产部署也必须有人去 Pipeline 页面上点一下"播放"按钮。KMS 内部约定:周五下午 4 点后禁止生产发布,除非是紧急修复。


    四、Nginx 配置与多环境注入

    镜像跑起来之后,最关键的一个环节就是 Nginx 配置。前端应用不同于后端服务,不需要处理数据库连接池、队列消费这些复杂逻辑,但静态资源缓存、SPA 路由和安全头配不好,线上体验能差一大截。

    完整的 nginx.conf

    KMS 生产环境的 Nginx 配置:

    server {
    listen 80;
    server_name _;

    # 根目录
    root /usr/share/nginx/html;
    index index.html;

    # ============================================
    # Gzip 压缩
    # ============================================
    gzip on;
    gzip_vary on;
    gzip_comp_level 6;
    gzip_min_length 1024;
    gzip_proxied any;
    gzip_types
    text/plain
    text/css
    text/javascript
    application/javascript
    application/json
    application/xml
    image/svg+xml
    font/ttf
    font/woff
    font/woff2;

    # ============================================
    # 安全头
    # ============================================
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    # ============================================
    # 日志格式
    # ============================================
    log_format main '$remote_addr – $remote_user [$time_local] '
    '"$request" $status $body_bytes_sent '
    '"$http_referer" "$http_user_agent" '
    'rt=$request_time';

    access_log /var/log/nginx/access.log main;
    error_log /var/log/nginx/error.log warn;

    # ============================================
    # 健康检查端点
    # ============================================
    location /health {
    access_log off;
    return 200 "OK";
    add_header Content-Type text/plain;
    }

    # ============================================
    # 静态资源(带 hash 的文件名)
    # 设置一年的强缓存,因为文件内容变了 hash 就变了
    # ============================================
    location ~* \\.(js|css|woff2?|ttf|eot)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
    }

    # ============================================
    # 图片和 SVG
    # ============================================
    location ~* \\.(png|jpg|jpeg|gif|svg|ico)$ {
    expires 30d;
    add_header Cache-Control "public";
    }

    # ============================================
    # index.html 禁止缓存
    # 确保每次部署后用户拿到的都是最新版本
    # ============================================
    location = /index.html {
    add_header Cache-Control "no-cache, no-store, must-revalidate";
    add_header Pragma "no-cache";
    add_header Expires "0";
    }

    # ============================================
    # SPA 路由兜底:所有路径都 fallback 到 index.html
    # ============================================
    location / {
    try_files $uri $uri/ /index.html;
    }
    }

    静态资源缓存策略——一个容易被忽视的细节

    注意上面配置文件里,我对不同文件类型用了完全不同的缓存策略。这个差异非常重要。

    Webpack/Vite 构建出来的 JS 和 CSS 文件名里带了 content hash,比如 main.a3f2b1c.js。代码改了 hash 就变,文件名就不同,所以这些文件可以放心设置一年的强缓存(immutable)。

    但 index.html 不能缓存。因为每次部署后 index.html 里引用的 JS/CSS 文件名都会变,用户如果拿到了旧的 index.html,就会请求旧的 JS/CSS——而旧文件可能已经被 CDN 清理了,导致页面白屏。KMS 曾经一个线上 bug 就是这么来的:index.html 被 CDN 缓存了 10 分钟,新版本部署后部分用户看到的是旧 HTML + 新 JS 的组合,直接报 chunk load error。

    图片、字体这些变化频率低的资源,30 天缓存就够了。

    多环境注入:构建时 vs 运行时

    前端应用大多需要根据环境切换 API 地址。KMS 的 dev 环境连 http://localhost:5001,staging 连 https://staging-api.kms.example.com,production 连 https://api.kms.example.com。

    业界有两种注入方式:

    方案 A:构建时注入

    把环境变量通过 webpack DefinePlugin 在构建时替换掉代码里的占位符。每个环境单独 build 一次,分别生成不同的镜像。

    缺点很明显:三个环境 = 三次 build = 三个镜像 = 三个 artifact。如果 staging 验证完要上生产,还得用生产环境变量重新 build 一遍,staging 上的验证等于作废——因为镜像不一样了。

    方案 B:运行时注入

    只构建一次,生成一个"通用"镜像。容器启动时通过 shell 脚本从环境变量读取配置,注入到 HTML 或 JS 文件中。KMS 选择的方案,通过 docker-entrypoint.sh 实现:

    #!/bin/sh
    set -e

    # 从环境变量读取 API 地址,注入到 index.html 的 <meta> 标签中
    # 前端代码从 meta 标签读取配置,而不是硬编码

    API_BASE_URL="${API_BASE_URL:-http://localhost:5001}"
    SENTRY_DSN="${SENTRY_DSN:-}"
    FEATURE_FLAGS="${FEATURE_FLAGS:-}"

    HTML_FILE="/usr/share/nginx/html/index.html"

    # 生成配置注入脚本(内联在 HTML 中)
    CONFIG_SCRIPT="<script>window.__KMS_CONFIG__={apiBaseUrl:\\"$API_BASE_URL\\",sentryDsn:\\"$SENTRY_DSN\\",featureFlags:\\"$FEATURE_FLAGS\\"};</script>"

    # 注入到 <head> 的第一个 <script> 之前
    sed -i "s|<script|$CONFIG_SCRIPT<script|" "$HTML_FILE"

    # 启动 Nginx
    exec "$@"

    前端代码里的配置读取层:

    // src/config/runtime.ts
    interface KMSConfig {
    apiBaseUrl: string;
    sentryDsn: string;
    featureFlags: string;
    }

    declare global {
    interface Window {
    __KMS_CONFIG__?: Partial<KMSConfig>;
    }
    }

    const defaultConfig: KMSConfig = {
    apiBaseUrl: 'http://localhost:5001',
    sentryDsn: '',
    featureFlags: '',
    };

    export function getRuntimeConfig(): KMSConfig {
    return {
    defaultConfig,
    window.__KMS_CONFIG__,
    };
    }

    docker-compose 里这样注入变量:

    # docker-compose.prod.yml
    version: '3.8'

    services:
    kms-web:
    image: registry.kms.example.com/kmsfrontendweb:${IMAGE_TAG:latest}
    ports:
    "80:80"
    environment:
    API_BASE_URL=https://api.kms.example.com
    SENTRY_DSN=https://abc123@sentry.io/456
    FEATURE_FLAGS=newDashboard:on,export:on
    healthcheck:
    test: ["CMD", "curl", "-f", "http://localhost/health"]
    interval: 30s
    timeout: 3s
    retries: 3
    start_period: 10s
    restart: unlessstopped

    注意 docker-compose 里用 ${IMAGE_TAG:-latest} 这个语法:IMAGE_TAG 从环境变量读取(CI 里设置成 $CI_COMMIT_SHORT_SHA),如果没有就默认用 latest。这样 CI 脚本和人工部署共享同一份 compose 文件。

    一个踩坑:环境变量覆盖顺序

    docker-compose 里环境变量的优先级是这样的(从低到高):

  • Dockerfile 里的 ENV
  • env_file 指定的文件
  • compose 文件里的 environment 字段
  • shell 环境变量(通过 export)
  • 有次我们在 compose 里写了 environment: – API_BASE_URL=…,又配了 env_file: .env.prod,结果 .env.prod 里也有一行 API_BASE_URL=(空的),直接覆盖了 compose 里的值,API 请求全发到空白地址去了。排查了半天才发现是覆盖顺序问题。建议只用一种方式,不要混用。


    五、零停机部署与回滚

    流水线搭好、Nginx 配好,最后一步是部署策略。我们希望每次发版时正在使用系统的用户不受影响——请求不中断、页面不白屏。

    Docker Compose 滚动更新

    Docker Compose v3 原生支持滚动更新配置。以 KMS 的 Web 前端为例,生产环境跑 3 个 Nginx 容器实例,更新时逐个替换:

    # docker-compose.prod.yml(补充 deploy 配置)
    version: '3.8'

    services:
    kms-web:
    image: registry.kms.example.com/kmsfrontendweb:${IMAGE_TAG:latest}
    ports:
    "80:80"
    environment:
    API_BASE_URL=https://api.kms.example.com
    deploy:
    mode: replicated
    replicas: 3
    update_config:
    parallelism: 1 # 每次更新 1 个副本
    delay: 10s # 每批之间等待 10 秒,等新容器 Ready
    failure_action: rollback # 更新失败自动回滚
    max_failure_ratio: 0.3 # 超过 30% 的容器更新失败就触发回滚
    order: startfirst # 先启动新容器,再停止旧容器
    rollback_config:
    parallelism: 1
    delay: 5s
    failure_action: pause
    restart_policy:
    condition: onfailure
    delay: 5s
    max_attempts: 3
    window: 120s

    # Nginx 做反向代理和负载均衡
    nginx-lb:
    image: nginx:1.25alpine
    ports:
    "443:443"
    volumes:
    ./nginxlb.conf:/etc/nginx/conf.d/default.conf
    depends_on:
    kmsweb

    关键参数说明:

    • parallelism: 1:一次只更新一个容器。假设 3 个副本,更新过程是"停 1 个 -> 启新的 -> 等 10 秒 -> 停下一个",全程 2/3 的实例正常服务。
    • order: start-first:先把新容器拉起来并通过健康检查,再停旧容器。默认的 stop-first 会先停再启,瞬间少一个实例。
    • failure_action: rollback:如果新容器启动失败或者健康检查没通过,自动回退到旧版本。KMS 有次发新版时 nginx.conf 格式写错了(少了个分号),容器直接启动失败,这个配置让我们免了一次生产事故。
    • max_failure_ratio: 0.3:3 个副本的情况下,如果 1 个失败(比例 33% > 30%),触发自动回滚。

    手动回滚流程

    自动回滚省了大事,但总有些情况需要手动回滚——比如新版本没有报错但业务逻辑异常,容器健康检查是过的(因为 Nginx 能正常返回 200),用户却在反馈功能异常。

    手动回滚的思路很简单:docker 镜像仓库里每次 push 都保留了 commit SHA 作为 tag,回滚就是跑上一个版本的镜像。

    # 在服务器上查看最近的镜像版本
    docker image ls registry.kms.example.com/kms-frontend-web –format '{{.Tag}}' | head -10

    # 回滚到指定版本
    export IMAGE_TAG=a3f2b1c # 这个 Tag 就是 CI_COMMIT_SHORT_SHA
    docker compose up -d –remove-orphans

    # 查看回滚后的容器状态
    docker compose ps

    KMS 团队内部维护了一个 wiki 页面叫"部署历史",每个发版后把 commit SHA、发布日期、主要变更写进去。回滚的时候查这个表就知道哪个版本是安全的。

    灰度发布的基本思路

    滚动更新保证了零停机,但如果想稳妥地验证新版本(比如只放 10% 的流量到新版本),需要用 Nginx 做流量分发。

    灰度发布的大致架构:

    # nginx-lb.conf — 灰度发布版
    upstream kms_web {
    # 稳定版本
    server kms-web-stable:80 weight=90;

    # 灰度版本(只接收 10% 流量)
    server kms-web-canary:80 weight=10;
    }

    server {
    listen 443 ssl http2;
    server_name kms.example.com;

    location / {
    proxy_pass http://kms_web;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
    }

    灰度版本和稳定版本是两套独立的 docker compose service,跑不同的镜像 tag。观察一段时间(日志、报错率、用户反馈)没问题后,逐步把 weight 调到 50:50,最后 100% 灰度版本,旧版本下线。

    KMS 目前灰度发布还在探索阶段,上面的配置是我们预研的方案,实际生产还没全量跑。但这个方向是对的——尤其是金融类应用,宁愿发版慢一点也不能出问题。


    六、CI/CD 的 5 个踩坑清单

    踩坑是搭建 CI/CD 流水线不可避免的环节。下面 5 个问题每一个都曾经在 KMS 的 Pipeline 日志里真实出现过,按"现象 -> 根因 -> 解决"的格式记录。

    1. 构建产物里带了 Source Map 上了生产

    现象:某天产品经理在群里发了一张截图,说"为什么我在浏览器开发者工具里能看到 KMS 所有源代码,连注释都在"。

    根因:webpack 配置里 devtool 被设置成了 'source-map',且没有根据 NODE_ENV 做区分。生产构建也会生成 .js.map 文件,而 Dockerfile 里 COPY –from=builder /app/packages/web/dist 把整个 dist 目录(包括 map 文件)都复制进了运行镜像。Nginx 又不会主动拦截 .map 请求。

    解决:两步走。第一步,webpack 配置按环境区分:

    // webpack.config.ts
    devtool: process.env.NODE_ENV === 'production'
    ? false // 生产环境不生成 source map
    : 'eval-source-map' // 开发环境用快速 source map

    第二步,如果需要保留 source map 用于 Sentry 上报但又不想暴露在公网,构建后把 map 文件上传到 Sentry,然后在 Dockerfile 的 Stage 2 中只复制非 .map 文件:

    COPY –from=builder /app/packages/web/dist /usr/share/nginx/html
    RUN find /usr/share/nginx/html -name "*.map" -type f -delete

    教训:Source map 不是不能有,但不能公开可访问。如果需要线上排错,应该用 Sentry 这类工具在服务端解析。

    2. COPY –from 路径写错,镜像只有 Nginx 默认页

    现象:部署完成后访问页面,看到的是 Nginx 的 “Welcome to nginx!” 默认页面。本地 build 完全正常,没有任何报错。

    根因:KMS 是 Monorepo 结构,web 端的代码在 packages/web 下面,构建产物也在 packages/web/dist。Dockerfile 里写的是:

    COPY –from=builder /app/dist /usr/share/nginx/html

    但实际路径是 /app/packages/web/dist。Docker 的 COPY 命令不会因为源路径不存在而报错——它只是什么都不复制,目标路径保持为空。Nginx 找不到 index.html,就返回了自己的默认欢迎页。

    解决:改路径:

    COPY –from=builder /app/packages/web/dist /usr/share/nginx/html

    另外加了一个校验脚本在 Dockerfile 最后,确保关键文件存在:

    RUN test -f /usr/share/nginx/html/index.html || \\
    (echo "ERROR: index.html not found in nginx html dir" && exit 1)

    教训:COPY 命令不会因为源路径不存在而失败,这是 Docker 的设计行为。在 CI 里可以加一个 docker run –rm <image> ls /usr/share/nginx/html 来验证内容。

    3. CI 里 Docker build 缓存不生效

    现象:GitLab CI 每次跑 docker build 都是全量构建,即使只改了一行 CSS 也要从头 npm ci。构建时间稳定在 5 分钟,没有任何缓存命中。

    根因:GitLab CI 的 Docker executor 每次启动一个全新容器,本地没有上一次构建的镜像 layer 缓存。Docker layer cache 依赖本地磁盘存储,而 CI Runner 每次 Job 结束后会把容器销毁。

    解决:在 CI 里使用 docker build –cache-from,先从镜像仓库拉取上一次的镜像作为缓存源:

    build:
    stage: build
    image: docker:24
    services:
    docker:24dind
    before_script:
    echo "$CI_REGISTRY_PASSWORD" | docker login $DOCKER_REGISTRY u $CI_REGISTRY_USER passwordstdin
    # 拉取上一次的镜像作为 layer cache
    docker pull $DOCKER_REGISTRY/$IMAGE_NAME:latest || true
    script:
    docker build
    cachefrom $DOCKER_REGISTRY/$IMAGE_NAME:latest
    buildarg BUILD_ENV=$CI_ENVIRONMENT_NAME
    t $DOCKER_REGISTRY/$IMAGE_NAME:$CI_COMMIT_SHORT_SHA
    t $DOCKER_REGISTRY/$IMAGE_NAME:latest
    .

    || true 是为了第一次构建时 latest 镜像还不存在的情况下不报错。

    加上这个配置后,增量构建从 5 分钟降到了 1 分钟出头,node_modules 层和系统依赖层基本都命中缓存。

    4. nginx.conf 里 try_files 写错导致 SPA 路由 404

    现象:用户反馈点击侧边栏菜单"知识图谱"后刷新页面,直接 404。但首页进去后点菜单跳转正常,只有刷新或直接输入 URL 会挂。日志显示 Nginx 返回 404,没有落到应用的路由逻辑。

    根因:Nginx 配置里 try_files 写的是:

    location / {
    try_files $uri /index.html;
    }

    少了 $uri/。对于 /knowledge-graph 这样的路径,Nginx 先尝试 $uri(一个不存在的文件)→ 没有 → 直接 fallback 到 index.html。这本来是 OK 的。但如果用户在 /knowledge-graph/(末尾有斜杠)刷新,Nginx 先尝试 $uri(不存在的文件)→ 没有 → 然后没有尝试 $uri/(目录)→ 直接 404。加上 $uri/ 后,Nginx 会在找不到文件时再尝试目录索引。

    解决:

    location / {
    try_files $uri $uri/ /index.html;
    }

    教训:SPA 的 try_files 完整写法就是 $uri $uri/ /index.html,三个参数缺一不可。测试时一定要覆盖"带末尾斜杠的 URL 直接访问"这个场景,光测点击跳转是不够的。

    5. 多环境部署时环境变量覆盖顺序踩坑

    现象:Staging 环境部署后,Sentry 上报的 error 全显示来自 “production” 环境。但 docker-compose.staging.yml 里明明配置了 ENVIRONMENT=staging。

    根因:docker-compose.staging.yml 同时使用了 env_file 和 environment:

    services:
    kms-web:
    env_file:
    .env.staging
    environment:
    ENVIRONMENT=staging

    而 .env.staging 文件是在 CI Job 里动态生成的一个文件(从 GitLab Variables 拼接出来的),里面也有一行 ENVIRONMENT=production——这是历史遗留问题,那个值当初是给另一个服务用的,但 env_file 会加载所有变量。

    Compose 的变量优先级:shell 环境变量 > env_file > environment。最关键的是,environment 里的值并不会覆盖 env_file 中同名的值——实验结果和文档有时不一致,取决于 docker-compose 版本。KMS 踩这个坑的时候用的是 docker-compose 1.29,environment 被 env_file 覆盖了。

    解决:只保留一种变量注入方式。KMS 最终选择全部走 environment,删掉 env_file。CI Job 里也不再生成 .env 文件,直接把值通过 -e 参数传入。

    services:
    kms-web:
    # 不再使用 env_file
    environment:
    ENVIRONMENT=staging
    API_BASE_URL=${API_BASE_URL}
    SENTRY_DSN=${SENTRY_DSN}

    教训:环境变量注入只用一个入口,env_file 和 environment 不要混用。如果确实需要 env_file(变量太多不方便全列在 compose 里),那就所有变量都走 env_file,不要在 compose 里额外定义同名的。


    六个章节能把 KMS 前端 CI/CD 全流程覆盖完,但实际上每个环节展开都有更多细节值得深挖——镜像安全扫描、CDN 回源策略、灰度发布的流量标识透传、跨区域部署等等。这些我会在后续的实际踩坑中继续记录。

    赞(0)
    未经允许不得转载:171主机测评 » 前端 Docker 实战:从构建到部署的 CI/CD 全流程
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址