修改 ConfigMap 首先改变的是 API 对象。运行中的应用是否改变行为,还取决于 Kubernetes 如何交付数据,以及应用如何读取它。环境变量注入、普通卷投射、subPath 挂载和已经打开的文件,分别拥有不同生命周期。排查时应找到真正影响业务的消费者,而不是把所有配置传递方式都看成同一种机制。

区分交付与消费

容器环境中的 ConfigMap 引用会在容器启动时解析。更新对象不会重写已有进程的环境,也不会改写同一容器后续 exec 进程所使用的配置环境。替换 Pod 并引用所需版本,可以建立新的环境快照,但这属于生命周期动作,不是修改 data 字段之后自然发生的结果。配置变更记录与进程替换记录应能够对应。

普通 ConfigMap 卷则不同,kubelet 可以在之后把新数据投射进去。延迟受到同步周期和变更发现策略影响,因此 API 修改成功与文件发生变化不是同时发生的事件。投射更新也不会自动调用应用的重新加载函数。只在启动时解析一次配置的程序,即使文件已经变化,也可能一直使用先前读入的值。

打开文件描述引用的是已经打开的文件,而不是每次都重新解析原路径。路径背后的文件被替换,不会迫使旧描述符自动跟随新文件。因此,关闭并重新打开投射路径的进程可能读取新内容,另一个保留的描述符却仍读取旧文件。即使文件系统视图已经刷新,应用自己的缓存还可能再增加一层滞后。

把投射内容复制到可写位置,又会创建一个独立快照。副本不是指向 ConfigMap 的实时链接。如果启动脚本采用这种复制方式,更新这个副本也需要单独实现,不能期待源文件变化自动传播到另一份普通文件。

坏掉的样子

通过 subPath 挂载的 ConfigMap 不会获得普通方式的自动更新。为了方便只挂一个文件,实际上可能改变了配置更新契约。不能因为某个完整目录投射刷新成功,就承诺所有来自 ConfigMap 的文件都会刷新。两个容器对同一个对象表现不一致时,应查看各自真实 volumeMount 定义,而不是只比较 ConfigMap 名称。

监听配置变化需要明确的应用协议。监听器必须处理所用文件系统和库暴露的替换行为;读到新字节之后,验证与真正采用仍然是另外两步。分别读取多个键也可能与版本切换发生竞争。应用应定义怎样加载一致的一组配置、怎样拒绝无效值,以及怎样报告自己目前真正使用的版本,避免把检测到变化直接当成已经生效。

否则,一次成功编辑可能制造分裂视图:API 说 green,重新打开的投射路径也返回 green,进程却继续按 blue 工作。直接重启全部进程也许能暂时掩盖差异,却没有解释原因。应先比较对象、交付路径和应用状态,再选择符合预期契约的重新加载或替换方式,才能让后续配置变更保持可预测。

快速验证

以下实验及观察结果沿用原文记录。本次审阅没有重新执行 Kubernetes 集成实验;文中的成功结果属于历史记录,不代表本轮取得了新的运行证据。

准备可丢弃的 Kubernetes 集群,确保 kubectl 可以使用,节点已有 python:3.11-slim-bookworm 镜像。把代码保存为 config-lifetime.py,执行 python3 config-lifetime.py KUBECONFIG_PATH CONTEXT,明确传入测试集群的配置路径及上下文。程序创建独有命名空间并在结束后删除。实测集群为 Kubernetes 1.34.11,普通投射刷新最多等待三分钟,这个等待上限不是对所有集群延迟的承诺。

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'}}}]}

