本节把 TaskHub 的配置从镜像里拆出来:数据库地址、日志级别进 ConfigMap,密码进 Secret,副本数交给 HPA 按负载自动伸缩。
上一节的清单里有个隐患:配置和密钥都藏在 env 与 secretKeyRef 背后,还没讲清楚它们到底怎么注入、改了会不会生效、安不安全。这一节补齐三块:ConfigMap(非敏感配置)、Secret(敏感数据)、HPA(自动伸缩)。
同样声明:本机无 Kubernetes 集群,本节所有清单均未
kubectl apply验证。 ConfigMap/Secret 的注入语义(环境变量 vs 文件挂载)我用docker run的-e与-v在本机等价复现并给出真实输出;HPA 的扩缩容行为必须在有 metrics-server 的真实集群里才能验证,本机未实测。
15.2.1 配置必须与镜像分离
把配置写死在镜像里的代价是:改一个日志级别就要重新构建、推送、拉取整个镜像。配置分离的目标是让「同一份镜像,在不同环境跑出不同行为」:
| 内容 | 放哪 | 例子 |
|---|---|---|
| 非敏感配置 | ConfigMap | 日志级别、超时、功能开关 |
| 敏感数据 | Secret | 数据库密码、JWT 密钥、API token |
| 环境标识 | 构建参数 / 环境变量 | 版本号(第 14 章的 -X main.version) |
这个分层和第 3 章讲的「默认值 < 配置文件 < 环境变量 < 命令行」是同一个思路——越靠后的来源优先级越高,也越贴近部署环境。
15.2.2 ConfigMap:两种注入方式
ConfigMap 有两种消费方式,语义完全不同:
apiVersion: v1
kind: ConfigMap
metadata:
name: taskhub-config
namespace: taskhub
data:
TASKHUB_LOG_LEVEL: "info"
TASKHUB_DB_HOST: "taskhub-db.taskhub.svc.cluster.local"
config.yaml: |
server:
port: 8080
read_timeout: 5s
cache:
ttl: 60s
方式一:环境变量注入(在 Pod spec 里):
envFrom:
- configMapRef:
name: taskhub-config
方式二:挂载成文件:
volumeMounts:
- name: config
mountPath: /etc/taskhub
readOnly: true
volumes:
- name: config
configMap:
name: taskhub-config
| 维度 | 环境变量 | 文件挂载 |
|---|---|---|
| 读取方式 | os.Getenv | os.ReadFile |
| 能否放多行内容 | 否(YAML 里有换行会麻烦) | 是 |
| 改 ConfigMap 后 | 不会更新到运行中的容器 | 会自动更新(约 1 分钟内) |
| 适合 | 简单标量(级别、地址) | 结构化配置(YAML/JSON 文件) |
最后一行是关键区别:环境变量在容器启动时一次性注入,之后改 ConfigMap,运行中的 Pod 看不到新值;文件挂载则是符号链接指向一个会被原子替换的文件,Kubernetes 更新 ConfigMap 后,挂载的文件内容会在约 1 分钟内同步。
所以有个常见组合:简单的开关用 env,复杂的配置用文件。TaskHub 就是这么分的——日志级别走 env,config.yaml 走文件。
15.2.3 Secret:base64 不是加密
Secret 的 data 字段是 base64 编码,不是加密:
apiVersion: v1
kind: Secret
metadata:
name: taskhub-secrets
namespace: taskhub
type: Opaque
data:
db-password: czNjcjN0LXBAc3M=
stringData: # 写明文也行,K8s 会自动 base64
jwt-secret: "change-me-in-production"
czNjcjN0LXBAc3M= 解出来就是 s3cr3t-p@ss——任何人都能 echo czNjcjN0LXBAc3M= | base64 -d 还原。base64 只是为了让二进制数据能放进 YAML,不提供任何机密性。
那 Secret 比 ConfigMap 强在哪?三点实际差别:
- 不会随 Pod 打印到日志/describe 里(
kubectl describe不显示 Secret 内容); - 可以限制 RBAC 访问(只有特定 ServiceAccount 能读);
- 支持
imagePullSecrets、TLS 证书等专用类型。
真正的机密性要靠外部密钥管理:etcd 加密静态数据(EncryptionConfiguration)、SealedSecrets、External Secrets Operator 接 Vault/AWS Secrets Manager。Secret 只是「不主动泄露」,不是「泄露了也没事」——这个认知差异是很多安全事故的根源。
一个绝对的红线:Secret 绝不能提交进 Git。哪怕 base64 了也不行。用 SealedSecrets 或 CI 注入。
15.2.4 实测:env 与文件挂载的等价性
清单没法 apply,但两种注入方式对进程的可见效果可以完全等价复现。我用 docker run 的 -e(等价环境变量注入)和 -v(等价文件挂载)跑了一个读配置的程序:
docker run --rm --entrypoint /configdemo \
-e TASKHUB_LOG_LEVEL=debug \
-e TASKHUB_DB_HOST=taskhub-db.default.svc.cluster.local \
-v /Users/.../.gbtmp:/etc/taskhub:ro \
taskhub15:2
真实输出:
LOG_LEVEL (env) = debug
DB_HOST (env) = taskhub-db.default.svc.cluster.local
config.yaml (mount) = "server:\n port: 8080\n read_timeout: 5s\n"
db_password (secret) = "s3cr3t-p@ss"
环境变量和挂载文件都被进程正确读到,与 Kubernetes 里 ConfigMap/Secret 的注入效果一致。config.yaml 的多行内容原样保留,db_password 文件内容就是那个「明文密码」。
一个本机踩到的坑值得记:我最初把文件挂到
/tmp下的目录,容器里读到的却是一个空目录——因为本机 Docker(colima 后端)只共享了/Users/...这一个路径,/tmp不在共享范围里,挂载被静默替换成了空目录。排查「挂载了但读不到」时,先确认宿主目录真的共享给了容器。 Kubernetes 里没有这个问题,但本地用 Docker 模拟时很容易中招。
15.2.5 配置改了,Pod 会更新吗
这是最容易被误解的一点。改 ConfigMap 之后:
| 注入方式 | 运行中的 Pod | 需要做什么 |
|---|---|---|
| 环境变量 | 不更新 | 重启 Pod 才会读到新值 |
| 文件挂载 | 文件内容约 1 分钟内更新 | 程序要自己 watch 文件并 reload |
注意:改 ConfigMap 不会自动触发 Deployment 滚动更新。 Deployment 只在 template 变化时重建 Pod,而 ConfigMap 的内容不在 template 里。要让配置变更触发滚动更新,业界常用一个技巧——把配置的哈希算进 Pod 模板的注解:
spec:
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
(这是 Helm 模板写法。)配置一变,checksum/config 就变,template 跟着变,Deployment 就滚动更新。否则你会遇到「配置改了、Pod 没重启、行为没变」的经典困惑。
至于「程序自己 watch 文件热更新」——那是应用层的责任。Go 里通常用 fsnotify 监听文件变化,或用 SIGHUP 信号触发重载。本节的 TaskHub 未实现热更新,配置变更依赖重启(通过 checksum 注解触发滚动更新),这是更简单也更不容易出错的选择。
15.2.6 HPA:让副本数跟着负载走
固定 replicas: 3 在流量低谷时浪费资源、高峰时不够用。HorizontalPodAutoscaler(HPA) 让副本数自动调整:
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: taskhub-api
namespace: taskhub
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: taskhub-api
minReplicas: 3
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleUp:
stabilizationWindowSeconds: 30
policies:
- type: Percent
value: 100
periodSeconds: 30
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 25
periodSeconds: 60
几个关键点:
averageUtilization是相对requests的百分比,不是绝对量。CPU 目标 70% 意味着「当所有 Pod 的平均 CPU 使用率超过 requests 的 70% 就扩容」。所以 HPA 要求 Pod 必须设resources.requests,否则无法计算百分比——没设 requests 的 Deployment 配 HPA 会一直报错。scaleUp快、scaleDown慢是刻意的。扩容要快(应对流量突增),缩容要慢(避免刚缩完又涨回去造成抖动)。scaleDown.stabilizationWindowSeconds: 300表示「观察 5 分钟,只有持续低于目标才缩」。maxReplicas: 20是成本护栏。没有上限的自动伸缩会在流量异常时把你的账单打爆。
15.2.7 HPA 的前提与陷阱
HPA 依赖 metrics-server,没有它 HPA 无法获取 CPU/内存指标,会一直显示 <unknown> 而不扩容。本机未实测 HPA,因为没有集群也没有 metrics-server,无法验证扩缩容行为。要在本地验证,需要 kind + metrics-server,本机均未安装。
即使有 metrics-server,还有几个陷阱:
| 陷阱 | 现象 | 应对 |
|---|---|---|
没设 resources.requests | HPA 报错、不工作 | 必须设 requests |
| 只有 CPU 指标 | 扩容后延迟仍高 | 加自定义指标(QPS、队列长度) |
| 扩容太快 | 副本数剧烈抖动 | 调大 stabilizationWindowSeconds |
| 指标采集延迟 | 扩容滞后 30–60s | 接受延迟,或提前扩容(定时 HPA) |
| 只扩不缩 | 成本持续偏高 | 检查 scaleDown 策略是否被禁用 |
CPU 不是万能的伸缩信号。 对于一个「等数据库返回」的 IO 密集服务,CPU 使用率可能一直很低,但请求已经堆积——HPA 看着 CPU 不会扩容,而用户已经在超时。这类服务应该用自定义指标(每秒请求数、P99 延迟、消息队列积压量),通过 Prometheus Adapter 暴露给 HPA。
TaskHub 的做法是:CPU 指标兜底,QPS 指标做主信号。CPU 扩容应对计算密集的突发,QPS 扩容应对 IO 等待导致的堆积——两者互补。
一个务实的建议:上线初期先用固定副本数 + 足够的余量,观察真实的资源曲线,再配 HPA。HPA 的参数(目标利用率、稳定窗口)需要真实数据才能调准,拍脑袋配的 HPA 比固定副本数更糟——它会在流量波动时制造副本抖动,反而降低稳定性。
小结
- 配置与镜像分离:非敏感进 ConfigMap、敏感进 Secret,同一镜像适配多环境。
- ConfigMap 两种注入方式:环境变量(启动时一次性)与文件挂载(可热更新)。
- 环境变量注入改 ConfigMap 后运行中的 Pod 不更新;文件挂载约 1 分钟内更新。
- 改 ConfigMap 不会自动触发 Deployment 滚动更新,要用
checksum/config注解把配置哈希进 Pod 模板。 - Secret 是 base64 编码,不是加密,真正的机密性靠 etcd 静态加密或外部密钥管理。
- 实测:环境变量与文件挂载两种注入方式都能被进程正确读取,与 K8s 语义一致。
- HPA 按指标自动伸缩,必须设
resources.requests,且需要 metrics-server。 - 扩容快、缩容慢是刻意设计;
maxReplicas是成本护栏。 - CPU 不是万能的伸缩信号,IO 密集服务要用 QPS/队列长度等自定义指标。
- HPA 本机未实测(无集群、无 metrics-server)。
配置和密钥都管好了,副本也能自动伸缩了。但还有最后一道关:滚动更新时怎么不丢请求。下一节我们把优雅停机、preStop 钩子、terminationGracePeriodSeconds 和 PodDisruptionBudget 拼起来,并用本机 docker stop 的真实数据说明「在途请求为什么必须跑完」。
阅读导航:上一节:15.1 清单与探针 · 下一节:15.3 优雅停机与滚动更新 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。