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 标识各不相同,但运行时观察到的镜像身份完全一致。全部断言已经通过,实验命名空间随后删除,未留下这些测试配置和工作负载。不过镜像标签仍然可变:观察到的身份一致,只证明这次运行,没有保证未来可复现;需要固定产物时应使用镜像摘要。

这个实验使用简单工作进程,验证配置选择、进程替换以及依赖旧输入的回退,没有证明应用兼容性、生产滚动期间的可用性或外部副作用已经回滚。发布证据应把镜像、配置版本、工作负载定义和应用观测结果放在一起,让每一部分变化都有具体依据可供审阅。

来源