This is an automated email from the ASF dual-hosted git repository.
potiuk 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 95bbf6ace25 Pin Docker Hub test images against K8s system-test
rate-limit flakes (#66423)
95bbf6ace25 is described below
commit 95bbf6ace25c013a67962c078ea7be75f7ac2190
Author: Jarek Potiuk <[email protected]>
AuthorDate: Thu May 7 04:10:15 2026 +0200
Pin Docker Hub test images against K8s system-test rate-limit flakes
(#66423)
* Pin Docker Hub test images against rate-limit flakes
The scheduled K8s system-test job has been intermittently red because
multiple test pods pull the unpinned `alpine:latest` (xcom sidecar) and
`busybox:latest` / `ubuntu:latest` (test pods) from Docker Hub
anonymously and trip its 100-pulls-per-6h limit
(https://github.com/apache/airflow/actions/runs/25365187430/job/74380551079).
Without a tag, kubelet defaults `imagePullPolicy` to `Always`, so even
nodes that already cached the image re-pull every run.
Changes
-------
1. **Production default**: `xcom_sidecar.PodDefaults.SIDECAR_CONTAINER`
now uses `alpine:3.23` via a new module-level `XCOM_SIDECAR_IMAGE`
constant. Tagged → `imagePullPolicy: IfNotPresent` by default →
nodes with the image cached do not re-pull.
2. **System / kubernetes-tests pin**: every bare `image="ubuntu"` /
`"busybox"` / `"alpine"` in `kubernetes-tests/...` and the
`cncf/kubernetes` system / unit tests is now pinned (ubuntu:24.04,
busybox:1.37, alpine:3.23). Test assertions in
`test_pod.py` updated to match the new sidecar default.
3. **Pre-load into kind**: a new `_preload_test_images_to_kind()` helper
in `breeze k8s` runs after `_upload_k8s_image()` in
`_run_complete_tests`. It pulls each image on the runner with
exponential-backoff retries on Docker Hub 429s, then `kind load
docker-image` puts it on every node — so kubelet never has to reach
out to the registry once the cluster is ready.
4. **Auto-tracker**: `scripts/ci/prek/upgrade_important_versions.py`
gains `UPGRADE_ALPINE` / `UPGRADE_BUSYBOX` flags, fetchers using the
existing Docker Hub `get_latest_image_version()`, regex patterns for
`alpine:` / `busybox:` literals plus chart `ALPINE_VERSION` ARGs, and
the relevant call-sites added to `FILES_TO_UPDATE`. The next "Upgrade
important CI environment" run will keep these pins fresh
automatically. Ubuntu is intentionally not auto-tracked: the tracker
would prefer the highest semver, which can be an interim
(non-LTS) release — system tests want LTS.
Drive-by
--------
`# type: ignore[no-redef]` on the standard `import tomli as tomllib`
fallback in `dev/registry/extract_{metadata,versions}.py` so `mypy-dev`
passes on edits to anything else under `dev/`. Identical fix lives in
PR #66314 — whichever lands first, the other becomes a no-op rebase.
* Fix expected_pod fixtures + changelog formatting + spelling
Three follow-ups to the original commit, surfaced by CI on
#66423:
1. The dict-literal `image` keys in `expected_pod` fixtures inside
`kubernetes-tests/tests/kubernetes_tests/test_kubernetes_pod_operator.py`
still pointed at the bare names (`"ubuntu"`, `"alpine"`) — only
the kwarg-style `image=` references were caught by the original
sed. Pinned them to match the new defaults. Without this, every
pod-spec equality assertion against `self.expected_pod` failed
on Python 3.10 K8s system tests.
2. The cncf.kubernetes changelog note used a level-3 `~~~` heading
directly under `Changelog ---`, which (a) shifted the entire
version-section hierarchy and produced ~700 cascading docs-build
errors, and (b) was 1 char short of the title length triggering
a `Title underline too short` warning. Replaced the heading with
a bold-led paragraph — same content, no hierarchy disruption.
3. `kubelet` was missing from `docs/spelling_wordlist.txt`, so the
sphinx spellcheck flagged it in the new note.
---
.../airflow_breeze/commands/kubernetes_commands.py | 101 +++++++++++++++++++++
dev/registry/extract_metadata.py | 2 +-
dev/registry/extract_versions.py | 2 +-
docs/spelling_wordlist.txt | 1 +
.../test_kubernetes_pod_operator.py | 90 +++++++++---------
providers/cncf/kubernetes/docs/changelog.rst | 14 +++
.../cncf/kubernetes/utils/xcom_sidecar.py | 10 +-
.../system/cncf/kubernetes/example_kubernetes.py | 2 +-
.../cncf/kubernetes/example_kubernetes_async.py | 4 +-
.../unit/cncf/kubernetes/operators/test_pod.py | 4 +-
scripts/ci/prek/upgrade_important_versions.py | 90 ++++++++++++++++++
11 files changed, 267 insertions(+), 53 deletions(-)
diff --git a/dev/breeze/src/airflow_breeze/commands/kubernetes_commands.py
b/dev/breeze/src/airflow_breeze/commands/kubernetes_commands.py
index 093cbeb960e..2645e811919 100644
--- a/dev/breeze/src/airflow_breeze/commands/kubernetes_commands.py
+++ b/dev/breeze/src/airflow_breeze/commands/kubernetes_commands.py
@@ -707,6 +707,97 @@ def _upload_k8s_image(python: str, kubernetes_version:
str, output: Output | Non
return kind_load_result.returncode, f"Uploaded K8S image to {cluster_name}"
+# Test-suite container images that Airflow's K8s system tests pull from Docker
+# Hub. Tagged (not `:latest`) so kubelet's default imagePullPolicy is
+# IfNotPresent — combined with `kind load` below, this means kubelet uses the
+# already-loaded image and never reaches out to Docker Hub. The pin protects
+# CI runs from Docker Hub anonymous-pull rate limits, which intermittently
+# turn the scheduled K8s test job red. Auto-bumped by
+# scripts/ci/prek/upgrade_important_versions.py.
+K8S_TEST_IMAGES_TO_PRELOAD: tuple[str, ...] = (
+ "alpine:3.23", # xcom_sidecar default in providers/cncf/kubernetes
+ "busybox:1.37", # busybox-based system tests in kubernetes-tests/
+ "ubuntu:24.04", # ubuntu-based system tests in kubernetes-tests/
+)
+
+
+def _docker_pull_with_429_retry(image: str, output: Output | None,
max_attempts: int = 5) -> int:
+ """Run `docker pull <image>` retrying with exponential backoff on Docker
Hub 429s.
+
+ Returns the final docker exit code (0 on success). Non-429 failures fail
+ fast — only the rate-limit pattern is retried, since for everything else
+ retrying would just amplify a real error.
+ """
+ import time
+
+ delay = 5
+ for attempt in range(1, max_attempts + 1):
+ result = run_command(
+ ["docker", "pull", image],
+ check=False,
+ output=output,
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode == 0:
+ return 0
+ stderr = (result.stderr or "") + (result.stdout or "")
+ rate_limited = "429" in stderr or "Too Many Requests" in stderr or
"toomanyrequests" in stderr
+ if not rate_limited:
+ get_console(output=output).print(
+ f"[error]docker pull {image} failed (non-rate-limit):
{stderr.strip()[:500]}"
+ )
+ return result.returncode
+ if attempt == max_attempts:
+ get_console(output=output).print(
+ f"[error]docker pull {image} hit Docker Hub 429 on every
{max_attempts} attempts; giving up."
+ )
+ return result.returncode
+ get_console(output=output).print(
+ f"[warning]docker pull {image} hit Docker Hub 429 "
+ f"(attempt {attempt}/{max_attempts}); sleeping {delay}s before
retry."
+ )
+ time.sleep(delay)
+ delay *= 2
+ return 1
+
+
+def _preload_test_images_to_kind(
+ python: str,
+ kubernetes_version: str,
+ output: Output | None,
+) -> tuple[int, str]:
+ """Pre-pull and `kind load` the pinned test-suite images.
+
+ See K8S_TEST_IMAGES_TO_PRELOAD for the list and rationale. Each image is
+ pulled once on the host (with retry-on-429), then loaded into every kind
+ node. Pods that reference these images then start without kubelet ever
+ reaching out to Docker Hub.
+ """
+ cluster_name = get_kind_cluster_name(python=python,
kubernetes_version=kubernetes_version)
+ for image in K8S_TEST_IMAGES_TO_PRELOAD:
+ get_console(output=output).print(
+ f"[info]Pre-pulling test image {image} for kind cluster
{cluster_name}"
+ )
+ pull_rc = _docker_pull_with_429_retry(image, output=output)
+ if pull_rc != 0:
+ return pull_rc, f"docker pull {image} failed"
+ get_console(output=output).print(f"[info]Loading {image} into kind
cluster {cluster_name}")
+ kind_load_result = run_command_with_k8s_env(
+ ["kind", "load", "docker-image", "--name", cluster_name, image],
+ python=python,
+ output=output,
+ kubernetes_version=kubernetes_version,
+ check=False,
+ )
+ if kind_load_result.returncode != 0:
+ get_console(output=output).print(
+ f"[error]kind load docker-image {image} into {cluster_name}
failed."
+ )
+ return kind_load_result.returncode, f"kind load {image} failed"
+ return 0, f"Pre-loaded {len(K8S_TEST_IMAGES_TO_PRELOAD)} test images into
{cluster_name}"
+
+
@kubernetes_group.command(
name="build-k8s-image",
help="Build k8s-ready airflow image (optionally all images in parallel).",
@@ -2043,6 +2134,16 @@ def _run_complete_tests(
returncode, message = _upload_k8s_image(
python=python, kubernetes_version=kubernetes_version, output=output
)
+ if returncode != 0:
+ _logs(python=python, kubernetes_version=kubernetes_version)
+ return returncode, message
+ get_console(output=output).print(
+ f"\n[info]Pre-loading pinned test images into kind cluster for "
+ f"Python {python}, Kubernetes {kubernetes_version}\n"
+ )
+ returncode, message = _preload_test_images_to_kind(
+ python=python, kubernetes_version=kubernetes_version, output=output
+ )
if returncode != 0:
_logs(python=python, kubernetes_version=kubernetes_version)
return returncode, message
diff --git a/dev/registry/extract_metadata.py b/dev/registry/extract_metadata.py
index 463a9c40808..5d9f635f74e 100644
--- a/dev/registry/extract_metadata.py
+++ b/dev/registry/extract_metadata.py
@@ -46,7 +46,7 @@ from typing import Any
try:
import tomllib # Python 3.11+ stdlib
except ModuleNotFoundError: # pragma: no cover -- Python 3.10 fallback
- import tomli as tomllib
+ import tomli as tomllib # type: ignore[no-redef]
import yaml
from registry_contract_models import validate_providers_catalog
diff --git a/dev/registry/extract_versions.py b/dev/registry/extract_versions.py
index d9dc4e166dc..2908b22b32e 100644
--- a/dev/registry/extract_versions.py
+++ b/dev/registry/extract_versions.py
@@ -49,7 +49,7 @@ from typing import Any
try:
import tomllib # Python 3.11+ stdlib
except ModuleNotFoundError: # pragma: no cover -- Python 3.10 fallback
- import tomli as tomllib
+ import tomli as tomllib # type: ignore[no-redef]
from registry_contract_models import validate_provider_version_metadata
try:
diff --git a/docs/spelling_wordlist.txt b/docs/spelling_wordlist.txt
index a0d9bea9e02..8d6413a2885 100644
--- a/docs/spelling_wordlist.txt
+++ b/docs/spelling_wordlist.txt
@@ -927,6 +927,7 @@ krb
Kube
kube
kubeconfig
+kubelet
Kubernetes
kubernetes
KubernetesPodOperator
diff --git
a/kubernetes-tests/tests/kubernetes_tests/test_kubernetes_pod_operator.py
b/kubernetes-tests/tests/kubernetes_tests/test_kubernetes_pod_operator.py
index 9cf94c98ac6..66825830715 100644
--- a/kubernetes-tests/tests/kubernetes_tests/test_kubernetes_pod_operator.py
+++ b/kubernetes-tests/tests/kubernetes_tests/test_kubernetes_pod_operator.py
@@ -137,7 +137,7 @@ class TestKubernetesPodOperatorSystem:
"affinity": {},
"containers": [
{
- "image": "ubuntu",
+ "image": "ubuntu:24.04",
"args": ["echo 10"],
"command": ["bash", "-cx"],
"env": [],
@@ -182,7 +182,7 @@ class TestKubernetesPodOperatorSystem:
shutil.copy(kubeconfig_path, new_config_path)
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -199,7 +199,7 @@ class TestKubernetesPodOperatorSystem:
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -218,7 +218,7 @@ class TestKubernetesPodOperatorSystem:
def test_working_pod(self):
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -235,7 +235,7 @@ class TestKubernetesPodOperatorSystem:
def test_skip_cleanup(self):
k = KubernetesPodOperator(
namespace="unknown",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -250,7 +250,7 @@ class TestKubernetesPodOperatorSystem:
def test_delete_operator_pod(self):
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -268,7 +268,7 @@ class TestKubernetesPodOperatorSystem:
def test_skip_on_specified_exit_code(self):
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["exit 42"],
task_id=str(uuid4()),
@@ -288,7 +288,7 @@ class TestKubernetesPodOperatorSystem:
"""
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -310,7 +310,7 @@ class TestKubernetesPodOperatorSystem:
"""
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["lalala"],
labels=self.labels,
@@ -331,7 +331,7 @@ class TestKubernetesPodOperatorSystem:
def test_pod_hostnetwork(self):
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -351,7 +351,7 @@ class TestKubernetesPodOperatorSystem:
dns_policy = "ClusterFirstWithHostNet"
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -373,7 +373,7 @@ class TestKubernetesPodOperatorSystem:
scheduler_name = "default-scheduler"
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -392,7 +392,7 @@ class TestKubernetesPodOperatorSystem:
node_selector = {"beta.kubernetes.io/os": "linux"}
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -414,7 +414,7 @@ class TestKubernetesPodOperatorSystem:
)
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -493,7 +493,7 @@ class TestKubernetesPodOperatorSystem:
}
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -516,7 +516,7 @@ class TestKubernetesPodOperatorSystem:
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -547,7 +547,7 @@ class TestKubernetesPodOperatorSystem:
]
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=args,
labels=self.labels,
@@ -577,7 +577,7 @@ class TestKubernetesPodOperatorSystem:
name = str(uuid4())
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
task_id=name,
@@ -602,7 +602,7 @@ class TestKubernetesPodOperatorSystem:
name = str(uuid4())
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
task_id=name,
@@ -626,7 +626,7 @@ class TestKubernetesPodOperatorSystem:
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -666,7 +666,7 @@ class TestKubernetesPodOperatorSystem:
def test_faulty_service_account(self):
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -688,7 +688,7 @@ class TestKubernetesPodOperatorSystem:
bad_internal_command = ["foobar 10 "]
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=bad_internal_command,
labels=self.labels,
@@ -708,7 +708,7 @@ class TestKubernetesPodOperatorSystem:
args = [f"echo '{json.dumps(expected)}' > /airflow/xcom/return.json"]
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=args,
labels=self.labels,
@@ -733,7 +733,7 @@ class TestKubernetesPodOperatorSystem:
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
env_vars=env_vars,
@@ -852,7 +852,7 @@ class TestKubernetesPodOperatorSystem:
containers=[
k8s.V1Container(
name="base",
- image="ubuntu",
+ image="ubuntu:24.04",
command=["/bin/bash"],
args=["-c", 'echo {\\"hello\\" : \\"world\\"} | cat >
/airflow/xcom/return.json'],
env=[k8s.V1EnvVar(name="env_name", value="value")],
@@ -901,7 +901,7 @@ class TestKubernetesPodOperatorSystem:
init_container = k8s.V1Container(
name="init-container",
- image="ubuntu",
+ image="ubuntu:24.04",
env=init_environments,
volume_mounts=volume_mounts,
command=["bash", "-cx"],
@@ -914,7 +914,7 @@ class TestKubernetesPodOperatorSystem:
)
expected_init_container = {
"name": "init-container",
- "image": "ubuntu",
+ "image": "ubuntu:24.04",
"command": ["bash", "-cx"],
"args": ["echo 10"],
"env": [{"name": "key1", "value": "value1"}, {"name": "key2",
"value": "value2"}],
@@ -923,7 +923,7 @@ class TestKubernetesPodOperatorSystem:
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -1037,7 +1037,7 @@ class TestKubernetesPodOperatorSystem:
},
{
"command": ["sh", "-c", 'trap "exit 0" INT; while
true; do sleep 1; done;'],
- "image": "alpine",
+ "image": "alpine:3.23",
"name": "airflow-xcom-sidecar",
"resources": {
"requests": {"cpu": "1m", "memory": "10Mi"},
@@ -1077,7 +1077,7 @@ class TestKubernetesPodOperatorSystem:
priority_class_name = "medium-test"
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -1100,7 +1100,7 @@ class TestKubernetesPodOperatorSystem:
pod_name_too_long = "a" * 221
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -1122,7 +1122,7 @@ class TestKubernetesPodOperatorSystem:
namespace = "default"
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["sleep 1000"],
labels=self.labels,
@@ -1164,7 +1164,7 @@ class TestKubernetesPodOperatorSystem:
def get_op():
return KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["exit 1"],
labels=self.labels,
@@ -1225,7 +1225,7 @@ class TestKubernetesPodOperatorSystem:
def test_changing_base_container_name_with_get_logs(self):
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -1255,7 +1255,7 @@ class TestKubernetesPodOperatorSystem:
"""
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["echo 10"],
labels=self.labels,
@@ -1285,7 +1285,7 @@ class TestKubernetesPodOperatorSystem:
"""
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["bash", "-cx"],
arguments=["sleep 3"],
labels=self.labels,
@@ -1311,7 +1311,7 @@ class TestKubernetesPodOperatorSystem:
def test_changing_base_container_name_failure(self):
k = KubernetesPodOperator(
namespace="default",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=["exit"],
arguments=["1"],
labels=self.labels,
@@ -1360,13 +1360,13 @@ class TestKubernetesPodOperatorSystem:
callback = MagicMock()
init_container = k8s.V1Container(
name="init-container",
- image="busybox",
+ image="busybox:1.37",
command=["sh", "-cx"],
args=[f"echo {marker_from_init_container}"],
)
k = KubernetesPodOperator(
namespace="default",
- image="busybox",
+ image="busybox:1.37",
cmds=["sh", "-cx"],
arguments=[f"echo {marker_from_main_container}"],
labels=self.labels,
@@ -1393,25 +1393,25 @@ class TestKubernetesPodOperatorSystem:
callback = MagicMock()
init_container_to_log_1 = k8s.V1Container(
name="init-container-to-log-1",
- image="busybox",
+ image="busybox:1.37",
command=["sh", "-cx"],
args=[f"echo {marker_from_init_container_to_log_1}"],
)
init_container_to_log_2 = k8s.V1Container(
name="init-container-to-log-2",
- image="busybox",
+ image="busybox:1.37",
command=["sh", "-cx"],
args=[f"echo {marker_from_init_container_to_log_2}"],
)
init_container_to_ignore = k8s.V1Container(
name="init-container-to-ignore",
- image="busybox",
+ image="busybox:1.37",
command=["sh", "-cx"],
args=[f"echo {marker_from_init_container_to_ignore}"],
)
k = KubernetesPodOperator(
namespace="default",
- image="busybox",
+ image="busybox:1.37",
cmds=["sh", "-cx"],
arguments=[f"echo {marker_from_main_container}"],
labels=self.labels,
@@ -1477,7 +1477,7 @@ class TestKubernetesPodOperatorSystem:
marker = f"test_log_{uuid4()}"
k = KubernetesPodOperator(
namespace="default",
- image="busybox",
+ image="busybox:1.37",
cmds=["sh", "-cx"],
arguments=[f"echo {marker}"],
labels={"test_label": "test"},
@@ -1559,7 +1559,7 @@ class TestKubernetesPodOperator(BaseK8STest):
k = KubernetesPodOperator(
task_id=f"test_task_{active_deadline_seconds}",
active_deadline_seconds=active_deadline_seconds,
- image="busybox",
+ image="busybox:1.37",
cmds=["sh", "-c", echo],
namespace=ns,
on_finish_action="keep_pod",
diff --git a/providers/cncf/kubernetes/docs/changelog.rst
b/providers/cncf/kubernetes/docs/changelog.rst
index d5672698561..73400e734e2 100644
--- a/providers/cncf/kubernetes/docs/changelog.rst
+++ b/providers/cncf/kubernetes/docs/changelog.rst
@@ -27,6 +27,20 @@
Changelog
---------
+**Default xcom-sidecar image is now pinned to** ``alpine:3.23``.
+The default container image for the xcom sidecar (used by
``KubernetesPodOperator``
+when ``do_xcom_push=True``) has changed from the unpinned ``alpine`` (which
resolves
+to ``alpine:latest``) to the pinned ``alpine:3.23``. The pin makes the
kubelet's
+default ``imagePullPolicy`` ``IfNotPresent`` instead of ``Always``, so a node
with
+the image cached does not re-pull on every task — protecting deployments and CI
+from Docker Hub anonymous-pull rate limits.
+
+Deployments that override the image via ``xcom_sidecar_container_image`` (or
the
+``[kubernetes] xcom_sidecar_container_image`` config) are unaffected.
Deployments
+that relied on the unpinned default will now be pinned to ``alpine:3.23`` until
+the next Airflow upgrade. Set ``xcom_sidecar_container_image`` explicitly if
you
+need a different alpine version, a private mirror, or another base image.
+
10.17.0
.......
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/xcom_sidecar.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/xcom_sidecar.py
index 6cdc9febb02..0931df7e04e 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/xcom_sidecar.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/utils/xcom_sidecar.py
@@ -22,6 +22,14 @@ import copy
from kubernetes.client import models as k8s
+# Pinned alpine version for the xcom sidecar default. Pinning (rather than
+# using the implicit `:latest`) makes kubelet's default imagePullPolicy
+# `IfNotPresent` instead of `Always`, so a node that has the image cached
+# does not re-pull on every task — protecting CI and disconnected
+# deployments from Docker Hub anonymous-pull rate limits. Tracked by
+# scripts/ci/prek/upgrade_important_versions.py.
+XCOM_SIDECAR_IMAGE = "alpine:3.23"
+
class PodDefaults:
"""Static defaults for Pods."""
@@ -34,7 +42,7 @@ class PodDefaults:
SIDECAR_CONTAINER = k8s.V1Container(
name=SIDECAR_CONTAINER_NAME,
command=["sh", "-c", XCOM_CMD],
- image="alpine",
+ image=XCOM_SIDECAR_IMAGE,
volume_mounts=[VOLUME_MOUNT],
resources=k8s.V1ResourceRequirements(
requests={
diff --git
a/providers/cncf/kubernetes/tests/system/cncf/kubernetes/example_kubernetes.py
b/providers/cncf/kubernetes/tests/system/cncf/kubernetes/example_kubernetes.py
index 732831e0e64..060d03fcb86 100644
---
a/providers/cncf/kubernetes/tests/system/cncf/kubernetes/example_kubernetes.py
+++
b/providers/cncf/kubernetes/tests/system/cncf/kubernetes/example_kubernetes.py
@@ -149,7 +149,7 @@ with DAG(
# [START howto_operator_k8s_write_xcom]
write_xcom = KubernetesPodOperator(
namespace="default",
- image="alpine",
+ image="alpine:3.23",
cmds=["sh", "-c", "mkdir -p /airflow/xcom/;echo '[1,2,3,4]' >
/airflow/xcom/return.json"],
name="write-xcom",
do_xcom_push=True,
diff --git
a/providers/cncf/kubernetes/tests/system/cncf/kubernetes/example_kubernetes_async.py
b/providers/cncf/kubernetes/tests/system/cncf/kubernetes/example_kubernetes_async.py
index e245314386c..3754c98ccf1 100644
---
a/providers/cncf/kubernetes/tests/system/cncf/kubernetes/example_kubernetes_async.py
+++
b/providers/cncf/kubernetes/tests/system/cncf/kubernetes/example_kubernetes_async.py
@@ -137,7 +137,7 @@ with DAG(
namespace="kubernetes_task_async_log",
in_cluster=False,
name="astro_k8s_test_pod",
- image="ubuntu",
+ image="ubuntu:24.04",
cmds=[
"bash",
"-cx",
@@ -180,7 +180,7 @@ with DAG(
write_xcom_async = KubernetesPodOperator(
task_id="kubernetes_write_xcom_task_async",
namespace="default",
- image="alpine",
+ image="alpine:3.23",
cmds=["sh", "-c", "mkdir -p /airflow/xcom/;echo '[1,2,3,4]' >
/airflow/xcom/return.json"],
name="write-xcom",
do_xcom_push=True,
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 e81f248bb50..e7b1a27e37c 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
@@ -892,7 +892,7 @@ class TestKubernetesPodOperator:
do_xcom_push=True,
)
pod = k.build_pod_request_obj(create_context(k))
- assert pod.spec.containers[1].image == "alpine"
+ assert pod.spec.containers[1].image == "alpine:3.23"
def test_xcom_sidecar_container_resources_default(self):
k = KubernetesPodOperator(
@@ -2741,7 +2741,7 @@ class TestKubernetesPodOperatorAsync:
deferrable=True,
)
pod = k.build_pod_request_obj(create_context(k))
- assert pod.spec.containers[1].image == "alpine"
+ assert pod.spec.containers[1].image == "alpine:3.23"
def
test_async_xcom_sidecar_container_resources_default_should_execute_successfully(self):
k = KubernetesPodOperator(
diff --git a/scripts/ci/prek/upgrade_important_versions.py
b/scripts/ci/prek/upgrade_important_versions.py
index 8b751e7d77f..64c3151cb4f 100755
--- a/scripts/ci/prek/upgrade_important_versions.py
+++ b/scripts/ci/prek/upgrade_important_versions.py
@@ -89,6 +89,79 @@ FILES_TO_UPDATE: list[tuple[Path, bool]] = [
(AIRFLOW_ROOT_PATH / "dev" / "provider_db_inventory.py", False),
(AIRFLOW_ROOT_PATH / "dev" / "pyproject.toml", False),
(AIRFLOW_ROOT_PATH / "go-sdk" / ".pre-commit-config.yaml", False),
+ # Files that pin Docker Hub `alpine:` / `busybox:` tags and should be
+ # auto-bumped alongside the rest of the "important versions". Adding new
+ # call sites? Add them here too — the regex in SIMPLE_VERSION_PATTERNS
+ # only mutates files in this list.
+ (
+ AIRFLOW_ROOT_PATH
+ / "providers"
+ / "cncf"
+ / "kubernetes"
+ / "src"
+ / "airflow"
+ / "providers"
+ / "cncf"
+ / "kubernetes"
+ / "utils"
+ / "xcom_sidecar.py",
+ False,
+ ),
+ (
+ AIRFLOW_ROOT_PATH
+ / "providers"
+ / "cncf"
+ / "kubernetes"
+ / "tests"
+ / "system"
+ / "cncf"
+ / "kubernetes"
+ / "example_kubernetes.py",
+ False,
+ ),
+ (
+ AIRFLOW_ROOT_PATH
+ / "providers"
+ / "cncf"
+ / "kubernetes"
+ / "tests"
+ / "system"
+ / "cncf"
+ / "kubernetes"
+ / "example_kubernetes_async.py",
+ False,
+ ),
+ (
+ AIRFLOW_ROOT_PATH
+ / "providers"
+ / "cncf"
+ / "kubernetes"
+ / "tests"
+ / "unit"
+ / "cncf"
+ / "kubernetes"
+ / "operators"
+ / "test_pod.py",
+ False,
+ ),
+ (
+ AIRFLOW_ROOT_PATH
+ / "kubernetes-tests"
+ / "tests"
+ / "kubernetes_tests"
+ / "test_kubernetes_pod_operator.py",
+ False,
+ ),
+ (
+ AIRFLOW_ROOT_PATH
+ / "dev"
+ / "breeze"
+ / "src"
+ / "airflow_breeze"
+ / "commands"
+ / "kubernetes_commands.py",
+ False,
+ ),
]
for file in DOCKER_IMAGES_EXAMPLE_DIR_PATH.rglob("*.sh"):
FILES_TO_UPDATE.append((file, False))
@@ -477,6 +550,8 @@ if UPGRADE_ALL_BY_DEFAULT and VERBOSE:
console.print("[bright_blue]Upgrading all important versions")
# Package upgrade flags
+UPGRADE_ALPINE: bool = get_env_bool("UPGRADE_ALPINE")
+UPGRADE_BUSYBOX: bool = get_env_bool("UPGRADE_BUSYBOX")
UPGRADE_FLIT_CORE: bool = get_env_bool("UPGRADE_FLIT_CORE")
UPGRADE_GITPYTHON: bool = get_env_bool("UPGRADE_GITPYTHON")
UPGRADE_GOLANG: bool = get_env_bool("UPGRADE_GOLANG")
@@ -604,6 +679,19 @@ SIMPLE_VERSION_PATTERNS: dict[str, list[tuple[str, str]]]
= {
"openapi_generator": [
(r"(OPENAPI_GENERATOR_CLI_VER = )(\"[0-9.]+\")",
'OPENAPI_GENERATOR_CLI_VER = "{version}"'),
],
+ # Pinning Docker Hub base-image tags used by Airflow's K8s system tests
+ # protects CI from anonymous-pull rate limits — the kind cluster
+ # `kind load`s the pre-pulled image so kubelet (default
+ # imagePullPolicy=IfNotPresent for tagged images) never reaches Docker
+ # Hub. Pattern matches both `alpine:X.Y[.Z]` literals in code and
+ # `ARG ALPINE_VERSION="X.Y[.Z]"` in chart Dockerfiles.
+ "alpine": [
+ (r"(alpine:)([0-9]+\.[0-9]+(?:\.[0-9]+)?)", "alpine:{version}"),
+ (r'(ALPINE_VERSION=")([0-9.]+)(")', 'ALPINE_VERSION="{version}"'),
+ ],
+ "busybox": [
+ (r"(busybox:)([0-9]+\.[0-9]+(?:\.[0-9]+)?)", "busybox:{version}"),
+ ],
"sphinx_airflow_theme": [
(
r"(sphinx-airflow-theme@https://airflow\.apache\.org/sphinx-airflow-theme/sphinx_airflow_theme-)([0-9.]+)(-py3-none-any\.whl)",
@@ -640,6 +728,8 @@ def fetch_all_package_versions() -> dict[str, str]:
"mypy": get_latest_pypi_version("mypy", UPGRADE_MYPY),
"node_lts": get_latest_lts_node_version() if UPGRADE_NODE_LTS else "",
"protoc": get_latest_image_version("rvolosatovs/protoc") if
UPGRADE_PROTOC else "",
+ "alpine": get_latest_image_version("alpine") if UPGRADE_ALPINE else "",
+ "busybox": get_latest_image_version("busybox") if UPGRADE_BUSYBOX else
"",
"mprocs": get_latest_github_release_version("pvolok/mprocs") if
UPGRADE_MPROCS else "",
"openapi_generator": get_latest_openapi_generator_version() if
UPGRADE_OPENAPI_GENERATOR else "",
"sphinx_airflow_theme": get_latest_sphinx_airflow_theme_version()