docker

关注公众号 jb51net

关闭
首页 > 网站技巧 > 服务器 > 云和虚拟化 > docker > 前端Docker部署CI/CD

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

作者:Funpaid

这篇文章主要介绍了前端Docker从构建到部署CI/CD实践的相关资料,文中通过实例代码拆解多阶段Docker镜像瘦身、GitLabCI配置、Nginx缓存策略与零停机回滚,需要的朋友可以参考下

前言

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

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

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

  1. 本地 npm run build,三种构建命令各跑一遍,大概 3 分钟;
  2. 把 dist 目录下的文件用 scp 传到测试服务器;
  3. SSH 上去,把旧文件备份一份(万一出问题还能回滚),然后覆盖;
  4. nginx -s reload,刷新浏览器,确认页面正常;
  5. 同样的操作在预发布和生产环境再执行一遍。

看起来也就几步,但问题出在"人"这个变量上。谁也不能保证每次都记得备份;谁也不能保证 scp 的目标路径不会手滑写错;更不能保证多人协作时,一个人在上线,另一个人也在上线。有一次同事 A 正在部署 Dashboard,同事 B 也同时连上了生产机器,结果两个人的文件交叉覆盖,页面白屏了 20 分钟。

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

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

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:18950MB基于 Debian,GLIBC 完整
node:18-slim240MBDebian 精简版
node:18-alpine115MB基于 Alpine Linux,musl libc

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

5. 最终效果

指标优化前优化后
镜像大小1.18 GB121 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: kms-frontend-web
  # DOCKER_AUTH_CONFIG 在 GitLab CI Variables 中配置,不要写在这里

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

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

# ============================================
# 全局缓存:node_modules
# ============================================
.node_cache: &node_cache
  cache:
    key:
      files:
        - package-lock.json
    paths:
      - node_modules/
    policy: pull-push

# ============================================
# Stage 1: Lint
# ============================================
lint:
  stage: lint
  image: node:18-alpine
  <<: *node_cache
  before_script:
    - npm ci
  script:
    - npm run lint
    - npm run type-check
  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:18-alpine
  <<: *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/cobertura-coverage.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:24-dind
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  before_script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login $DOCKER_REGISTRY -u $CI_REGISTRY_USER --password-stdin
  script:
    # 用 CI_COMMIT_SHORT_SHA 作为 tag,保证可追溯
    - docker build
        --build-arg 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 --no-cache openssh-client docker-compose
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
    - ssh-keyscan -H $STAGING_HOST >> ~/.ssh/known_hosts
  script:
    - scp docker-compose.staging.yml $STAGING_USER@$STAGING_HOST:/opt/kms/docker-compose.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 --password-stdin &&
        export IMAGE_TAG=$CI_COMMIT_SHORT_SHA &&
        docker compose pull &&
        docker compose up -d --remove-orphans
      "
  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 --no-cache openssh-client docker-compose
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
    - ssh-keyscan -H $PRODUCTION_HOST >> ~/.ssh/known_hosts
  script:
    - scp docker-compose.prod.yml $PRODUCTION_USER@$PRODUCTION_HOST:/opt/kms/docker-compose.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 --password-stdin &&
        export IMAGE_TAG=$CI_COMMIT_SHORT_SHA &&
        docker compose pull &&
        docker compose up -d --remove-orphans
      "
  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 --no-cache openssh-client
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
    - ssh-keyscan -H $REVIEW_HOST >> ~/.ssh/known_hosts
  script:
    - ssh $REVIEW_USER@$REVIEW_HOST "
        docker run -d --rm
          --name kms-review-$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: stop-review
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  tags:
    - docker

stop-review:
  stage: cleanup
  image: alpine:3.18
  before_script:
    - apk add --no-cache openssh-client
    - mkdir -p ~/.ssh
    - echo "$SSH_PRIVATE_KEY" | base64 -d > ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
    - ssh-keyscan -H $REVIEW_HOST >> ~/.ssh/known_hosts
  script:
    - ssh $REVIEW_USER@$REVIEW_HOST "docker stop kms-review-$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:
      - package-lock.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_USERDocker Registry 用户名全局
CI_REGISTRY_PASSWORDDocker 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/kms-frontend-web:${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: unless-stopped

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

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

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

  1. Dockerfile 里的 ENV
  2. env_file 指定的文件
  3. compose 文件里的 environment 字段
  4. 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/kms-frontend-web:${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: start-first    # 先启动新容器,再停止旧容器
      rollback_config:
        parallelism: 1
        delay: 5s
        failure_action: pause
      restart_policy:
        condition: on-failure
        delay: 5s
        max_attempts: 3
        window: 120s

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

关键参数说明:

手动回滚流程

自动回滚省了大事,但总有些情况需要手动回滚——比如新版本没有报错但业务逻辑异常,容器健康检查是过的(因为 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:24-dind
  before_script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login $DOCKER_REGISTRY -u $CI_REGISTRY_USER --password-stdin
    # 拉取上一次的镜像作为 layer cache
    - docker pull $DOCKER_REGISTRY/$IMAGE_NAME:latest || true
  script:
    - docker build
        --cache-from $DOCKER_REGISTRY/$IMAGE_NAME:latest
        --build-arg 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_fileenvironment

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,environmentenv_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 回源策略、灰度发布的流量标识透传、跨区域部署等等。这些我会在后续的实际踩坑中继续记录。

总结

到此这篇关于前端Docker从构建到部署的CI/CD实践的文章就介绍到这了,更多相关前端Docker部署CI/CD内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文