返回笔记首页

CICD 流水线搭建

主题配置

简历怎么写

负责搭建公司前端 CI/CD 自动化体系,从零到一建设 Jenkins 流水线,实现代码提交到生产部署的全流程自动化。部署效率提升 85%,从手动 30 分钟缩短至自动化 4.5 分钟,日均部署次数从 2 次提升到 50+ 次,部署失败率从 15% 降至 2%。

  • 设计多环境发布策略,实现 dev/test/pre/prod 四套环境的自动化部署和回滚
  • 使用 Docker 多阶段构建优化镜像,体积从 800MB 压缩至 80MB,部署速度提升 70%
  • 实施蓝绿发布和灰度发布策略,生产环境零停机部署,问题发现时间从 2 小时缩短至 5 分钟
  • 建立完善的回滚机制,支持一键回退,平均回滚时间从 15 分钟降至 1 分钟

面试怎么说

面试官: 讲讲你们的 CI/CD 是怎么做的?

我的回答

我们项目从手动部署升级到全自动化 CI/CD,整个过程经历了几个阶段。

最开始的痛点是这样的:每次上线需要人工打包、上传服务器、重启服务,一套流程下来至少 30 分钟,而且经常出错。有一次因为配置文件没改对,导致生产环境报错,回滚又花了半小时,影响了线上用户。

所以我就主导搭建了自动化流水线,核心思路是代码提交自动触发,一键部署到生产

具体做了这几件事:

第一步,Jenkins 流水线。我配置了完整的构建流程:代码检出 → 安装依赖 → 跑 lint 和测试 → 构建打包 → 构建 Docker 镜像 → 推送到私有仓库 → 部署到 K8s。整个流程完全自动化,开发人员只需要 git push,剩下的交给流水线。

第二步,Docker 容器化。之前部署要在服务器上装 Node、配置 Nginx,环境不一致经常出问题。我用 Docker 把应用打包成镜像,多阶段构建把体积从 800MB 优化到 80MB,这样部署快,而且开发、测试、生产环境完全一致。

第三步,多环境管理。我们有四套环境:dev、test、pre、prod。每个环境对应不同分支,开发分支自动部署到 dev,发布分支走审批流程部署到 pre 和 prod。用 Kubernetes 的 namespace 做隔离,配置用 ConfigMap 管理,敏感信息用 Secret。

第四步,灰度发布。生产环境我不敢直接全量发布,所以做了灰度策略。先把新版本部署 1 个 Pod,只给 10% 流量,观察 10 分钟没问题再放到 30%、50%,最后全量。期间一旦发现错误率上升,自动回滚。

效果很明显:部署时间从 30 分钟降到 4.5 分钟,一天能部署 50 多次,快速迭代。部署成功率从 85% 提升到 98%,回滚时间只要 1 分钟。最关键是,生产环境出问题能快速定位是哪个版本,一键回退。

面试官: 你们的回滚机制具体怎么做的?

遇到问题回滚分三层:

第一层是应用回滚。Kubernetes 有版本历史,执行 kubectl rollout undo 就能回到上一个稳定版本,30 秒搞定。

第二层是配置回滚。配置文件都在 Git 管理,有问题直接 revert 提交,触发重新部署。

第三层是数据回滚。数据库迁移我们用的是前向兼容策略,新增字段但保留旧字段,代码先部署新版本,数据迁移完成后再删除旧字段。万一要回滚,不会因为字段删了导致旧代码跑不起来。

另外还做了降级开关,紧急情况下可以通过配置中心关闭有问题的新功能,让流量走旧逻辑。


完整技术实现

一、Jenkins 流水线配置

项目结构

plain
project/
├── src/                    # 源代码
├── Dockerfile             # Docker 配置
├── Jenkinsfile            # Jenkins 配置
├── nginx.conf             # Nginx 配置
├── k8s/                   # K8s 配置
│   ├── deployment.yaml
│   ├── service.yaml
│   └── ingress.yaml
└── scripts/               # 部署脚本
    └── deploy.sh

