This is an automated email from the ASF dual-hosted git repository.
henry3260 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/main by this push:
new 5925de6d01e Fail fast when an init container image cannot be pulled
(#76393)
5925de6d01e is described below
commit 5925de6d01e6802616be2c7f0ea206d4cafe54f6
Author: Henry Chen <[email protected]>
AuthorDate: Sun Nov 8 19:34:28 2026 +0800
Fail fast when an init container image cannot be pulled (#76393)
* Fail fast when an init container image cannot be pulled
An unpullable init container image left the pod Pending until
startup_timeout expired, because the main container only reports
PodInitializing while init containers run. Deployments hit the full
120s wait for a typo in an init container image instead of an
immediate, actionable error.
* Name the stuck container in the image pull error
The raised error did not name the stuck container, and the failure
diagnostics omitted init containers and run behind a non-default flag, so
by default a reader had to work out the container from other output, such
as the kubelet message or the raw pod data.
* Keep a restarting sidecar from failing a pod that is already running
Native sidecars are init containers with ``restartPolicy: Always``, so
unlike
other init containers they restart, and can go back to waiting, long after
start-up. Scanning ``init_container_statuses`` unconditionally let a sidecar
that fails an image pull mid-run end the monitoring loop while the phase was
still ``Running``; without istio or ``do_xcom_push``, ``cleanup`` then
raised a
failure for a task whose work had already succeeded. The re-defer guard in
``trigger_reentry`` reads the same check, so a deferred task whose trigger
errored failed there instead of deferring again.
The pod's ``Initialized`` condition separates the two cases. An init
container
that cannot pull at start-up keeps it false, so failing fast still applies.
Once any main container has started, kubelet keeps it true even while a
sidecar restarts, so from then on only the main containers are checked.
---
.../providers/cncf/kubernetes/operators/pod.py | 5 +-
.../providers/cncf/kubernetes/utils/pod_manager.py | 41 ++++---
.../unit/cncf/kubernetes/operators/test_pod.py | 42 +++++++
.../unit/cncf/kubernetes/utils/test_pod_manager.py | 133 +++++++++++++++++----
4 files changed, 180 insertions(+), 41 deletions(-)
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod.py
index f9716532765..65c57cafd24 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/operators/pod.py
@@ -1488,7 +1488,10 @@ class KubernetesPodOperator(BaseOperator):
pod_reason = getattr(remote_pod.status, "reason", None)
self.log.info("Pod phase: %s, reason: %s", pod_phase, pod_reason)
- container_statuses = getattr(remote_pod.status,
"container_statuses", None) or []
+ container_statuses = [
+ *(getattr(remote_pod.status, "init_container_statuses", None)
or []),
+ *(getattr(remote_pod.status, "container_statuses", None) or
[]),
+ ]
for status in container_statuses:
name = status.name
state = status.state
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/pod_manager.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/pod_manager.py
index 4b9c683f30e..8f18ec7792a 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/pod_manager.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/pod_manager.py
@@ -254,30 +254,39 @@ def detect_pod_terminate_early_issues(pod: V1Pod) -> str
| None:
FATAL_STATES = ["InvalidImageName", "ErrImageNeverPull"]
TRANSIENT_STATES = ["ErrImagePull", "ImagePullBackOff"]
- ERROR_MESSAGE = "Image cannot be pulled, unable to start:
{reason}\n{message}"
+ ERROR_MESSAGE = "Image for container '{name}' cannot be pulled, unable to
start: {reason}\n{message}"
pod_status = pod.status
- if not pod_status.container_statuses:
- return None
-
- for container_status in pod_status.container_statuses:
+ # ``init_container_statuses`` also lists native sidecars -- init
containers with
+ # ``restartPolicy: Always`` -- which can fail a restart mid-run. Scan init
containers
+ # only until ``Initialized`` is true: one that cannot pull keeps it false,
and kubelet
+ # keeps it true once any main container has started, even while a sidecar
restarts.
+ initialized = any(
+ condition.type == "Initialized" and condition.status == "True"
+ for condition in (pod_status.conditions or [])
+ )
+ container_statuses = [
+ # Init containers first: while one of them is stuck pulling, the main
+ # container only reports ``PodInitializing``, which matches no state
below.
+ *([] if initialized else (pod_status.init_container_statuses or [])),
+ *(pod_status.container_statuses or []),
+ ]
+ for container_status in container_statuses:
container_state: V1ContainerState = container_status.state
container_waiting: V1ContainerStateWaiting | None =
container_state.waiting
if not container_waiting:
continue
- if container_waiting.reason in FATAL_STATES:
- return ERROR_MESSAGE.format(
- reason=container_waiting.reason,
message=container_waiting.message or ""
- )
+ reason = container_waiting.reason
+ message = container_waiting.message or ""
- if container_waiting.reason in TRANSIENT_STATES:
- message_lower = (container_waiting.message or "").lower()
- is_transient = any(pattern in message_lower for pattern in
TRANSIENT_ERROR_PATTERNS)
- if not is_transient:
- return ERROR_MESSAGE.format(
- reason=container_waiting.reason,
message=container_waiting.message or ""
- )
+ # The message can quote the image name, so a transient pattern can
match by
+ # accident; it only ever excuses a TRANSIENT_STATES reason.
+ if reason in FATAL_STATES or (
+ reason in TRANSIENT_STATES
+ and not any(pattern in message.lower() for pattern in
TRANSIENT_ERROR_PATTERNS)
+ ):
+ return ERROR_MESSAGE.format(name=container_status.name,
reason=reason, message=message)
return None
diff --git
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/operators/test_pod.py
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/operators/test_pod.py
index 5f3219001b4..a151c8ae718 100644
--- a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/operators/test_pod.py
+++ b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/operators/test_pod.py
@@ -19,6 +19,7 @@ from __future__ import annotations
import asyncio
import datetime
import json
+import logging
import re
from contextlib import contextmanager, nullcontext
from typing import TYPE_CHECKING
@@ -4641,3 +4642,44 @@ def test_read_pod_events(mock_log, mock_pod_manager):
mock_event_error.reason,
mock_event_error.message,
)
+
+
+def _build_waiting_container_status(name: str, reason: str, message: str = "")
-> k8s.V1ContainerStatus:
+ return k8s.V1ContainerStatus(
+ name=name,
+ image="alpine",
+ image_id="1",
+ ready=False,
+ restart_count=0,
+
state=k8s.V1ContainerState(waiting=k8s.V1ContainerStateWaiting(reason=reason,
message=message)),
+ )
+
+
+@patch(
+
"airflow.providers.cncf.kubernetes.operators.pod.KubernetesPodOperator.pod_manager",
+ spec=PodManager,
+)
+@patch(
+
"airflow.providers.cncf.kubernetes.operators.pod.KubernetesPodOperator.log",
+ spec=logging.Logger,
+)
+def test_read_pod_container_states_names_the_stuck_init_container(mock_log,
mock_pod_manager):
+ mock_pod_manager.read_pod.return_value = k8s.V1Pod(
+ status=k8s.V1PodStatus(
+ phase=PodPhase.PENDING,
+ init_container_statuses=[
+ _build_waiting_container_status("init", "ImagePullBackOff",
"Back-off pulling image")
+ ],
+ container_statuses=[_build_waiting_container_status("base",
"PodInitializing")],
+ )
+ )
+
+ operator = KubernetesPodOperator(task_id="test-task")
+ operator._read_pod_container_states(k8s.V1Pod())
+
+ mock_log.warning.assert_any_call(
+ "Container '%s': state='WAITING', reason='%s', message='%s'",
+ "init",
+ "ImagePullBackOff",
+ "Back-off pulling image",
+ )
diff --git
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_pod_manager.py
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_pod_manager.py
index 17b1a7c80f9..fdc4b057509 100644
---
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_pod_manager.py
+++
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/utils/test_pod_manager.py
@@ -26,7 +26,19 @@ from unittest.mock import MagicMock, PropertyMock, patch
import pendulum
import pytest
import time_machine
-from kubernetes.client import CoreV1Event, CoreV1EventList, V1ObjectMeta,
V1ObjectReference, V1Pod
+from kubernetes.client import (
+ CoreV1Event,
+ CoreV1EventList,
+ V1ContainerState,
+ V1ContainerStateTerminated,
+ V1ContainerStateWaiting,
+ V1ContainerStatus,
+ V1ObjectMeta,
+ V1ObjectReference,
+ V1Pod,
+ V1PodCondition,
+ V1PodStatus,
+)
from kubernetes.client.rest import ApiException
from urllib3.exceptions import HTTPError as BaseHTTPError
@@ -194,15 +206,62 @@ def test_log_pod_event_multiple_events():
assert mock_pod_manager.log.info.call_count == 2
-def _pod_with_waiting_container(reason: str, message: str):
- pod = mock.MagicMock()
- container_status = mock.MagicMock()
- waiting_state = mock.MagicMock()
- waiting_state.reason = reason
- waiting_state.message = message
- container_status.state.waiting = waiting_state
- pod.status.container_statuses = [container_status]
- return pod
+def _build_waiting_container_status(name: str, reason: str, message: str = "")
-> V1ContainerStatus:
+ return V1ContainerStatus(
+ name=name,
+ image="alpine",
+ image_id="1",
+ ready=False,
+ restart_count=0,
+ state=V1ContainerState(waiting=V1ContainerStateWaiting(reason=reason,
message=message)),
+ )
+
+
+def _build_pod_with_waiting_container(reason: str, message: str, *, init: bool
= False) -> V1Pod:
+ stuck = _build_waiting_container_status("init" if init else "base",
reason, message)
+ if not init:
+ return V1Pod(status=V1PodStatus(container_statuses=[stuck]))
+ return V1Pod(
+ status=V1PodStatus(
+ init_container_statuses=[stuck],
+ container_statuses=[_build_waiting_container_status("base",
"PodInitializing")],
+ )
+ )
+
+
+def _build_terminated_container_status(name: str) -> V1ContainerStatus:
+ return V1ContainerStatus(
+ name=name,
+ image="alpine",
+ image_id="1",
+ ready=False,
+ restart_count=0,
+
state=V1ContainerState(terminated=V1ContainerStateTerminated(exit_code=0)),
+ )
+
+
+def _build_pod_with_sidecar_pull_failure(*, initialized: bool) -> V1Pod:
+ """Build a pod whose native sidecar cannot pull its image.
+
+ With ``initialized=True`` the base container has already run, which keeps
+ ``Initialized`` true while the sidecar restarts.
+ """
+ return V1Pod(
+ status=V1PodStatus(
+ phase=PodPhase.RUNNING if initialized else PodPhase.PENDING,
+ conditions=[V1PodCondition(type="Initialized", status="True" if
initialized else "False")],
+ init_container_statuses=[
+ _build_waiting_container_status(
+ "sidecar", "ImagePullBackOff", 'Back-off pulling image
"sidecar:latest"'
+ )
+ ],
+ container_statuses=[
+ _build_terminated_container_status("base")
+ if initialized
+ else _build_waiting_container_status("base", "PodInitializing")
+ ],
+ )
+ )
@pytest.mark.parametrize(
@@ -225,14 +284,17 @@ def
test_detect_pod_terminate_early_issues_returns_none_for_transient_messages(r
total wait. Returning None here keeps the monitoring loop going so those
retries have a chance to succeed.
"""
- pod = _pod_with_waiting_container(reason, message)
+ pod = _build_pod_with_waiting_container(reason, message)
assert detect_pod_terminate_early_issues(pod) is None
[email protected]("init", [False, True], ids=["main_container",
"init_container"])
@pytest.mark.parametrize(
("reason", "message"),
[
("InvalidImageName", "Failed to apply default image tag"),
+ # The image name contains the transient pattern "timeout".
+ ("InvalidImageName", 'Failed to apply default image tag
"timeout-worker:Bad Tag"'),
("ErrImageNeverPull", "Container image pull policy is Never"),
("ErrImagePull", "manifest unknown"),
("ImagePullBackOff", "unauthorized: authentication required"),
@@ -241,14 +303,38 @@ def
test_detect_pod_terminate_early_issues_returns_none_for_transient_messages(r
("ImagePullBackOff", 'Back-off pulling image "nonexistent:latest"'),
],
)
-def
test_detect_pod_terminate_early_issues_returns_error_for_fatal_messages(reason,
message):
- pod = _pod_with_waiting_container(reason, message)
+def
test_detect_pod_terminate_early_issues_returns_error_for_fatal_messages(reason,
message, init):
+ pod = _build_pod_with_waiting_container(reason, message, init=init)
error = detect_pod_terminate_early_issues(pod)
assert error is not None
assert reason in error
assert message in error
[email protected](
+ ("initialized", "expected_error_contains"),
+ [
+ pytest.param(False, "Image for container 'sidecar' cannot be pulled",
id="still_initializing"),
+ pytest.param(True, None, id="already_initialized"),
+ ],
+)
+def test_detect_pod_terminate_early_issues_gates_sidecars_on_initialized(
+ initialized, expected_error_contains
+):
+ """A sidecar stuck pulling only justifies early termination while it gates
start-up.
+
+ Once the pod is ``Initialized`` init containers no longer gate start-up,
so a sidecar
+ failing a restart must not end the wait: the main container's work can
still succeed.
+ """
+ pod = _build_pod_with_sidecar_pull_failure(initialized=initialized)
+ error = detect_pod_terminate_early_issues(pod)
+ if expected_error_contains is None:
+ assert error is None
+ else:
+ assert error is not None
+ assert expected_error_contains in error
+
+
class TestPodManager:
def setup_method(self):
self.mock_kube_client = mock.Mock()
@@ -1028,11 +1114,13 @@ class TestPodManager:
waiting_state.reason = fail_reason
waiting_state.message = "Test error"
container_statuse.state.waiting = waiting_state
+ container_statuse.name = "base"
pod_response.status.container_statuses = [container_statuse]
self.mock_kube_client.read_namespaced_pod.return_value = pod_response
expected_msg = (
- f"Image cannot be pulled, unable to start:
{waiting_state.reason}\n{waiting_state.message}"
+ "Image for container 'base' cannot be pulled, unable to start: "
+ f"{waiting_state.reason}\n{waiting_state.message}"
)
mock_pod = MagicMock()
with pytest.raises(AirflowException, match=expected_msg):
@@ -1734,19 +1822,16 @@ class TestAsyncPodManager:
self.mock_async_hook.get_pod.assert_called()
@pytest.mark.asyncio
+ @pytest.mark.parametrize("init", [False, True], ids=["main_container",
"init_container"])
@pytest.mark.parametrize("fail_reason", ["ErrImagePull",
"ImagePullBackOff", "InvalidImageName"])
- async def test_start_pod_raises_fast_error_on_image_error(self,
fail_reason):
- pod_response = mock.MagicMock()
- pod_response.status.phase = "Pending"
- container_status = mock.MagicMock()
- waiting_state = mock.MagicMock()
- waiting_state.reason = fail_reason
- waiting_state.message = "Test error"
- container_status.state.waiting = waiting_state
- pod_response.status.container_statuses = [container_status]
+ async def test_start_pod_raises_fast_error_on_image_error(self,
fail_reason, init):
+ pod_response = _build_pod_with_waiting_container(fail_reason, "Test
error", init=init)
+ pod_response.status.phase = PodPhase.PENDING
self.mock_async_hook.get_pod.return_value = pod_response
+ stuck_container = "init" if init else "base"
expected_msg = (
- f"Image cannot be pulled, unable to start:
{waiting_state.reason}\n{waiting_state.message}"
+ f"Image for container '{stuck_container}' cannot be pulled, "
+ f"unable to start: {fail_reason}\nTest error"
)
mock_pod = mock.MagicMock()
with pytest.raises(AirflowException, match=expected_msg):