Files
conti-backend/docs/09-build-deploy.md
T
2026-08-17 15:31:27 +08:00

442 lines
31 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 09. 构建与多环境部署
## 决策
Gradle 多模块统一构建,`bootstrap` 产出单一 jar/Docker 镜像;三个环境里 UAT/Prod 部署到 Azure AKS(见 [Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio](../../conti-docs/Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio)),Dev 部署到公司内网一台 Ubuntu 服务器上的 k3s 集群(团队通过公司 VPN 访问,手动执行部署脚本,不接入 CI/CD 自动触发),个人日常调试用的是更轻量的 `local` profile(不经过任何 K8s,见下文区分)。
## 结构约定
```
settings.gradle # include 所有 platform-* / domains/* / integration/*(见 01-project-structure.md
build.gradle # 根工程统一 Kotlin/Spring Boot 插件版本、依赖约束
bootstrap/build.gradle # bootJar,构建出可执行 jar
Dockerfile # 基于 CI 已构建好的 jar 打镜像(不在镜像里重新编译)
k8s/ # Deployment / ConfigMap / PodDisruptionBudget 等清单
.gitlab-ci.yml # validate -> package -> release -> deploy-uat -> deploy-prod
```
## Dockerfile:复用 CI 产物 + 分层解包
```dockerfile
# Dockerfile
# 前提:CI 的 validate 阶段已经跑过 ./gradlew :bootstrap:bootJar
# 产物通过 GitLab artifacts 传递到 package 阶段,这里直接用,不重新编译。
FROM eclipse-temurin:21-jre AS layers
WORKDIR /layers
COPY bootstrap/build/libs/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination .
FROM eclipse-temurin:21-jre
WORKDIR /app
# 非 root 运行:容器内一旦被攻破,攻击者拿到的也只是一个无特权用户
RUN useradd --system --uid 10001 --create-home appuser
USER 10001
# 按变更频率从低到高逐层 COPY,前三层几乎不变,可以吃满 Docker layer 缓存,
# 每次发版真正推送到 ACR 的通常只有最后一层(几百 KB 的业务代码)
COPY --from=layers --chown=10001:10001 /layers/dependencies/ ./
COPY --from=layers --chown=10001:10001 /layers/spring-boot-loader/ ./
COPY --from=layers --chown=10001:10001 /layers/snapshot-dependencies/ ./
COPY --from=layers --chown=10001:10001 /layers/application/ ./
ENTRYPOINT ["java", \
"-XX:MaxRAMPercentage=75.0", \
"-XX:+ExitOnOutOfMemoryError", \
"org.springframework.boot.loader.launch.JarLauncher"]
```
三个容易被忽略但都会真正咬人的点:
1. **不在镜像里重新构建**。上一版 Dockerfile 是 `COPY . . && ./gradlew bootJar`,等于 CI 已经编译测试过一遍,打镜像时又原样编译一遍——既浪费流水线时间,又违背本篇自己的"Build once"原则(这次构建的产物和 CI 里验证过的产物严格来说不是同一个)。改为直接消费 CI 产物,`docker-build-push` job 用 `needs:` 声明依赖 `build-package` 的 artifacts。
2. **`-XX:MaxRAMPercentage`**。JVM 在容器里会读 cgroup 限制推算堆大小,但默认上限只有可用内存的 **25%**——给 Pod 配 2Gi,堆只用 512Mi,剩下的白白浪费,然后在流量高峰时莫名其妙地 OOM 或频繁 Full GC。配 75% 把剩余空间留给 metaspace、线程栈和堆外内存。配套的 `ExitOnOutOfMemoryError` 让 OOM 直接结束进程,交给 K8s 重启,而不是留一个半死不活、探针还返回健康的 Pod。
3. **非 root + 数字 UID**`USER 10001` 写数字而不是 `appuser`,是为了让 K8s 的 `runAsNonRoot: true` 能在启动前静态校验通过(K8s 无法解析镜像里的用户名,只认数字 UID)。
## Deployment 清单:资源、优雅停机与滚动更新
```yaml
# k8s/deployment-uat.yaml(节选。探针配置见 08-observability.md「K8s 探针配置」一节,合并到同一份清单里)
apiVersion: apps/v1
kind: Deployment
metadata:
name: conti-backend
spec:
replicas: 2
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0 # 更新期间不允许可用副本数低于 replicas,先起新的再停旧的
maxSurge: 1
template:
spec:
terminationGracePeriodSeconds: 45 # 必须 > preStop 等待 + 应用 graceful 超时
securityContext:
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: conti-backend
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
memory: "2Gi" # 只限内存,不限 CPU —— 理由见下
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 10"]
volumeMounts:
- name: tmp
mountPath: /tmp # readOnlyRootFilesystem 的必需配套:内嵌 Tomcat 要可写的临时目录
volumes:
- name: tmp
emptyDir: {}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: conti-backend-pdb
spec:
minAvailable: 1
selector:
matchLabels:
app: conti-backend
```
配套的应用侧配置:
```yaml
server:
shutdown: graceful # Boot 默认是 immediate,收到 SIGTERM 直接掐断在途请求
spring:
lifecycle:
timeout-per-shutdown-phase: 25s
```
### 为什么 preStop 要 `sleep 10`
这是滚动更新期间最常见的"零星 502"的根因。Pod 进入 Terminating 时,K8s 会**并行**做两件事:给容器发 SIGTERM,以及把 Pod 从 Service Endpoints 里摘掉。后者要经过 kube-proxy/Ingress 逐节点更新转发规则,**不是瞬时的**。如果应用收到 SIGTERM 立刻开始停机,这几百毫秒到几秒的窗口里仍然会有新请求被转发进来,而它已经不接了。
`preStop``sleep 10` 把 SIGTERM 推迟 10 秒,这段时间里应用照常服务,而 Endpoints 摘除早已完成——等真正开始停机时,已经没有新流量进来了。然后 `server.shutdown: graceful` 负责把已经在处理的请求跑完(最多 25s)。三个数字的关系必须是:`terminationGracePeriodSeconds (45) > preStop (10) + timeout-per-shutdown-phase (25)`,否则超时后 K8s 直接 SIGKILL,优雅停机等于白配。
### 为什么只限内存、不限 CPU
内存超限的后果是 Pod 被 OOMKilled,必须设 limit 防止一个 Pod 拖垮整个节点。CPU 则不同:Linux 的 CPU limit 通过 cfs quota 实现,一旦触及就**限流(throttling)**——表现为请求延迟毫无规律地抖动,而监控上 CPU 使用率看起来还很健康,极难排查。JVM 启动阶段(JIT 编译)尤其吃 CPU,配了 limit 会显著拉长启动时间甚至拖垮 startupProbe。设好 `requests` 保证调度到有余量的节点即可;节点整体过载靠 `ResourceQuota` 和扩容解决,不靠 per-Pod 限流。
`PodDisruptionBudget` 保证节点维护、集群升级这类**自愿中断**时至少留一个副本在跑——没有它,AKS 节点池升级可能把两个副本同时驱逐,造成一次没人预料到的短暂全站不可用。
## 环境层级: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_KEYS_V1="dev-only-fake-secret-at-least-32-bytes-long" \
--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](../../conti-docs/Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio) 里已经确认的流水线阶段,核心原则是 **"Build once, promote across environments with versioned artifacts and gated approvals"**——这条原则针对的是 UAT/Prod 之间的晋升;Dev 不在这条流水线里(见上一节,手动脚本部署)。下面按阶段展开 UAT/Prod 这条主链路,并结合我们 [部署架构](../../conti-docs/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
artifacts:
paths:
- bootstrap/build/libs/*.jar # 传给 package 阶段的 Dockerfile 直接消费
expire_in: 1 week
security-scan:
stage: validate
script:
# 依赖漏洞扫描。注意:从 2023 年起 NVD API 对匿名调用限流极严,
# 不配 API Key 会卡在 "Updating the NVD CVE data" 几十分钟甚至直接超时失败。
# NVD_API_KEY 需去 https://nvd.nist.gov/developers/request-an-api-key 免费申请,存为 CI masked variable。
- ./gradlew dependencyCheckAnalyze -Dnvd.api.key=$NVD_API_KEY --no-daemon
cache:
key: nvd-db # 缓存漏洞库,避免每次流水线重新拉全量数据
paths:
- build/dependency-check-data
```
这四个 job 对应架构图里 CI Validation 阶段的四项检查,都跑在 GitLab Runner 上,任意一项失败流水线即中止——这一步只验证代码质量,**不产出会被部署的镜像**,MR 流水线到这里就结束。
`dependencyCheckAnalyze` 只覆盖**我们自己声明的依赖**,管不到基础镜像里的 OS 包(glibc、openssl 这类),而那恰恰是镜像 CVE 的大头。所以镜像层面要单独扫,并同时产出 SBOM:
```yaml
image-scan:
stage: package
needs: [docker-build-push]
script:
- trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
# SBOM:记录这个镜像里到底装了什么。将来爆出新 CVE 时,能直接查"我们哪些线上版本受影响",
# 而不是挨个把历史镜像拉下来重新扫一遍。
- syft $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -o cyclonedx-json > sbom.json
artifacts:
paths: [sbom.json]
```
`--ignore-unfixed` 是刻意的:上游还没发补丁的 CVE 报出来也无法处理,让它阻断流水线只会训练团队去无脑加白名单,最后所有告警一起失效。基础镜像的 tag 建议钉到 digest,并定期(比如每月)主动升一次,而不是长期用 `21-jre` 这个内容会漂移的浮动 tag。
### 阶段三:Artifact & Release Controls(制品与发布控制)
```yaml
docker-build-push:
stage: package
needs: [build-package] # 直接消费 validate 阶段的 jar artifact,镜像里不再重新编译
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_KEY=$(az keyvault secret show --vault-name conti-backend-kv --name security-jwt-key-v1 --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_KEYS_V1="$JWT_KEY"
--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
# ROLLBACK_TAG 不是自动推导出来的,而是触发这个 job 时由操作人手工填入的变量
#GitLab 手动 job 支持在点击时输入变量值)。
# 取值来源:GitLab Environment "production" 的部署历史里,当前版本之前的那个 tag。
# 刻意不做成自动取"上一个"——回滚目标必须是人明确确认过的版本,
# 不能出现"上一个版本本身就是有问题的、结果自动回滚到它"这种情况。
- '[ -n "$ROLLBACK_TAG" ] || { echo "必须指定 ROLLBACK_TAG"; exit 1; }'
- kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$ROLLBACK_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 构建——这就是"同一个制品在环境间晋升"而不是"每个环境各自构建"。
- **回滚**不重新跑构建流水线,只是把 `ROLLBACK_TAG`(操作人从 GitLab Environment 部署历史里选定的、上一个已在 Prod 正常跑过的 release tag)重新 `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 用 **Azure Database for MySQL Flexible Server**,通过 Private Endpoint 接入 AKS 所在 VNet(见 [07-config-governance.md](./07-config-governance.md)),不对公网开放。UAT 和 Prod 是**两个独立的 server 实例**,不是同一个实例上的两个 database——共用实例意味着 UAT 的一次压测或一条慢查询能直接影响生产。
## 数据库迁移与回滚的协同(最容易翻车的一环)
镜像可以秒回滚,**数据库不能**。Flyway 社区版没有 `undo`(那是商业版功能),而且即使有,`drop column` 之后的数据也回不来。再叠加滚动更新的机制——`maxUnavailable: 0` 意味着更新期间**新旧两个版本的 Pod 同时在线,连的是同一个数据库**——就得出一条硬约束:
> **每一个迁移脚本都必须同时兼容"上一个版本的代码"和"这个版本的代码"。**
不满足这条,滚动更新的中间态就会直接报错(旧 Pod 查一个已经被删掉的列),而且此时想回滚镜像也救不了,因为库已经改了。
### expand-contract:把破坏性变更拆成两次发布
以"把 `user.phone` 改名为 `user.mobile`"为例,一次改完必然出事,正确做法是拆成两个 release:
| 阶段 | 迁移脚本 | 代码 | 中间态是否安全 |
| --- | --- | --- | --- |
| **Expand**v1.4.0 | `add column mobile`,回填历史数据,加触发器/双写保持两列同步 | 读 `mobile`,同时写 `phone``mobile` | 安全:旧 Pod 读写 `phone` 照常 |
| (观察期,至少一个发布周期) | — | — | 此时回滚到 v1.3.0 完全安全 |
| **Contract**v1.5.0 | `drop column phone` | 只读写 `mobile` | 安全:线上已无代码引用 `phone` |
对应到常见变更类型:
| 变更 | 能否一次做完 | 做法 |
| --- | --- | --- |
| 加表、加可空列、加索引 | 可以 | 直接加。旧代码看不见它,不受影响 |
| 加**非空**列 | 不可以 | 先加可空列 + 默认值 → 回填 → 下个版本再加 `not null` |
| 删列、删表 | 不可以 | 先发一个版本让代码不再引用它,下个版本再删 |
| 改列名、改类型 | 不可以 | 按上表的 expand-contract 走 |
| 加唯一约束 | 谨慎 | 先查历史数据有没有重复,有重复会导致迁移失败、Pod 起不来 |
### 已发布的迁移脚本不可修改
Flyway 会校验每个脚本的 checksum。改一个已经在任何环境执行过的 `V*.sql`,下次启动会直接 `Validate failed`,应用起不来。要改就新写一个版本号更大的脚本。**这一条对 Dev 环境也适用**——Dev 上随手改了脚本,等到 UAT 部署时才炸,那时候已经不知道当初改了什么。
### 迁移在哪跑
沿用 [03-persistence.md](./03-persistence.md) 的方案:迁移由应用启动时执行(`DomainFlywayConfig` 保证在 JPA `validate` 之前跑完)。`maxSurge: 1` 保证同时只有一个新 Pod 启动,加上 Flyway 自身的表级锁,不会出现多个副本并发迁移。
代价是:**迁移失败 = Pod 起不来 = 部署卡住但线上服务不受影响**(旧 Pod 还在跑,因为 `maxUnavailable: 0`)。这个失败模式是可接受的——比"迁移半途成功、服务带着不一致的 schema 上线"要好得多。
大表变更(几百万行以上加索引/改列)是这个方案的例外:它会让启动探针超时、Pod 被反复重启,同时还可能长时间锁表。这类变更走单独的 K8s `Job` 在业务低峰期执行,执行完再发应用版本,不要塞进启动流程。
### 迁移脚本的数据库账号
迁移用的账号需要 DDL 权限,运行时账号只需要 DML 权限,两者必须分开(见 [07-config-governance.md](./07-config-governance.md))——运行时账号如果有 `drop table` 权限,一个 SQL 注入的破坏半径就完全不一样了。
## 关键规则
- 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`
- **镜像里不编译代码**Dockerfile 消费 CI `validate` 阶段产出的 jar artifact,保证部署的字节就是被测试验证过的字节。
- **每个迁移脚本必须前向兼容**(旧版本代码在新 schema 上能正常跑),破坏性变更一律走 expand-contract 两次发布。已经执行过的迁移脚本不可修改。
- 容器以非 rootUID 10001)运行,`readOnlyRootFilesystem` + `drop ALL capabilities`,堆内存用 `-XX:MaxRAMPercentage` 而不是写死 `-Xmx`
- 优雅停机三件套必须同时配齐且数值满足 `terminationGracePeriodSeconds > preStop sleep + timeout-per-shutdown-phase`,缺一个滚动更新期间就会掉请求。
## 附录:为什么坚持"一个镜像走所有环境"
一种常见但有风险的做法是:给每个环境单独打包(比如构建时注入 `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 的扩缩容、镜像更新)。
- HPA(水平自动扩缩)的指标与阈值——目前 `replicas` 是写死的。
- 数据库备份与恢复演练周期(Azure Flexible Server 自带 PITR,但"能恢复"和"演练过能恢复"是两回事)。
## 参考链接
- [The Twelve-Factor App](https://12factor.net/zh_cn/)
- [Spring Boot 官方 Docker 打包指南](https://docs.spring.io/spring-boot/reference/packaging/container-images/dockerfiles.html)
- [Spring Boot: Graceful Shutdown](https://docs.spring.io/spring-boot/reference/web/graceful-shutdown.html)
- [Kubernetes: Pod 生命周期与终止流程](https://kubernetes.io/zh-cn/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination)
- [Kubernetes: PodDisruptionBudget](https://kubernetes.io/zh-cn/docs/concepts/workloads/pods/disruptions/)
- [Flyway: 零停机迁移与 expand-contract](https://documentation.red-gate.com/fd/zero-downtime-deployments-268173154.html)
- [OWASP: Kubernetes Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Kubernetes_Security_Cheat_Sheet.html)
- [k3s 官方文档](https://docs.k3s.io/)