Jenkinsfile(完整版)

groovy

groovy
pipeline {
    agent any

    environment {
        PROJECT_NAME = 'my-frontend'
        DOCKER_REGISTRY = 'registry.example.com'
        K8S_NAMESPACE = 'production'
        DINGTALK_WEBHOOK = credentials('dingtalk-webhook')
    }

    parameters {
        choice(name: 'ENV', choices: ['dev', 'test', 'pre', 'prod'], description: '部署环境')
        booleanParam(name: 'SKIP_TEST', defaultValue: false, description: '跳过测试')
    }

    stages {
        stage('代码检出') {
            steps {
                checkout scm
                script {
                    env.GIT_COMMIT_MSG = sh(returnStdout: true, script: 'git log -1 --pretty=%B').trim()
                    env.GIT_COMMIT_ID = sh(returnStdout: true, script: 'git rev-parse --short HEAD').trim()
                    env.BUILD_VERSION = "${env.BUILD_NUMBER}-${env.GIT_COMMIT_ID}"
                }
            }
        }

        stage('安装依赖') {
            steps {
                sh '''
                    if [ -d "node_modules" ]; then
                        echo "使用缓存依赖"
                    else
                        npm ci --prefer-offline
                    fi
                '''
            }
        }

        stage('代码检查') {
            steps {
                sh 'npm run lint'
            }
        }

        stage('单元测试') {
            when {
                expression { !params.SKIP_TEST }
            }
            steps {
                sh 'npm run test:ci'
                publishHTML([
                    reportDir: 'coverage',
                    reportFiles: 'index.html',
                    reportName: '测试覆盖率'
                ])
            }
        }

        stage('构建打包') {
            steps {
                sh """
                    npm run build:${params.ENV}
                    du -sh dist
                """
            }
        }

        stage('构建镜像') {
            steps {
                script {
                    def imageTag = "${DOCKER_REGISTRY}/${PROJECT_NAME}:${BUILD_VERSION}"
                    sh """
                        docker build -t ${imageTag} .
                        docker push ${imageTag}
                    """
                }
            }
        }

        stage('部署') {
            steps {
                script {
                    if (params.ENV == 'prod') {
                        input message: '确认部署到生产环境?', submitter: 'admin'
                    }

                    sh """
                        kubectl set image deployment/${PROJECT_NAME} \
                            ${PROJECT_NAME}=${DOCKER_REGISTRY}/${PROJECT_NAME}:${BUILD_VERSION} \
                            -n ${params.ENV}

                        kubectl rollout status deployment/${PROJECT_NAME} -n ${params.ENV}
                    """
                }
            }
        }

        stage('健康检查') {
            steps {
                sh """
                    sleep 10
                    curl -f http://${params.ENV}.example.com/health || exit 1
                """
            }
        }
    }

    post {
        success {
            sh """
                curl -X POST ${DINGTALK_WEBHOOK} \
                -H 'Content-Type: application/json' \
                -d '{
                    "msgtype": "markdown",
                    "markdown": {
                        "title": "部署成功",
                        "text": "## ✅ 部署成功\\n\\n**项目**: ${PROJECT_NAME}\\n**环境**: ${params.ENV}\\n**版本**: ${BUILD_VERSION}\\n**提交**: ${GIT_COMMIT_MSG}"
                    }
                }'
            """
        }
        failure {
            sh """
                curl -X POST ${DINGTALK_WEBHOOK} \
                -H 'Content-Type: application/json' \
                -d '{
                    "msgtype": "markdown",
                    "markdown": {
                        "title": "部署失败",
                        "text": "## ❌ 部署失败\\n\\n**项目**: ${PROJECT_NAME}\\n**环境**: ${params.ENV}\\n**版本**: ${BUILD_VERSION}\\n**查看日志**: ${BUILD_URL}"
                    }
                }'
            """
        }
    }
}

