Files
conti-docs/backend/09-build-deploy.md
T
Guangfei.Zhao 8c0fcd84e8 feat: Enhance documentation on layering, object naming conventions, and API design
- Added object naming conventions (PO/DAO/BO/DTO/VO) in 02-layering.md to clarify terminology and usage within the team.
- Updated 06-api-design.md to include MapStruct for DTO and entity conversion, providing examples and configuration details.
- Expanded 07-config-governance.md with local development instructions and strategies for running without K8s, including two recommended approaches.
- Included K8s probe configuration details in 08-observability.md for liveness and readiness checks.
- Clarified CI/CD processes in 09-build-deploy.md, detailing environment distinctions and deployment strategies for local, Dev, UAT, and Prod.
- Introduced ArchUnit for architectural testing in 10-testing.md, ensuring adherence to defined layering rules and coverage verification with Jacoco.
2026-08-13 15:19:05 +08:00

18 KiB
Raw Blame History

09. 构建与多环境部署

决策

Gradle 多模块统一构建,bootstrap 产出单一 jar/Docker 镜像;三个环境里 UAT/Prod 部署到 Azure AKS(见 Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio),Dev 部署到公司内网一台 Ubuntu 服务器上的 k3s 集群(团队通过公司 VPN 访问,手动执行部署脚本,不接入 CI/CD 自动触发),个人日常调试用的是更轻量的 local profile(不经过任何 K8s,见下文区分)。

结构约定

settings.gradle              # include 所有 platform-* / domains/*(见 01-project-structure.md
build.gradle                 # 根工程统一 Kotlin/Spring Boot 插件版本、依赖约束
bootstrap/build.gradle       # bootJar,构建出可执行 jar
Dockerfile                   # 基于 bootstrap 的 jar 打镜像
.gitlab-ci.yml                # build -> test -> docker build/push -> deploy

Dockerfile 示例(多阶段构建)

# Dockerfile
FROM eclipse-temurin:21-jdk AS build
WORKDIR /workspace
COPY . .
RUN ./gradlew :bootstrap:bootJar --no-daemon

FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /workspace/bootstrap/build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]

多阶段构建的好处:最终镜像只包含 JRE + 一个 jar,不带 Gradle 缓存、源码、编译工具链,镜像体积和攻击面都更小。

环境层级:local(个人本机)vs Dev(内网 Ubuntu k3s 集群)vs UAT/ProdAzure AKS

三层环境的定位不一样,容易混淆,先说清楚区别:

