Перейти до змісту

Docker vs Sheep: що насправді робить контейнерний рантайм

Docker vs Sheep: що насправді робить контейнерний рантайм

Written by:

Igor Gorovyy
DevOps Engineer Lead & Senior Solutions Architect

LinkedIn


Docker - це стек з п'яти компонентів: docker CLI, dockerd, containerd, containerd-shim, runc. Sheep - один бінарник. Ось що робить кожен компонент Docker і що з цього ми залишили.

Стек Docker

graph TB
    CLI["docker CLI<br/>Парсить команди, REST API клієнт"]
    CLI -->|"REST API<br/>через 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"]

Чому так складно? Кожен шар вирішує конкретну проблему:

dockerd - API для зовнішнього світу. Image builds, volumes, networks, compose. Якщо dockerd перезапуститься - containerd продовжує працювати.

containerd - lifecycle контейнерів. Pull images, create snapshots, manage containers. Використовується не тільки Docker, а й Kubernetes напряму.

containerd-shim - один процес на контейнер. Тримає stdin/stdout, відстежує exit code. Якщо containerd перезапуститься - shim продовжує працювати, контейнери не зупиняються.

runc - тільки створення контейнера. Прочитав config.json, створив namespace'и і cgroups, запустив процес, вийшов. Не daemon, а one-shot tool.

Стек Sheep

graph TB
    CLI["sheep CLI<br/>Парсить команди, напряму<br/>викликає 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"]

Один бінарник. CLI напряму викликає Manager. Manager напряму створює namespace'и. Немає daemon, немає gRPC, немає shim.

Компонент за компонентом

Функція Docker Sheep Що втратили
CLI → runtime REST API через socket Прямий виклик Go функцій Можливість віддаленого управління
Container monitor containerd-shim (per container) go cmd.Wait() в горутині Переживання перезапуску daemon
OCI runtime runc (окремий бінарник) Вбудований в sheep Взаємозамінність рантаймів
Image layers Content-addressable store, шари діляться Один rootfs на образ Дедуплікація шарів
Storage driver overlayfs, btrfs, zfs, devicemapper overlayfs + copy fallback Вибір бекенду
Networking libnetwork, CNM, плагіни bridge + veth, hardcoded Port mapping, кілька мереж
Image build Dockerfile, BuildKit Немає docker build

Що залишилось - ядро

І Docker, і Sheep роблять одне і те саме на рівні ядра Linux:

1. clone() з прапорцями CLONE_NEW* → namespace'и
2. pivot_root() → ізольована файлова система
3. mount proc, sys, dev → мінімальне оточення
4. cgroups v2 → ліміти ресурсів
5. bridge + veth → мережа
6. iptables MASQUERADE → NAT

Це і є контейнер. Все інше - UX, reliability, ecosystem.

containerd-shim - чого нам не вистачає

Ось конкретний сценарій. Ти запустив 10 контейнерів через Sheep. Потім Sheep впав (баг, kill, оновлення). Контейнери-сироти продовжують працювати (вони ж окремі процеси), але Sheep при наступному старті не знає їхні stdin/stdout, не може отримати exit code.

Docker вирішує це через shim: окремий процес на кожен контейнер, який переживає перезапуск containerd. Shim тримає файлові дескриптори і чекає на exit.

Sheep при перезапуску перевіряє PID:

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

Якщо процес живий - вважаємо running. Якщо ні - stopped. Простіше, але exit code втрачається.

Числа

Метрика Docker Sheep
Binary size ~50MB (dockerd + containerd + runc) ~8MB
Рядків коду 100,000+ ~2,500
Dependencies ~200 Go modules ~5
Час старту ~2с (daemon) ~10ms

Sheep на два порядки менший, але Docker обробляє тисячі edge cases, які Sheep просто ігнорує.

Висновок

Docker побудований як layered architecture, де кожен шар можна замінити або перезапустити незалежно. Sheep - монолітний бінарник, де все з'єднано напряму. Для навчання монолітний підхід кращий: ти бачиш весь шлях від CLI команди до Linux syscall в одному call stack.

Де це ламається

Монолітний підхід Sheep означає, що будь-який баг в менеджері може вбити весь рантайм. В Docker layered architecture ізолює збої: containerd може впасти, а контейнери продовжують працювати через shim.

Ще два місця, де монолітність кусає:

  • Оновлення Sheep = зупинка всіх контейнерів. У Docker можна оновити dockerd, не торкаючись containerd і запущених контейнерів (live restore). У нас немає поділу на data plane і control plane.
  • Один rootfs на образ означає відсутність дедуплікації: десять контейнерів з одного nginx займуть десять копій файлової системи. Docker завдяки content-addressable шарам ділить їх між усіма.

💡 Цікаві факти

  • Поділ Docker на dockerd і containerd стався не випадково - containerd навмисно винесли як окремий проєкт і передали в CNCF, щоб Kubernetes міг використовувати його напряму, без Docker. Саме тому K8s зрештою прибрав підтримку Docker як рантайму ("Dockershim removal").
  • runc виріс із коду самого Docker: спочатку це був вбудований libcontainer, який потім виділили в окремий бінарник і зробили еталонною реалізацією OCI Runtime Spec. По суті, runc - це той самий код, що колись жив усередині Docker.
  • containerd-shim існує переважно заради однієї проблеми: "reparenting". Коли батьківський процес помирає, його діти переходять до init (PID 1). Shim стає проміжним батьком, щоб ловити exit code контейнера навіть після перезапуску демона.
  • "Контейнер" - це не сутність ядра Linux. У ядрі немає об'єкта "container", є лише namespaces, cgroups і процеси. Контейнер існує тільки як домовленість у user space - саме тому Sheep може "робити контейнери" тими ж syscall'ами, що й Docker.

Що я зрозумів, поки розбирався з темою

Поки не написав Sheep, я підсвідомо вважав, що в Docker п'ять компонентів через "ентерпрайз" і легасі. А виявилось, що майже кожен шар - це відповідь на конкретний production-біль, з яким я ще не стикався в іграшці: shim - щоб пережити рестарт демона, containerd - щоб його можна було перевикористати без Docker, content-addressable шари - щоб не зберігати десять копій одного образу. Мій "елегантний" монолітний Sheep елегантний рівно до першого збою посеред роботи. Складність Docker - це не роздуття, це накопичені шрами.

Що можна покращити

  • Винести моніторинг контейнерів у окремий shim-процес, щоб Sheep міг перезапускатися без втрати exit code і потоків контейнерів.
  • Додати content-addressable сховище шарів: зберігати кожен шар за його sha256 і ділити між образами замість повної копії rootfs.
  • Розділити монолітний бінарник хоча б на два: довгоживучий runtime-демон і тонкий CLI, що ходить до нього через сокет - це вже дасть віддалене управління.
  • Винести створення контейнера за OCI Runtime Spec (читати config.json), щоб runtime став взаємозамінним і Sheep міг запускати контейнери через runc - або навпаки.

Спробуй сам

# Порівняй розміри:
ls -lh ./sheep           # ~8MB
which dockerd && ls -lh $(which dockerd)  # ~50MB+
# Кількість процесів:
ps aux | grep -c sheep   # 1 + containers
ps aux | grep -c docker  # dockerd + containerd + shim*N

Далі - Kubernetes vs Shepherd: що можна вирізати з оркестратора і залишити його працюючим.

Ресурси

Вихідний код циклу: github.com/igorgorovoy/sheep-shepherd-meadow

Попередня: JSON serialization