Skip to content

CI/CD 实践 ​

Java 项目的持续集成与持续部署。


目录 ​

  1. CI/CD 基础
  2. GitHub Actions
  3. Docker 多阶段构建
  4. 环境管理
  5. 部署方式

CI/CD 基础 ​

什么是 CI/CD ​

CI/CD 是持续集成(Continuous Integration)、持续交付(Continuous Delivery)和持续部署(Continuous Deployment)的合称,是现代软件开发中最重要的工程实践之一。

持续集成(CI) 指开发人员频繁地将代码变更合并到主干分支,每次合并都会触发自动化构建和测试流程。其核心理念是让问题尽早暴露,避免"集成地狱"。

持续交付(CD) 在 CI 的基础上,确保代码随时可以部署到生产环境。每次通过 CI 的代码变更都会自动部署到类生产环境,经过验收测试后,可以一键发布到生产。

持续部署(CD) 是持续交付的延伸,将通过自动化测试的代码变更自动部署到生产环境,无需人工干预。

CI/CD 的核心价值 ​

  • 快速反馈:提交代码后几分钟内就能知道是否引入了问题
  • 降低风险:小批量变更频繁集成,问题定位范围小
  • 减少手工操作:自动化构建、测试、部署,减少人为失误
  • 提高交付频率:从数月一次发布到一天多次发布
  • 可重复的构建过程:构建环境标准化,消除"在我机器上能运行"的问题

Java 项目 CI/CD 全流程 ​

一个典型的 Java 项目 CI/CD 流程包含以下阶段:

阶段详解:

阶段工具/技术说明
代码提交Git开发者推送代码到远程仓库
代码检查SonarQube / Checkstyle / PMD静态代码分析,检查代码质量和规范
单元测试JUnit / TestNG / Mockito运行单元测试,验证代码逻辑
编译打包Maven / Gradle编译源码并打包为 JAR/WAR
构建镜像Docker / Jib将应用打包为 Docker 镜像
部署测试环境kubectl / Helm / Ansible部署到测试或预发布环境
集成测试REST Assured / Selenium / Testcontainers运行集成测试和端到端测试
部署生产Spinnaker / ArgoCD / Jenkins发布到生产环境

CI/CD 工具生态 ​

CI/CD 平台:

  • Jenkins:老牌 CI/CD 工具,插件丰富,Pipeline as Code
  • GitHub Actions:与 GitHub 深度集成,YAML 配置,生态活跃
  • GitLab CI:与 GitLab 深度集成,功能强大
  • CircleCI:云端 CI 服务,速度较快
  • Azure DevOps:微软提供的 DevOps 全流程工具
  • Drone:轻量级容器化 CI/CD 工具

构建工具:

  • Maven:约定优于配置,XML 配置,生态成熟
  • Gradle:灵活性高,Groovy/Kotlin DSL,性能优于 Maven

质量工具:

  • SonarQube:代码质量与安全分析
  • JaCoCo:代码覆盖率统计
  • Checkstyle:代码风格检查
  • PMD:潜在缺陷检测
  • SpotBugs:字节码级别缺陷检测

GitHub Actions ​

基本概念 ​

GitHub Actions 是 GitHub 内置的 CI/CD 引擎,使用 YAML 文件定义工作流。

核心概念:

概念说明
Workflow一个完整的自动化流程,定义在 .github/workflows/ 目录下的 YAML 文件中
Event触发 Workflow 的事件,如 push、pull_request、schedule
JobWorkflow 中的一个任务单元,包含多个 Step,默认并行执行
StepJob 中的一个步骤,可以是运行命令或使用 Action
Action可复用的工作单元,可以从 Marketplace 获取或自定义
Runner运行 Workflow 的服务器,GitHub 托管或自托管

Workflow 基本结构 ​

yaml
name: Java CI

# 触发条件
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

# 环境变量
env:
  JAVA_VERSION: '17'

# 任务定义
jobs:
  build:
    # 运行环境
    runs-on: ubuntu-latest

    # 步骤
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK
        uses: actions/setup-java@v4
        with:
          java-version: ${{ env.JAVA_VERSION }}
          distribution: 'temurin'

      - name: Build with Maven
        run: mvn clean verify
name: Java CI

# 触发条件
on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]

# 环境变量
env:
  JAVA_VERSION: '17'

# 任务定义
jobs:
  build:
    # 运行环境
    runs-on: ubuntu-latest

    # 步骤
    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK
        uses: actions/setup-java@v4
        with:
          java-version: ${{ env.JAVA_VERSION }}
          distribution: 'temurin'

      - name: Build with Maven
        run: mvn clean verify

Maven 构建工作流 ​

一个完整的 Maven 项目 CI 工作流示例:

yaml
name: Maven CI

