Verified12 commandsAI-assisted

crictl

.md

Verified against crictl v1.36.0, flags verified via `crictl --help` and every listed subcommand's · official docs

What it is and where it fits 🎯#

crictl talks directly to a node's container runtime over the CRI (Container Runtime Interface) gRPC socket — the same interface the kubelet itself uses — bypassing the kubelet and the Kubernetes API server entirely. That makes it the tool for exactly one situation: something is wrong below Kubernetes' own view of the world, and kubectl either can't see it or the API server isn't answering at all. kubectl describe pod tells you what Kubernetes thinks is happening to a Pod; crictl inspect tells you what the runtime is actually doing with the containers behind it — the two can disagree, and when they do, crictl is how you find out which one is lying. It's runtime-agnostic by design (works identically against containerd, CRI-O, or any other CRI implementation) — install it alongside kubeadm/kubelet on any node you'll ever need to debug directly.

Where crictl sits in the stack#

Diagram

crictl and the kubelet are peers talking to the same socket, not a client-of-a-client — this is why crictl can see (and, dangerously, change) container state the kubelet doesn't know about yet, which is exactly the source of the biggest pitfall covered below.

Installation#

# Pin the crictl version to your cluster's Kubernetes MINOR version, not "latest" —
# cri-tools follows the k8s release cycle and mismatched minors can behave subtly differently.
VERSION="v1.36.0"
curl -LO "https://github.com/kubernetes-sigs/cri-tools/releases/download/${VERSION}/crictl-${VERSION}-linux-amd64.tar.gz"
sudo tar -C /usr/local/bin -xzf "crictl-${VERSION}-linux-amd64.tar.gz"
rm "crictl-${VERSION}-linux-amd64.tar.gz"

crictl --version

Note

There is no arm64/amd64-agnostic install script the way Trivy or cosign have — download the tarball matching the node's actual architecture from the cri-tools releases page.

Configuring the runtime endpoint#

crictl config --set runtime-endpoint=unix:///run/containerd/containerd.sock
crictl config --set image-endpoint=unix:///run/containerd/containerd.sock
crictl config --get runtime-endpoint
crictl config --list
crictl --runtime-endpoint unix:///run/containerd/containerd.sock ps    # override per-invocation instead

Real captured output from this sandbox — no config file exists and no CRI socket is reachable, which is exactly the failure shape you'll see running crictl on a machine that either doesn't have containerd/CRI-O running or where you lack permission on the socket:

time="2026-08-29T03:20:09+03:00" level=warning msg="Config \"/etc/crictl.yaml\" does not exist, trying next: \"/home/msalah/.local/bin/crictl.yaml\"" time="2026-08-29T03:20:09+03:00" level=warning msg="runtime connect using default endpoints: [unix:///run/containerd/containerd.sock unix:///run/crio/crio.sock unix:///var/run/cri-dockerd.sock]. As the default settings are now deprecated, you should set the endpoint instead." time="2026-08-29T03:20:09+03:00" level=error msg="validate service connection: validate CRI v1 runtime API for endpoint \"unix:///run/containerd/containerd.sock\": rpc error: code = Unavailable desc = connection error: desc = \"transport: Error while dialing: dial unix /run/containerd/containerd.sock: connect: permission denied\"" time="2026-08-29T03:20:09+03:00" level=fatal msg="validate service connection: validate CRI v1 runtime API for endpoint \"unix:///var/run/cri-dockerd.sock\": rpc error: code = Unavailable desc = connection error: desc = \"transport: Error while dialing: dial unix /var/run/cri-dockerd.sock: connect: no such file or directory\""

Important

crictl silently tries three default socket paths in order (containerd, then CRI-O, then cri-dockerd) when no endpoint is configured — and that default-guessing behavior is itself deprecated. A future crictl release will require an explicit endpoint. Set runtime-endpoint in /etc/crictl.yaml (or $CONTAINER_RUNTIME_ENDPOINT) on every node rather than relying on the auto-detection, both to future-proof the setup and because auto-detection silently picks the first matching socket, which is wrong on a host running more than one candidate runtime.

Config file format#

# /etc/crictl.yaml
runtime-endpoint: unix:///run/containerd/containerd.sock
image-endpoint: unix:///run/containerd/containerd.sock
timeout: 10
debug: false
pull-image-on-create: false

timeout is the connection timeout in seconds (default 2s) — worth raising on a node under heavy load where the runtime's gRPC server is slow to respond, rather than crictl reporting a spurious connection failure that's really just a timeout.

