This is an automated email from the ASF dual-hosted git repository.
shahar1 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 2257e956912 Fix KubernetesExecutor leaking a Manager process when
reading running task logs (#68800)
2257e956912 is described below
commit 2257e95691247cbb4b31ebe2ad2c00aa1ec3bdde
Author: Kaxil Naik <[email protected]>
AuthorDate: Wed Jul 1 23:59:07 2026 +0530
Fix KubernetesExecutor leaking a Manager process when reading running task
logs (#68800)
The API server constructs a KubernetesExecutor solely to call get_task_log()
for RUNNING tasks (via FileTaskHandler ->
ExecutorLoader.get_default_executor)
and never starts or ends it. KubernetesExecutor.__init__ eagerly created a
multiprocessing.Manager(), which forks a serve_forever child process.
Because
that instance is cached per process and never shut down, the Manager child
is
orphaned -- one leaked process (~350-400 MB resident) per API-server worker,
growing with worker recycling and pushing the API server toward OOM.
get_task_log() only needs the kube client and pod namespace; it never
touches
the task/result queues. Create the Manager and its queues lazily in start()
(the scheduling loop is their only consumer), mirroring how LocalExecutor
already defers process/queue creation. end() now no-ops when the executor
was
never started. Constructing the executor for log reading no longer spawns a
Manager.
Co-authored-by: Rahul Vats <[email protected]>
---
.../kubernetes/executors/kubernetes_executor.py | 25 +++++-
.../executors/test_kubernetes_executor.py | 90 +++++++++++++++++++++-
2 files changed, 108 insertions(+), 7 deletions(-)
diff --git
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor.py
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor.py
index 50ad4a5e929..46137915afb 100644
---
a/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor.py
+++
b/providers/cncf/kubernetes/src/airflow/providers/cncf/kubernetes/executors/kubernetes_executor.py
@@ -60,6 +60,7 @@ from airflow.utils.state import TaskInstanceState
if TYPE_CHECKING:
from collections.abc import Sequence
+ from multiprocessing.managers import SyncManager
from kubernetes import client
from kubernetes.client import models as k8s
@@ -102,9 +103,14 @@ class KubernetesExecutor(BaseExecutor):
# Override parallelism with team-aware config value
self.parallelism = self.kube_config.parallelism
- self._manager = multiprocessing.Manager()
- self.task_queue: Queue[KubernetesJob] = self._manager.JoinableQueue()
- self.result_queue: Queue[KubernetesResults] =
self._manager.JoinableQueue()
+ # The multiprocessing.Manager() (and the queues it backs) is only
needed once the
+ # scheduler actually runs the executor, so it is created lazily in
start(). Constructing
+ # the executor without starting it -- as the API server does to call
get_task_log() for a
+ # RUNNING task -- must not spawn a Manager process, otherwise that
serve_forever child is
+ # orphaned and leaks (one per API-server worker).
+ self._manager: SyncManager | None = None
+ self.task_queue: Queue[KubernetesJob] | None = None
+ self.result_queue: Queue[KubernetesResults] | None = None
self.kube_scheduler: AirflowKubernetesScheduler | None = None
self.kube_client: client.CoreV1Api | None = None
self.scheduler_job_id: str | None = None
@@ -188,6 +194,9 @@ class KubernetesExecutor(BaseExecutor):
def start(self) -> None:
"""Start the executor."""
self.log.info("Start Kubernetes executor")
+ self._manager = multiprocessing.Manager()
+ self.task_queue = self._manager.JoinableQueue()
+ self.result_queue = self._manager.JoinableQueue()
self.scheduler_job_id = str(self.job_id)
self.log.debug("Start with scheduler_job_id: %s",
self.scheduler_job_id)
from
airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils import (
@@ -961,10 +970,15 @@ class KubernetesExecutor(BaseExecutor):
def end(self) -> None:
"""Shut down the executor."""
+ if self._manager is None:
+ # start() was never called (e.g. the executor was only constructed
to read task
+ # logs), so there is no Manager process or queues to shut down.
+ return
if TYPE_CHECKING:
assert self.task_queue
assert self.result_queue
assert self.kube_scheduler
+ assert self._manager
self.log.info("Shutting down Kubernetes executor")
try:
@@ -985,6 +999,11 @@ class KubernetesExecutor(BaseExecutor):
except Exception:
self.log.exception("Unknown error while flushing task queue
and result queue.")
self._manager.shutdown()
+ # Return to the unstarted state so a second end() is a no-op (the
guard above) and the
+ # Manager/queues are recreated cleanly if start() is ever called again.
+ self._manager = None
+ self.task_queue = None
+ self.result_queue = None
def terminate(self):
"""Terminate the executor is not doing anything."""
diff --git
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/executors/test_kubernetes_executor.py
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/executors/test_kubernetes_executor.py
index e899681f741..b3129c15ae9 100644
---
a/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/executors/test_kubernetes_executor.py
+++
b/providers/cncf/kubernetes/tests/unit/cncf/kubernetes/executors/test_kubernetes_executor.py
@@ -914,6 +914,8 @@ class TestKubernetesExecutor:
kubernetes_executor = self.kubernetes_executor
kubernetes_executor.task_publish_max_retries =
task_publish_max_retries
kubernetes_executor.start()
+ task_queue = kubernetes_executor.task_queue
+ assert task_queue is not None
try:
# Execute a task while the Api Throws errors
try_number = 1
@@ -928,7 +930,7 @@ class TestKubernetesExecutor:
assert mock_kube_client.create_namespaced_pod.call_count == 1
# type: ignore[attr-defined]
if should_requeue:
- assert not kubernetes_executor.task_queue.empty()
+ assert not task_queue.empty()
# Disable the ApiException
if task_expected_state == State.SUCCESS or
task_expected_state == State.QUEUED:
@@ -942,11 +944,11 @@ class TestKubernetesExecutor:
kubernetes_executor.sync()
assert mock_kube_client.create_namespaced_pod.called #
type: ignore[attr-defined]
- assert kubernetes_executor.task_queue.empty()
+ assert task_queue.empty()
if task_expected_state != State.SUCCESS:
assert
kubernetes_executor.event_buffer[task_instance_key][0] == task_expected_state
else:
- assert kubernetes_executor.task_queue.empty()
+ assert task_queue.empty()
assert
kubernetes_executor.event_buffer[task_instance_key][0] == task_expected_state
finally:
kubernetes_executor.end()
@@ -2353,6 +2355,72 @@ class TestKubernetesExecutor:
"Reading from k8s pod logs failed: error_fetching_pod_log",
]
+ def test_init_does_not_create_manager_process(self):
+ """
+ Constructing the executor must not spawn a ``multiprocessing.Manager``.
+
+ The API server builds a ``KubernetesExecutor`` purely to call
``get_task_log()`` for
+ RUNNING tasks and never starts it. Eagerly creating the Manager in
``__init__`` leaked an
+ orphaned ``serve_forever`` process per API-server worker.
+ """
+ executor = KubernetesExecutor()
+
+ assert executor._manager is None
+ assert executor.task_queue is None
+ assert executor.result_queue is None
+
+ @pytest.mark.db_test
+
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client")
+ def test_get_task_log_does_not_create_manager_process(
+ self, mock_get_kube_client, create_task_instance_of_operator
+ ):
+ """Reading a running task's logs must not spawn a
``multiprocessing.Manager``."""
+ mock_kube_client = mock_get_kube_client.return_value
+ mock_kube_client.read_namespaced_pod_log.return_value = [b"a_"]
+ mock_pod = mock.Mock(spec=k8s.V1Pod)
+ mock_pod.metadata.name = "x"
+ mock_kube_client.list_namespaced_pod.return_value.items = [mock_pod]
+ ti = create_task_instance_of_operator(EmptyOperator,
dag_id="test_k8s_log_dag", task_id="test_task")
+
+ executor = KubernetesExecutor()
+ executor.get_task_log(ti=ti, try_number=1)
+
+ assert executor._manager is None
+
+ def test_end_without_start_is_noop(self):
+ """``end()`` on an executor that was never started must not raise."""
+ executor = KubernetesExecutor()
+
+ # Must not raise even though no Manager/queues were ever created.
+ executor.end()
+
+ assert executor._manager is None
+
+ @pytest.mark.skipif(
+ AirflowKubernetesScheduler is None, reason="kubernetes python package
is not installed"
+ )
+
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client")
+
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.client")
+
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher")
+ def test_start_creates_manager_and_queues(self, mock_watcher, mock_client,
mock_kube_client):
+ """``start()`` creates the Manager and queues; ``end()`` tears them
down idempotently."""
+ executor = KubernetesExecutor()
+ executor.job_id = 1
+ try:
+ executor.start()
+
+ assert executor._manager is not None
+ assert executor.task_queue is not None
+ assert executor.result_queue is not None
+ finally:
+ executor.end()
+
+ # end() returns the executor to the unstarted state, so a second end()
is a safe no-op.
+ assert executor._manager is None
+ assert executor.task_queue is None
+ assert executor.result_queue is None
+ executor.end()
+
@pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="Airflow 3.2+ prefers
new configuration")
def test_sentry_integration(self):
assert not KubernetesExecutor.sentry_integration
@@ -2866,16 +2934,30 @@ class TestKubernetesExecutorMultiTeam:
assert executor.team_name == "ml_team"
assert executor.kube_config is not None
+ @pytest.mark.skipif(
+ AirflowKubernetesScheduler is None, reason="kubernetes python package
is not installed"
+ )
@pytest.mark.skipif(not AIRFLOW_V_3_2_PLUS, reason="Multi-team requires
Airflow 3.2+")
- def test_multiple_team_executors_isolation(self, monkeypatch):
+
@mock.patch("airflow.providers.cncf.kubernetes.kube_client.get_kube_client")
+
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.client")
+
@mock.patch("airflow.providers.cncf.kubernetes.executors.kubernetes_executor_utils.KubernetesJobWatcher")
+ def test_multiple_team_executors_isolation(
+ self, mock_watcher, mock_client, mock_kube_client, monkeypatch
+ ):
"""Test that multiple team executors can coexist with isolated
resources."""
monkeypatch.setenv("AIRFLOW__TEAM_A___KUBERNETES_EXECUTOR__WORKER_PODS_CREATION_BATCH_SIZE",
"4")
monkeypatch.setenv("AIRFLOW__TEAM_B___KUBERNETES_EXECUTOR__WORKER_PODS_CREATION_BATCH_SIZE",
"8")
team_a_executor = KubernetesExecutor(team_name="team_a")
team_b_executor = KubernetesExecutor(team_name="team_b")
+ team_a_executor.job_id = 1
+ team_b_executor.job_id = 2
try:
+ # Queues are created lazily in start(), so each team executor gets
its own.
+ team_a_executor.start()
+ team_b_executor.start()
+
assert team_a_executor.task_queue is not team_b_executor.task_queue
assert team_a_executor.result_queue is not
team_b_executor.result_queue
assert team_a_executor.running is not team_b_executor.running