环境 跑在哪 是否过 K8s 是否走 CI/CD 访问方式
local 开发者自己电脑 否,local profile 直接关掉 Spring Cloud Kubernetes(见 07-config-governance.md 只有自己,localhost
Dev 公司内网一台 Ubuntu 服务器,跑 k3s(轻量级单节点 K8s 发行版) 是,真实 K8s 集群 否,手动执行部署脚本(见下文) 团队通过公司 VPN 访问(连上 VPN 后即可直接访问这台机器的内网地址)
UAT / Prod Azure Private AKS 见后面阶段四/五

local 是纯个人编码调试用的,跑得最快、依赖最少;Dev 是团队共享的、真实跑在 K8s 里的验证环境,行为上(ConfigMap 热更新、Secret 挂载方式、Deployment 滚动更新)跟 UAT/Prod 是一致的,只是物理上跑在公司内网的一台 Ubuntu 服务器而不是 Azure——这也是为什么选 k3s 这样一个真实的、哪怕是单节点的 K8s 发行版,而不是简单用 docker compose 起一堆容器:能验证真实 K8s 行为,而不只是"能不能跑起来"。这台机器本身就是 Ubuntu(跟 AKS 节点同为 Linux),k3s 直接跑在宿主机上,没有额外的虚拟化层。

Dev 环境怎么部署(手动脚本,不接入 CI/CD 自动触发)

Dev 不需要跟 UAT/Prod 一样接自动化流水线,谁想更新 Dev 环境,连上公司 VPN,本机配置好指向这台机器的 KUBECONFIG,手动跑一下部署脚本就行:

#!/usr/bin/env bash
# scripts/deploy-dev.sh
# 用法:./scripts/deploy-dev.sh <commit-sha 或 release tag>
set -euo pipefail
IMAGE_TAG=${1:?"必须传一个镜像 tag,比如某次 main 分支的 commit-sha"}

kubectl create secret generic conti-backend-secret -n retailapp-dev \
  --from-literal=SECURITY_JWT_SECRET="dev-only-fake-secret" \
  --from-literal=DB_PASSWORD="dev-only-fake-password" \
  --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f k8s/configmap-dev.yaml -n retailapp-dev
kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$IMAGE_TAG -n retailapp-dev
kubectl rollout status deployment/conti-backend -n retailapp-dev --timeout=180s

几个和 UAT/Prod 不一样的地方:

  • 不需要 Runner,也不需要接进 .gitlab-ci.yml:镜像已经由阶段三的 docker-build-push job(合入 main 时自动触发)推到 ACR 了,Dev 这一步只是"把已经存在的镜像 apply 到这台机器",谁需要验证最新代码,自己连 VPN 跑一下脚本,不需要为此单独维护一条自动化流水线。
  • 不用 Key Vault:这台机器到不了 Azure Key Vault 的 Private EndpointDev 环境的 Secret 就是写死的假值(跟 local profile 里的假密码同一个思路),不是真实密钥,本来 Dev 环境也不该碰生产密钥。
  • 镜像可以是任意 commit-sha:想验证哪次提交,脚本参数传哪个 tag,不需要等到打 release tag,因为 Dev 不参与"Build once, promote across environments"这条只针对 UAT/Prod 的发布晋升链路。
  • 需要提前把 KUBECONFIGk3s 默认生成在 /etc/rancher/k3s/k3s.yaml,把里面 https://127.0.0.1:6443 换成机器的内网 IP)分发给需要部署/排查 Dev 环境的团队成员,连上 VPN 后即可直接用。

CI/CD 到 Kubernetes 的完整流程

整体沿用 Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio 里已经确认的流水线阶段,核心原则是 "Build once, promote across environments with versioned artifacts and gated approvals"——这条原则针对的是 UAT/Prod 之间的晋升;Dev 不在这条流水线里(见上一节,手动脚本部署)。下面按阶段展开 UAT/Prod 这条主链路,并结合我们 部署架构Private AKS(只能通过 Private Endpoint 访问)这个关键约束说明每一步具体怎么落地。

阶段一:Source & Triggers(触发)

# .gitlab-ci.yml(节选)
workflow:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'   # MR 触发 CI Validation(不部署)
    - if: '$CI_COMMIT_BRANCH == "main"'                     # main 分支合入触发 CI Validation
    - if: '$CI_COMMIT_TAG'                                   # 打 protected tag 触发"选择版本发布"流程
  • 日常开发:Developer 提 Merge Request → 触发 CI Validation(下一阶段),只做质量门禁,不产出可发布制品。
  • 发布:在 main 分支上打一个 protected tag(如 v1.4.0),触发"选择要部署的版本"这条链路——这是唯一能进入 Artifact & Release Controls 之后阶段的入口,普通分支/MR 流水线到 CI Validation 就结束,避免任何未评审代码意外流入生产。

阶段二:CI Validation(质量门禁)

stages:
  - validate
  - package
  - release
  - deploy-uat
  - deploy-prod

lint:
  stage: validate
  script:
    - ./gradlew ktlintCheck detekt --no-daemon   # Lint / Static Checks

unit-integration-test:
  stage: validate
  script:
    - ./gradlew test --no-daemon                  # Unit/Integration Tests,含 Testcontainers(见 10-testing.md
  artifacts:
    reports:
      junit: '**/build/test-results/test/TEST-*.xml'

build-package:
  stage: validate
  script:
    - ./gradlew :bootstrap:bootJar --no-daemon     # Build/Package

security-scan:
  stage: validate
  script:
    - ./gradlew dependencyCheckAnalyze             # Security/Quality Scan(依赖漏洞扫描)

这四个 job 对应架构图里 CI Validation 阶段的四项检查,都跑在 GitLab Runner 上,任意一项失败流水线即中止——这一步只验证代码质量,不产出会被部署的镜像MR 流水线到这里就结束。

阶段三:Artifact & Release Controls(制品与发布控制)

docker-build-push:
  stage: package
  rules:
    - if: '$CI_COMMIT_BRANCH == "main"'
    - if: '$CI_COMMIT_TAG'
  script:
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA .
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA   # 推送到 Azure Container RegistryACR

cut-release:
  stage: release
  rules:
    - if: '$CI_COMMIT_TAG'
  script:
    # 把已经验证过的 commit-sha 镜像"打标"成不可变的发布版本,而不是重新构建
    - az acr import --name $ACR_NAME --source $ACR_NAME.azurecr.io/conti-backend:$CI_COMMIT_SHORT_SHA --image conti-backend:$CI_COMMIT_TAG
  • docker-build-push 把镜像推到 Azure Container RegistryRunner 需要有 ACR 的 AcrPush 权限(通过 Managed Identity 或 Service Principal 认证,不使用固定密码)。
  • cut-release 是"Promotion Gate"的起点:只有打了 tag 才会生成一个不可变的发布版本az acr import 把 commit-sha 镜像复制成一个语义化 tag,源镜像内容不变,只是多一个别名),后续 UAT/Prod 部署的都是这同一个镜像摘要(digest),保证"UAT 验证过的和 Prod 部署的字节级一致",呼应前面"一个镜像走所有环境"的原则。
  • 回滚就是这一层的直接应用:出问题时不重新构建,而是把 Deployment 的镜像 tag 改回上一个已批准的 release 版本(见下面 rollback job)。

