普通 init container 在 Pod 的应用容器启动之前执行准备工作。它有自己的命令、镜像和文件系统视图,也可以挂载应用稍后读取的共享卷。这让前置步骤与主进程分开,但不意味着任意初始化操作都会自动安全,也不意味着重试默认存在固定次数上限。应根据真实重试方式及产物生命周期设计这个步骤,而不是把启动顺序当成完整容错机制。

顺序约束只属于一个 Pod

Kubelet 按声明顺序运行普通初始化容器。每一步必须成功结束,下一步才开始,应用容器则等待整个序列完成。检查 initContainerStatuses,才能区分正在等待的步骤、运行中的步骤和已经终止的某次尝试。仅查看 Pod 的阶段,无法说明哪条命令失败,也无法说明新的尝试是否正在进行;排查需要结合具体容器状态和日志。

共享存储需要显式挂载。emptyDir 可以在初始化容器与应用容器之间传递生成文件,并在同一 Pod 内的容器重启期间保留,但会随着 Pod 消失。不同容器不会自动共享镜像根文件系统,只写在初始化容器可写层中的文件,不会自然出现在应用容器里面。产物的写入位置和读取位置必须通过真实卷定义对应起来。

普通初始化容器需要运行到完成。Kubernetes 也支持在 initContainers 中通过容器级 restartPolicy Always 表示可重启边车,它们具有不同生命周期。因此,不能仅因为某个条目位于这个字段,就认定它必须退出后应用才能继续。本例只使用普通初始化容器,不把持续运行的边车混进顺序完成模型。

坏掉的样子

非零退出并不总会让整个 Pod 立即失败。在普通应用 Pod 采用 Always 策略时,失败的普通初始化容器会继续重试,直到成功;已经成功的初始化步骤则不会因为应用仍在运行就不断重启。退避会放慢连续尝试,却不是固定的重试次数限制。如果 Pod 的 restartPolicy 为 Never,初始化失败会使该 Pod 进入失败状态,恢复方式需要另行安排。

准备脚本应能够安全面对前一次留下的内容。中断的下载可能留下半个文件,emptyDir 中的标记也可能在失败进程退出后继续存在。应验证完整产物,再明确交付给消费者。下面用于记录次数的计数器只是诊断数据,不能作为生产逻辑,推断某个外部依赖在第二次尝试时就必然可用。真实条件必须由对应检查来判断。

一个 Pod 内的顺序并不会把整组副本串行化。把数据库迁移放进每个副本的初始化流程,仍可能让多份迁移同时执行。应遵循迁移系统真实的锁与兼容性契约。只等待一次数据库,也不能保证初始化之后数据库持续可达;应用后续请求处理和就绪判断仍需要独立设计,不能依靠先前某次成功永久推导健康。

初始化同样影响资源放置。对于普通初始化容器,调度需要比较初始化期间的峰值需求与应用容器的汇总需求,并按适用规则计入额外开销。即使长期运行的应用很小,短暂准备步骤提出过大的请求,也可能导致 Pod 无法调度。边车及 Pod 级资源功能还有自己的计算规则,不能把简化公式无条件套用到所有清单。

快速验证

