Changing a ConfigMap updates an API object. Whether a running application changes behavior depends on how Kubernetes delivers the value and how the application reads it. Environment injection, normal volume projection, a subPath mount, and an already-open file are different paths with different lifetimes. Diagnose the consumer that actually matters rather than treating all configuration delivery as one mechanism.
Separate delivery from consumption
A ConfigMap reference in a container’s environment is resolved when that container starts. Updating the object does not rewrite the environment of an existing process or the configured environment used for later exec processes in that same container. Replacing the Pod with the intended reference can produce a new environment, but that is a lifecycle action, not an automatic result of editing data.
A normal ConfigMap volume is different: kubelet can project updated data eventually. The delay depends on its synchronization period and change-detection strategy, so an API update and a changed file are not simultaneous events. The projection also does not invoke the application’s reload handler. A program that reads only at startup may continue using its earlier parsed values indefinitely.
An open file description refers to the opened file rather than repeatedly resolving the original pathname. Replacing the file behind a path does not force existing descriptors to follow the replacement. A process that closes and reopens a projected path can therefore see new data while another descriptor still reads the old file. Application caching adds a further layer even after the filesystem view has changed.
Copying projected contents into a writable location creates another snapshot. The copied file is not a live link to the ConfigMap. If a startup script performs this copy, refreshing the copy needs its own explicit process.
When it breaks
A ConfigMap mounted through subPath does not receive the ordinary automatic updates. Choosing a convenient single-file mount can therefore change the update contract. Do not promise that every ConfigMap-backed file will refresh because one full-directory projection did. Inspect the actual volumeMount definition when two containers appear to disagree about the same object.
Watching configuration requires a deliberate application protocol. A watcher must handle the replacement behavior exposed by its filesystem and library; validating and applying the new settings are separate steps. Reading several keys independently can also race a version change. Define how the application loads a coherent configuration, rejects invalid values, and reports which version it is actually using.
A successful edit can otherwise create a split view: the API reports green, a newly opened projected path returns green, and the process still acts on blue. Restarting everything may hide that distinction without explaining it. First compare the object, delivery path, and application state, then choose a reload or replacement consistent with the workload’s intended contract.
Check it quickly
Use a disposable Kubernetes cluster with kubectl and python:3.11-slim-bookworm available on its nodes. Save the code as config-lifetime.py, then run python3 config-lifetime.py KUBECONFIG_PATH CONTEXT with explicit test-cluster arguments. The script creates and later deletes a unique namespace. The recorded cluster ran Kubernetes 1.34.11; allow up to three minutes for the ordinary projection to refresh.
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')
One Pod consumes blue in five ways: its environment, a full-directory projection, a subPath mount, a startup copy, and a file descriptor opened by the original process. The initial check reads blue through all five. The script updates the ConfigMap to green and repeatedly reopens the full projected path until it sees the change.
After propagation, only full_volume reports green. Environment, subpath, startup_copy, and held_inode still report blue. The Pod UID remains identical and the container restart count stays zero. All assertions passed, and cleanup removed the namespace. This is direct evidence of different lifetimes within one running container, rather than an inference from the configuration manifest.
The fixture does not implement or test an application reload watcher, multi-key transactional loading, or a guaranteed propagation deadline. Its startup copy deliberately remains unchanged. In production, verify both delivery and the application’s accepted configuration version; a refreshed file alone cannot prove that requests are now served under the new settings.
Sources
- Kubernetes: ConfigMaps, environment snapshots, eventual volume updates and subPath limits, checked 2026-09-13.
- Linux: open(2), open file descriptions and pathname-independent references, checked 2026-09-12.