阶段四:CD to Azure(部署到 UAT/Prod

私有 AKS 对 Runner 的网络要求:架构图确认 AKS 是 Private ClusterKubernetes API Server 只能通过 Private Endpoint 访问),这意味着 GitLab 默认的共享公网 Runner 连不上这个 API Server。落地方式:在 AKS 所在 VNet(或对等互联的 VNet)内部署 self-hosted GitLab Runner(跑成 AKS 里的一个专门 namespace,或者 VNet 里的一台 VM/VMSS),只有这个 Runner 能执行 deploy-* 系列 job。

deploy-uat:
  stage: deploy-uat
  tags:
    - azure-vnet-runner        # 指定跑在能访问私有 AKS 的 self-hosted runner 上
  environment:
    name: uat
  rules:
    - if: '$CI_COMMIT_TAG'
  when: manual                # Promotion Gate:需要人工点击"Promote to UAT"
  script:
    - az login --identity                                   # Runner 用 Managed Identity 登录 Azure
    - az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing
    # Key Vault / Config Retrieval:从 Key Vault 读值渲染成 K8s Secret(见 07-config-governance.md 方式一)
    - JWT_SECRET=$(az keyvault secret show --vault-name conti-backend-kv --name security-jwt-secret --query value -o tsv)
    - DB_PASSWORD=$(az keyvault secret show --vault-name conti-backend-kv --name db-password --query value -o tsv)
    - kubectl create secret generic conti-backend-secret -n retailapp-uat
        --from-literal=SECURITY_JWT_SECRET="$JWT_SECRET"
        --from-literal=DB_PASSWORD="$DB_PASSWORD"
        --dry-run=client -o yaml | kubectl apply -f -
    - kubectl apply -f k8s/configmap-uat.yaml -n retailapp-uat
    - kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG -n retailapp-uat
    - kubectl rollout status deployment/conti-backend -n retailapp-uat --timeout=180s
    - curl -sf https://uat.internal.example.com/actuator/health || exit 1

deploy-prod:
  stage: deploy-prod
  tags:
    - azure-vnet-runner
  environment:
    name: production
  rules:
    - if: '$CI_COMMIT_TAG'
  when: manual                # Promotion Gate:需要更高权限的人工审批
  script:
    - az login --identity
    - az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing
    - kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG -n retailapp-prod
    - kubectl rollout status deployment/conti-backend -n retailapp-prod --timeout=180s
    - curl -sf https://api.example.com/actuator/health || exit 1

rollback-prod:
  stage: deploy-prod
  tags:
    - azure-vnet-runner
  environment:
    name: production
  when: manual                 # 手动触发,回滚到"上一个已批准的镜像 tag"
  script:
    - az login --identity
    - az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing
    - kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$LAST_APPROVED_TAG -n retailapp-prod
    - kubectl rollout status deployment/conti-backend -n retailapp-prod --timeout=180s

几个关键点:

  • tags: [azure-vnet-runner]:强制这几个 job 只能被部署在 AKS 私有网络内、能直连 API Server 的 self-hosted Runner 执行,公网共享 Runner 没有这个 tag,天然不会被误调度去执行部署。
  • Runner 认证 Azure 用 Managed Identityaz login --identity),不在 CI 变量里存长期有效的 Service Principal 密码,减少凭证泄漏面。
  • Key Vault/Config RetrievalRunner 用 az keyvault secret show 现取值,kubectl create secret --dry-run=client -o yaml | kubectl apply -f - 渲染成 K8s Secret(见 07-config-governance.md);密钥值只在这个 job 的执行过程中短暂存在(不会打印到日志、不落盘到镜像),换来的好处是不需要在 AKS 上额外装 CSI 插件、不需要给节点配 Managed Identity 绑定,配置都集中在 GitLab 侧。
  • when: manual = Promotion Gate:UAT/Prod 部署都设成手动触发(GitLab Protected Environments 可以进一步限制"只有某些角色能点这个按钮")——打了 tag 之后不会自动上线,需要专人点一次"Promote to UAT",验证通过后再点一次"Promote to Prod"。
  • 部署的是 tag 不是 commit-shadeploy-uat/deploy-prod 用的镜像引用都是 $CI_COMMIT_TAG(对应阶段三里 az acr import 生成的不可变发布版本),而不是重新拿 commit-sha 构建——这就是"同一个制品在环境间晋升"而不是"每个环境各自构建"。
  • 回滚不重新跑构建流水线,只是把 LAST_APPROVED_TAG(上一个已经在 Prod 跑过的 release tag,记录在部署记录/GitLab Environment 历史里)重新 kubectl set image 一次,几秒钟内完成,这也是为什么"发布版本必须不可变"很重要——回滚目标必须是确定性的、镜像内容不会变的一个 tag。