Listing pods, containers, and images#

crictl pods                                     # every pod sandbox the runtime knows about
crictl ps                                        # running containers only
crictl ps -a                                      # every container, including exited/created ones
crictl ps --state Running --label app=catalog-api  # filter by state and label, same idea as `kubectl get pods -l`
crictl images                                     # every image cached on this node
crictl images --filter dangling=true              # untagged/orphaned image layers
crictl stats                                       # live CPU/memory per container, like `docker stats`

crictl ps output columns (CONTAINER, IMAGE, CREATED, STATE, NAME, ATTEMPT, POD ID, POD) map closely to what kubectl get pods -o wide shows for a Pod's containers, but sourced straight from the runtime — useful specifically when the two disagree.

Illustrative crictl ps -a table shape (this node has no CRI runtime reachable, so this reflects the documented column layout rather than a live capture — mark it as such if you paste it anywhere):

CONTAINER IMAGE CREATED STATE NAME ATTEMPT POD ID POD a1b2c3d4e5f6a catalog-api:1.4.2 10 minutes ago Running catalog-api 0 9f8e7d6c5b4a3 catalog-api-7d9f8c6b5-x2k9p b2c3d4e5f6a1b redis:7.2-alpine 2 hours ago Running cache 0 1a2b3c4d5e6f7 cache-0

Inspecting and debugging#

crictl inspect <container-id>                     # full container JSON: config, mounts, env, state
crictl inspectp <pod-id>                            # the pod sandbox's own network namespace/config
crictl logs <container-id>                           # like `kubectl logs`, but reads straight from the runtime's log path
crictl logs -f --tail 100 <container-id>
crictl exec -it <container-id> /bin/sh              # like `kubectl exec`, no API server round-trip
crictl attach <container-id>                          # attach to PID 1's stdio instead of spawning a new process
crictl info                                            # runtime version, config, and CNI plugin status for the whole node

Tip

crictl info is often the fastest way to confirm a CNI misconfiguration — its JSON output includes the runtime's own view of network plugin readiness, which surfaces a broken CNI config before you'd ever see it from a Pod stuck in ContainerCreating.

Managing images directly#

crictl pull myregistry.io/myapp:latest                       # pull without creating a container from it
crictl pull --creds myuser:mypass myregistry.io/myapp:latest   # authenticated pull, credentials never touch a container's env
crictl rmi <image-id>                                          # remove one image
crictl rmi --prune                                              # remove every image not referenced by any container/pod

Warning

crictl rmi on a tag removes the whole image, every tag included, not just the one you named — this is a real, documented CRI API limitation, not a crictl design choice: the CRI spec identifies images by ID, and removing by tag resolves to that ID first. docker rmi/nerdctl rmi/ctr image rm all remove only the specified tag; crictl rmi cannot replicate that distinction. If a node has myapp:v1 and myapp:v2 both pointing at tags of the same underlying image and you only want v1 gone, use the runtime's own native CLI (nerdctl, ctr, or crictl rmi --prune once nothing references the old tag anymore) instead.

Low-level container lifecycle: create, run, and stop#

crictl runp pod-config.yaml                          # create a new pod sandbox (network namespace, etc.)
crictl create <pod-id> container-config.yaml pod-config.yaml   # create a container inside an existing sandbox
crictl start <container-id>                            # start a created-but-not-started container
crictl run container-config.yaml pod-config.yaml        # runp + create + start in one call — the closest thing to `docker run`
crictl stop <container-id>
crictl stopp <pod-id>                                    # stop every container in a pod sandbox at once
crictl rm <container-id>
crictl rmp <pod-id>

These commands take structured JSON/YAML config files (a PodSandboxConfig/ContainerConfig, the same shapes the kubelet itself builds from a Pod spec), not simple image-name-and-flags the way docker run does — crictl create <id> --help lists a jsonschema subcommand that prints the exact schema expected. Reach for these only for genuinely low-level runtime testing/debugging (confirming the runtime itself can start a container at all, independent of anything Kubernetes-shaped) — see the CAUTION above on why this isn't a routine workload-management path.

Streaming events and forwarding a port for debugging#

crictl events                                          # stream container/pod lifecycle events live, like `kubectl get events -w` but runtime-sourced
crictl port-forward <pod-id> 8080:80                     # forward a local port into a pod sandbox, bypassing kube-apiserver entirely

crictl port-forward is the direct-to-node equivalent of kubectl port-forward — genuinely useful when the API server itself is the thing that's down and kubectl port-forward can't establish its usual proxied connection through it.

