Skip to content

Commit e4753ff

Browse files
authored
Merge pull request #213 from Yelp/u/krall/what_if_multi_cluster_is_easy
A quick attempt at making taskproc handle migrations from one k8s cluster to another
2 parents 8c76fed + ba70ca1 commit e4753ff

File tree

2 files changed

+128
-26
lines changed

2 files changed

+128
-26
lines changed

task_processing/plugins/kubernetes/kubernetes_pod_executor.py

Lines changed: 55 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import Collection
77
from typing import Optional
88

9-
from kubernetes import watch
9+
from kubernetes import watch as kube_watch
1010
from kubernetes.client import V1Affinity
1111
from kubernetes.client import V1Container
1212
from kubernetes.client import V1ContainerPort
@@ -72,13 +72,22 @@ def __init__(
7272
kubeconfig_path: Optional[str] = None,
7373
task_configs: Optional[Collection[KubernetesTaskConfig]] = [],
7474
emit_events_without_state_transitions: bool = False,
75+
# kubeconfigs used to continue to watch other clusters
76+
# Used when transitioning to a new cluster in the primary kubeconfig_path to continue watching still-running pods on other clusters
77+
watcher_kubeconfig_paths: Collection[str] = (),
7578
) -> None:
7679
if not version:
7780
version = "unknown_task_processing"
7881
user_agent = f"{namespace}/v{version}"
7982
self.kube_client = KubeClient(
8083
kubeconfig_path=kubeconfig_path, user_agent=user_agent
8184
)
85+
86+
self.watcher_kube_clients = [
87+
KubeClient(kubeconfig_path=watcher_kubeconfig_path, user_agent=user_agent)
88+
for watcher_kubeconfig_path in watcher_kubeconfig_paths
89+
]
90+
8291
self.namespace = namespace
8392

8493
# Pod modified events that did not result in a pod state transition are usually not
@@ -106,17 +115,23 @@ def __init__(
106115

107116
# TODO(TASKPROC-243): keep track of resourceVersion so that we can continue event processing
108117
# from where we left off on restarts
109-
self.watch = watch.Watch()
110-
self.pod_event_watch_thread = threading.Thread(
111-
target=self._pod_event_watch_loop,
112-
# ideally this wouldn't be a daemon thread, but a watch.Watch() only checks
113-
# if it should stop after receiving an event - and it's possible that we
114-
# have periods with no events so instead we'll attempt to stop the watch
115-
# and then join() with a small timeout to make sure that, if we shutdown
116-
# with the thread alive, we did not drop any events
117-
daemon=True,
118-
)
119-
self.pod_event_watch_thread.start()
118+
self.pod_event_watch_threads = []
119+
self.watches = []
120+
for kube_client in [self.kube_client] + self.watcher_kube_clients:
121+
watch = kube_watch.Watch()
122+
pod_event_watch_thread = threading.Thread(
123+
target=self._pod_event_watch_loop,
124+
args=(kube_client, watch),
125+
# ideally this wouldn't be a daemon thread, but a watch.Watch() only checks
126+
# if it should stop after receiving an event - and it's possible that we
127+
# have periods with no events so instead we'll attempt to stop the watch
128+
# and then join() with a small timeout to make sure that, if we shutdown
129+
# with the thread alive, we did not drop any events
130+
daemon=True,
131+
)
132+
pod_event_watch_thread.start()
133+
self.pod_event_watch_threads.append(pod_event_watch_thread)
134+
self.watches.append(watch)
120135

121136
self.pending_event_processing_thread = threading.Thread(
122137
target=self._pending_event_processing_loop,
@@ -143,7 +158,9 @@ def _initialize_existing_task(self, task_config: KubernetesTaskConfig) -> None:
143158
),
144159
)
145160