准备可丢弃的 Kubernetes 集群,确保 kubectl 可访问,节点可以使用 python:3.11-slim-bookworm 镜像。保存为 init-order.py,执行 python3 init-order.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-init-' + uuid4().hex[:8]
k('create', 'namespace', namespace)
try:
    prepare = r'''from pathlib import Path
import sys,time
root=Path('/work'); counter=root/'attempts'
attempt=int(counter.read_text())+1 if counter.exists() else 1
counter.write_text(str(attempt))
with (root/'order').open('a') as f: f.write('prepare'+str(attempt)+'\n')
if attempt == 1: sys.exit(7)
print('waiting for release',flush=True)
while not (root/'go').exists(): time.sleep(0.1)
(root/'input').write_text('ready')
'''
    transform = r'''from pathlib import Path
root=Path('/work'); assert (root/'input').read_text()=='ready'
(root/'output').write_text('READY')
with (root/'order').open('a') as f: f.write('transform\n')
'''
    main = r'''from pathlib import Path
import json,time
root=Path('/work'); assert (root/'output').read_text()=='READY'
with (root/'order').open('a') as f: f.write('app\n')
print(json.dumps({'order':(root/'order').read_text().splitlines(),
                  'artifact':(root/'output').read_text()}),flush=True)
time.sleep(600)
'''
    podspec = spec(main)
    mount = [{'name': 'work', 'mountPath': '/work'}]
    podspec['volumes'] = [{'name': 'work', 'emptyDir': {}}]
    podspec['containers'][0]['volumeMounts'] = mount
    podspec['initContainers'] = []
    for name, script in [('prepare', prepare), ('transform', transform)]:
        container = spec(script)['containers'][0]
        container['name'] = name
        container['volumeMounts'] = mount
        podspec['initContainers'].append(container)
    apply(namespace, {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': 'ordered'}, 'spec': podspec})
    def retry_running(p):
        statuses = p.get('status', {}).get('initContainerStatuses', [])
        return statuses and statuses[0]['restartCount'] >= 1 and 'running' in statuses[0]['state']
    waiting = wait_for(lambda: obj(namespace, 'pod', 'ordered'), retry_running)
    first = waiting['status']['initContainerStatuses'][0]
    assert first['lastState']['terminated']['exitCode'] == 7
    assert waiting['status']['phase'] == 'Pending'
    assert all('running' not in c['state'] for c in waiting['status'].get('containerStatuses', []))
    assert 'running' not in waiting['status']['initContainerStatuses'][1]['state']
    k('exec', '-n', namespace, 'ordered', '-c', 'prepare', '--', 'python3', '-c',
      'from pathlib import Path; Path("/work/go").touch()')
    ready = wait_for(lambda: obj(namespace, 'pod', 'ordered'), pod_ready)
    assert ready['metadata']['uid'] == waiting['metadata']['uid']
    output = wait_for(lambda: k('logs', '-n', namespace, 'ordered', '-c', 'app').stdout.strip(), bool)
    observed = json.loads(output)
    assert observed == {'order': ['prepare1', 'prepare2', 'transform', 'app'], 'artifact': 'READY'}
    assert ready['status']['initContainerStatuses'][0]['restartCount'] == 1
    assert all(c['state']['terminated']['exitCode'] == 0 for c in ready['status']['initContainerStatuses'])
    never = spec()
    never['restartPolicy'] = 'Never'
    failed_init = spec('raise SystemExit(7)')['containers'][0]
    failed_init['name'] = 'fails'
    never['initContainers'] = [failed_init]
    apply(namespace, {'apiVersion': 'v1', 'kind': 'Pod', 'metadata': {'name': 'never'}, 'spec': never})
    failed = wait_for(lambda: obj(namespace, 'pod', 'never'), lambda p: p.get('status', {}).get('phase') == 'Failed')
    status = failed['status']['initContainerStatuses'][0]
    assert status['restartCount'] == 0 and status['state']['terminated']['exitCode'] == 7
    assert not any('running' in c['state'] for c in failed['status'].get('containerStatuses', []))
    print(json.dumps({'ordered_artifact': observed, 'retried_init_count': 1,
                      'app_waited_for_both_regular_init_containers': True,
                      'never_policy_failed_pod_without_retry': True}))
finally:
    k('delete', 'namespace', namespace, '--wait=true', '--timeout=90s')

准备容器记录第一次尝试并以退出码七结束。第二次尝试等待一个放行文件,脚本在此时确认 Pod 仍为 Pending、上一次失败已记录,而且后续初始化步骤与应用都没有运行。然后通过这个容器的共享卷放行准备过程。这个暂停点提供直接状态证据,而不是根据最终日志猜测应用曾经等待。

第二个初始化容器把 ready 转成 READY,应用随后校验产物。日志中的顺序为 prepare1、prepare2、transform、app,第一个初始化容器的重启次数为一。另一个采用 Never 策略的 Pod 初始化失败后进入 Failed,没有重试。原文报告全部断言通过,命名空间清理了两个 Pod;这里保留历史执行归属,不表示本轮独立重现。实验验证的是本地顺序、共享产物及失败策略,没有证明分布式迁移安全,也没有提供持久化存储保障。

来源