Filters
How to search
  • Plain words find names, texts and examples.
  • "two words" finds the exact phrase.
  • --name or -x finds an option.
  • #word looks only inside commands.
  • @grep finds lessons and labs that use grep.
  • Filters: cat:network · in:labs · in:notes · level:rhcsa · ch:8 · root:yes. Put ! before a word to leave it out. OR between words means one of them.
  • ↑ ↓ to move · Enter to open · Esc to close · / to search

Python scriptsPythonFixes series

Why is this pod Pending?

It is 02:40 and checkout has been Pending for eleven minutes. There is no log to read: no container started.

Quick reference

The full script is at the end of this note: k8s_pending.py (standard library only, no pip install).

Run it

python3 k8s_pending.py /tmp/pods.json /tmp/events.json

Example output

text
PENDING checkout-7f9c2 cpu
ALERT 1 pending

Explained

What it does

The script reads two exports: kubectl get pods -A -o json and kubectl get events -A -o json. For every Pending pod it finds the FailedScheduling event and maps the message to cpu, memory, taint, pvc, affinity, unknown or no event. It prints one PENDING line per pod and a count, and exits 1 while anything is Pending.

What the screen shows

The 14 visible lines index the events, collect the Pending pods, pull each scheduler message and test five tokens. On the dirty fixture they print PENDING checkout-7f9c2 cpu, then ALERT 1 pending.

How to run it

python3 k8s_pending.py /tmp/pods.json /tmp/events.json, after saving both exports. Run it every five minutes; 0 is clean, 1 is Pending, 2 is bad input.

The fix

For cpu or memory, raise the request or add capacity; do not lower it to fit. For a taint, add a toleration only if the workload belongs there. For pvc, check the StorageClass and access mode. For affinity, read the selector first.

Prevention

Alert when a pod has been Pending for more than ten minutes. A new pod is not an incident; a stuck one is.

The honest caveat: a quota or a webhook can block a pod before the scheduler, and neither writes FailedScheduling. A missing event is a clue, not a verdict.

The lesson: the scheduler writes the reason; the pod cannot.

In one line

Pending is a schedule problem. The event names the tier; the pod only reports the symptom.

Read the event

kubectl describe pod -n prod checkout-7f9c2

Script: k8s_pending.py

Download: scripts/k8s_pending.py

python
#!/usr/bin/env python3
"""Explain why pods are Pending from a kubectl JSON dump and its events.
 
Reads two read-only exports:
 
    kubectl get pods -A -o json > /tmp/pods.json
    kubectl get events -A -o json > /tmp/events.json
 
For every pod whose status.phase is Pending it finds the FailedScheduling
event and reports one short reason:
 
    cpu        the scheduler reports insufficient CPU
    memory     the scheduler reports insufficient memory
    taint      a node taint is not tolerated by the pod
    pvc        a PersistentVolumeClaim is unbound or missing
    affinity   node affinity, pod affinity or a node selector excludes nodes
    unknown    a scheduling event exists but names none of the above
    no event   no FailedScheduling event in the dump (a brand-new pod, or
               one blocked before the scheduler, such as a quota or an
               admission webhook)
 
Usage:
    python3 k8s_pending.py
    python3 k8s_pending.py /tmp/pods.json /tmp/events.json
    kubectl get pods -A -o json | python3 k8s_pending.py - /tmp/events.json
    python3 k8s_pending.py /tmp/pods.json /tmp/events.json --verbose
 
Exit codes: 0 no Pending pods, 1 at least one Pending pod, 2 usage,
unreadable or invalid input.
 
CI (fail the pipeline while anything is stuck):
    kubectl get pods -A -o json > /tmp/pods.json
    kubectl get events -A -o json > /tmp/events.json
    python3 k8s_pending.py /tmp/pods.json /tmp/events.json || exit 1
 
Cron (every five minutes, page on a new stuck pod):
    */5 * * * * kubectl get events -A -o json > /tmp/events.json && kubectl get pods -A -o json | python3 /opt/s0sta/k8s_pending.py - /tmp/events.json
 
Read-only: it parses two local files (or stdin) and never calls the API
server, so it is safe against production. The reason is only as good as
the event dump: a Pending pod can be blocked by a quota or a webhook that
never writes FailedScheduling, and an old event can describe a condition
that has since changed. Pair the output with kubectl describe pod.
 
Standard library only, Python 3.8+.
 
By s0sta.com
"""
import argparse
import json
import sys
 
CPU = ("insufficient cpu",)
MEMORY = ("insufficient memory",)
TAINT = ("taint", "untolerated taint")
PVC = ("persistentvolumeclaim", "unbound", "volume", "pvc")
AFFINITY = ("affinity", "selector", "didn't match", "did not match")
 
 
class PendingError(Exception):
    """Anything that should end the run with exit code 2."""
 
 
def read_json(source):
    """Read one JSON document from a path or '-' for stdin."""
    try:
        if source == "-":
            return json.load(sys.stdin)
        with open(source, encoding="utf-8") as handle:
            return json.load(handle)
    except OSError as exc:
        raise PendingError("cannot read %s: %s" % (source, exc))
    except ValueError as exc:
        raise PendingError("%s is not JSON: %s" % (source, exc))
 
 