146-
def _pod_event_watch_loop(self) -> None:
161+
def _pod_event_watch_loop(
162+
self, kube_client: KubeClient, watch: kube_watch.Watch
163+
) -> None:
147164
logger.debug(f"Starting watching Pod events for namespace={self.namespace}.")
148165
# TODO(TASKPROC-243): we'll need to correctly handle resourceVersion expiration for the case
149166
# where the gap between task_proc shutting down and coming back up is long enough for data
@@ -155,8 +172,8 @@ def _pod_event_watch_loop(self) -> None:
155172
# see: https://github.com/kubernetes/kubernetes/issues/74022
156173
while not self.stopping:
157174
try:
158-
for pod_event in self.watch.stream(
159-
self.kube_client.core.list_namespaced_pod, self.namespace
175+
for pod_event in watch.stream(
176+
kube_client.core.list_namespaced_pod, self.namespace
160177
):
161178
# it's possible that we've received an event after we've already set the stop
162179
# flag since Watch streams block forever, so re-check if we've stopped before
@@ -168,7 +185,7 @@ def _pod_event_watch_loop(self) -> None:
168185
break
169186
except ApiException as e:
170187
if not self.stopping:
171-
if not self.kube_client.maybe_reload_on_exception(exception=e):
188+
if not kube_client.maybe_reload_on_exception(exception=e):
172189
logger.exception(
173190
"Unhandled API exception while watching Pod events - restarting watch!"
174191
)
@@ -589,11 +606,18 @@ def run(self, task_config: KubernetesTaskConfig) -> Optional[str]:
589606

590607
def reconcile(self, task_config: KubernetesTaskConfig) -> None:
591608
pod_name = task_config.pod_name
592-
try:
593-
pod = self.kube_client.get_pod(namespace=self.namespace, pod_name=pod_name)
594-
except Exception:
595-
logger.exception(f"Hit an exception attempting to fetch pod {pod_name}")
596-
pod = None
609+
pod = None
610+
for kube_client in [self.kube_client] + self.watcher_kube_clients:
611+
try:
612+
pod = kube_client.get_pod(namespace=self.namespace, pod_name=pod_name)
613+
except Exception:
614+
logger.exception(
615+
f"Hit an exception attempting to fetch pod {pod_name} from {kube_client.kubeconfig_path}"
616+
)
617+
else:
618+
# kube_client.get_pod will return None with no exception if it sees a 404 from API
619+
if pod:
620+
break
597621

598622
if pod_name not in self.task_metadata:
599623
self._initialize_existing_task(task_config)
@@ -640,9 +664,12 @@ def kill(self, task_id: str) -> bool:
640664
This function will request that Kubernetes delete the named Pod and will return
641665
True if the Pod termination request was succesfully emitted or False otherwise.
642666
"""
643-
terminated = self.kube_client.terminate_pod(
644-
namespace=self.namespace,
645-
pod_name=task_id,
667+
terminated = any(
668+
kube_client.terminate_pod(
669+
namespace=self.namespace,
670+
pod_name=task_id,
671+
)
672+
for kube_client in [self.kube_client] + self.watcher_kube_clients
646673
)
647674
if terminated:
648675
logger.info(
@@ -678,12 +705,14 @@ def stop(self) -> None:
678705
logger.debug("Signaling Pod event Watch to stop streaming events...")
679706
# make sure that we've stopped watching for events before calling join() - otherwise,
680707
# join() will block until we hit the configured timeout (or forever with no timeout).
681-
self.watch.stop()
708+
for watch in self.watches:
709+
watch.stop()
682710
# timeout arbitrarily chosen - we mostly just want to make sure that we have a small
683711
# grace period to flush the current event to the pending_events queue as well as
684712
# any other clean-up - it's possible that after this join() the thread is still alive
685713
# but in that case we can be reasonably sure that we're not dropping any data.
686-
self.pod_event_watch_thread.join(timeout=POD_WATCH_THREAD_JOIN_TIMEOUT_S)
714+
for pod_event_watch_thread in self.pod_event_watch_threads:
715+
pod_event_watch_thread.join(timeout=POD_WATCH_THREAD_JOIN_TIMEOUT_S)
687716

688717
logger.debug("Waiting for all pending PodEvents to be processed...")
689718
# once we've stopped updating the pending events queue, we then wait until we're done

tests/unit/plugins/kubernetes/kubernetes_pod_executor_test.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,24 @@ def k8s_executor(mock_Thread):
5252
executor.stop()
5353

5454

55+
@pytest.fixture
56+
def k8s_executor_with_watcher_clusters(mock_Thread):
57+
with mock.patch(
58+
"task_processing.plugins.kubernetes.kube_client.kube_config.load_kube_config",
59+
autospec=True,
60+
), mock.patch(
61+
"task_processing.plugins.kubernetes.kube_client.kube_client", autospec=True
62+
), mock.patch.dict(
63+
os.environ, {"KUBECONFIG": "/this/doesnt/exist.conf"}
64+
):
65+
executor = KubernetesPodExecutor(
66+
namespace="task_processing_tests",
67+
watcher_kubeconfig_paths=["/this/also/doesnt/exist.conf"],
68+
)
69+
yield executor
70+
executor.stop()
71+
72+
5573
@pytest.fixture
5674
def mock_task_configs():
5775
test_task_names = ["job1.action1", "job1.action2", "job2.action1", "job3.action2"]
@@ -86,6 +104,18 @@ def k8s_executor_with_tasks(mock_Thread, mock_task_configs):
86104
executor.stop()
87105

88106

107+
def test_init_watch_setup(k8s_executor):
108+
assert len(k8s_executor.watches) == len(k8s_executor.pod_event_watch_threads) == 1
109+
110+
111+
def test_init_watch_setup_multicluster(k8s_executor_with_watcher_clusters):
112+
assert (
113+
len(k8s_executor_with_watcher_clusters.watches)
114+
== len(k8s_executor_with_watcher_clusters.pod_event_watch_threads)
115+
== 2
116+
)
117+
118+
89119
def test_run_updates_task_metadata(k8s_executor):
90120
task_config = KubernetesTaskConfig(
91121
name="name", uuid="uuid", image="fake_image", command="fake_command"
@@ -866,6 +896,49 @@ def test_reconcile_missing_pod(
866896
assert tm.task_state == KubernetesTaskState.TASK_LOST
867897

868898

899+
def test_reconcile_multicluster(
900+
k8s_executor_with_watcher_clusters,
901+
):
902+
task_config = mock.Mock(spec=KubernetesTaskConfig)
903+
task_config.pod_name = "pod--name.uuid"
904+
task_config.name = "job-name"
905+
906+
k8s_executor_with_watcher_clusters.task_metadata = pmap(
907+
{
908+
task_config.pod_name: KubernetesTaskMetadata(
909+
task_config=mock.Mock(spec=KubernetesTaskConfig),
910+
task_state=KubernetesTaskState.TASK_UNKNOWN,
911+
task_state_history=v(),
912+
)
913+
}
914+
)
915+
916+
mock_watcher_kube_client = mock.Mock(autospec=True)
917+
mock_found_pod = mock.Mock(spec=V1Pod)
918+
mock_found_pod.metadata.name = task_config.pod_name
919+
mock_found_pod.status.phase = "Running"
920+
mock_found_pod.status.host_ip = "1.2.3.4"
921+
mock_found_pod.spec.node_name = "kubenode"
922+
mock_watcher_kube_client.get_pod.return_value = mock_found_pod
923+
mock_watcher_kube_clients = [mock_watcher_kube_client]
924+
925+
with mock.patch.object(
926+
k8s_executor_with_watcher_clusters, "kube_client", autospec=True
927+
) as mock_kube_client, mock.patch.object(
928+
k8s_executor_with_watcher_clusters,
929+
"watcher_kube_clients",
930+
mock_watcher_kube_clients,
931+
):
932+
mock_kube_client.get_pod.return_value = None
933+
k8s_executor_with_watcher_clusters.reconcile(task_config)
934+
935+
mock_watcher_kube_client.get_pod.assert_called()
936+
assert k8s_executor_with_watcher_clusters.event_queue.qsize() == 1
937+
assert len(k8s_executor_with_watcher_clusters.task_metadata) == 1
938+
tm = k8s_executor_with_watcher_clusters.task_metadata["pod--name.uuid"]
939+
assert tm.task_state == KubernetesTaskState.TASK_RUNNING
940+
941+
869942
def test_reconcile_existing_pods(k8s_executor, mock_task_configs):
870943
mock_pods = []
871944
test_phases = ["Running", "Succeeded", "Failed", "Unknown"]

0 commit comments

Comments
 (0)