Files
conti-docs/backend/09-build-deploy.md
T

250 lines
18 KiB
Markdown
Raw Normal View History

# 09. 构建与多环境部署
## 决策
Gradle 多模块统一构建,`bootstrap` 产出单一 jar/Docker 镜像;三个环境里 UAT/Prod 部署到 Azure AKS(见 [Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio](../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
# 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](./07-config-governance.md#本地开发怎么跑不需要真的连-k8s) | 否 | 只有自己,localhost |
| **Dev** | 公司内网一台 Ubuntu 服务器,跑 [k3s](https://k3s.io/)(轻量级单节点 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`,手动跑一下部署脚本就行:
```bash
#!/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 的发布晋升链路。
- 需要提前把 `KUBECONFIG`k3s 默认生成在 `/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](../Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio) 里已经确认的流水线阶段,核心原则是 **"Build once, promote across environments with versioned artifacts and gated approvals"**——这条原则针对的是 UAT/Prod 之间的晋升;Dev 不在这条流水线里(见上一节,手动脚本部署)。下面按阶段展开 UAT/Prod 这条主链路,并结合我们 [部署架构](../Architecture-Diagram/deployment-architecture-diagram.drawio) 是 **Private AKS**(只能通过 Private Endpoint 访问)这个关键约束说明每一步具体怎么落地。
### 阶段一:Source & Triggers(触发)
```yaml
# .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(质量门禁)
```yaml
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(制品与发布控制)
```yaml
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 Registry**Runner 需要有 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 Cluster**Kubernetes API Server 只能通过 Private Endpoint 访问),这意味着 GitLab 默认的共享公网 Runner **连不上**这个 API Server。落地方式:在 AKS 所在 VNet(或对等互联的 VNet)内部署 **self-hosted GitLab Runner**(跑成 AKS 里的一个专门 namespace,或者 VNet 里的一台 VM/VMSS),只有这个 Runner 能执行 `deploy-*` 系列 job。
```yaml
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 Identity**`az login --identity`),不在 CI 变量里存长期有效的 Service Principal 密码,减少凭证泄漏面。
- **Key Vault/Config Retrieval**Runner 用 `az keyvault secret show` 现取值,`kubectl create secret --dry-run=client -o yaml | kubectl apply -f -` 渲染成 K8s Secret(见 [07-config-governance.md](./07-config-governance.md#azure-上-secret-的真正来源key-vault不是手写-k8s-secret));密钥值只在这个 job 的执行过程中短暂存在(不会打印到日志、不落盘到镜像),换来的好处是不需要在 AKS 上额外装 CSI 插件、不需要给节点配 Managed Identity 绑定,配置都集中在 GitLab 侧。
- **`when: manual` = Promotion Gate**UAT/Prod 部署都设成手动触发(GitLab Protected Environments 可以进一步限制"只有某些角色能点这个按钮")——打了 tag 之后不会自动上线,需要专人点一次"Promote to UAT",验证通过后再点一次"Promote to Prod"。
- **部署的是 tag 不是 commit-sha**`deploy-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 集群里两个独立的 namespace`retailapp-uat` / `retailapp-prod`),各自有独立的 Deployment/Pod/Service,各自的 ConfigMap/Secret(见 [07-config-governance.md](./07-config-governance.md))、各自的资源配额(`ResourceQuota`/`LimitRange`,防止某个环境的异常负载影响另一个)。两个环境共享同一个物理集群,靠 namespace + NetworkPolicy 隔离,而不是各自起一个集群——集群运维成本更低,也符合"环境差异只在配置层面"的原则。Dev 不在这个集群里,跑在公司内网一台 Ubuntu 服务器的 k3s 集群上,见前面"环境层级"一节。
## 关键规则
- UAT/Prod 通过 K8s namespace + ConfigMap/Secret 区分(见 [07-config-governance.md](./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](./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](https://12factor.net/zh_cn/build-release-run) 里"严格分离构建和运行"这条原则的直接应用。
## 待补充
- 灰度发布(金丝雀/蓝绿)方案——目前只有整体切流的滚动更新,还没有按流量比例灰度的方案。
- Gradle 构建缓存/并行构建的 CI 加速配置。
- self-hosted Runner 本身的高可用和运维(比如 Runner 所在 VM/VMSS 的扩缩容、镜像更新)。
## 参考链接
- [The Twelve-Factor App](https://12factor.net/zh_cn/)
- [Gradle 官方 Docker 集成建议](https://docs.gradle.org/current/userguide/multi_project_builds.html)
- [Spring Boot 官方 Docker 打包指南](https://docs.spring.io/spring-boot/reference/packaging/container-images/dockerfiles.html)
- [k3s 官方文档](https://docs.k3s.io/)