# crictl Cheat Sheet

> **Tool:** crictl (part of kubernetes-sigs/cri-tools)
> **Category:** Containers & Orchestration
> **Verified against:** crictl v1.36.0, flags verified via `crictl --help` and every listed subcommand's
> `--help` run locally, 2026-08-29. This sandbox has no running containerd/CRI-O socket, so every command
> beyond `--help`/`--version` in this page is either a real captured "no runtime available" error (marked
> as such) or documented from official docs/`--help` output and marked illustrative — never invented sample
> data passed off as a live capture.
> **Official docs:** https://kubernetes.io/docs/tasks/debug/debug-cluster/crictl/ and
> https://github.com/kubernetes-sigs/cri-tools

## 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

```mermaid
flowchart TD
    U(["Operator, on the node"]) --> C["crictl"]
    K["kubelet"] -->|"same CRI gRPC protocol"| S{"CRI socket"}
    C -->|"unix:///run/containerd/containerd.sock\nor /run/crio/crio.sock"| S
    S --> R["containerd or CRI-O\n(the CRI implementation)"]
    R --> O["runc / crun / gVisor\n(the actual OCI runtime)"]
    O --> P["Running container process"]

    classDef info fill:#e5f0fa,stroke:#1d6fb8,color:#10161c
    classDef accent fill:#f0e9fb,stroke:#6d43c0,color:#10161c
    classDef muted fill:#eaeef1,stroke:#c3ccd4,color:#10161c
    class C,K accent
    class S info
    class R,O,P muted
```

**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

```bash
# 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](https://github.com/kubernetes-sigs/cri-tools/releases).

## Configuring the runtime endpoint

```bash
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

```yaml
# /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

```bash
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

```bash
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

```bash
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

```bash
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

```bash
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:

```bash
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:

```bash
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:

```bash
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

```bash
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

| Tool | Use when... |
|---|---|
| `kubectl logs`/`exec`/`describe` | The API server is healthy — always the default, RBAC-aware path |
| `crictl` | The 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 |
| `ctr` | Containerd-specific low-level operations (namespaces, snapshotters) crictl doesn't expose — containerd-only |
| `nerdctl` | A Docker-CLI-compatible experience specifically for containerd, for someone who wants `docker`-shaped ergonomics without CRI's structured-config model |
| `docker`/`podman` | Only 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.
