cek (Container Exploration Kit)

cek is a small CLI I built to read and explore OCI container images without running them, and without needing a container daemon. It talks directly to any registry that implements the OCI Distribution Spec, pulls the manifest and layer blobs that make up an image, and walks them in memory.

$ cek cat nginx:latest /etc/nginx/nginx.conf

Under the tooling, an OCI image is a JSON manifest pointing at a stack of gzipped tar layers. What a running container sees is those layers merged top-down. cek reads from that same stack, just without the container.

The subcommands

The subcommands are ls, cat, cp, tree, diff, tags, inspect, manifest, config, blob and export. Where a coreutils or docker command with the same name exists, the arguments and output follow it, so there’s little to learn.

List the contents of a directory inside an image. Directories end in a slash and symlinks show their target:

$ cek ls nginx:latest /etc/nginx
Mode        Size    Path
drwxr-xr-x  0 B     /etc/nginx/
drwxr-xr-x  0 B     /etc/nginx/conf.d/
-rw-r--r--  1.0 KB  /etc/nginx/conf.d/default.conf
-rw-r--r--  1007 B  /etc/nginx/fastcgi_params
-rw-r--r--  5.2 KB  /etc/nginx/mime.types
lrwxrwxrwx  0 B     /etc/nginx/modules -> /usr/lib/nginx/modules
-rw-r--r--  644 B   /etc/nginx/nginx.conf
-rw-r--r--  636 B   /etc/nginx/scgi_params
-rw-r--r--  664 B   /etc/nginx/uwsgi_params

Read a single file out of an image. On Alpine, /etc/os-release is a symlink to /usr/lib/os-release, and cat follows it, the same as a shell inside the container would:

$ cek cat alpine:latest /etc/os-release
NAME="Alpine Linux"
ID=alpine
VERSION_ID=3.24.1
PRETTY_NAME="Alpine Linux v3.24"
HOME_URL="https://alpinelinux.org/"
BUG_REPORT_URL="https://gitlab.alpinelinux.org/alpine/aports/-/issues"

Copy a file or a directory out to disk. The destination rules are docker cp’s: a file lands at the destination, or inside it if that’s a directory; a directory is copied under its own name if the destination already exists; a trailing /. copies only the contents. Inside a copied directory, symlinks stay symlinks and permissions are kept:

$ cek cp nginx:latest /etc/nginx ./nginx-conf
Copied 7 files (9.2 KB) from nginx:latest:/etc/nginx to ./nginx-conf

Show the top-level directories of an image:

$ cek tree nginx:latest -L 1
.
├── bin
├── boot/
├── dev/
├── docker-entrypoint.d/
├── docker-entrypoint.sh
├── etc/
├── home/
├── lib
├── media/
├── mnt/
├── opt/
├── proc/
├── root/
├── run/
├── sbin
├── srv/
├── sys/
├── tmp/
├── usr/
└── var/

It’s built to pipe. Diffing a config file between two image versions is a regular diff over two cek cat invocations:

$ diff <(cek cat nginx:1.28 /etc/nginx/nginx.conf) \
       <(cek cat nginx:1.26 /etc/nginx/nginx.conf)

For two whole images there’s cek diff. It lists the layers the images do and don’t share, then the files that were added, removed or modified between their merged filesystems. Files are compared by content, so a file that changed without changing size still shows up, and a permission change or a retargeted symlink counts as a modification:

$ cek diff alpine:3.21 alpine:3.22 /etc
Layers:
  - sha256:897d797d2723cf0e318402f4d6f37d51b011517e5cf09246b22155f0fa90dc81  3.5 MB
  + sha256:f7ee36c9aa34bbb665f975c76e5c0d1607f0674b94c84cfb0061f87006ea5d10  3.6 MB

Files:
  ~ /etc/alpine-release                 7 B -> 7 B
  ~ /etc/apk/repositories               103 B -> 103 B
  ~ /etc/issue                          54 B -> 51 B
  - /etc/modprobe.d/kms.conf            91 B
  ~ /etc/secfixes.d/alpine              97 B -> 97 B
  ~ /etc/ssl/certs/ca-certificates.crt  212.7 KB -> 175.2 KB
  ~ /etc/ssl/openssl.cnf                12.0 KB -> 12.1 KB
  ~ /etc/ssl/openssl.cnf.dist           12.0 KB -> 12.1 KB

0 added, 1 removed, 7 modified

Three of those changed without changing size, which a size or timestamp comparison would miss. With --json, every file carries its mode, size, symlink target and content digest on both sides:

$ cek --json diff myapp:v1 myapp:v2 | jq -r '.files[] | select(.status == "modified") | .path'

Colour is only used when stdout is a terminal, so none of this needs stripping before it hits the next command.

Layers

