Skip to content

Docker vs Sheep: What a Container Runtime Actually Does

Docker vs Sheep: What a Container Runtime Actually Does

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Docker is a stack of five components: docker CLI, dockerd, containerd, containerd-shim, runc. Sheep is a single binary. Here's what each Docker component does and what we kept.

The Docker Stack

graph TB
    CLI["docker CLI<br/>Parses commands, REST API client"]
    CLI -->|"REST API<br/>via unix socket"| DOCKERD["dockerd<br/>Docker daemon<br/>Image management, volumes,<br/>networking, build"]
    DOCKERD -->|"gRPC"| CONTAINERD["containerd<br/>Container lifecycle,<br/>image pulling, snapshots"]
    CONTAINERD --> SHIM1["containerd-shim<br/>Process monitor<br/>per container"]
    CONTAINERD --> SHIM2["containerd-shim<br/>..."]
    SHIM1 --> RUNC1["runc<br/>OCI runtime<br/>namespaces + cgroups"]
    SHIM2 --> RUNC2["runc"]
    RUNC1 --> C1["Container 1"]
    RUNC2 --> C2["Container 2"]

Why so complex? Each layer solves a specific problem:

dockerd - the API for the outside world. Image builds, volumes, networks, compose. If dockerd restarts, containerd keeps running.

containerd - container lifecycle. Pull images, create snapshots, manage containers. Used not just by Docker, but also by Kubernetes directly.

containerd-shim - one process per container. Holds stdin/stdout, tracks the exit code. If containerd restarts, the shim keeps running and containers don't stop.

runc - container creation only. Reads config.json, creates namespaces and cgroups, starts the process, exits. Not a daemon, a one-shot tool.

The Sheep Stack

graph TB
    CLI["sheep CLI<br/>Parses commands, directly<br/>calls Manager"]
    CLI --> MGR["Container Manager<br/>Create, Start, Stop, Remove<br/>State persistence"]
    MGR --> RT["Runtime<br/>startContainer(): namespaces + cgroups<br/>ContainerInit(): mount, pivot_root, exec"]
    MGR --> IMG["Image Manager<br/>Import, Bootstrap, Pull, Push"]
    MGR --> NET["Network<br/>Bridge, veth pairs, IP allocation"]
    MGR --> OVL["OverlayFS<br/>mount/unmount/cleanup"]
    RT --> C["Container process"]

One binary. The CLI calls the Manager directly. The Manager creates namespaces directly. No daemon, no gRPC, no shim.

Component by Component

Function Docker Sheep What we lost
CLI -> runtime REST API via socket Direct Go function calls Remote management
Container monitor containerd-shim (per container) go cmd.Wait() in a goroutine Surviving daemon restarts
OCI runtime runc (separate binary) Built into sheep Swappable runtimes
Image layers Content-addressable store, shared layers One rootfs per image Layer deduplication
Storage driver overlayfs, btrfs, zfs, devicemapper overlayfs + copy fallback Backend choice
Networking libnetwork, CNM, plugins bridge + veth, hardcoded Port mapping, multiple networks
Image build Dockerfile, BuildKit None docker build

What Remains - The Core

Both Docker and Sheep do the same thing at the Linux kernel level:

1. clone() with CLONE_NEW* flags -> namespaces
2. pivot_root() -> isolated filesystem
3. mount proc, sys, dev -> minimal environment
4. cgroups v2 -> resource limits
5. bridge + veth -> networking
6. iptables MASQUERADE -> NAT

That's a container. Everything else is UX, reliability, ecosystem.

containerd-shim - What We're Missing

Here's a specific scenario. You started 10 containers through Sheep. Then Sheep crashes (bug, kill, update). The orphaned containers keep running (they're separate processes), but on the next startup Sheep doesn't know their stdin/stdout and can't get exit codes.

Docker solves this with the shim: a separate process per container that survives containerd restarts. The shim holds file descriptors and waits for exit.

Sheep checks the PID on restart:

if c.State == StateRunning && c.Pid > 0 {
    if !isProcessAlive(c.Pid) {
        c.State = StateStopped
        c.Pid = 0
    }
}

If the process is alive, we consider it running. If not, stopped. Simpler, but the exit code is lost.

The Numbers

Metric Docker Sheep
Binary size ~50MB (dockerd + containerd + runc) ~8MB
Lines of code 100,000+ ~2,500
Dependencies ~200 Go modules ~5
Startup time ~2s (daemon) ~10ms

Sheep is two orders of magnitude smaller, but Docker handles thousands of edge cases that Sheep simply ignores.

Takeaway

Docker is built as a layered architecture where each layer can be replaced or restarted independently. Sheep is a monolithic binary where everything is wired together directly. For learning, the monolithic approach is better: you can see the entire path from CLI command to Linux syscall in a single call stack.

Where This Breaks

Sheep's monolithic approach means any bug in the manager can kill the entire runtime. In Docker's layered architecture, failures are isolated: containerd can crash, but containers keep running through the shim.

Two more places where being monolithic bites:

  • Updating Sheep = stopping all containers. With Docker you can update dockerd without touching containerd or the running containers (live restore). We have no split between data plane and control plane.
  • One rootfs per image means no deduplication: ten containers from the same nginx take ten copies of the filesystem. Docker, thanks to content-addressable layers, shares them across all of them.

💡 Fun facts

  • Docker's split into dockerd and containerd wasn't an accident - containerd was deliberately carved out as a separate project and donated to the CNCF so Kubernetes could use it directly, without Docker. That's exactly why K8s eventually dropped Docker as a runtime ("Dockershim removal").
  • runc grew out of Docker's own code: it started as the embedded libcontainer, later extracted into a standalone binary and made the reference implementation of the OCI Runtime Spec. Essentially, runc is the same code that once lived inside Docker.
  • containerd-shim exists mostly for one problem: "reparenting". When a parent process dies, its children are adopted by init (PID 1). The shim becomes an intermediate parent so it can catch the container's exit code even after the daemon restarts.
  • A "container" isn't a Linux kernel entity. The kernel has no "container" object - only namespaces, cgroups, and processes. A container exists purely as a user-space convention - which is exactly why Sheep can "make containers" with the same syscalls as Docker.

What I figured out while digging into this

Until I wrote Sheep, I subconsciously assumed Docker had five components because of "enterprise" and legacy. It turned out almost every layer is an answer to a concrete production pain I hadn't hit yet in a toy: the shim - to survive a daemon restart; containerd - so it can be reused without Docker; content-addressable layers - to avoid storing ten copies of one image. My "elegant" monolithic Sheep is elegant right up to the first crash mid-operation. Docker's complexity isn't bloat, it's accumulated scar tissue.

What could be improved

  • Move container monitoring into a separate shim process so Sheep can restart without losing exit codes and container streams.
  • Add a content-addressable layer store: keep each layer under its sha256 and share it across images instead of copying the full rootfs.
  • Split the monolithic binary into at least two: a long-lived runtime daemon and a thin CLI talking to it over a socket - that alone buys remote management.
  • Move container creation to the OCI Runtime Spec (read config.json) so the runtime becomes swappable and Sheep could launch containers via runc - or the other way around.

Try It Yourself

# Compare sizes:
ls -lh ./sheep           # ~8MB
which dockerd && ls -lh $(which dockerd)  # ~50MB+
# Process count:
ps aux | grep -c sheep   # 1 + containers
ps aux | grep -c docker  # dockerd + containerd + shim*N

Next up - Kubernetes vs Shepherd: what you can cut from an orchestrator and still have it work.

Resources

Source code for the series: github.com/igorgorovoy/sheep-shepherd-meadow

Previous: JSON Serialization