二、Docker 容器化

Dockerfile(优化版)

dockerfile

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

WORKDIR /app

# 只复制依赖文件,利用缓存
COPY package*.json ./
RUN npm ci --only=production && npm cache clean --force

# 复制源代码
COPY . .

# 构建
ARG ENV=production
RUN npm run build:${ENV}

# ========== 运行阶段 ==========
FROM nginx:alpine

# 安装 curl(健康检查用)
RUN apk add --no-cache curl

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

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

# 健康检查
HEALTHCHECK --interval=30s --timeout=3s \
    CMD curl -f http://localhost/health || exit 1

# 暴露端口
EXPOSE 80

CMD ["nginx", "-g", "daemon off;"]

nginx.conf

nginx

nginx
server {
    listen 80;
    server_name _;
    root /usr/share/nginx/html;
    index index.html;

    # 健康检查
    location /health {
        access_log off;
        return 200 "healthy\n";
    }

    # 静态资源缓存
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # API 代理
    location /api/ {
        proxy_pass http://backend:8080/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    # SPA 路由
    location / {
        try_files $uri $uri/ /index.html;
    }
}

构建脚本

bash

bash
#!/bin/bash
# build-docker.sh

ENV=${1:-prod}
VERSION=$(git rev-parse --short HEAD)
IMAGE="registry.example.com/my-frontend:${VERSION}"

echo "构建镜像: ${IMAGE}"

docker build \
    --build-arg ENV=${ENV} \
    -t ${IMAGE} \
    -t registry.example.com/my-frontend:${ENV}-latest \
    .

docker push ${IMAGE}
docker push registry.example.com/my-frontend:${ENV}-latest

echo "镜像已推送: ${IMAGE}"

三、Kubernetes 多环境部署

deployment.yaml

yaml

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-frontend
  namespace: production
spec:
  replicas: 3
  selector:
    matchLabels:
      app: my-frontend
  template:
    metadata:
      labels:
        app: my-frontend
        version: v1
    spec:
      containers:
      - name: my-frontend
        image: registry.example.com/my-frontend:latest
        ports:
        - containerPort: 80

        # 环境变量
        envFrom:
        - configMapRef:
            name: my-frontend-config

        # 资源限制
        resources:
          requests:
            memory: "128Mi"
            cpu: "100m"
          limits:
            memory: "512Mi"
            cpu: "500m"

        # 健康检查
        livenessProbe:
          httpGet:
            path: /health
            port: 80
          initialDelaySeconds: 30
          periodSeconds: 10

        readinessProbe:
          httpGet:
            path: /health
            port: 80
          initialDelaySeconds: 5
          periodSeconds: 5

configmap.yaml

yaml

yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: my-frontend-config
  namespace: production
data:
  API_URL: "https://api.example.com"
  NODE_ENV: "production"
  LOG_LEVEL: "info"

service.yaml

yaml

yaml
apiVersion: v1
kind: Service
metadata:
  name: my-frontend
  namespace: production
spec:
  selector:
    app: my-frontend
  ports:
  - port: 80
    targetPort: 80
  type: ClusterIP

ingress.yaml

yaml

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-frontend
  namespace: production
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
  ingressClassName: nginx
  tls:
  - hosts:
    - www.example.com
    secretName: example-tls
  rules:
  - host: www.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: my-frontend
            port:
              number: 80

部署脚本

bash

bash
#!/bin/bash
# deploy-k8s.sh

ENV=$1
VERSION=$2

if [ -z "$ENV" ] || [ -z "$VERSION" ]; then
    echo "用法: ./deploy-k8s.sh <env> <version>"
    exit 1
fi

echo "部署到 ${ENV} 环境,版本 ${VERSION}"

# 更新镜像
kubectl set image deployment/my-frontend \
    my-frontend=registry.example.com/my-frontend:${VERSION} \
    -n ${ENV}

# 等待部署完成
kubectl rollout status deployment/my-frontend -n ${ENV}

# 验证
kubectl get pods -n ${ENV} -l app=my-frontend

echo "部署完成"

四、蓝绿发布

蓝绿发布架构

plain
┌─────────────┐
│   Ingress   │
└──────┬──────┘
       │
   ┌───┴────┐
   │ Service │ ──► selector: version=blue
   └────────┘
       │
   ┌───┴─────┬────────┐
   │         │        │
 ┌─▼──┐   ┌─▼──┐  ┌─▼──┐
 │Blue│   │Blue│  │Blue│  (v1 - 旧版本)
 └────┘   └────┘  └────┘

 ┌────┐   ┌────┐  ┌────┐
 │Green│  │Green│ │Green│ (v2 - 新版本,待切换)
 └────┘   └────┘  └────┘

blue-green-deploy.sh

bash

bash
#!/bin/bash

set -e

APP="my-frontend"
NAMESPACE="production"
NEW_VERSION=$1
CURRENT_COLOR=$(kubectl get service ${APP} -n ${NAMESPACE} -o jsonpath='{.spec.selector.color}')

if [ -z "$NEW_VERSION" ]; then
    echo "用法: ./blue-green-deploy.sh <version>"
    exit 1
fi

# 确定新颜色
if [ "$CURRENT_COLOR" == "blue" ]; then
    NEW_COLOR="green"
else
    NEW_COLOR="blue"
fi

echo "当前运行: ${CURRENT_COLOR}"
echo "准备部署: ${NEW_COLOR} (${NEW_VERSION})"

# 1. 部署新版本
echo "Step 1: 部署新版本到 ${NEW_COLOR} 环境"
kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ${APP}-${NEW_COLOR}
  namespace: ${NAMESPACE}
spec:
  replicas: 3
  selector:
    matchLabels:
      app: ${APP}
      color: ${NEW_COLOR}
  template:
    metadata:
      labels:
        app: ${APP}
        color: ${NEW_COLOR}
    spec:
      containers:
      - name: ${APP}
        image: registry.example.com/${APP}:${NEW_VERSION}
        ports:
        - containerPort: 80
EOF

# 2. 等待就绪
echo "Step 2: 等待新版本就绪"
kubectl rollout status deployment/${APP}-${NEW_COLOR} -n ${NAMESPACE}

# 3. 健康检查
echo "Step 3: 健康检查"
sleep 10
POD=$(kubectl get pod -l app=${APP},color=${NEW_COLOR} -n ${NAMESPACE} -o jsonpath='{.items[0].metadata.name}')
kubectl exec ${POD} -n ${NAMESPACE} -- curl -f http://localhost/health

# 4. 切换流量
echo "Step 4: 切换流量"
read -p "确认切换到 ${NEW_COLOR}?(y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
    kubectl patch service ${APP} -n ${NAMESPACE} -p "{\"spec\":{\"selector\":{\"color\":\"${NEW_COLOR}\"}}}"
    echo "流量已切换到 ${NEW_COLOR}"

    # 5. 观察期
    echo "Step 5: 观察 30 秒"
    sleep 30

    # 6. 清理旧版本
    read -p "删除旧版本 ${CURRENT_COLOR}?(y/n) " -n 1 -r
    echo
    if [[ $REPLY =~ ^[Yy]$ ]]; then
        kubectl delete deployment ${APP}-${CURRENT_COLOR} -n ${NAMESPACE}
        echo "已删除 ${CURRENT_COLOR} 环境"
    fi
else
    echo "取消部署"
    kubectl delete deployment ${APP}-${NEW_COLOR} -n ${NAMESPACE}
fi

五、灰度发布

canary-deploy.sh

bash

bash
#!/bin/bash

set -e

APP="my-frontend"
NAMESPACE="production"
NEW_VERSION=$1
STAGES=(10 30 50 100)  # 灰度阶段

if [ -z "$NEW_VERSION" ]; then
    echo "用法: ./canary-deploy.sh <version>"
    exit 1
fi

echo "开始灰度发布: ${NEW_VERSION}"

# 1. 部署 Canary 版本(1个副本)
echo "Step 1: 部署 Canary 版本"
kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ${APP}-canary
  namespace: ${NAMESPACE}
spec:
  replicas: 1
  selector:
    matchLabels:
      app: ${APP}
      version: canary
  template:
    metadata:
      labels:
        app: ${APP}
        version: canary
    spec:
      containers:
      - name: ${APP}
        image: registry.example.com/${APP}:${NEW_VERSION}
        ports:
        - containerPort: 80
EOF

kubectl rollout status deployment/${APP}-canary -n ${NAMESPACE}

# 2. 逐步放量
for PERCENTAGE in "${STAGES[@]}"; do
    echo ""
    echo "Step 2: 放量到 ${PERCENTAGE}%"

    # 计算副本数(假设 Stable 有 9 个副本)
    CANARY_REPLICAS=$((9 * PERCENTAGE / 100))
    if [ $CANARY_REPLICAS -lt 1 ]; then
        CANARY_REPLICAS=1
    fi

    echo "Canary 副本数: ${CANARY_REPLICAS}"
    kubectl scale deployment/${APP}-canary --replicas=${CANARY_REPLICAS} -n ${NAMESPACE}

    # 观察
    echo "观察 5 分钟..."
    for i in {1..5}; do
        echo "  第 ${i} 分钟..."
        sleep 60

        # 检查错误率(这里简化处理,实际应该对接监控系统)
        ERROR_COUNT=$(kubectl logs -l app=${APP},version=canary -n ${NAMESPACE} --tail=100 | grep -c "ERROR" || true)
        echo "  错误数: ${ERROR_COUNT}"

        if [ $ERROR_COUNT -gt 10 ]; then
            echo "❌ 错误率过高,回滚"
            kubectl delete deployment/${APP}-canary -n ${NAMESPACE}
            exit 1
        fi
    done

    echo "✅ ${PERCENTAGE}% 阶段通过"
done

# 3. 全量切换
echo ""
echo "Step 3: 全量切换"
kubectl delete deployment/${APP}-stable -n ${NAMESPACE}
kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ${APP}-stable
  namespace: ${NAMESPACE}
spec:
  replicas: 9
  selector:
    matchLabels:
      app: ${APP}
      version: stable
  template:
    metadata:
      labels:
        app: ${APP}
        version: stable
    spec:
      containers:
      - name: ${APP}
        image: registry.example.com/${APP}:${NEW_VERSION}
        ports:
        - containerPort: 80
EOF

kubectl delete deployment/${APP}-canary -n ${NAMESPACE}

echo "灰度发布完成"

六、回滚机制

rollback.sh

bash

bash
#!/bin/bash

APP="my-frontend"
NAMESPACE=$1
REVISION=${2:-0}  # 默认回滚到上一个版本

if [ -z "$NAMESPACE" ]; then
    echo "用法: ./rollback.sh <namespace> [revision]"
    exit 1
fi

echo "回滚 ${APP} 在 ${NAMESPACE} 环境"

# 查看历史版本
echo "历史版本:"
kubectl rollout history deployment/${APP} -n ${NAMESPACE}

# 回滚
if [ "$REVISION" == "0" ]; then
    echo "回滚到上一个版本"
    kubectl rollout undo deployment/${APP} -n ${NAMESPACE}
else
    echo "回滚到版本 ${REVISION}"
    kubectl rollout undo deployment/${APP} --to-revision=${REVISION} -n ${NAMESPACE}
fi

# 等待完成
kubectl rollout status deployment/${APP} -n ${NAMESPACE}

echo "回滚完成"

# 发送通知
curl -X POST ${DINGTALK_WEBHOOK} \
-H 'Content-Type: application/json' \
-d '{
    "msgtype": "text",
    "text": {
        "content": "⚠️ 生产环境已回滚\n项目: '${APP}'\n环境: '${NAMESPACE}'"
    }
}'

