307 lines
17 KiB
Markdown
307 lines
17 KiB
Markdown
# 07. 配置与服务治理
|
||
|
||
## 决策
|
||
|
||
K8s 原生方案:ConfigMap + Secret + Spring Cloud Kubernetes,不引入 Nacos 等额外治理组件,贴合已有 Azure + GitLab CI/CD 部署方式(见 [Architecture-Diagram/deployment-architecture-diagram.drawio](../../conti-docs/Architecture-Diagram/deployment-architecture-diagram.drawio))。
|
||
|
||
选这条路而不是 Spring Cloud Alibaba(Nacos)的原因:部署环境已经是 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 BOM(2025.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](../../conti-docs/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 / Prod(Azure 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/)
|