ConfigMap 把非敏感配置放在镜像之外,让环境相关设置发生变化时,不必重新构建应用产物。真正有用的边界需要明确:镜像提供代码,工作负载定义则指定这些代码应该读取哪份配置。因此,一次发布不仅是一个镜像标签,配置引用同样属于正在交付的行为。只记录镜像版本,可能无法解释两个环境为什么表现不同。
给配置一个可审阅的身份
ConfigMap 的 data 值是字符串,也可以通过 binaryData 表达二进制内容及其 API 编码形式。它适合小规模配置,数据大小有一 MiB 限制,不是通用文件仓库,更不是保护秘密的机制。API 能检查对象结构,却不知道某个应用参数在业务上是否合理。因此,结构合法和业务正确仍需分别验证,不能以创建成功代替配置审查。
Pod 可以通过环境变量引用或挂载文件消费配置。普通 Pod 引用要求 ConfigMap 位于这个 Pod 所在的命名空间。名称、必需键和应用解释方式都应明确。Deployment 对象被 API 接受,并不证明它引用的每个键都存在,也不证明新容器能够带着这些值成功启动。发布检查必须继续观察真实工作负载的结果。
单位、允许值与默认值应属于应用配置规范。某个字符串看起来像布尔值或数字,并不说明接收方会采用什么转换规则。在不同环境之间推广配置之前,需要结合真实消费者检查这些规则,避免同一份文本被解释成不同业务含义。
版本化的不可变 ConfigMap 更容易表达发布身份。settings-v2 可以与 settings-v1 同时保留,而 immutable 会禁止修改当前对象的数据。但这项保护不阻止有权限的操作者删除对象,再用同名重新创建另一份内容。对象生命周期权限和发布记录,应与希望实现的不可变承诺一致,不能把一个字段理解成所有可能路径都被永久封闭。
坏掉的样子
修改配置与替换进程是两种不同动作。已经注入运行中容器的环境值,不会因为 ConfigMap 更新而被改写。普通投射文件可能最终更新,但应用仍要重新读取;subPath 挂载和已经打开的文件又有不同生命周期。因此,API 中出现了新值,并不能直接宣布应用已经完成配置切换,还需要查看消费者实际读到了什么。
把 Deployment 的 Pod 模板改为引用新的 ConfigMap,是明确的滚动替换触发条件。仅修改 ConfigMap 本身,不会自动改写这个模板。同样,校验和 annotation 只有在某个工具真正把它更新到 Pod 模板中时才起作用。Kubernetes 不会自行发现任意外部配置变化,并替应用生成期望的校验和;这部分自动化需要有真实实现。
回退依赖旧输入仍然存在。旧对象已经删除,再把工作负载指回 settings-v1 就可能失败;只恢复镜像,也不能恢复被修改的配置。清理前应检查现有消费者及回退需要,并保留必要版本。配置还要通过业务验证,例如格式正确的阈值仍可能禁用服务或改变行为,不能把版本化本身当成兼容性证明。
快速验证
准备可丢弃的 Kubernetes 测试集群,确保 kubectl 可以访问,且节点能够使用 python:3.11-slim-bookworm 镜像。把代码保存为 config-releases.py,执行 python3 config-releases.py KUBECONFIG_PATH CONTEXT,将后两个参数替换成专用集群的配置文件路径和上下文。测试账号需要创建、删除本次命名空间的权限。实测服务端为 Kubernetes 1.34.11,客户端为 kubectl 1.34.1。
import json
import subprocess
import sys
import time
from uuid import uuid4
assert len(sys.argv) == 3, 'usage: python3 example.py DISPOSABLE_KUBECONFIG CONTEXT'
base = ['kubectl', '--kubeconfig', sys.argv[1], '--context', sys.argv[2], '--request-timeout=30s']
image = 'python:3.11-slim-bookworm'
def k(*args, payload=None, expected=0):
result = subprocess.run(base + list(args), input=json.dumps(payload) if payload is not None else None,
capture_output=True, text=True, timeout=150)
if expected is not None:
assert result.returncode == expected, result.stderr
return result
def obj(namespace, kind, name=None, selector=None):
args = ['get', kind, '-n', namespace, '-o', 'json']
if name:
args.append(name)
if selector:
args += ['-l', selector]
return json.loads(k(*args).stdout)
def apply(namespace, data):
return k('apply', '-n', namespace, '-f', '-', payload=data)
def wait_for(read, ready, seconds=120):
until = time.monotonic() + seconds
while time.monotonic() < until:
value = read()
if ready(value):
return value
time.sleep(0.5)
raise AssertionError('expected Kubernetes condition did not arrive')
def pod_ready(pod):
return any(c['type'] == 'Ready' and c['status'] == 'True'
for c in pod.get('status', {}).get('conditions', []))
def spec(script='import time; time.sleep(600)'):
return {'automountServiceAccountToken': False, 'terminationGracePeriodSeconds': 1,
'containers': [{'name': 'app', 'image': image, 'imagePullPolicy': 'IfNotPresent',
'command': ['python3', '-c', script],
'resources': {'requests': {'cpu': '10m', 'memory': '24Mi'},
'limits': {'cpu': '200m', 'memory': '96Mi'}}}]}
def deployment(namespace, name, podspec):
return apply(namespace, {'apiVersion': 'apps/v1', 'kind': 'Deployment', 'metadata': {'name': name},
'spec': {'replicas': 1, 'selector': {'matchLabels': {'app': name}},
'template': {'metadata': {'labels': {'app': name}}, 'spec': podspec}}})
def deployment_pod(namespace, name):
wait_for(lambda: obj(namespace, 'deployment', name), lambda d:
d.get('status', {}).get('observedGeneration', 0) >= d['metadata']['generation']
and d.get('status', {}).get('updatedReplicas') == 1
and d.get('status', {}).get('availableReplicas') == 1
and d.get('status', {}).get('replicas') == 1)
def pods():
return [p for p in obj(namespace, 'pods', selector='app=' + name)['items']
if not p['metadata'].get('deletionTimestamp')]
return wait_for(pods, lambda p: len(p) == 1 and pod_ready(p[0]))[0]
namespace = 'notes-config-' + uuid4().hex[:8]
k('create', 'namespace', namespace)
try:
for version, mode in [('v1', 'blue'), ('v2', 'green')]:
apply(namespace, {'apiVersion': 'v1', 'kind': 'ConfigMap',
'metadata': {'name': 'settings-' + version}, 'immutable': True, 'data': {'mode': mode}})
podspec = spec()
podspec['containers'][0]['env'] = [{'name': 'MODE', 'valueFrom':
{'configMapKeyRef': {'name': 'settings-v1', 'key': 'mode'}}}]
deployment(namespace, 'web', podspec)
first = deployment_pod(namespace, 'web')
def mode(pod):
return k('exec', '-n', namespace, pod['metadata']['name'], '--', 'python3', '-c',
'import os; print(os.environ["MODE"])').stdout.strip()
assert mode(first) == 'blue'
rejected = k('patch', 'configmap', 'settings-v1', '-n', namespace, '--type=merge',
'-p', json.dumps({'data': {'mode': 'changed'}}), expected=None)
assert rejected.returncode != 0 and 'immutable' in rejected.stderr
assert obj(namespace, 'configmap', 'settings-v1')['data']['mode'] == 'blue'
def select(version):
patch = [{'op': 'replace',
'path': '/spec/template/spec/containers/0/env/0/valueFrom/configMapKeyRef/name',
'value': 'settings-' + version}]
k('patch', 'deployment', 'web', '-n', namespace, '--type=json', '-p', json.dumps(patch))
return deployment_pod(namespace, 'web')
second = select('v2')
assert mode(second) == 'green' and second['metadata']['uid'] != first['metadata']['uid']
third = select('v1')
assert mode(third) == 'blue' and third['metadata']['uid'] != second['metadata']['uid']
image_ids = [p['status']['containerStatuses'][0]['imageID'] for p in (first, second, third)]
assert len(set(image_ids)) == 1
assert len({p['metadata']['uid'] for p in (first, second, third)}) == 3
print(json.dumps({'modes': ['blue', 'green', 'blue'], 'distinct_pod_uids': 3,
'same_image_id': image_ids[0], 'immutable_update_rejected': True,
'retained_config_rollback_verified': True}))
finally:
k('delete', 'namespace', namespace, '--wait=true', '--timeout=90s')
程序创建两个不可变 ConfigMap,以及引用 settings-v1 的单副本 Deployment。第一个 Pod 从环境变量读取 blue。随后尝试覆盖这份 ConfigMap 数据,API 会拒绝操作,再读取对象也确认原值仍是 blue。这验证的是服务器实际执行了约束,而不是仅在文件中找到了 immutable 字样。
接着修改 Pod 模板,让它引用 settings-v2。控制器协调完成、容器就绪之后,另一个 Pod 读取 green。再选择保留的第一版,会再次替换 Pod 并读到 blue。三次 Pod 标识各不相同,但运行时观察到的镜像身份完全一致。全部断言已经通过,实验命名空间随后删除,未留下这些测试配置和工作负载。不过镜像标签仍然可变:观察到的身份一致,只证明这次运行,没有保证未来可复现;需要固定产物时应使用镜像摘要。
这个实验使用简单工作进程,验证配置选择、进程替换以及依赖旧输入的回退,没有证明应用兼容性、生产滚动期间的可用性或外部副作用已经回滚。发布证据应把镜像、配置版本、工作负载定义和应用观测结果放在一起,让每一部分变化都有具体依据可供审阅。
来源
Kubernetes: ConfigMaps,非敏感配置、消费方式与不可变属性,已核验 2026-09-13。
Kubernetes: Deployments,Pod 模板变更及滚动替换,已核验 2026-09-13。
Kubernetes:镜像,可变标签与摘要固定身份,已核验 2026-10-15。