自动回滚(基于监控)

bash

bash
#!/bin/bash
# auto-rollback.sh

APP="my-frontend"
NAMESPACE="production"
ERROR_THRESHOLD=5  # 错误率阈值 5%

while true; do
    # 获取最近 5 分钟的错误率
    ERROR_RATE=$(curl -s "http://prometheus:9090/api/v1/query?query=rate(http_requests_total{status=~\"5..\",app=\"${APP}\"}[5m])" | jq -r '.data.result[0].value[1]')

    if [ -z "$ERROR_RATE" ]; then
        ERROR_RATE=0
    fi

    ERROR_RATE_PERCENT=$(echo "$ERROR_RATE * 100" | bc)

    echo "当前错误率: ${ERROR_RATE_PERCENT}%"

    # 如果错误率超过阈值,自动回滚
    if (( $(echo "$ERROR_RATE_PERCENT > $ERROR_THRESHOLD" | bc -l) )); then
        echo "⚠️ 错误率超过阈值,触发自动回滚"
        ./rollback.sh ${NAMESPACE}

        # 发送告警
        curl -X POST ${DINGTALK_WEBHOOK} \
        -H 'Content-Type: application/json' \
        -d '{
            "msgtype": "text",
            "text": {
                "content": "🚨 自动回滚触发\n错误率: '${ERROR_RATE_PERCENT}'%\n已回滚到上一个稳定版本"
            }
        }'

        break
    fi

    sleep 60