阶段五:Azure Environments(部署目标)

UAT/Production 对应同一个 Private AKS 集群里两个独立的 namespaceretailapp-uat / retailapp-prod),各自有独立的 Deployment/Pod/Service,各自的 ConfigMap/Secret(见 07-config-governance.md)、各自的资源配额(ResourceQuota/LimitRange,防止某个环境的异常负载影响另一个)。两个环境共享同一个物理集群,靠 namespace + NetworkPolicy 隔离,而不是各自起一个集群——集群运维成本更低,也符合"环境差异只在配置层面"的原则。Dev 不在这个集群里,跑在公司内网一台 Ubuntu 服务器的 k3s 集群上,见前面"环境层级"一节。

关键规则

  • UAT/Prod 通过 K8s namespace + ConfigMap/Secret 区分(见 07-config-governance.md),镜像本身不区分环境,同一个镜像跨环境部署,只是挂载的 ConfigMap/Secret 和 SPRING_PROFILES_ACTIVE 不同——避免"UAT 验证过的镜像和 Prod 部署的镜像不是同一个产物"这种环境不一致风险。
  • Dev 是独立的一层:跑在公司内网一台 Ubuntu 服务器的 k3s 集群上,不接入 CI/CD 自动触发,谁需要更新就手动跑 scripts/deploy-dev.sh(不接 Key Vault),跟 UAT/Prod 的"打 tag 才能晋升"这条链路是分开的,见前面"环境层级"一节。
  • CI 流程顺序:Gradle build(含单元测试)→ 打 Docker 镜像 → 推送 ACR → 打 release tag → 按 UAT(人工晋升)/Prod(人工审批)顺序部署;Dev 不在这条流水线里,需要时手动执行部署脚本。
  • 部署到私有 AKS 的 job 必须跑在能访问集群私有网络的 self-hosted Runner 上,公网共享 Runner 无法执行这些 job(网络层面直接不通,不是权限层面的限制);Dev 环境没有 Runner,直接由团队成员在自己电脑上连 VPN 手动执行部署脚本。
  • 模块化单体阶段只有一个部署产物(一个 Deployment);如果后续拆分微服务,每个 domain 各自补一份 Dockerfile 和 CI job,工程结构上已经按模块划好边界(见 01-project-structure.md),拆分成本较低——本质上是把 bootstrap 依赖的某个 domains/xxx 模块摘出来,单独套一层 @SpringBootApplication 入口和自己的 Dockerfile

附录:为什么坚持"一个镜像走所有环境"

一种常见但有风险的做法是:给每个环境单独打包(比如构建时注入 application-uat.yml 到镜像里),这样看起来"环境隔离更彻底",但实际引入了一个更严重的问题——UAT 验证通过的镜像和 Prod 部署的镜像,字节级别就不是同一个东西,即使代码版本号一样,构建过程中的依赖解析、基础镜像 layer 缓存状态都可能有细微差异,理论上会出现"UAT 测过没问题,Prod 部署后行为不一致"的情况,而且事后很难证明"两次构建到底有没有差异"。

"一个镜像走所有环境"Build once, deploy many)反过来保证:镜像本身在所有环境完全一致,环境差异只体现在外部注入的配置(ConfigMap/Secret/环境变量)上。这也是 The Twelve-Factor App 里"严格分离构建和运行"这条原则的直接应用。

待补充

  • 灰度发布(金丝雀/蓝绿)方案——目前只有整体切流的滚动更新,还没有按流量比例灰度的方案。
  • Gradle 构建缓存/并行构建的 CI 加速配置。
  • self-hosted Runner 本身的高可用和运维(比如 Runner 所在 VM/VMSS 的扩缩容、镜像更新)。

参考链接