Real-world scenario: debugging when the API server is unreachable#

A control-plane outage means kubectl can't talk to anything, but workloads on healthy worker nodes are still running — SSH onto the affected node and go straight to the runtime:

crictl ps -a                       # is the container actually still running, or did it crash silently?
crictl inspect <container-id>       # exact exit code, OOM status, and restart count from the runtime's own record
crictl logs --tail 200 <container-id>

This is the scenario crictl exists for — every one of these facts is normally surfaced through kubectl, but only because the kubelet reports them up to an API server that, in this scenario, isn't there to receive the report.

Real-world scenario: confirming what image tag actually got pulled#

A team suspects a :latest-tagged deployment is running stale code because the registry's :latest moved but the node's local image cache didn't get refreshed:

crictl images --digests | grep myapp
crictl inspecti myapp:latest | grep -A2 '"repoDigests"'

Comparing the repoDigests crictl reports against the digest currently pushed in the registry proves definitively whether the node is running the image it thinks it's running — a kubectl describe pod alone only shows the tag, which by definition can't prove what it currently points to.

Real-world scenario: recovering a node with a wedged container#

A container is stuck in Terminating for far longer than its terminationGracePeriodSeconds, and kubectl delete pod --force isn't clearing it because the kubelet itself is the thing stuck waiting on the runtime:

crictl ps -a --state Running | grep <pod-name>
crictl stop <container-id>              # ask the runtime directly to stop it
crictl stop --timeout 0 <container-id>   # skip graceful shutdown entirely if it's truly hung
crictl rm <container-id>

Caution

Stopping or removing a container directly with crictl does not tell Kubernetes about it. The kubelet reconciles actual runtime state against the desired Pod spec on its own schedule, but forcing state changes underneath it — outside of a genuine "the kubelet itself is stuck" incident — can produce a confusing window where kubectl get pods and crictl ps disagree about what's actually running. Treat direct crictl stop/rm/create/run as a last-resort incident tool, not a routine way to manage workloads; kubectl delete pod is the correct tool whenever the kubelet is healthy enough to act on it.

Shell completion#

source <(crictl completion bash)     # bash, current session
crictl completion zsh > "${fpath[1]}/_crictl"   # zsh, persistent
crictl completion fish | source       # fish

Common pitfalls#

  • Running crictl without root or the right group membership on the CRI socket — the real captured error above (connect: permission denied) is exactly this; the socket is root-owned by default on most distributions.
  • Trusting default endpoint auto-detection — see the IMPORTANT callout above; set runtime-endpoint explicitly.
  • Mismatched crictl/Kubernetes minor versions — an old crictl against a newer CRI API (or vice versa) can silently miss fields the newer/older side added; pin crictl's version to the cluster's.
  • Using crictl rm/stop as routine Pod management — see the CAUTION above; it fights the kubelet's own reconciliation instead of working with it.
  • Forgetting -a on ps/pods and concluding a container "doesn't exist" when it's actually just not in the Running state crictl shows by default.
  • Expecting crictl rmi <tag> to behave like docker rmi <tag> — see the WARNING above; it removes the whole image, every tag, not just the one named.

Choosing a debugging tool at this layer#

ToolUse when...
kubectl logs/exec/describeThe API server is healthy — always the default, RBAC-aware path
crictlThe API server is down, or the kubelet's view of a container needs cross-checking against the runtime's own — works identically across containerd/CRI-O
ctrContainerd-specific low-level operations (namespaces, snapshotters) crictl doesn't expose — containerd-only
nerdctlA Docker-CLI-compatible experience specifically for containerd, for someone who wants docker-shaped ergonomics without CRI's structured-config model
docker/podmanOnly relevant if the node's actual runtime is one of these directly (rare on a modern kubeadm-built node, which defaults to containerd)

Exit codes and when to reach for something else#

0 on success; non-zero on any RPC failure to the CRI socket (connection refused, permission denied, timeout) or a not-found ID. The verbose error text (see the captured example above) almost always states which of the three things went wrong — no socket, no permission, or no such runtime — directly in the message.

Reach for kubectl logs/kubectl exec/kubectl describe first, always — they're the supported, RBAC-aware path and work identically regardless of which CRI implementation a node runs. Drop down to crictl specifically when the API server is unreachable, when you suspect the kubelet's view of a container has drifted from reality, or when debugging the runtime/CNI layer itself rather than a specific workload. kubeadm (this category's companion cheat sheet) is what installs the node in the first place; crictl is what you reach for once something on that node needs a closer look.