442 lines
31 KiB
Markdown
442 lines
31 KiB
Markdown
# 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/Prod(Azure 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 Endpoint,Dev 环境的 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 Registry(ACR)
|
||
|
||
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 两次发布。已经执行过的迁移脚本不可修改。
|
||
- 容器以非 root(UID 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/)
|