namespace = 'notes-lifetime-' + uuid4().hex[:8]
k('create', 'namespace', namespace)
try:
    apply(namespace, {'apiVersion': 'v1', 'kind': 'ConfigMap', 'metadata': {'name': 'settings'},
                      'data': {'mode': 'blue'}})
    script = ('from pathlib import Path; import time; '
              'held=open("/etc/lab/config/mode"); '
              'Path("/tmp/startup").write_text(held.read()); '
              'Path("/tmp/held-fd").write_text(str(held.fileno())); time.sleep(600)')
    podspec = spec(script)
    podspec['containers'][0]['env'] = [{'name': 'MODE', 'valueFrom':
        {'configMapKeyRef': {'name': 'settings', 'key': 'mode'}}}]
    podspec['containers'][0]['volumeMounts'] = [
        {'name': 'settings', 'mountPath': '/etc/lab/config', 'readOnly': True},
        {'name': 'settings', 'mountPath': '/etc/lab/single-mode', 'subPath': 'mode', 'readOnly': True}]
    podspec['volumes'] = [{'name': 'settings', 'configMap': {'name': 'settings'}}]
    apply(namespace, {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': 'reader'}, 'spec': podspec})
    initial_pod = wait_for(lambda: obj(namespace, 'pod', 'reader'), pod_ready)
    wait_for(lambda: k('exec', '-n', namespace, 'reader', '--', 'python3', '-c',
                      'from pathlib import Path; raise SystemExit(not Path("/tmp/held-fd").exists())',
                      expected=None), lambda result: result.returncode == 0)
    probe = '''from pathlib import Path
import json,os
fd=Path('/tmp/held-fd').read_text()
print(json.dumps({'environment':os.environ['MODE'],
                  'full_volume':Path('/etc/lab/config/mode').read_text(),
                  'subpath':Path('/etc/lab/single-mode').read_text(),
                  'startup_copy':Path('/tmp/startup').read_text(),
                  'held_inode':Path('/proc/1/fd/'+fd).read_text()}))'''
    def observe():
        return json.loads(k('exec', '-n', namespace, 'reader', '--', 'python3', '-c', probe).stdout)
    before = observe()
    assert set(before.values()) == {'blue'}
    k('patch', 'configmap', 'settings', '-n', namespace, '--type=merge',
      '-p', json.dumps({'data': {'mode': 'green'}}))
    after = wait_for(observe, lambda values: values['full_volume'] == 'green', seconds=180)
    assert {v for key, v in after.items() if key != 'full_volume'} == {'blue'}
    final_pod = obj(namespace, 'pod', 'reader')
    assert final_pod['metadata']['uid'] == initial_pod['metadata']['uid']
    assert final_pod['status']['containerStatuses'][0]['restartCount'] == 0
    print(json.dumps({'before': before, 'after': after, 'same_pod_uid': True,
                      'container_restart_count': 0, 'automatic_app_reload_tested': False}))
finally:
    k('delete', 'namespace', namespace, '--wait=true', '--timeout=90s')

同一个 Pod 通过五种方式消费 blue:环境变量、完整目录投射、subPath 挂载、启动时保存的副本,以及原进程一直持有的文件描述符。最初检查确认五条路径都得到 blue。随后程序把 ConfigMap 改为 green,并持续重新打开普通投射路径,直到观察到文件更新;这个过程没有主动替换 Pod。

传播完成之后,只有 full_volume 字段变成 green,environment、subpath、startup_copy 和 held_inode 仍然是 blue。Pod 标识没有变化,容器重启计数保持零。原文报告全部断言已通过,命名空间随后清理。这直接展示同一个运行中容器里的生命周期差异,而不是仅凭清单里存在某个字段就推断结果。

实验没有实现或验证应用重载监听器、多键事务式加载,也没有建立通用传播时限。启动副本刻意保持初值,用来表示只读取一次的消费者。生产验证应同时覆盖交付结果和应用已经接受的配置版本;仅确认某个文件刷新,不能证明之后的请求已经按新设置处理。

来源

  • Kubernetes: ConfigMaps,环境快照、卷的最终更新及 subPath 限制,已核验 2026-10-15。
  • Linux: open(2),打开文件描述与独立于路径的引用,已核验 2026-10-15。