A regular init container performs setup before a Pod’s application containers start. It has its own command, image and filesystem view, and can mount a volume that the application later reads. This separates a prerequisite from the main process, but does not make arbitrary setup safe or automatically bounded. Design the step around its actual retry behavior and the lifetime of its output.

Sequence is local to one Pod

Kubelet runs ordinary init containers in their declared order. Each must finish successfully before the next begins, and the application containers wait for that sequence. Inspect initContainerStatuses to distinguish a waiting step, a running step and a terminated attempt. A Pod’s phase alone does not explain which command failed or whether another attempt is underway.

Shared storage requires explicit mounts. An emptyDir can carry a generated file between init and application containers, and it survives a container restart within that Pod. It disappears with the Pod. The separate containers do not share their image root filesystems, and a file written only inside an init container’s writable layer is not automatically delivered to the application.

Ordinary init containers must complete. Kubernetes also supports restartable sidecars represented in initContainers with container-level restartPolicy set to Always; those have different lifecycle rules. Do not apply the ordinary run-to-completion description to every entry merely because it appears in that field. This article’s example uses only regular init containers.

When it breaks

A nonzero exit does not always fail the whole Pod immediately. With an ordinary application Pod’s Always policy, a failed regular init is retried until it succeeds; successful init steps do not restart merely because the application remains running. Backoff slows repeated attempts, but is not a fixed retry-count limit. With Pod restartPolicy Never, a failed initialization instead makes that Pod fail.

Write setup so another attempt can encounter existing output safely. An interrupted download may leave a partial file, and an emptyDir marker may survive the failed process. Validate the completed artifact and publish it deliberately. The counter used below is diagnostic state; it is not a production strategy for deciding that an external prerequisite must have become valid.

Ordering inside one Pod also does not serialize a fleet. Putting a database migration in every replica’s init sequence can run several migrations concurrently. Use the migration system’s actual locking and compatibility contract. Waiting for a dependency once cannot prove that dependency will remain available after initialization; continuing request handling and readiness need their own design.

Initialization affects resource placement too. For ordinary init containers, scheduling accounts for peak initialization requirements alongside the summed application requirements, with overhead where applicable. A brief but large setup request can prevent scheduling even when the steady application is small. Sidecars and Pod-level resource features require checking their additional accounting rules.

Check it quickly

Use a disposable Kubernetes cluster, kubectl access and python:3.11-slim-bookworm on its nodes. Save the code as init-order.py and run python3 init-order.py KUBECONFIG_PATH CONTEXT, replacing the arguments with explicit test-cluster details. The account must create and delete namespaces. The recorded server was Kubernetes 1.34.11; allow time for one failed initialization and its retry.

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')

The preparation container records its first attempt and exits seven. During its second attempt it waits for a release file. The script confirms the Pod is Pending, the first failure is recorded, and neither the next init step nor the application is running. It then releases preparation through that container’s shared volume.

The second init transforms ready into READY, and the application validates the artifact. Its log records prepare1, prepare2, transform and app in that order; the first init’s restart count is one. A separate Pod with Never policy exits initialization unsuccessfully and reaches Failed with no init retry. All assertions passed, and both Pods were removed with their namespace. The experiment verifies local sequencing and failure policy, not distributed migration safety or durable storage.

Sources