on:
  push:
    branches: [main, develop, feature/**]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Build and test
        run: mvn clean verify -B

      - name: Upload test reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-reports
          path: '**/target/surefire-reports/*.xml'

      - name: Upload JAR artifact
        uses: actions/upload-artifact@v4
        with:
          name: application-jar
          path: target/*.jar
name: Maven CI

on:
  push:
    branches: [main, develop, feature/**]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Build and test
        run: mvn clean verify -B

      - name: Upload test reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-reports
          path: '**/target/surefire-reports/*.xml'

      - name: Upload JAR artifact
        uses: actions/upload-artifact@v4
        with:
          name: application-jar
          path: target/*.jar

Maven 缓存优化:

GitHub Actions 的内置 setup-java Action 已经支持 Maven 依赖缓存。设置 cache: maven 后会自动缓存 ~/.m2/repository 目录,将后续构建的时间从几分钟缩短到几十秒。

缓存策略说明:

  • 缓存键基于 pom.xml 的哈希值生成
  • 如果 pom.xml 发生变化,缓存会失效并重建
  • 命中缓存时跳过依赖下载,大幅加速构建

Gradle 构建工作流 ​

yaml
name: Gradle CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: gradle

      - name: Grant execute permission for Gradle wrapper
        run: chmod +x gradlew

      - name: Build with Gradle
        run: ./gradlew clean build

      - name: Run tests
        run: ./gradlew test

      - name: Publish Test Report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: build/reports/tests/
name: Gradle CI

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
          cache: gradle

      - name: Grant execute permission for Gradle wrapper
        run: chmod +x gradlew

      - name: Build with Gradle
        run: ./gradlew clean build

      - name: Run tests
        run: ./gradlew test

      - name: Publish Test Report
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-results
          path: build/reports/tests/

Gradle Wrapper 最佳实践:

  • 将 gradle-wrapper.jar 和 gradlew 提交到代码仓库
  • 这样 CI 环境无需预先安装 Gradle
  • 确保所有开发者使用相同的 Gradle 版本
  • GitHub Actions 中通过 cache: gradle 缓存 ~/.gradle/caches 目录

多 JDK 版本矩阵测试 ​

为了确保应用在不同 JDK 版本上的兼容性,可以使用矩阵策略并行测试:

yaml
name: Multi-JDK Test

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        java: [11, 17, 21]
        distribution: [temurin, zulu]

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK ${{ matrix.java }} (${{ matrix.distribution }})
        uses: actions/setup-java@v4
        with:
          java-version: ${{ matrix.java }}
          distribution: ${{ matrix.distribution }}
          cache: maven

      - name: Run tests
        run: mvn clean test

      - name: Check Java version
        run: java -version
name: Multi-JDK Test

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        java: [11, 17, 21]
        distribution: [temurin, zulu]

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK ${{ matrix.java }} (${{ matrix.distribution }})
        uses: actions/setup-java@v4
        with:
          java-version: ${{ matrix.java }}
          distribution: ${{ matrix.distribution }}
          cache: maven

      - name: Run tests
        run: mvn clean test

      - name: Check Java version
        run: java -version

矩阵策略高级用法:

yaml
jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        java: [17, 21]
        spring-boot: [3.1.x, 3.2.x, 3.3.x]
        exclude:
          # 排除不需要的组合
          - java: 17
            spring-boot: 3.3.x

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK ${{ matrix.java }}
        uses: actions/setup-java@v4
        with:
          java-version: ${{ matrix.java }}
          distribution: 'temurin'
          cache: maven

      - name: Test with Spring Boot ${{ matrix.spring-boot }}
        run: mvn test -Dspring-boot.version=${{ matrix.spring-boot }}
jobs:
  test:
    runs-on: ubuntu-latest

    strategy:
      matrix:
        java: [17, 21]
        spring-boot: [3.1.x, 3.2.x, 3.3.x]
        exclude:
          # 排除不需要的组合
          - java: 17
            spring-boot: 3.3.x

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK ${{ matrix.java }}
        uses: actions/setup-java@v4
        with:
          java-version: ${{ matrix.java }}
          distribution: 'temurin'
          cache: maven

      - name: Test with Spring Boot ${{ matrix.spring-boot }}
        run: mvn test -Dspring-boot.version=${{ matrix.spring-boot }}

SonarQube 代码分析 ​

集成 SonarQube 进行代码质量和安全分析:

yaml
name: SonarQube Analysis

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  sonar:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # SonarQube 需要完整 git 历史

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Cache SonarQube packages
        uses: actions/cache@v4
        with:
          path: ~/.sonar/cache
          key: ${{ runner.os }}-sonar
          restore-keys: ${{ runner.os }}-sonar

      - name: SonarQube Analysis
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
          SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
        run: mvn verify sonar:sonar \
          -Dsonar.token=${{ secrets.SONAR_TOKEN }} \
          -Dsonar.host.url=${{ secrets.SONAR_HOST_URL }} \
          -Dsonar.qualitygate.wait=true
name: SonarQube Analysis

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  sonar:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # SonarQube 需要完整 git 历史

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Cache SonarQube packages
        uses: actions/cache@v4
        with:
          path: ~/.sonar/cache
          key: ${{ runner.os }}-sonar
          restore-keys: ${{ runner.os }}-sonar

      - name: SonarQube Analysis
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
          SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
        run: mvn verify sonar:sonar \
          -Dsonar.token=${{ secrets.SONAR_TOKEN }} \
          -Dsonar.host.url=${{ secrets.SONAR_HOST_URL }} \
          -Dsonar.qualitygate.wait=true

SonarQube 质量门禁:

质量门禁(Quality Gate)是 SonarQube 的核心功能,在 CI 管道中用于判断代码质量是否达标。常见门禁指标:

指标阈值说明
可靠性评级A没有 Bug
安全评级A没有安全漏洞
可维护性评级A代码异味在可接受范围
覆盖率>= 80%单元测试覆盖率
重复率<= 3%代码重复率

设置 sonar.qualitygate.wait=true 会让构建等待 SonarQube 质量门禁结果,未通过则构建失败。

Docker 构建与推送 ​

yaml
name: Build and Push Docker Image

on:
  push:
    branches: [main]
    tags: ['v*']

jobs:
  docker:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Build JAR
        run: mvn clean package -DskipTests

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: your-dockerhub-user/app-name
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=ref,event=branch
            type=sha,format=long

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
name: Build and Push Docker Image

on:
  push:
    branches: [main]
    tags: ['v*']

jobs:
  docker:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Build JAR
        run: mvn clean package -DskipTests

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: your-dockerhub-user/app-name
          tags: |
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=ref,event=branch
            type=sha,format=long

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

镜像标签策略最佳实践:

  • v1.2.3 — 语义化版本标签,用于正式发布
  • v1.2 — 次要版本标签,指向最新补丁版本
  • main — 分支标签,用于开发环境
  • sha-xxxxxx — 唯一标识,用于回滚定位
  • latest — 谨慎使用,容易造成混淆

部署到服务器 ​

通过 SSH 将应用部署到远程服务器:

yaml
name: Deploy to Server

on:
  workflow_run:
    workflows: ['Build and Push Docker Image']
    types:
      - completed
  workflow_dispatch:  # 支持手动触发

jobs:
  deploy:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}

    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          port: ${{ secrets.DEPLOY_PORT }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd /opt/app
            docker-compose pull
            docker-compose up -d --remove-orphans
            docker system prune -f

      - name: Health check
        run: |
          sleep 10
          curl -f http://${{ secrets.DEPLOY_HOST }}:8080/actuator/health
name: Deploy to Server

on:
  workflow_run:
    workflows: ['Build and Push Docker Image']
    types:
      - completed
  workflow_dispatch:  # 支持手动触发

jobs:
  deploy:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}

    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          port: ${{ secrets.DEPLOY_PORT }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            cd /opt/app
            docker-compose pull
            docker-compose up -d --remove-orphans
            docker system prune -f

      - name: Health check
        run: |
          sleep 10
          curl -f http://${{ secrets.DEPLOY_HOST }}:8080/actuator/health

SSH 部署安全注意事项:

  • 使用 SSH 私钥认证,而非密码
  • 私钥存储为 GitHub Secrets
  • 使用专用的部署用户,权限最小化
  • 配置 SSH 免密 sudo 执行特定命令
  • 考虑使用 Bastion 主机作为跳板机

GitHub Actions 进阶技巧 ​

工作流复用:

yaml
# .github/workflows/maven-build.yml (可复用的工作流)
on:
  workflow_call:
    inputs:
      java-version:
        required: true
        type: string
    secrets:
      SONAR_TOKEN:
        required: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up JDK
        uses: actions/setup-java@v4
        with:
          java-version: ${{ inputs.java-version }}
          distribution: 'temurin'
      - run: mvn clean verify
# .github/workflows/maven-build.yml (可复用的工作流)
on:
  workflow_call:
    inputs:
      java-version:
        required: true
        type: string
    secrets:
      SONAR_TOKEN:
        required: false

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up JDK
        uses: actions/setup-java@v4
        with:
          java-version: ${{ inputs.java-version }}
          distribution: 'temurin'
      - run: mvn clean verify
yaml
# 在另一个工作流中调用
jobs:
  call-build:
    uses: ./.github/workflows/maven-build.yml
    with:
      java-version: '17'
    secrets:
      SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
# 在另一个工作流中调用
jobs:
  call-build:
    uses: ./.github/workflows/maven-build.yml
    with:
      java-version: '17'
    secrets:
      SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

条件执行:

yaml
steps:
  - name: Run on main branch only
    if: github.ref == 'refs/heads/main'
    run: echo "Deploying to production"

  - name: Run on PRs
    if: github.event_name == 'pull_request'
    run: echo "Running PR checks"

  - name: Run on tag push
    if: startsWith(github.ref, 'refs/tags/v')
    run: echo "Releasing version"

  - name: Always run, even if previous steps fail
    if: always()
    run: echo "Cleanup"

  - name: Run only on failure
    if: failure()
    run: echo "Send notification"
steps:
  - name: Run on main branch only
    if: github.ref == 'refs/heads/main'
    run: echo "Deploying to production"

  - name: Run on PRs
    if: github.event_name == 'pull_request'
    run: echo "Running PR checks"

  - name: Run on tag push
    if: startsWith(github.ref, 'refs/tags/v')
    run: echo "Releasing version"

  - name: Always run, even if previous steps fail
    if: always()
    run: echo "Cleanup"

  - name: Run only on failure
    if: failure()
    run: echo "Send notification"

手动审批环境:

yaml
jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment:
      name: staging
    steps:
      - run: echo "Deploy to staging"

  deploy-prod:
    needs: [deploy-staging]
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://your-app.com
    steps:
      - run: echo "Deploy to production"
jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment:
      name: staging
    steps:
      - run: echo "Deploy to staging"

  deploy-prod:
    needs: [deploy-staging]
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://your-app.com
    steps:
      - run: echo "Deploy to production"

通过在 GitHub 仓库 Settings > Environments 中配置 production 环境的 Required reviewers,可以在部署生产前添加人工审批环节。


Docker 多阶段构建 ​

为什么需要多阶段构建 ​

传统的 Java 应用 Docker 构建面临两个问题:

  1. 镜像体积过大:Maven 镜像通常超过 200MB,JDK 镜像超过 300MB,构建产物中包含了编译工具和依赖,这些在生产运行时完全不需要
  2. 构建产物不一致:本地环境和 CI 环境使用不同版本的构建工具,可能导致构建结果不同

多阶段构建(Multi-stage Build)通过在 Dockerfile 中使用多个 FROM 语句,将构建环境和运行环境分离,有效解决以上问题。

基础多阶段构建 ​

dockerfile
# Stage 1: 构建阶段
FROM maven:3.9-eclipse-temurin-17 AS builder

WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B

COPY src ./src
RUN mvn clean package -DskipTests

# Stage 2: 运行阶段
FROM eclipse-temurin:17-jre-alpine

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
# Stage 1: 构建阶段
FROM maven:3.9-eclipse-temurin-17 AS builder

WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B

COPY src ./src
RUN mvn clean package -DskipTests

# Stage 2: 运行阶段
FROM eclipse-temurin:17-jre-alpine

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar

EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

构建结果对比:

方案镜像大小包含内容
单阶段构建(Maven)~700MBJDK + Maven + 依赖 + 源码 + 编译产物
多阶段构建(JRE)~180MBJRE + 编译产物
多阶段 + Alpine~120MBJRE 精简版 + 编译产物
多阶段 + Distroless~100MB运行时所需的最小文件

瘦 JAR + 外部依赖层 ​

Spring Boot 打包的 fat JAR 包含所有依赖,导致每次代码变更都需要重新下载层缓存。可以将依赖分层,利用 Docker 的层缓存加速构建:

dockerfile
# Stage 1: 依赖下载阶段
FROM maven:3.9-eclipse-temurin-17 AS deps

WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B

# Stage 2: 构建阶段
FROM maven:3.9-eclipse-temurin-17 AS builder

WORKDIR /app
COPY --from=deps /root/.m2 /root/.m2
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests

# Stage 3: 运行阶段 - 利用 Spring Boot 分层
FROM eclipse-temurin:17-jre-alpine AS extractor

WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

# Stage 4: 最终运行镜像
FROM eclipse-temurin:17-jre-alpine

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

WORKDIR /app
COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/application/ ./

EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]
# Stage 1: 依赖下载阶段
FROM maven:3.9-eclipse-temurin-17 AS deps

WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B

# Stage 2: 构建阶段
FROM maven:3.9-eclipse-temurin-17 AS builder

WORKDIR /app
COPY --from=deps /root/.m2 /root/.m2
COPY pom.xml .
COPY src ./src
RUN mvn clean package -DskipTests

# Stage 3: 运行阶段 - 利用 Spring Boot 分层
FROM eclipse-temurin:17-jre-alpine AS extractor

WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract

# Stage 4: 最终运行镜像
FROM eclipse-temurin:17-jre-alpine

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

WORKDIR /app
COPY --from=extractor /app/dependencies/ ./
COPY --from=extractor /app/spring-boot-loader/ ./
COPY --from=extractor /app/snapshot-dependencies/ ./
COPY --from=extractor /app/application/ ./

EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

Spring Boot 分层说明:

Spring Boot 2.3+ 支持 JAR 分层(jarmode=layertools),将 JAR 包解压为四层:

层内容变化频率缓存策略
dependencies项目依赖的第三方 JAR低(修改 pom.xml 时)长期缓存
spring-boot-loaderSpring Boot 加载器类极低(升级 Boot 版本时)几乎不变
snapshot-dependenciesSNAPSHOT 版本的依赖中按需缓存
application应用代码和资源高(每次代码变更)频繁更新

利用分层后,大部分时候只需要重新构建 application 层,前面三层可以直接使用缓存,极大加速 CI 构建。

Jib 构建 ​

Google 的 Jib 工具无需 Dockerfile,直接通过 Maven/Gradle 插件构建优化过的 Docker 镜像:

Maven 配置:

xml
<plugin>
    <groupId>com.google.cloud.tools</groupId>
    <artifactId>jib-maven-plugin</artifactId>
    <version>3.4.0</version>
    <configuration>
        <from>
            <image>eclipse-temurin:17-jre-alpine</image>
        </from>
        <to>
            <image>your-registry/app-name</image>
            <tags>
                <tag>${project.version}</tag>
                <tag>latest</tag>
            </tags>
        </to>
        <container>
            <jvmFlags>
                <jvmFlag>-Xms512m</jvmFlag>
                <jvmFlag>-Xmx512m</jvmFlag>
            </jvmFlags>
            <ports>
                <port>8080</port>
            </ports>
        </container>
    </configuration>
</plugin>
<plugin>
    <groupId>com.google.cloud.tools</groupId>
    <artifactId>jib-maven-plugin</artifactId>
    <version>3.4.0</version>
    <configuration>
        <from>
            <image>eclipse-temurin:17-jre-alpine</image>
        </from>
        <to>
            <image>your-registry/app-name</image>
            <tags>
                <tag>${project.version}</tag>
                <tag>latest</tag>
            </tags>
        </to>
        <container>
            <jvmFlags>
                <jvmFlag>-Xms512m</jvmFlag>
                <jvmFlag>-Xmx512m</jvmFlag>
            </jvmFlags>
            <ports>
                <port>8080</port>
            </ports>
        </container>
    </configuration>
</plugin>

构建命令:

bash
# 构建并推送到镜像仓库
mvn jib:build

# 仅构建到 Docker daemon(本地使用)
mvn jib:dockerBuild

# 构建到指定 tar 文件(离线分发)
mvn jib:buildTar
# 构建并推送到镜像仓库
mvn jib:build

# 仅构建到 Docker daemon(本地使用)
mvn jib:dockerBuild

# 构建到指定 tar 文件(离线分发)
mvn jib:buildTar

Jib 优势:

  • 无需 Docker daemon,可在任何环境中构建
  • 自动分层,优化缓存
  • 与 Maven/Gradle 无缝集成
  • 支持多种镜像仓库认证方式

镜像优化技巧 ​

选择更小的基础镜像:

dockerfile
# 标准 JDK 镜像 (~300MB)
FROM eclipse-temurin:17-jdk

# JRE 镜像 (~180MB)
FROM eclipse-temurin:17-jre

# Alpine + JRE (~120MB)
FROM eclipse-temurin:17-jre-alpine

# Distroless (~100MB)
FROM gcr.io/distroless/java17-debian12
# 标准 JDK 镜像 (~300MB)
FROM eclipse-temurin:17-jdk

# JRE 镜像 (~180MB)
FROM eclipse-temurin:17-jre

# Alpine + JRE (~120MB)
FROM eclipse-temurin:17-jre-alpine

# Distroless (~100MB)
FROM gcr.io/distroless/java17-debian12

Distroless 镜像特点:

  • 仅包含应用程序及其运行时依赖
  • 不包含包管理器、shell、或其他非必要工具
  • 攻击面最小化
  • 需要额外处理调试和健康检查
dockerfile
# 使用 Distroless 镜像
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn clean package -DskipTests

FROM gcr.io/distroless/java17-debian12
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
# 使用 Distroless 镜像
FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn clean package -DskipTests

FROM gcr.io/distroless/java17-debian12
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

调试 Distroless 容器:

由于 Distroless 镜像没有 shell,调试需要使用:

bash
# 使用 debug 镜像
FROM gcr.io/distroless/java17-debian12:debug

# 或者在运行时附加调试容器
kubectl debug -it pod-name --image=busybox --target=app-container
# 使用 debug 镜像
FROM gcr.io/distroless/java17-debian12:debug

# 或者在运行时附加调试容器
kubectl debug -it pod-name --image=busybox --target=app-container

使用 Spring Boot 的 AOT 编译优化:

Spring Boot 3.0+ 支持 AOT(Ahead-of-Time)编译,配合 GraalVM Native Image 可以显著减小镜像大小和启动时间:

dockerfile
FROM ghcr.io/graalvm/jdk:ol8-java17 AS builder
WORKDIR /app
COPY . .
RUN ./mvnw native:compile -Pnative

FROM gcr.io/distroless/java-base-debian12:latest
COPY --from=builder /app/target/app /app/app
EXPOSE 8080
ENTRYPOINT ["/app/app"]
FROM ghcr.io/graalvm/jdk:ol8-java17 AS builder
WORKDIR /app
COPY . .
RUN ./mvnw native:compile -Pnative

FROM gcr.io/distroless/java-base-debian12:latest
COPY --from=builder /app/target/app /app/app
EXPOSE 8080
ENTRYPOINT ["/app/app"]

Native Image 的优势:

  • 启动时间从秒级降到毫秒级
  • 内存占用降低 50-80%
  • 镜像大小可以压缩到 50MB 以内
  • 无需 JVM,直接作为系统可执行文件运行

环境管理 ​

多环境配置 ​

Java 项目通常需要管理多套环境配置,常用的方案:

方案一:Spring Profile

yaml
# application.yml
spring:
  profiles:
    active: ${SPRING_PROFILES_ACTIVE:dev}

---
# application-dev.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/dev_db
    username: dev_user
    password: dev_pass

---
# application-prod.yml
spring:
  datasource:
    url: jdbc:mysql://prod-db-host:3306/prod_db
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}
# application.yml
spring:
  profiles:
    active: ${SPRING_PROFILES_ACTIVE:dev}

---
# application-dev.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/dev_db
    username: dev_user
    password: dev_pass

---
# application-prod.yml
spring:
  datasource:
    url: jdbc:mysql://prod-db-host:3306/prod_db
    username: ${DB_USERNAME}
    password: ${DB_PASSWORD}

启动时通过环境变量指定 Profile:

bash
java -jar app.jar --spring.profiles.active=prod
# 或
SPRING_PROFILES_ACTIVE=prod java -jar app.jar
java -jar app.jar --spring.profiles.active=prod
# 或
SPRING_PROFILES_ACTIVE=prod java -jar app.jar

方案二:Maven Profile + 资源过滤

xml
<profiles>
    <profile>
        <id>dev</id>
        <activation>
            <activeByDefault>true</activeByDefault>
        </activation>
        <properties>
            <build.profile.id>dev</build.profile.id>
        </properties>
    </profile>
    <profile>
        <id>prod</id>
        <properties>
            <build.profile.id>prod</build.profile.id>
        </properties>
    </profile>
</profiles>
<profiles>
    <profile>
        <id>dev</id>
        <activation>
            <activeByDefault>true</activeByDefault>
        </activation>
        <properties>
            <build.profile.id>dev</build.profile.id>
        </properties>
    </profile>
    <profile>
        <id>prod</id>
        <properties>
            <build.profile.id>prod</build.profile.id>
        </properties>
    </profile>
</profiles>

配合 Maven 资源过滤,在构建时替换占位符:

yaml
# application.yml (模板)
database:
  url: jdbc:mysql://${db.host}:3306/app_db
  username: ${db.username}
# application.yml (模板)
database:
  url: jdbc:mysql://${db.host}:3306/app_db
  username: ${db.username}
bash
# 构建不同环境的包
mvn clean package -Pdev
mvn clean package -Pprod
# 构建不同环境的包
mvn clean package -Pdev
mvn clean package -Pprod

方案三:外部配置中心

生产环境推荐使用配置中心统一管理配置:

配置中心说明
Spring Cloud Config基于 Git 的配置中心
Alibaba Nacos配置注册一体化
HashiCorp Consul服务发现 + 配置管理
Apache Apollo携程开源的配置中心

CI/CD 中的配置管理 ​

GitHub Secrets 安全管理:

敏感信息不应硬编码在代码中,应通过 CI/CD 平台提供的 Secrets 功能注入:

yaml
steps:
  - name: Build with secrets
    env:
      DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
      API_KEY: ${{ secrets.API_KEY }}
      MAVEN_SETTINGS: ${{ secrets.MAVEN_SETTINGS_XML }}
    run: |
      # 将 Maven settings 写入文件
      echo "$MAVEN_SETTINGS" > ~/.m2/settings.xml
      mvn clean package
steps:
  - name: Build with secrets
    env:
      DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
      API_KEY: ${{ secrets.API_KEY }}
      MAVEN_SETTINGS: ${{ secrets.MAVEN_SETTINGS_XML }}
    run: |
      # 将 Maven settings 写入文件
      echo "$MAVEN_SETTINGS" > ~/.m2/settings.xml
      mvn clean package

常见需要保密的配置:

  • 数据库连接串和密码
  • API 密钥和 Token
  • JWT 签名密钥
  • TLS/SSL 证书私钥
  • 第三方服务的认证凭据

环境变量管理策略:

开发环境 (dev)
  ├─ 默认配置写在 application-dev.yml 中
  ├─ 敏感信息使用本地 .env 文件(不提交到 Git)
  └─ CI 中通过组织级别的 Secrets 注入

测试环境 (staging)
  ├─ 使用与生产环境尽可能一致的配置
  ├─ 数据使用脱敏后的生产数据副本
  └─ 所有秘密通过 GitHub Environments Secrets 管理

生产环境 (prod)
  ├─ 所有配置通过环境变量或配置中心注入
  ├─ 不同实例使用不同的密钥
  └─ 开启自动轮转机制
开发环境 (dev)
  ├─ 默认配置写在 application-dev.yml 中
  ├─ 敏感信息使用本地 .env 文件(不提交到 Git)
  └─ CI 中通过组织级别的 Secrets 注入

测试环境 (staging)
  ├─ 使用与生产环境尽可能一致的配置
  ├─ 数据使用脱敏后的生产数据副本
  └─ 所有秘密通过 GitHub Environments Secrets 管理

生产环境 (prod)
  ├─ 所有配置通过环境变量或配置中心注入
  ├─ 不同实例使用不同的密钥
  └─ 开启自动轮转机制

数据库迁移的 CI/CD 处理 ​

Flyway 是 Java 生态中最流行的数据库迁移工具,它使用版本化的 SQL 脚本管理数据库 schema 变更。

Flyway 集成 Maven:

xml
<plugin>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-maven-plugin</artifactId>
    <version>9.22.0</version>
    <configuration>
        <url>${flyway.url}</url>
        <user>${flyway.user}</user>
        <password>${flyway.password}</password>
    </configuration>
</plugin>
<plugin>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-maven-plugin</artifactId>
    <version>9.22.0</version>
    <configuration>
        <url>${flyway.url}</url>
        <user>${flyway.user}</user>
        <password>${flyway.password}</password>
    </configuration>
</plugin>

迁移脚本命名规范:

src/main/resources/db/migration/
├── V1__create_user_table.sql
├── V1.1__add_email_to_user.sql
├── V2__create_order_table.sql
├── V2.1__add_index_to_order.sql
├── V3__add_order_status.sql
└── R__daily_cleanup.sql  -- 可重复执行的脚本
src/main/resources/db/migration/
├── V1__create_user_table.sql
├── V1.1__add_email_to_user.sql
├── V2__create_order_table.sql
├── V2.1__add_index_to_order.sql
├── V3__add_order_status.sql
└── R__daily_cleanup.sql  -- 可重复执行的脚本

CI 中的数据库迁移流程:

yaml
name: Database Migration CI

on:
  push:
    branches: [main]
    paths:
      - 'src/main/resources/db/migration/**'

jobs:
  validate-migration:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: testpass
          MYSQL_DATABASE: testdb
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Validate migrations
        run: mvn flyway:validate flyway:migrate
        env:
          flyway.url: jdbc:mysql://localhost:3306/testdb
          flyway.user: root
          flyway.password: testpass

      - name: Run integration tests
        run: mvn test -Pintegration-test
        env:
          SPRING_DATASOURCE_URL: jdbc:mysql://localhost:3306/testdb
          SPRING_DATASOURCE_USERNAME: root
          SPRING_DATASOURCE_PASSWORD: testpass
name: Database Migration CI

on:
  push:
    branches: [main]
    paths:
      - 'src/main/resources/db/migration/**'

jobs:
  validate-migration:
    runs-on: ubuntu-latest

    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: testpass
          MYSQL_DATABASE: testdb
        ports:
          - 3306:3306
        options: >-
          --health-cmd="mysqladmin ping"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Validate migrations
        run: mvn flyway:validate flyway:migrate
        env:
          flyway.url: jdbc:mysql://localhost:3306/testdb
          flyway.user: root
          flyway.password: testpass

      - name: Run integration tests
        run: mvn test -Pintegration-test
        env:
          SPRING_DATASOURCE_URL: jdbc:mysql://localhost:3306/testdb
          SPRING_DATASOURCE_USERNAME: root
          SPRING_DATASOURCE_PASSWORD: testpass

CI/CD 中数据库迁移的最佳实践:

  1. 迁移脚本向前兼容:新的迁移不应破坏正在运行的旧版本应用,确保零停机部署
  2. 不可逆迁移需谨慎:破坏性变更(如删除列)应分多个版本逐步进行
  3. 在 CI 中验证迁移:每次提交都使用独立的数据库实例验证迁移脚本
  4. 迁移即代码:所有数据库变更都通过迁移脚本管理,禁止手动修改数据库
  5. 回滚策略:虽然 Flyway 不支持自动回滚,但应准备逆向迁移脚本
  6. PR 审核:数据库迁移脚本应和代码一起经过 Code Review

CI/CD 安全最佳实践 ​

Secrets 管理:

  • 使用 CI/CD 平台提供的 Secrets 管理功能
  • 定期轮换密钥
  • 不要将 Secrets 输出到构建日志中
  • 使用专门的 CI/CD 服务账号,权限最小化

依赖安全扫描:

yaml
steps:
  - name: Check for vulnerable dependencies
    run: mvn org.owasp:dependency-check-maven:check

  - name: Run Trivy scan on Docker image
    uses: aquasecurity/trivy-action@master
    with:
      image-ref: 'your-app:latest'
      format: 'sarif'
      output: 'trivy-results.sarif'
steps:
  - name: Check for vulnerable dependencies
    run: mvn org.owasp:dependency-check-maven:check

  - name: Run Trivy scan on Docker image
    uses: aquasecurity/trivy-action@master
    with:
      image-ref: 'your-app:latest'
      format: 'sarif'
      output: 'trivy-results.sarif'

供应链安全:

  • 锁定依赖版本,使用 Maven Enforcer 插件禁止 SNAPSHOT 依赖
  • 验证依赖的签名和校验和
  • 定期扫描已知漏洞
  • 使用 SBOM(Software Bill of Materials)追踪依赖

部署方式 ​

部署到虚拟机(SCP + SSH) ​

传统的部署方式,将打包好的应用通过 SCP 传输到服务器,然后通过 SSH 启动:

yaml
name: Deploy to VM

on:
  push:
    tags: ['v*']

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Build application
        run: mvn clean package -DskipTests

      - name: SCP to server
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          source: "target/app.jar"
          target: "/opt/app/tmp/"
          strip_components: 1

      - name: SSH restart application
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            # 备份旧版本
            TIMESTAMP=$(date +%Y%m%d%H%M%S)
            cp /opt/app/app.jar /opt/app/backups/app-$TIMESTAMP.jar

            # 替换新版本
            mv /opt/app/tmp/app.jar /opt/app/app.jar

            # 重启应用
            sudo systemctl restart myapp

            # 健康检查
            sleep 15
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/actuator/health)
            if [ "$STATUS" != "200" ]; then
              echo "Health check failed!"
              exit 1
            fi

            # 清理
            find /opt/app/backups -name "*.jar" -mtime +30 -delete
name: Deploy to VM

on:
  push:
    tags: ['v*']

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: '17'
          distribution: 'temurin'
          cache: maven

      - name: Build application
        run: mvn clean package -DskipTests

      - name: SCP to server
        uses: appleboy/scp-action@v0.1.7
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          source: "target/app.jar"
          target: "/opt/app/tmp/"
          strip_components: 1

      - name: SSH restart application
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.DEPLOY_HOST }}
          username: ${{ secrets.DEPLOY_USER }}
          key: ${{ secrets.DEPLOY_SSH_KEY }}
          script: |
            # 备份旧版本
            TIMESTAMP=$(date +%Y%m%d%H%M%S)
            cp /opt/app/app.jar /opt/app/backups/app-$TIMESTAMP.jar

            # 替换新版本
            mv /opt/app/tmp/app.jar /opt/app/app.jar

            # 重启应用
            sudo systemctl restart myapp

            # 健康检查
            sleep 15
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/actuator/health)
            if [ "$STATUS" != "200" ]; then
              echo "Health check failed!"
              exit 1
            fi

            # 清理
            find /opt/app/backups -name "*.jar" -mtime +30 -delete

使用 systemd 管理 Java 应用:

ini
[Unit]
Description=My Java Application
After=network.target

[Service]
Type=simple
User=appuser
WorkingDirectory=/opt/app
ExecStart=/usr/bin/java \
  -Xms512m -Xmx1024m \
  -jar /opt/app/app.jar \
  --spring.profiles.active=prod

Restart=always
RestartSec=10

Environment=DB_URL=jdbc:mysql://localhost:3306/prod_db
Environment=SPRING_PROFILES_ACTIVE=prod

[Install]
WantedBy=multi-user.target
[Unit]
Description=My Java Application
After=network.target

[Service]
Type=simple
User=appuser
WorkingDirectory=/opt/app
ExecStart=/usr/bin/java \
  -Xms512m -Xmx1024m \
  -jar /opt/app/app.jar \
  --spring.profiles.active=prod

Restart=always
RestartSec=10

Environment=DB_URL=jdbc:mysql://localhost:3306/prod_db
Environment=SPRING_PROFILES_ACTIVE=prod

[Install]
WantedBy=multi-user.target

部署到 Kubernetes ​

使用 kubectl 直接部署:

yaml
name: Deploy to Kubernetes

on:
  workflow_run:
    workflows: ['Docker Build']
    types: [completed]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up kubectl
        uses: azure/setup-kubectl@v4
        with:
          version: 'latest'

      - name: Configure K8s context
        run: |
          mkdir -p $HOME/.kube
          echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > $HOME/.kube/config

      - name: Deploy to Kubernetes
        run: |
          # 使用环境变量替换模板中的占位符
          export IMAGE_TAG=${{ github.sha }}
          envsubst < k8s/deployment.yaml | kubectl apply -f -
          kubectl apply -f k8s/service.yaml
          kubectl rollout status deployment/myapp --timeout=300s
name: Deploy to Kubernetes

on:
  workflow_run:
    workflows: ['Docker Build']
    types: [completed]

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Set up kubectl
        uses: azure/setup-kubectl@v4
        with:
          version: 'latest'

      - name: Configure K8s context
        run: |
          mkdir -p $HOME/.kube
          echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > $HOME/.kube/config

      - name: Deploy to Kubernetes
        run: |
          # 使用环境变量替换模板中的占位符
          export IMAGE_TAG=${{ github.sha }}
          envsubst < k8s/deployment.yaml | kubectl apply -f -
          kubectl apply -f k8s/service.yaml
          kubectl rollout status deployment/myapp --timeout=300s

Kubernetes 部署清单示例:

yaml
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  namespace: production
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: your-registry/myapp:${IMAGE_TAG}
          ports:
            - containerPort: 8080
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: "prod"
            - name: DB_URL
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: url
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 60
            periodSeconds: 30
          resources:
            requests:
              memory: "512Mi"
              cpu: "250m"
            limits:
              memory: "1Gi"
              cpu: "500m"
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  namespace: production
spec:
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: your-registry/myapp:${IMAGE_TAG}
          ports:
            - containerPort: 8080
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: "prod"
            - name: DB_URL
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: url
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 60
            periodSeconds: 30
          resources:
            requests:
              memory: "512Mi"
              cpu: "250m"
            limits:
              memory: "1Gi"
              cpu: "500m"

使用 Helm 部署:

yaml
- name: Deploy with Helm
  run: |
    helm upgrade --install myapp ./charts/myapp \
      --namespace production \
      --set image.tag=${{ github.sha }} \
      --set replicaCount=3 \
      --set resources.requests.memory=512Mi \
      --wait --timeout 5m
- name: Deploy with Helm
  run: |
    helm upgrade --install myapp ./charts/myapp \
      --namespace production \
      --set image.tag=${{ github.sha }} \
      --set replicaCount=3 \
      --set resources.requests.memory=512Mi \
      --wait --timeout 5m

Helm 的 Chart 结构示例:

charts/myapp/
├── Chart.yaml          # Chart 元数据
├── values.yaml         # 默认配置值
├── values-prod.yaml    # 生产环境覆盖配置
└── templates/
    ├── deployment.yaml
    ├── service.yaml
    ├── ingress.yaml
    ├── configmap.yaml
    └── _helpers.tpl     # 模板辅助函数
charts/myapp/
├── Chart.yaml          # Chart 元数据
├── values.yaml         # 默认配置值
├── values-prod.yaml    # 生产环境覆盖配置
└── templates/
    ├── deployment.yaml
    ├── service.yaml
    ├── ingress.yaml
    ├── configmap.yaml
    └── _helpers.tpl     # 模板辅助函数

蓝绿部署 ​

蓝绿部署(Blue-Green Deployment)维护两套完全相同的生产环境(蓝环境和绿环境),通过切换流量来实现零停机部署。

蓝绿部署流程:

用户流量 → 负载均衡器
                 ↓
         ┌───────┴───────┐
         ↓               ↓
     蓝环境(v2)      绿环境(v1)
     [新版本]        [旧版本]
用户流量 → 负载均衡器
                 ↓
         ┌───────┴───────┐
         ↓               ↓
     蓝环境(v2)      绿环境(v1)
     [新版本]        [旧版本]

部署步骤:

  1. 所有流量当前指向绿环境(旧版本)
  2. 在蓝环境部署新版本应用
  3. 对蓝环境进行健康检查和集成测试
  4. 将负载均衡器从绿环境切换到蓝环境
  5. 观察一段时间,如果出现问题则立即切回绿环境
  6. 确认无误后,释放绿环境资源
yaml
name: Blue-Green Deployment

on:
  workflow_dispatch:
    inputs:
      version:
        required: true
        description: 'Version to deploy'

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Determine target environment
        id: env
        run: |
          # 检查当前活跃环境
          ACTIVE=$(kubectl get svc myapp -n production -o jsonpath='{.spec.selector.env}')
          if [ "$ACTIVE" == "blue" ]; then
            echo "target=green" >> $GITHUB_OUTPUT
            echo "inactive=green" >> $GITHUB_OUTPUT
          else
            echo "target=blue" >> $GITHUB_OUTPUT
            echo "inactive=blue" >> $GITHUB_OUTPUT
          fi

      - name: Deploy to ${{ steps.env.outputs.target }}
        run: |
          kubectl apply -f k8s/deployment-${{ steps.env.outputs.target }}.yaml
          kubectl rollout status deployment/myapp-${{ steps.env.outputs.target }} \
            --namespace production --timeout=300s

      - name: Run smoke tests
        run: |
          TARGET_URL="http://${{ steps.env.outputs.target }}.myapp.internal/actuator/health"
          curl -f --retry 3 --retry-delay 5 $TARGET_URL

      - name: Switch traffic
        run: |
          kubectl patch svc myapp -n production \
            -p "{\"spec\":{\"selector\":{\"env\":\"${{ steps.env.outputs.target }}\"}}}"

      - name: Verify all traffic healthy
        run: |
          sleep 10
          curl -f https://myapp.com/actuator/health --retry 5 --retry-delay 10

      - name: Scale down old environment
        if: success()
        run: |
          kubectl scale deployment myapp-${{ steps.env.outputs.inactive }} \
            --namespace production --replicas=0
name: Blue-Green Deployment

on:
  workflow_dispatch:
    inputs:
      version:
        required: true
        description: 'Version to deploy'

jobs:
  deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Determine target environment
        id: env
        run: |
          # 检查当前活跃环境
          ACTIVE=$(kubectl get svc myapp -n production -o jsonpath='{.spec.selector.env}')
          if [ "$ACTIVE" == "blue" ]; then
            echo "target=green" >> $GITHUB_OUTPUT
            echo "inactive=green" >> $GITHUB_OUTPUT
          else
            echo "target=blue" >> $GITHUB_OUTPUT
            echo "inactive=blue" >> $GITHUB_OUTPUT
          fi

      - name: Deploy to ${{ steps.env.outputs.target }}
        run: |
          kubectl apply -f k8s/deployment-${{ steps.env.outputs.target }}.yaml
          kubectl rollout status deployment/myapp-${{ steps.env.outputs.target }} \
            --namespace production --timeout=300s

      - name: Run smoke tests
        run: |
          TARGET_URL="http://${{ steps.env.outputs.target }}.myapp.internal/actuator/health"
          curl -f --retry 3 --retry-delay 5 $TARGET_URL

      - name: Switch traffic
        run: |
          kubectl patch svc myapp -n production \
            -p "{\"spec\":{\"selector\":{\"env\":\"${{ steps.env.outputs.target }}\"}}}"

      - name: Verify all traffic healthy
        run: |
          sleep 10
          curl -f https://myapp.com/actuator/health --retry 5 --retry-delay 10

      - name: Scale down old environment
        if: success()
        run: |
          kubectl scale deployment myapp-${{ steps.env.outputs.inactive }} \
            --namespace production --replicas=0

滚动升级 ​

滚动升级(Rolling Update)是 Kubernetes 默认的部署策略,逐步用新版本 Pod 替换旧版本 Pod,过程中保持服务可用。

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1        # 最多超出期望副本数 1 个
      maxUnavailable: 0  # 部署期间必须保持所有 Pod 可用
  minReadySeconds: 30    # Pod 就绪后等待 30 秒再继续
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1        # 最多超出期望副本数 1 个
      maxUnavailable: 0  # 部署期间必须保持所有 Pod 可用
  minReadySeconds: 30    # Pod 就绪后等待 30 秒再继续

滚动升级过程:

初始状态: 5 个旧版本 Pod

Step 1: 创建 1 个新 Pod,总数变为 6(maxSurge=1)
  旧旧旧旧旧 新

Step 2: 新 Pod 就绪后,终止 1 个旧 Pod
  旧旧旧旧 新

Step 3: 创建下一个新 Pod
  旧旧旧旧 新新

Step 4: 新 Pod 就绪后,终止下一个旧 Pod
  旧旧旧 新新

...重复直到所有 Pod 都替换为新版本...
初始状态: 5 个旧版本 Pod

Step 1: 创建 1 个新 Pod,总数变为 6(maxSurge=1)
  旧旧旧旧旧 新

Step 2: 新 Pod 就绪后,终止 1 个旧 Pod
  旧旧旧旧 新

Step 3: 创建下一个新 Pod
  旧旧旧旧 新新

Step 4: 新 Pod 就绪后,终止下一个旧 Pod
  旧旧旧 新新

...重复直到所有 Pod 都替换为新版本...

部署策略对比 ​

策略优点缺点适用场景
滚动升级渐进式替换,资源消耗低回滚速度慢,兼容性要求高常规发布
蓝绿部署即时切换,回滚快需要双倍资源关键业务系统
金丝雀发布风险可控,逐步放量流量管理复杂大版本变更
A/B 测试支持业务验证需要业务指标配合功能验证
重建部署简单直接有停机时间开发/测试环境

金丝雀发布 ​

金丝雀发布(Canary Release)是逐步将新版本流量从小比例增加到全量的策略:

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp-canary
  annotations:
    nginx.ingress.kubernetes.io/canary: "true"
    nginx.ingress.kubernetes.io/canary-weight: "10"  # 10% 流量
spec:
  rules:
    - host: myapp.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: myapp-canary
                port:
                  number: 8080
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp-canary
  annotations:
    nginx.ingress.kubernetes.io/canary: "true"
    nginx.ingress.kubernetes.io/canary-weight: "10"  # 10% 流量
spec:
  rules:
    - host: myapp.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: myapp-canary
                port:
                  number: 8080

金丝雀发布的 CI/CD 集成:

yaml
- name: Deploy canary
  run: |
    kubectl apply -f k8s/deployment-canary.yaml

- name: Monitor canary metrics
  run: |
    # 监控错误率和延迟
    ERROR_RATE=$(curl -s http://prometheus:9090/api/v1/query \
      --data "query=rate(http_requests_total{app='myapp-canary',status=~'5..'}[5m])")
    echo "Canary error rate: $ERROR_RATE"

    # 如果错误率低于阈值,逐步增加流量
    if [ "$ERROR_RATE" -lt "0.01" ]; then
      echo "Canary healthy, increasing traffic to 50%"
      kubectl annotate ingress myapp-canary \
        nginx.ingress.kubernetes.io/canary-weight="50"
    fi

- name: Promote canary to full release
  if: success()
  run: |
    # 将所有流量切换到新版本
    kubectl patch ingress myapp-canary \
      --type=json \
      -p='[{"op": "remove", "path": "/metadata/annotations/nginx.ingress.kubernetes.io/canary"}]'
    kubectl scale deployment myapp-stable --replicas=0
- name: Deploy canary
  run: |
    kubectl apply -f k8s/deployment-canary.yaml

- name: Monitor canary metrics
  run: |
    # 监控错误率和延迟
    ERROR_RATE=$(curl -s http://prometheus:9090/api/v1/query \
      --data "query=rate(http_requests_total{app='myapp-canary',status=~'5..'}[5m])")
    echo "Canary error rate: $ERROR_RATE"

    # 如果错误率低于阈值,逐步增加流量
    if [ "$ERROR_RATE" -lt "0.01" ]; then
      echo "Canary healthy, increasing traffic to 50%"
      kubectl annotate ingress myapp-canary \
        nginx.ingress.kubernetes.io/canary-weight="50"
    fi

- name: Promote canary to full release
  if: success()
  run: |
    # 将所有流量切换到新版本
    kubectl patch ingress myapp-canary \
      --type=json \
      -p='[{"op": "remove", "path": "/metadata/annotations/nginx.ingress.kubernetes.io/canary"}]'
    kubectl scale deployment myapp-stable --replicas=0

部署后的监控与回滚 ​

健康检查:

yaml
- name: Post-deployment health check
  run: |
    # 多维度健康检查
    ENDPOINTS=(
      "https://myapp.com/actuator/health"
      "https://myapp.com/actuator/info"
      "https://myapp.com/api/ping"
    )

    for endpoint in "${ENDPOINTS[@]}"; do
      STATUS=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 $endpoint)
      if [ "$STATUS" != "200" ]; then
        echo "Health check failed: $endpoint returned $STATUS"
        exit 1
      fi
    done
- name: Post-deployment health check
  run: |
    # 多维度健康检查
    ENDPOINTS=(
      "https://myapp.com/actuator/health"
      "https://myapp.com/actuator/info"
      "https://myapp.com/api/ping"
    )

    for endpoint in "${ENDPOINTS[@]}"; do
      STATUS=$(curl -s -o /dev/null -w "%{http_code}" --connect-timeout 5 $endpoint)
      if [ "$STATUS" != "200" ]; then
        echo "Health check failed: $endpoint returned $STATUS"
        exit 1
      fi
    done

自动回滚策略:

yaml
- name: Monitor and rollback if needed
  run: |
    # 部署后监控 5 分钟
    for i in $(seq 1 10); do
      ERROR_RATE=$(curl -s "http://prometheus:9090/api/v1/query" \
        --data "query=sum(rate(http_requests_total{status=~'5..'}[1m]))/sum(rate(http_requests_total[1m]))")

      if (( $(echo "$ERROR_RATE > 0.01" | bc -l) )); then
        echo "Error rate $ERROR_RATE exceeds threshold, triggering rollback..."
        kubectl rollout undo deployment/myapp
        exit 1
      fi

      sleep 30
    done
- name: Monitor and rollback if needed
  run: |
    # 部署后监控 5 分钟
    for i in $(seq 1 10); do
      ERROR_RATE=$(curl -s "http://prometheus:9090/api/v1/query" \
        --data "query=sum(rate(http_requests_total{status=~'5..'}[1m]))/sum(rate(http_requests_total[1m]))")

      if (( $(echo "$ERROR_RATE > 0.01" | bc -l) )); then
        echo "Error rate $ERROR_RATE exceeds threshold, triggering rollback..."
        kubectl rollout undo deployment/myapp
        exit 1
      fi

      sleep 30
    done

Kubernetes 回滚操作:

bash
# 回滚到上一个版本
kubectl rollout undo deployment/myapp

# 回滚到指定版本
kubectl rollout undo deployment/myapp --to-revision=3

# 查看部署历史
kubectl rollout history deployment/myapp

# 查看回滚状态
kubectl rollout status deployment/myapp
# 回滚到上一个版本
kubectl rollout undo deployment/myapp

# 回滚到指定版本
kubectl rollout undo deployment/myapp --to-revision=3

# 查看部署历史
kubectl rollout history deployment/myapp

# 查看回滚状态
kubectl rollout status deployment/myapp

总结 ​

CI/CD 是 Java 项目 DevOps 实践的核心,本文涵盖了从基础概念到具体实现的全流程:

  1. CI/CD 基础:理解了持续集成、持续交付和持续部署的概念与价值
  2. GitHub Actions:掌握了工作流定义、Maven/Gradle 构建、矩阵测试、SonarQube 分析和 Docker 构建推送
  3. Docker 多阶段构建:学会了分层构建、镜像优化、Spring Boot 分层打包和 Jib 工具
  4. 环境管理:了解了多环境配置方案、Secrets 安全管理和数据库迁移处理
  5. 部署方式:覆盖了虚拟机部署、K8s 部署、蓝绿部署、滚动升级和金丝雀发布等策略

推荐实践路线:

入门阶段:GitHub Actions + Maven + 单阶段 Docker 构建 + 虚拟机部署
进阶阶段:增加 SonarQube + 多阶段构建 + Docker 镜像优化
高级阶段:引入 K8s + Helm + 蓝绿/金丝雀部署 + 自动化监控回滚
入门阶段:GitHub Actions + Maven + 单阶段 Docker 构建 + 虚拟机部署
进阶阶段:增加 SonarQube + 多阶段构建 + Docker 镜像优化
高级阶段:引入 K8s + Helm + 蓝绿/金丝雀部署 + 自动化监控回滚

CI/CD 的核心目标是让软件交付更快、更安全、更可靠。选择合适的技术栈和策略,根据团队成熟度和业务需求逐步演进,是实施 CI/CD 的关键。