done

实际遇到的问题和解决方案

问题 1:镜像构建慢

现象: Docker 构建每次都要 8 分钟

原因:

  • 没有利用缓存
  • 依赖全量安装

解决:

dockerfile

dockerfile
# 优化前
COPY . .
RUN npm install
RUN npm run build

# 优化后
COPY package*.json ./
RUN npm ci  # 使用 ci 而不是 install
COPY . .
RUN npm run build

问题 2:部署后偶尔 502

现象: 新版本部署后,偶尔会出现 502 错误

原因: Pod 还没完全启动,K8s 就把流量导入了

解决:

yaml

yaml
readinessProbe:
  httpGet:
    path: /health
    port: 80
  initialDelaySeconds: 10  # 增加初始延迟
  periodSeconds: 5

问题 3:回滚后配置不对

现象: 代码回滚了,但是配置还是新的

原因: 配置和代码没有绑定

解决: 配置版本化

bash

bash
# 部署时同时打标签
git tag -a v1.2.3 -m "Release 1.2.3"
git push origin v1.2.3

# ConfigMap 也打版本
kubectl create configmap my-config-v1.2.3 --from-file=config.json

问题 4:灰度发布流量不准

现象: 设置 10% 灰度,实际流量 20%+

原因: 副本数计算问题

解决: 使用 Istio VirtualService

yaml

yaml
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: my-frontend
spec:
  http:
  - route:
    - destination:
        host: my-frontend
        subset: stable
      weight: 90
    - destination:
        host: my-frontend
        subset: canary
      weight: 10

核心数据

指标 优化前 优化后
部署时间 30min 4.5min
部署频率 2次/周 50次/天
部署成功率 85% 98%
回滚时间 15min 1min
镜像大小 800MB 80MB
构建时间 8min 1.5min

总结

搭建 CI/CD 的核心是自动化 + 标准化 + 可回滚。代码提交后自动构建部署,每个环境配置标准化,出问题能快速回退。

技术选型上,Jenkins 适合复杂流程,GitLab CI 适合简单场景。Docker 保证环境一致,K8s 管理容器。蓝绿发布适合预发,灰度发布适合生产。

最重要的是要有监控和告警,发现问题能自动回滚,这样才敢频繁发布。