An image is a stack of tarballs. What a running container sees is the result of those layers merged top-down, with upper layers shadowing lower ones and whiteouts removing what a RUN rm deleted. I default cek to that merged view because it’s what users see inside a running container, and almost always what they actually mean to read.

When you do want to dig into a specific layer, most commands take a --layer flag. Layer indices come from cek inspect:

$ cek inspect --pull always nginx:latest
Image: nginx:latest
Registry: index.docker.io
Digest: sha256:1eeaa05a95ec5fc83894371f1de0e2a9359329050a4eae5eba5510dc76af0ada
Created: 2026-09-15T22:48:33Z
OS/Arch: linux/amd64
Size: 63.2 MB
Entrypoint: /docker-entrypoint.sh
Cmd: nginx -g "daemon off;"
Ports: 80/tcp
Env:
  PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
  NGINX_VERSION=1.31.6
  NJS_VERSION=1.0.1
  ...
Labels:
  maintainer=NGINX Docker Maintainers <docker-maint@nginx.com>

Layers:
#  Digest                                                                   Size     Media Type
1  sha256:6310eb16bf4251731feab01e8f633bf5e2d75a657ccad97f420b1f83cce457be  28.4 MB  application/vnd.oci.image.layer.v1.tar+gzip
2  sha256:9302921ce9b3730380e1963eb2e8a695f872814d07628eb3ab21852c475018ec  34.8 MB  application/vnd.oci.image.layer.v1.tar+gzip
3  sha256:ab606a349520ed616d5ddee4e3c135760086a580a7841720af3d034832bdef95  628 B    application/vnd.oci.image.layer.v1.tar+gzip
...
$ cek ls --layer 4 nginx:latest
$ cek cat --layer 2 nginx:latest /etc/nginx/nginx.conf
$ cek cp --layer 2 myapp:latest /app ./app-layer-2

By default, cek walks the layer stack top-down and returns the first non-whiteout match for a given path. That’s the same resolution an overlayfs performs at runtime, so the result is exactly what a running container would see. With --layer, cek skips that walk and reads directly from the tarball entries of the named layer, ignoring any shadowing and whiteouts contributed by the layers above it.

inspect is a summary of two documents, and sometimes you want the documents. manifest prints the image manifest and config prints the config blob, as the registry serves them rather than a reinterpretation:

$ cek manifest --pull always nginx:latest | jq '.layers[-1]'
{
  "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
  "digest": "sha256:3fe5ab3f861484e72b05fcb727f2082cf06a641349ffaf604f1f23c2628aef84",
  "size": 1397
}
$ cek config --pull always nginx:latest | jq -r '.history[].created_by'
# debian.sh --arch 'amd64' out/ 'trixie' '@1787529600'
LABEL maintainer=NGINX Docker Maintainers <docker-maint@nginx.com>
ENV NGINX_VERSION=1.31.6
...

Both are indented for reading. With --json the exact bytes are written instead, so the output hashes to the digest inspect reported above:

$ cek --json manifest --pull always nginx:latest | shasum -a 256
1eeaa05a95ec5fc83894371f1de0e2a9359329050a4eae5eba5510dc76af0ada  -

One level further down, blob writes a layer exactly as the registry stores it, neither decompressed nor unpacked:

$ cek blob --layer 1 alpine:latest | head -c 16 | xxd
00000000: 1f8b 0800 0000 0000 00ff ecfd 0b7c 54d5  .............|T.

1f8b is the gzip magic number. Use --pull always when the bytes have to match the registry: a blob served by a local daemon is re-exported by the daemon and its hash can differ. Layers that carry annotations in the manifest, such as encrypted ones, get a table of their own in inspect. Nobody encrypts container images leans on these three commands to look at what a registry actually holds.

The daemon, when it’s around

When a daemon is around, cek uses it as a cache over the Unix socket addressed by DOCKER_HOST. The path differs per runtime (Docker, Podman, containerd, and Colima each have their own) and falls back to the default Docker socket if DOCKER_HOST is unset. Anything already pulled by docker, podman, or nerdctl is immediately visible.

The --pull flag controls cache behaviour. if-not-present (the default) checks the daemon first and falls back to the registry, always skips the cache and re-fetches every time, and never keeps you offline.

For anything the daemon doesn’t have, cek sends the credentials docker login stored, credential helpers included, so a private repository works the same way it does with docker pull or crane. Logging in to Docker Hub also lifts the anonymous pull rate limit, which --pull always runs into quickly.

How it differs from dive

dive is a TUI. It requires Docker to be running, and it isn’t built for piping or plugging into shell workflows. It does give you layer-efficiency analysis, which I deliberately left out of cek, and it’s still what I reach for when I want to explore an image visually.

Install

$ brew install cek

or

$ go install github.com/bschaatsbergen/cek@latest

Completion scripts exist for bash, zsh, fish and PowerShell:

$ cek completion zsh > "${fpath[1]}/_cek"

Source is at github.com/bschaatsbergen/cek.