~/2026/02/04/python-talos-health-checking-a-kubernetes-cluster-from-a-cron-job.md
Python: Talos – Health-Checking a Kubernetes Cluster from a Cron Job
--- author: Tom Lasswell date: updated: read: 5 min in: [scripts, engineering] tags: [python, talos, kubernetes] ---
$ grep -n '^#' post.md
talosctl health is a genuinely good command: it waits for etcd to be healthy and its membership to be consistent, the control plane static pods and components to be ready, every node to report Ready and schedulable, and kube-proxy and CoreDNS to be up, and it tells you clearly when the control plane isn't happy. What it doesn't tell you is whether an application pod has been crash-looping for two days on an otherwise healthy cluster, or whether etcd has raised a NOSPACE alarm. I run this script from cron on a jump box that holds both a talosconfig and a kubeconfig, and it combines the Talos-level checks with Kubernetes-level ones so both kinds of problem land in the same alert instead of two dashboards nobody checks at the same time.
The script is written against Talos 1.14 and the official kubernetes Python client (36.x at the time of writing). It pairs with the rolling upgrade workflow in Talos Linux: Upgrading a Cluster Without a Maintenance Window. talosctl health already fails when a node is unschedulable, but the separate cordon check names the node in the alert, which is what you want after an upgrade that stopped halfway.
Requirements
- Python 3.10 or later with the official client:
pip install kubernetes. talosctlon thePATH. Sidero Labs recommends atalosctlversion that matches the Talos version running on the cluster.- A
talosconfigwhose context can reach every node you list. Sidero Labs' RBAC guide places etcd alarm management in theos:operatorrole, soos:readerisn't enough for the alarm check. Generate a dedicated client config for the monitoring box withtalosctl config new --roles=os:operator healthcheckrather than copying theos:adminone. - A
kubeconfigbound to a role that canlistnodes and pods cluster-wide. The admin kubeconfig fromtalosctl kubeconfigworks, but a read-only ServiceAccount token is the better habit. - Network reach from the cron host to the Talos API (TCP 50000) on the nodes and to the Kubernetes API server (TCP 6443).
Parameters
| Parameter | Required | Description |
|---|---|---|
--talosconfig | yes | Path to the Talos client config. |
--kubeconfig | yes | Path to the kubeconfig. |
--control-plane-nodes | yes | Control plane node IPs. The first one is the node talosctl health runs through. |
--worker-nodes | no | Worker node IPs. Omit on a cluster where every node is a control plane node. |
--restart-threshold | no | Restart count that counts as unhealthy (default 5). |
--timeout | no | Seconds passed to talosctl health --wait-timeout (default 120; the talosctl default is 20 minutes, far too long for cron). |
--ignore-namespace | no | Namespace to skip in the pod check, repeatable (for example a namespace full of short-lived test Jobs). |
--webhook-url | no | URL that receives the JSON summary as a POST when something is wrong. |
--lock-file | no | Lock file used to skip a run while the previous one is still going (default /tmp/talos-healthcheck.lock). |
Usage
Run it by hand against a three-node cluster where all three nodes are control plane nodes:
python3 talos_cluster_healthcheck.py \
--talosconfig /etc/talos/talosconfig \
--kubeconfig /etc/kubernetes/kubeconfig \
--control-plane-nodes 10.10.20.11 10.10.20.12 10.10.20.13 \
--webhook-url "https://alerts.example.com/hooks/talos"
With dedicated workers, list them separately so talosctl health knows what membership to expect:
python3 talos_cluster_healthcheck.py \
--talosconfig /etc/talos/talosconfig \
--kubeconfig /etc/kubernetes/kubeconfig \
--control-plane-nodes 10.10.20.11 10.10.20.12 10.10.20.13 \
--worker-nodes 10.10.20.21 10.10.20.22
Sample output when the control plane is fine but one worker was left cordoned and one pod is crash-looping:
{
"checkedAt": "2026-02-03T14:20:01+00:00",
"healthy": false,
"problems": [
{
"check": "cordoned nodes",
"detail": [
"talos-w02"
]
},
{
"check": "pod health",
"detail": [
"billing/invoice-worker-6c9d8f7b8-x2p4q: container invoice-worker is CrashLoopBackOff"
]
}
]
}
Schedule it every 10 minutes and rely on the webhook for alerting (cron's own mail-on-output gets noisy fast). The exit code is 0 when healthy, 1 when a check found a problem, and 2 when the check itself couldn't run:
*/10 * * * * /usr/bin/python3 /opt/scripts/talos_cluster_healthcheck.py --talosconfig /etc/talos/talosconfig --kubeconfig /etc/kubernetes/kubeconfig --control-plane-nodes 10.10.20.11 10.10.20.12 10.10.20.13 --webhook-url "https://alerts.example.com/hooks/talos" >> /var/log/talos-healthcheck.log 2>&1
Script
#!/usr/bin/env python3
"""
talos_cluster_healthcheck.py
Health-checks a Talos Linux Kubernetes cluster from a cron job:
1. `talosctl health` (server-side), run through one control plane node
against an explicit list of control plane and worker nodes.
2. `talosctl etcd alarm list` across the control plane nodes (NOSPACE and
CORRUPT alarms stop etcd writes long before anything else complains).
3. Kubernetes node readiness and cordon state, through the Kubernetes API.
4. Pod health: Failed/Unknown phases, init and app containers stuck in a
back-off waiting state, and containers over a restart threshold.
Prints a JSON summary, posts it to an optional webhook on failure, and exits:
0 healthy
1 one or more checks found a problem
2 the health check itself could not run (lock held, talosctl missing, ...)
Requires: Python 3.10+, the `kubernetes` package, `talosctl` on PATH.
"""
import argparse
import fcntl
import json
import subprocess
import sys
import urllib.request
from datetime import datetime, timezone
from kubernetes import client, config
BACKOFF_REASONS = {"CrashLoopBackOff", "ImagePullBackOff", "ErrImagePull", "CreateContainerConfigError"}
def run_talosctl(args, talosconfig, timeout):
"""Run a talosctl command and return (returncode, combined output)."""
cmd = ["talosctl", "--talosconfig", talosconfig, *args]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
return 124, f"timed out after {timeout}s: {' '.join(cmd)}"
return result.returncode, (result.stdout + result.stderr).strip()
def check_talos_health(talosconfig, control_plane_nodes, worker_nodes, timeout_seconds):
"""talosctl health talks to exactly one node, which checks the whole cluster."""
args = [
"health",
"--nodes", control_plane_nodes[0],
"--control-plane-nodes", ",".join(control_plane_nodes),
"--wait-timeout", f"{timeout_seconds}s",
]
if worker_nodes:
args += ["--worker-nodes", ",".join(worker_nodes)]
code, output = run_talosctl(args, talosconfig, timeout_seconds + 30)
return code == 0, output
def check_etcd_alarms(talosconfig, control_plane_nodes):
"""Return a list of alarm lines; an empty list means no alarms."""
code, output = run_talosctl(
["etcd", "alarm", "list", "--nodes", ",".join(control_plane_nodes)], talosconfig, 60
)
if code != 0:
return [f"talosctl etcd alarm list failed: {output}"]
lines = [line for line in output.splitlines() if line.strip()]
return [line for line in lines if not line.startswith("NODE")]
def check_nodes(v1):
"""Return (not_ready, cordoned) lists of node names."""
not_ready, cordoned = [], []
for node in v1.list_node(_request_timeout=30).items:
conditions = node.status.conditions or []
ready = next((c for c in conditions if c.type == "Ready"), None)
if ready is None or ready.status != "True":
reason = ready.reason if ready else "no Ready condition"
not_ready.append(f"{node.metadata.name} ({reason})")
if node.spec.unschedulable:
cordoned.append(node.metadata.name)
return not_ready, cordoned
def check_pods(v1, restart_threshold, ignore_namespaces):
"""Return a list of 'namespace/pod: reason' strings for unhealthy pods."""
problems = []
for pod in v1.list_pod_for_all_namespaces(_request_timeout=60).items:
ns, name = pod.metadata.namespace, pod.metadata.name
if ns in ignore_namespaces:
continue
phase = pod.status.phase
if phase == "Succeeded":
continue
if phase in ("Failed", "Unknown"):
problems.append(f"{ns}/{name}: phase={phase} reason={pod.status.reason}")
continue
# A failing init container keeps the pod Pending (Init:CrashLoopBackOff),
# so check init container statuses as well as the app containers.
statuses = [("init container", cs) for cs in pod.status.init_container_statuses or []]
statuses += [("container", cs) for cs in pod.status.container_statuses or []]
for kind, cs in statuses:
waiting = cs.state.waiting if cs.state else None
if waiting and waiting.reason in BACKOFF_REASONS:
problems.append(f"{ns}/{name}: {kind} {cs.name} is {waiting.reason}")
elif cs.restart_count >= restart_threshold:
problems.append(f"{ns}/{name}: {kind} {cs.name} restarted {cs.restart_count} times")
return problems
def send_webhook(webhook_url, summary):
"""POST the JSON summary. Best effort: a failed webhook is logged, not fatal."""
payload = json.dumps(summary).encode("utf-8")
request = urllib.request.Request(
webhook_url, data=payload, headers={"Content-Type": "application/json"}, method="POST"
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
response.read()
except Exception as exc: # noqa: BLE001 - alerting must never crash the check
print(f"warning: webhook POST failed: {exc}", file=sys.stderr)
def main():
parser = argparse.ArgumentParser(description="Health-check a Talos Linux Kubernetes cluster.")
parser.add_argument("--talosconfig", required=True, help="Path to talosconfig")
parser.add_argument("--kubeconfig", required=True, help="Path to kubeconfig")
parser.add_argument("--control-plane-nodes", nargs="+", required=True, help="Control plane node IPs")
parser.add_argument("--worker-nodes", nargs="*", default=[], help="Worker node IPs (omit if none)")
parser.add_argument("--restart-threshold", type=int, default=5, help="Restart count treated as unhealthy (default 5)")
parser.add_argument("--timeout", type=int, default=120, help="talosctl health --wait-timeout in seconds (default 120)")
parser.add_argument("--ignore-namespace", action="append", default=[], help="Namespace to skip in the pod check (repeatable)")
parser.add_argument("--webhook-url", help="Webhook to POST the JSON summary to on failure")
parser.add_argument("--lock-file", default="/tmp/talos-healthcheck.lock", help="Lock file that prevents overlapping runs")
args = parser.parse_args()
lock = open(args.lock_file, "w")
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
print("another health check is still running; skipping this run", file=sys.stderr)
sys.exit(2)
problems = []
try:
talos_ok, talos_output = check_talos_health(
args.talosconfig, args.control_plane_nodes, args.worker_nodes, args.timeout
)
if not talos_ok:
problems.append({"check": "talosctl health", "detail": talos_output.splitlines()[-20:]})
alarms = check_etcd_alarms(args.talosconfig, args.control_plane_nodes)
if alarms:
problems.append({"check": "etcd alarms", "detail": alarms})
except FileNotFoundError:
print("talosctl not found on PATH", file=sys.stderr)
sys.exit(2)
try:
config.load_kube_config(config_file=args.kubeconfig)
v1 = client.CoreV1Api()
not_ready, cordoned = check_nodes(v1)
if not_ready:
problems.append({"check": "node readiness", "detail": not_ready})
if cordoned:
problems.append({"check": "cordoned nodes", "detail": cordoned})
pod_problems = check_pods(v1, args.restart_threshold, set(args.ignore_namespace))
if pod_problems:
problems.append({"check": "pod health", "detail": pod_problems})
except Exception as exc: # noqa: BLE001 - an unreachable API server is itself a finding
problems.append({"check": "kubernetes api", "detail": [f"{type(exc).__name__}: {exc}"]})
summary = {
"checkedAt": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"healthy": not problems,
"problems": problems,
}
print(json.dumps(summary, indent=2))
if problems:
if args.webhook_url:
send_webhook(args.webhook_url, summary)
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()
Notes
- One node for
talosctl health.talosctl healthis built to talk to a single node, which then checks the rest of the cluster server-side; passing several addresses to--nodesmakes it refuse to run. That is why the script sends the request through the first control plane node and passes the full membership with--control-plane-nodesand--worker-nodes. An earlier version of this script passed every node to--nodes, which doesn't work. - Keep the timeout under the cron interval.
talosctl healthblocks until its checks pass or--wait-timeoutexpires, and the default is 20 minutes. The lock file is a second line of defence: if a run is still going, the next one exits with code 2 instead of piling up. - etcd alarms matter more than they look. Talos' etcd maintenance guide notes that etcd's default space quota is 2 GiB and that etcd stops operations when the database exceeds it; the condition shows up as a
NOSPACErow intalosctl etcd alarm list. The fix is defragmentation (talosctl -n <IP> etcd defrag, one node at a time) or a largerquota-backend-bytes, followed bytalosctl etcd alarm disarm. - Restart counts are a blunt instrument on purpose. The kubelet resets a container's back-off timer after 10 minutes of clean running, but the restart count stays with the pod, so a pod that crashed six times last month and has been fine since keeps tripping the threshold until it is replaced. The back-off check (
CrashLoopBackOff,ImagePullBackOffand friends, read fromstate.waiting.reasonfor init containers as well as app containers, since a failing init container leaves the podPendingrather thanFailed) is the precise signal; the restart threshold catches the slow leak without needing a metrics backend. Raise the threshold or add--ignore-namespacerather than deleting the check. - The Kubernetes API being unreachable is a finding, not a crash. Any exception from the client (a TLS error, a timeout, a 403 from an RBAC mistake) is reported as a
kubernetes apiproblem so the webhook still fires. - Read-only by design. The script doesn't restart, uncordon or delete anything. A health check that also remediates needs far more thought about blast radius than a cron job should be trusted with unsupervised.
- Don't point the talosconfig at the VIP. If the cluster uses a Talos Layer 2 VIP, Sidero Labs warns against using it as a
talosconfigendpoint, because the VIP depends on etcd and the API server; list the node IPs instead.
Source
- talosctl CLI reference (
health,etcd alarm list,etcd status) - Role-based access control (RBAC) (
os:reader,os:operator,talosctl config new --roles) - etcd Maintenance (space quota, alarms, defragmentation)
- Virtual (shared) IP (why the VIP is not a talosconfig endpoint)
- Kubernetes Python client (
CoreV1Api.list_node,CoreV1Api.list_pod_for_all_namespaces,V1ContainerStatus) - Pod Lifecycle: container states and restart policy