Files
conti-docs/backend/07-config-governance.md
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

307 lines
17 KiB
Markdown
Raw Permalink 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.
# 07. 配置与服务治理
## 决策
K8s 原生方案:ConfigMap + Secret + Spring Cloud Kubernetes,不引入 Nacos 等额外治理组件,贴合已有 Azure + GitLab CI/CD 部署方式(见 [Architecture-Diagram/deployment-architecture-diagram.drawio](../Architecture-Diagram/deployment-architecture-diagram.drawio))。
选这条路而不是 Spring Cloud AlibabaNacos)的原因:部署环境已经是 K8s,K8s 本身自带 Service(服务发现)、ConfigMap/Secret(配置)、Deployment 滚动更新(发布),再引入 Nacos 意味着多维护一套集群、多一套"配置到底以谁为准"的心智负担。除非未来有 Nacos 才能提供、K8s 原生方案覆盖不了的能力(比如更细粒度的灰度配置推送),否则先用 K8s 原生的就够。
## 结构约定
- **非敏感配置**(菜单开关、超时参数、日志级别等)放 ConfigMap,挂载成 `application-{profile}.yml` 或环境变量。
- **敏感配置**DB 密码、JWT secret、F6 API key)放 Secret,都不进代码库、不进镜像。
- 各环境(dev/uat/prod)对应各自 namespace 下的 ConfigMap/Secret + Spring profile`bootstrap``SPRING_PROFILES_ACTIVE` 加载对应配置。
## ConfigMap / Secret 示例
```yaml
# k8s/configmap-workbench-uat.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: conti-backend-config
namespace: retailapp-uat
data:
application-uat.yml: |
resilience4j:
circuitbreaker:
instances:
f6-api:
failure-rate-threshold: 50
workbench:
degrade-message: "部分数据暂时无法显示,请稍后刷新"
```
```yaml
# k8s/secret-uat.yaml(实际值由 CI/CD 从密钥管理服务注入,不手写明文提交)
apiVersion: v1
kind: Secret
metadata:
name: conti-backend-secret
namespace: retailapp-uat
type: Opaque
stringData:
SECURITY_JWT_SECRET: "__injected_by_pipeline__"
DB_PASSWORD: "__injected_by_pipeline__"
F6_API_KEY: "__injected_by_pipeline__"
```
```yaml
# k8s/deployment-uat.yaml(节选)
spec:
containers:
- name: conti-backend
image: registry.example.com/conti-backend:__TAG__
envFrom:
- secretRef:
name: conti-backend-secret
env:
- name: SPRING_PROFILES_ACTIVE
value: uat
volumeMounts:
- name: config-volume
mountPath: /app/config
volumes:
- name: config-volume
configMap:
name: conti-backend-config
```
## 启用 Spring Cloud Kubernetes 配置热更新
```groovy
// bootstrap/build.gradle —— 版本由根工程的 spring-cloud-dependencies BOM2025.1.2)统一管理,
// 见 01-project-structure.md 的版本基线表
dependencies {
implementation 'org.springframework.cloud:spring-cloud-starter-kubernetes-client-config'
}
```
```yaml
# application.yml
spring:
cloud:
kubernetes:
config:
enabled: true
sources:
- name: conti-backend-config
reload:
enabled: true
mode: polling # 怎么发现变化:定期轮询 ConfigMap(另一个选项 event 需要 watch 权限)
strategy: refresh # 发现变化后做什么:只刷新 @RefreshScope bean,不重启容器
period: 15s
```
```kotlin
// 需要热更新的配置类加 @RefreshScope
@RefreshScope
@ConfigurationProperties(prefix = "workbench")
@Component
class WorkbenchProperties {
var degradeMessage: String = "部分数据暂时无法显示"
}
```
**`mode``strategy` 是两件事,别混**`mode` 决定怎么感知 ConfigMap 变化,`strategy` 决定感知到之后做什么。只配 `mode` 而漏掉 `strategy`,行为会依赖框架默认值。
**并不是所有配置都能热更新**,这一点要在改配置之前想清楚,否则会出现"改了 ConfigMap、观察半天没生效"的困惑:
| 配置 | 能否热更新 | 说明 |
| --- | --- | --- |
| 自定义的 `@RefreshScope` + `@ConfigurationProperties`(如 `workbench.*`) | ✅ | 这是热更新真正的适用范围 |
| 日志级别 | ✅ | 也可以直接用 `/actuator/loggers` 端点改,更直接 |
| `resilience4j.*` 的熔断/重试参数 | ⚠️ | 这些实例在启动时创建,普通 `refresh` 刷不到。真要动,用 `strategy: restart_context`(会重建应用上下文,等于一次内部重启)或者干脆走发布流程 |
| 数据源、连接池、JPA 相关 | ❌ | 走发布流程 |
| Secret 里的值 | ❌ | 见下面"关键规则"Secret 变更需要重启 Pod |
上面 ConfigMap 示例里同时放了 `resilience4j``workbench` 两段,正是为了说明这个差别:`workbench.degradeMessage` 改完 15 秒内生效,`failure-rate-threshold` 不会。
## 本地开发怎么跑(不需要真的连 K8s)
`spring-cloud-starter-kubernetes-client-config` 启动时默认会尝试读 `~/.kube/config` 或 in-cluster 凭证去调 K8s API 拿 ConfigMap,本地电脑上没有这些东西的话,启动会报错或者卡住去连一个不存在的 API Server。本地开发不需要也不应该依赖真实 K8s,两种可选方案:
**方案一(推荐,日常开发默认用这个):本地 profile 直接关掉 Spring Cloud Kubernetes**
```yaml
# application-local.yml
spring:
cloud:
kubernetes:
config:
enabled: false # 本地不连 K8s API,配置全部走本地文件
reload:
enabled: false
datasource:
url: jdbc:mysql://localhost:3306/?connectionTimeZone=UTC&preserveInstants=true&rewriteBatchedStatements=true
username: conti
password: conti_local_password # 仅本地开发用,不是真实密钥
security:
jwt:
active-key-id: local
keys:
# 仅本地开发用的假密钥;HS256 要求 base64 解码后 ≥ 32 字节,见 04-security-auth.md
local: bG9jYWwtZGV2LW9ubHktc2VjcmV0LW5vdC1mb3ItcmVhbC11c2UtMzJi
```
```bash
# 本地起依赖(DB 等),配合 docker-compose 用
docker compose up -d mysql
# 用 local profile 启动
SPRING_PROFILES_ACTIVE=local ./gradlew :bootstrap:bootRun
```
`application-local.yml` 不提交敏感真实值(本来也没有,本地密码本身就是假的),可以放进代码库方便新人直接跑起来;`local` profile 和 dev/uat/prod 的关键区别就是 `spring.cloud.kubernetes.config.enabled=false`,其余代码逻辑完全一致——这也正是**配置外置**的价值所在:业务代码不知道、也不需要知道配置到底来自 K8s ConfigMap 还是本地文件,换配置来源不需要改一行 Kotlin。
这是**个人本机调试**用的,跟团队共享的 **Dev 环境**不是一回事——Dev 环境跑在公司内网一台 Ubuntu 服务器的 k3s 集群上,走真实的 K8s ConfigMap/Secret(跟下面"方案二"是同一套思路,只是长期跑着给团队用,而不是临时验证),团队通过公司 VPN 访问,具体见 [09-build-deploy.md](./09-build-deploy.md#环境层级local个人本机vs-dev内网-ubuntu-k3s-集群vs-uatprodazure-aks)。
**方案二(需要验证 ConfigMap 热更新等 K8s 特有行为时才用):本地起一个真实的小集群**
用 Docker Desktop 自带的 Kubernetes、[Kind](https://kind.sigs.k8s.io/)Kubernetes in Docker)或 [Minikube](https://minikube.sigs.k8s.io/) 在本机跑一个单节点集群,把上面"ConfigMap / Secret 示例"里的 yaml 应用到本地集群,验证 `@RefreshScope` 热更新、`ServiceAccount` 权限这些真正依赖 K8s API 的行为:
```bash
kind create cluster --name conti-local
kubectl config use-context kind-conti-local
kubectl create namespace retailapp-local
kubectl apply -f k8s/configmap-local.yaml -n retailapp-local
kubectl apply -f k8s/secret-local.yaml -n retailapp-local
# 应用本身也可以跑成本地集群里的 Pod(用本地构建的镜像),
# 或者在宿主机直接跑 jar、用 KUBECONFIG 指向 kind 集群验证配置读取
export KUBECONFIG=~/.kube/config
SPRING_PROFILES_ACTIVE=dev ./gradlew :bootstrap:bootRun
```
日常业务开发用方案一就够了,只有专门验证"配置中心相关能力本身"(比如调试 `spring.cloud.kubernetes.reload` 轮询逻辑)才需要方案二。
## 关键规则
- 配置变更优先走 ConfigMap 热更新(`@RefreshScope` + `spring.cloud.kubernetes.reload`),不重新构建镜像;涉及 Secret 轮换的走正常发布流程(Secret 变化通常需要重启 Pod 才能生效,不像 ConfigMap 可以做到无重启热更)。
- 对应架构图里 `Config Center`(菜单配置、开关配置、WebView 入口策略)暂时也落在 ConfigMap;如果后续配置项复杂到需要审批流程、按门店灰度下发、版本回滚,再评估引入独立配置中心(比如 Apollo)。
- Pod 需要有权限读取所在 namespace 的 ConfigMap`spring-cloud-kubernetes` 底层调用 K8s API),需要配置好对应的 `ServiceAccount` + `Role`/`RoleBinding`
## 附录:ConfigMap 和 Secret 的本质区别,以及为什么两者都要用
两者在 K8s API 层面结构几乎一样,都是 key-value 集合,区别主要在于:
- **Secret 的值默认 base64 编码存储**(不是加密,只是编码),K8s 对 Secret 有一些额外处理:不会出现在 `kubectl describe` 的默认输出里、可以配置只挂载到内存卷(`tmpfs`)不落盘、可以对接外部密钥管理服务(Azure Key Vault 等)做真正的静态加密和访问审计。
- **ConfigMap 没有这些额外保护**,设计上就是给"泄漏了也不严重"的配置用的。
所以规则很简单:**这个值如果出现在日志里、被同事在 `kubectl get configmap -o yaml` 时看到会不会造成安全问题**——会,就放 Secret;不会,就放 ConfigMap。DB 密码、JWT 签名密钥、第三方 API key 毫无疑问要放 Secret;而"首页降级提示文案"这种放哪都无所谓的东西放 ConfigMap 就行,还能享受到热更新不用走发布流程的好处。
## Azure 上 Secret 的真正来源:Key Vault(不是手写 K8s Secret
前面 `k8s/secret-uat.yaml` 示例里 `stringData` 写的是占位符(`__injected_by_pipeline__`),这一节说清楚这个占位符具体是怎么被替换成真实值的。
根据现有部署架构(见 [Architecture-Diagram/deployment-architecture-diagram.drawio](../Architecture-Diagram/deployment-architecture-diagram.drawio)),我们的 AKS 是 **Private AKS Cluster**Key Vault 也是通过 **Private Endpoint**`privatelink.vaultcore.azure.net`)访问的——也就是说真实密钥长期存在 Azure Key Vault 里,代码库、镜像、Git 历史里都不出现明文。落地到 K8s Secret 有两种方式,我们现在用的是方式一(跟 CI/GitLab 侧的配置习惯一致,不需要额外在 AKS 上装东西)。
**方式一(现用):CI/CD 流水线在部署前从 Key Vault 读值,渲染成 K8s Secret**
```bash
# GitLab CI job 里(Runner 需要有权限访问 Key Vault,见 09-build-deploy.md
JWT_SECRET=$(az keyvault secret show --vault-name conti-backend-kv --name security-jwt-secret --query value -o tsv)
kubectl create secret generic conti-backend-secret \
--namespace retailapp-uat \
--from-literal=SECURITY_JWT_SECRET="$JWT_SECRET" \
--dry-run=client -o yaml | kubectl apply -f -
```
- 密钥值只在 CI job 的执行过程中短暂出现(不会写进 CI 日志、不会落盘到镜像里),`kubectl apply` 之后就是一个普通的 K8s Secret,`Deployment` 照常用 `envFrom.secretRef` 引用(见前面 `k8s/deployment-uat.yaml` 示例)。
- 好处是**不需要在 AKS 上额外装插件、不需要给节点/Pod 配置 Managed Identity 绑定**,全部配置集中在 GitLab(CI 变量里存 Runner 访问 Key Vault 所需的 Service Principal/Managed Identity,业务代码和 K8s yaml 完全不感知 Key Vault 的存在),跟本地开发用 `application-local.yml` 手工填值、只是"谁来填值"变成了流水线,心智负担更小。
- 权衡:Key Vault 里密钥更新后,**不会自动同步**到已经跑着的 Secret,需要重新跑一次部署(或专门加一个"仅同步 Secret,不发版本"的 job)才能生效——这点上不如方式二自动。日常密钥轮换频率不高的情况下这个权衡是划算的。
**方式二(可选的未来增强):CSI Secret Store Driver,运行时直接挂载,K8s Secret 不落地明文**
<details>
<summary>展开查看(AKS 需装 `azure-keyvault-secrets-provider` 插件 + 配置 Managed Identity,运维成本更高,暂不采用)</summary>
```yaml
# k8s/secretproviderclass-uat.yaml
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: conti-backend-kv-uat
namespace: retailapp-uat
spec:
provider: azure
parameters:
usePodIdentity: "false"
useVMManagedIdentity: "true" # 用 AKS 节点/Pod 的 Managed Identity 免密访问 Key Vault
userAssignedIdentityID: "<managed-identity-client-id>"
keyvaultName: "conti-backend-kv"
tenantId: "<azure-tenant-id>"
objects: |
array:
- |
objectName: security-jwt-secret
objectType: secret
secretObjects: # 顺便同步成一个 K8s Secret,供 envFrom 引用
- secretName: conti-backend-secret
type: Opaque
data:
- objectName: security-jwt-secret
key: SECURITY_JWT_SECRET
```
优点是密钥更新后 CSI driver 会定期轮询自动同步、Pod 用 Managed Identity 直连 Key Vault 不经过 CI;代价是要在 AKS 上启用插件、每个环境配置对应的 `SecretProviderClass` 和身份绑定,运维配置面更大。如果以后密钥轮换频率变高、或者审计要求"密钥不能经过 CI 执行上下文",再切换到这条路径,现阶段先用方式一。
</details>
两种方式**不需要同时维护**——选一个用,文档里保留方式二只是留个参考路径,不是说两者要并存。
## 配置绑定与启动期校验
**所有配置一律绑定到 `@ConfigurationProperties` 类,不散着写 `@Value`。** `@Value` 散落在各处时,没人说得清这个应用到底需要哪些配置项,漏配一个只能等到运行到那行代码才报错。
```kotlin
@ConfigurationProperties(prefix = "integration.clients.f6")
@Validated
data class F6ClientProperties(
@field:NotBlank val baseUrl: String,
@field:Min(100) @field:Max(10_000) val readTimeoutMs: Long = 2000,
@field:Min(1) val maxConcurrentCalls: Int = 20,
)
```
配上 `@Validated` 之后,配置缺失或越界会在**启动时**直接失败,Pod 起不来,K8s 的就绪探针不通过,滚动更新会自动停住并保留旧版本(见 [09-build-deploy.md](./09-build-deploy.md))。这比"启动成功了,但半夜某个接口因为超时配成 0 而全线失败"要好得多——**配错了就别起来**是这里的核心原则。
同理,[04-security-auth.md](./04-security-auth.md) 里 JWT 密钥长度的校验也放在启动期,绝不允许带着弱密钥跑起来。
## 命名规范
| 对象 | 规范 | 示例 |
| --- | --- | --- |
| namespace | `retailapp-<env>` | `retailapp-uat` |
| ConfigMap | `conti-backend-config` | 每个环境一份,同名不同 namespace |
| Secret | `conti-backend-secret` | 同上 |
| ConfigMap 里的 key | `application-<profile>.yml` | `application-uat.yml` |
| Secret 里的 key | 全大写下划线,与 Spring 的 relaxed binding 对齐 | `DB_PASSWORD``spring.datasource.password`(配合 `${DB_PASSWORD}` 引用) |
| Key Vault secret 名 | 中划线小写,与配置路径对应 | `security-jwt-secret` |
| 自定义配置前缀 | 模块名小驼峰 | `workbench.*``integration.clients.*` |
**同名不同 namespace** 这一点是有意的:环境差异全部体现在 namespace 和 ConfigMap 内容上,Deployment yaml 在各环境之间只差镜像 tag 和 namespace,减少"UAT 好好的、生产漏配了一项"这类问题。
## 数据库与外部依赖的实例形态
| 依赖 | dev(内网 k3s | UAT / ProdAzure AKS |
| --- | --- | --- |
| MySQL | 集群内一个 MySQL 容器(数据可丢,随时重建) | **Azure Database for MySQL Flexible Server**,通过 **Private Endpoint** 接入 AKS 所在 VNet,不开公网访问 |
| 密钥 | k3s Secret,值由内网 CI 注入 | Azure Key Vault + Private Endpoint(见下一节) |
| 缓存 | 无(不引入 Redis,理由见 [04-security-auth.md](./04-security-auth.md) | 同左 |
生产 MySQL 的连接信息(host/账号)走 ConfigMap + Secret 注入,应用侧配置见 [03-persistence.md](./03-persistence.md)。**连接串里必须带 `sslMode=REQUIRED`**——Flexible Server 默认要求 TLS,漏了会连不上;同时也别为了图省事把它降级成 `DISABLED`
数据库账号分两个:Flyway 迁移用的账号有 DDL 权限(只在部署流程中使用),应用运行时账号只有 DML 权限,理由见 03。
## 待补充
- 多环境 profile 的完整参数清单(等各 domain 的配置项定下来后汇总成一张表)。
## 参考链接
- [Spring Cloud Kubernetes 官方文档](https://docs.spring.io/spring-cloud-kubernetes/reference/)
- [Kubernetes ConfigMap 官方文档](https://kubernetes.io/docs/concepts/configuration/configmap/)
- [Kubernetes Secret 官方文档](https://kubernetes.io/docs/concepts/configuration/secret/)