Files
Guangfei.Zhao 74b02ed427 feat: add documentation for cross-domain collaboration and aggregation
- Introduced a new section on cross-domain collaboration and aggregation, detailing decision-making processes, contract module usage for cross-domain reads, and domain events for writes.
- Added guidelines for parallel aggregation using a dedicated thread pool and context propagation.
- Established rules for transaction boundaries, idempotency, optimistic locking, scheduled tasks, and caching strategies in a concurrent environment.
- Included examples and best practices for implementing these concepts in the application.
2026-08-14 16:03:47 +08:00

31 KiB
Raw Permalink 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/* / 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
# 前提: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 + 数字 UIDUSER 10001 写数字而不是 appuser,是为了让 K8s 的 runAsNonRoot: true 能在启动前静态校验通过(K8s 无法解析镜像里的用户名,只认数字 UID)。

Deployment 清单:资源、优雅停机与滚动更新

# 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

配套的应用侧配置:

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 立刻开始停机,这几百毫秒到几秒的窗口里仍然会有新请求被转发进来,而它已经不接了。

preStopsleep 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 只有自己,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_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 的发布晋升链路。
  • 需要提前把 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
  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:

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(制品与发布控制)

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 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_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 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 构建——这就是"同一个制品在环境间晋升"而不是"每个环境各自构建"。
  • 回滚不重新跑构建流水线,只是把 ROLLBACK_TAG(操作人从 GitLab Environment 部署历史里选定的、上一个已在 Prod 正常跑过的 release tag)重新 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 用 Azure Database for MySQL Flexible Server,通过 Private Endpoint 接入 AKS 所在 VNet(见 07-config-governance.md),不对公网开放。UAT 和 Prod 是两个独立的 server 实例,不是同一个实例上的两个 database——共用实例意味着 UAT 的一次压测或一条慢查询能直接影响生产。

数据库迁移与回滚的协同(最容易翻车的一环)

镜像可以秒回滚,数据库不能。Flyway 社区版没有 undo(那是商业版功能),而且即使有,drop column 之后的数据也回不来。再叠加滚动更新的机制——maxUnavailable: 0 意味着更新期间新旧两个版本的 Pod 同时在线,连的是同一个数据库——就得出一条硬约束:

每一个迁移脚本都必须同时兼容"上一个版本的代码"和"这个版本的代码"。

不满足这条,滚动更新的中间态就会直接报错(旧 Pod 查一个已经被删掉的列),而且此时想回滚镜像也救不了,因为库已经改了。

expand-contract:把破坏性变更拆成两次发布

以"把 user.phone 改名为 user.mobile"为例,一次改完必然出事,正确做法是拆成两个 release:

阶段 迁移脚本 代码 中间态是否安全
Expandv1.4.0 add column mobile,回填历史数据,加触发器/双写保持两列同步 mobile,同时写 phonemobile 安全:旧 Pod 读写 phone 照常
(观察期,至少一个发布周期) 此时回滚到 v1.3.0 完全安全
Contractv1.5.0 drop column phone 只读写 mobile 安全:线上已无代码引用 phone

对应到常见变更类型:

变更 能否一次做完 做法
加表、加可空列、加索引 可以 直接加。旧代码看不见它,不受影响
非空 不可以 先加可空列 + 默认值 → 回填 → 下个版本再加 not null
删列、删表 不可以 先发一个版本让代码不再引用它,下个版本再删
改列名、改类型 不可以 按上表的 expand-contract 走
加唯一约束 谨慎 先查历史数据有没有重复,有重复会导致迁移失败、Pod 起不来

已发布的迁移脚本不可修改

Flyway 会校验每个脚本的 checksum。改一个已经在任何环境执行过的 V*.sql,下次启动会直接 Validate failed,应用起不来。要改就新写一个版本号更大的脚本。这一条对 Dev 环境也适用——Dev 上随手改了脚本,等到 UAT 部署时才炸,那时候已经不知道当初改了什么。

迁移在哪跑

沿用 03-persistence.md 的方案:迁移由应用启动时执行(DomainFlywayConfig 保证在 JPA validate 之前跑完)。maxSurge: 1 保证同时只有一个新 Pod 启动,加上 Flyway 自身的表级锁,不会出现多个副本并发迁移。

代价是:迁移失败 = Pod 起不来 = 部署卡住但线上服务不受影响(旧 Pod 还在跑,因为 maxUnavailable: 0)。这个失败模式是可接受的——比"迁移半途成功、服务带着不一致的 schema 上线"要好得多。

大表变更(几百万行以上加索引/改列)是这个方案的例外:它会让启动探针超时、Pod 被反复重启,同时还可能长时间锁表。这类变更走单独的 K8s Job 在业务低峰期执行,执行完再发应用版本,不要塞进启动流程。

迁移脚本的数据库账号

迁移用的账号需要 DDL 权限,运行时账号只需要 DML 权限,两者必须分开(见 07-config-governance.md)——运行时账号如果有 drop table 权限,一个 SQL 注入的破坏半径就完全不一样了。

关键规则

  • 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
  • 镜像里不编译代码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 里"严格分离构建和运行"这条原则的直接应用。

待补充

  • 灰度发布(金丝雀/蓝绿)方案——目前只有整体切流的滚动更新,还没有按流量比例灰度的方案。
  • Gradle 构建缓存/并行构建的 CI 加速配置。
  • self-hosted Runner 本身的高可用和运维(比如 Runner 所在 VM/VMSS 的扩缩容、镜像更新)。
  • HPA(水平自动扩缩)的指标与阈值——目前 replicas 是写死的。
  • 数据库备份与恢复演练周期(Azure Flexible Server 自带 PITR,但"能恢复"和"演练过能恢复"是两回事)。

参考链接