def as_dict(value, label):
    if value is None:
        return {}
    if not isinstance(value, dict):
        raise PendingError("%s must be an object" % label)
    return value
 
 
def as_list(value, label):
    if value is None:
        return []
    if not isinstance(value, list):
        raise PendingError("%s must be a list" % label)
    return value
 
 
def items_of(doc, label):
    """Return the item list from a List, or wrap a single object."""
    if not isinstance(doc, dict):
        raise PendingError("%s must be a JSON object" % label)
    items = doc.get("items")
    if items is not None:
        return as_list(items, "%s items" % label)
    if "metadata" in doc or "kind" in doc:
        return [doc]
    raise PendingError("%s has no items list and no metadata" % label)
 
 
def kind_of(obj):
    return str(obj.get("kind") or "").lower()
 
 
def pod_identity(pod, number):
    metadata = as_dict(pod.get("metadata"), "pod %d metadata" % number)
    name = metadata.get("name")
    if not isinstance(name, str) or not name:
        raise PendingError("pod %d has no metadata.name" % number)
    namespace = metadata.get("namespace") or "default"
    if not isinstance(namespace, str):
        raise PendingError("pod %d has a non-string namespace" % number)
    uid = metadata.get("uid")
    return namespace, name, uid if isinstance(uid, str) else ""
 
 
def pod_phase(pod, number):
    status = as_dict(pod.get("status"), "pod %d status" % number)
    phase = status.get("phase") or ""
    if not isinstance(phase, str):
        raise PendingError("pod %d status.phase must be a string" % number)
    return phase
 
 
def event_pod(event, number):
    """Return (namespace, name, uid, reason, message) for one event."""
    if not isinstance(event, dict):
        raise PendingError("event %d is not an object" % number)
    involved = as_dict(event.get("involvedObject"),
                       "event %d involvedObject" % number)
    if kind_of(involved) not in ("", "pod"):
        return None
    name = involved.get("name")
    if not isinstance(name, str) or not name:
        return None
    namespace = involved.get("namespace") or "default"
    uid = involved.get("uid") if isinstance(involved.get("uid"), str) else ""
    reason = event.get("reason") or ""
    message = event.get("message") or event.get("note") or ""
    if not isinstance(reason, str) or not isinstance(message, str):
        raise PendingError("event %d reason/message must be strings" % number)
    return namespace, name, uid, reason, message
 
 
def same_pod(event, namespace, name, uid):
    e_ns, e_name, e_uid, _reason, _message = event
    if e_ns != namespace or e_name != name:
        return False
    if uid and e_uid:
        return uid == e_uid
    return True
 
 
def classify(message):
    """Map one scheduler message to a short reason label."""
    text = message.lower()
    if any(token in text for token in CPU):
        return "cpu"
    if any(token in text for token in MEMORY):
        return "memory"
    if any(token in text for token in TAINT):
        return "taint"
    if any(token in text for token in PVC):
        return "pvc"
    if any(token in text for token in AFFINITY):
        return "affinity"
    return "unknown"
 
 
def main(argv=None):
    parser = argparse.ArgumentParser(
        description="Explain Pending pods from kubectl pods and events JSON.",
        epilog="Exit codes: 0 no Pending pods, 1 Pending found, 2 usage or "
               "input error.")
    parser.add_argument("pods", nargs="?", default="-",
                        help="kubectl get pods -A -o json output, or - for stdin")
    parser.add_argument("events", nargs="?", default=None,
                        help="kubectl get events -A -o json output (optional)")
    parser.add_argument("-v", "--verbose", action="store_true",
                        help="print the scheduler message for each pod")
    args = parser.parse_args(argv)
 
    try:
        pods = items_of(read_json(args.pods), "pod dump")
        events = []
        if args.events:
            events = items_of(read_json(args.events), "event dump")
        parsed_events = [event_pod(e, i) for i, e in enumerate(events, 1)]
        parsed_events = [e for e in parsed_events if e is not None]
        parsed_events = [e for e in parsed_events if e[3] == "FailedScheduling"]
 
        pending = []
        for number, pod in enumerate(pods, 1):
            if not isinstance(pod, dict):
                raise PendingError("pod %d is not an object" % number)
            if pod_phase(pod, number) != "Pending":
                continue
            namespace, name, uid = pod_identity(pod, number)
            messages = [e[4] for e in parsed_events
                        if same_pod(e, namespace, name, uid)]
            reason = "no event"
            detail = ""
            if messages:
                labels = [classify(m) for m in messages]
                reason = next((r for r in labels if r != "unknown"), "unknown")
                detail = next((m for m, r in zip(messages, labels)
                               if r == reason), messages[0])
            pending.append((namespace, name, reason, detail))
    except PendingError as exc:
        print("ERR %s" % exc, file=sys.stderr)
        return 2
 
    if not pending:
        print("OK %d pods scheduled" % len(pods))
        return 0
 
    for namespace, name, reason, detail in pending:
        print("PENDING %s/%s %s" % (namespace, name, reason))
        if args.verbose and detail:
            print("  %s" % detail)
 
    print("ALERT %d pending" % len(pending))
    if args.events is None:
        print("NOTE no events dump; reasons are unknown", file=sys.stderr)
    return 1
 
 
if __name__ == "__main__":
    sys.exit(main())

More notes: Python scripts

Open in the notes app: search, filters, copy This note on GitHub