Контейнер представляет собой обычный процесс Linux, изолированный ядром: namespaces дают ему собственный взгляд на систему, а cgroups ограничивают, сколько CPU и памяти он может занять. Виртуальная машина загружает целое гостевое ядро на виртуальном железе; все контейнеры на хосте делят одно ядро хоста.
Namespaces решают, что он видит: свою корневую файловую систему, распакованную из образа (mnt), своё дерево процессов, в котором он PID 1 (pid), свои интерфейсы, IP и порты (net), имя хоста (uts) и id пользователей (user).
cgroups решают, сколько он получит: cpu.max (квота, которую задаёт limits.cpu в Kubernetes и которую Go 1.25 читает для GOMAXPROCS), memory.max (за ним OOM killer и код выхода 137), а также лимиты на число процессов и I/O.
Образ и контейнер: образ состоит из слоёв файловой системы только для чтения; контейнер работает поверх них как процесс с тонким слоем для записи, который умирает вместе с ним.
Компромисс: контейнер стартует за миллисекунды и стоит мегабайты; ВМ загружается за секунды и резервирует гигабайты. Цена в общем ядре: один баг в ядре достаёт всё на хосте, поэтому недоверенным арендаторам дают ВМ, микро-ВМ (Firecracker) или gVisor.
На macOS Docker Desktop запускает маленькую Linux-ВМ, потому что Linux-контейнерам нужно ядро Linux.
GoMiddle
Docker и контейнеры
Образы, слои и кэш сборки, multi-stage Dockerfile для Go, scratch и distroless, Compose для локальной разработки, тома и сети, сигналы и PID 1, безопасность образа, multi-arch сборки
Закрыто вопросов
0 / 18
- 0Повторить
Вопросы
Образ (image) состоит из read-only стопки слоёв файловой системы и конфига (команда, переменные окружения, пользователь); контейнер является запущенным экземпляром образа с тонким записываемым слоем сверху; реестр (registry) хранит образы по имени и тегу. Слой содержит изменения файлов от одного шага сборки.
Слои адресуются по содержимому: у каждого слоя есть sha256, поэтому десять образов, собранных FROM одной базы, хранят её один раз, а docker pull и docker push передают только те слои, которых нет на другой стороне.
Следующий слой не уменьшает предыдущий: RUN rm только скрывает файл, а его байты остаются в слое, который его добавил. Поэтому Go-тулчейн живёт в стадии сборки и не попадает в итоговый образ.
Контейнеры одноразовые: записываемый слой переживает docker stop и docker start, но удаляется по docker rm, поэтому данные, которые должны пережить контейнер, кладут в volume. Из одного образа можно одновременно запустить сколько угодно контейнеров.
Имена: ghcr.io/acme/api:1.4.0 состоит из реестра, репозитория и тега. Без хоста реестра Docker подразумевает Docker Hub: golang:1.25 означает docker.io/library/golang:1.25.
RUN выполняется при сборке и сохраняет слой; ENTRYPOINT (программа) и CMD (её аргументы по умолчанию, их заменяет всё, что стоит после имени образа в docker run) срабатывают при старте контейнера. Используйте exec-форму ["/api"]: бинарник становится PID 1, без /bin/sh -c перед ним.
Переопределение: docker run api -addr=:9090 запускает /api -addr=:9090; --entrypoint меняет программу и заодно сбрасывает CMD. Без ENTRYPOINT CMD задаёт всю команду, поэтому docker run api sh заменяет её целиком.
Exec-форма и shell-форма: ENTRYPOINT ["/api"] запускает бинарник напрямую как PID 1. ENTRYPOINT /api -addr=:8080 запускает его через /bin/sh -c: на distroless shell нет, и контейнер падает с exec: "/bin/sh": stat /bin/sh: no such file or directory, а в других образах shell может остаться PID 1 и проглотить SIGTERM. Кроме того, shell-форма игнорирует CMD и аргументы docker run.
JSON должен быть валидным: одинарные кавычки или # комментарий в конце той же строки молча превращают exec-форму в shell-форму. docker build --check сообщает об этом как JSONArgsRecommended.
**В exec-форме нет $VAR**: подставлять переменные некому, shell нет. Go-сервис и так читает окружение сам через os.Getenv.
EXPOSE только документирует порт, который слушает приложение, и ничего не открывает. -p 8080:8080 публикует его: Docker пробрасывает порт 8080 хоста на порт 8080 контейнера. Этот трафик приходит на сетевой интерфейс контейнера, а не на его loopback, поэтому сервер должен слушать :8080.
**хост:контейнер**: -p 9000:8080 отдаёт сервис на порту 9000 хоста, а приложение по-прежнему слушает 8080. -P публикует каждый порт из EXPOSE на случайном порту хоста, и едва ли не только на это EXPOSE и влияет.
Ловушка loopback: http.ListenAndServe("127.0.0.1:8080", mux) (или localhost:8080, популярный на macOS, потому что так не всплывает запрос файрвола) принимает только соединения изнутри контейнера. Через опубликованный порт они сбрасываются или отклоняются, а сервер ничего не логирует.
Публикация открывает все интерфейсы: -p 5432:5432 слушает 0.0.0.0, так что dev-Postgres доступен из всей сети, а на Linux правила iptables от Docker обходят ufw. -p 127.0.0.1:5432:5432 оставляет его локальным.
**Контейнерам между собой -p не нужен**: в пользовательской сети (Compose создаёт её на каждый проект) контейнеры обращаются друг к другу по имени и порту контейнера: postgres:5432. В сети bridge по умолчанию разрешения имён нет.
Оба хранят данные вне записываемого слоя контейнера, поэтому данные переживают docker rm. Named volume является хранилищем под управлением Docker, и данные Postgres кладут туда; bind mount пробрасывает в контейнер каталог хоста для кода, конфигов и фикстур. down -v удаляет named volumes.
Named volume: pgdata:/var/lib/postgresql/data. Он живёт в хранилище самого Docker (на Mac внутри VM Docker Desktop), переживает docker compose down и обновление образа, а новый пустой volume сначала заполняется тем, что лежит в образе по этому пути.
Bind mount: ./migrations:/migrations:ro. Контейнер видит файлы хоста и их владельцев как есть: на Linux пользователь контейнера без root не может писать в каталог, принадлежащий другому UID, а на macOS общий доступ к файлам медленнее volume.
**down и down -v**: docker compose down удаляет контейнеры и сети и оставляет volumes. -v удаляет ещё и объявленные в файле named volumes и анонимные volumes, так что следующий up начнёт с пустой базы. Каталоги хоста из bind mount не трогаются никогда.
Путь зависит от мажорной версии: до postgres:17 монтируйте /var/lib/postgresql/data; начиная с 18 образ хранит данные в подкаталоге с номером версии (/var/lib/postgresql/18/docker) и ждёт volume на /var/lib/postgresql. Если volume по-прежнему смонтирован по старому пути, контейнер при старте завершается с ошибкой про новую раскладку каталогов.
Используйте multi-stage сборку: соберите статический бинарник с CGO_ENABLED=0 в образе golang, а затем скопируйте только его в distroless/static или scratch. На scratch ломается обычно не бинарник, а файлы, которые он читает в рантайме: CA-сертификаты и база часовых поясов.
Скопируйте go.mod и go.sum и выполните go mod download до копирования исходников, чтобы слой с зависимостями оставался в кеше; --mount=type=cache для кеша сборки ещё сильнее ускоряет пересборки.
CGO_ENABLED=0 даёт статически слинкованный бинарник. С включённым cgo (по умолчанию при нативной сборке под Linux) бинарнику нужны libc и динамический загрузчик, и на scratch он падает с exec /app: no such file or directory.
На scratch первый HTTPS-запрос падает с x509: certificate signed by unknown authority (нет /etc/ssl/certs), а time.LoadLocation падает с unknown time zone (нет /usr/share/zoneinfo). Скопируйте бандл сертификатов из build-стадии и добавьте import _ "time/tzdata" (около 450 КБ), чтобы вшить базу поясов.
В gcr.io/distroless/static-debian12:nonroot уже есть сертификаты, tzdata и пользователь без root, поэтому обычно берут его. В любом случае запускайте не от root.
-trimpath -ldflags="-s -w" убирает локальные пути и отладочные символы, и бинарник становится меньше; стектрейсы паник при этом работают.
COPY . . стоит перед go mod download, поэтому изменение любого файла инвалидирует этот слой и все слои после него, включая скачивание. Сначала копируйте только go.mod и go.sum, скачайте зависимости, потом копируйте код; cache mounts сохраняют скомпилированные пакеты между сборками.
Как кэш принимает решение: инструкция переиспользуется, только если переиспользован родительский слой и её входные данные не изменились: текст команды для RUN, контрольная сумма копируемых файлов для COPY. Первый промах пересобирает всё, что ниже.
**Почему сначала go.mod и go.sum**: они меняются только вместе с зависимостями, поэтому слой go mod download остаётся в кэше на обычных коммитах, а изменение кода даёт промах только начиная со второго COPY.
Чего слой не сохранит: go build в новом слое начинает с пустым кэшем сборки и снова компилирует каждую зависимость. --mount=type=cache,target=/root/.cache/go-build сохраняет GOCACHE между сборками на том же билдере, и компилируются только изменённые пакеты; cache mount на /go/pkg/mod избавляет от повторных скачиваний, когда go.sum всё же меняется.
Cache mounts остаются на билдере: они не входят ни в образ, ни в экспортируемый кэш слоёв, поэтому на свежем CI-раннере они пустые (см. вопрос про CI-пайплайн).
Контекст тоже важен: без .dockerignore изменение в .git, лог-файле или локальном бинарнике тоже ломает кэш COPY . ..
Контекст сборки представляет собой каталог, переданный в docker build (та самая . в конце), и только эти файлы хоста может читать COPY. Без .dockerignore COPY . . копирует его целиком, вместе с .git и .env, поэтому секреты попадают в слои образа, а посторонние изменения ломают кэш.
Секреты: .env, *.pem или kubeconfig, скопированные через COPY . ., лежат в слое стадии сборки; в одностадийном Dockerfile они уходят вместе с образом и остаются в его истории даже после RUN rm.
Промахи кэша: COPY . . считает контрольную сумму всего, что копирует, поэтому git fetch, новый лог-файл или пересобранный локальный бинарник инвалидируют слой с кодом, хотя ни один Go-файл не изменился.
Скорость: BuildKit передаёт только то, что читает сборка, и только изменившееся с прошлого раза, но COPY . . читает всё, что не исключено, так что случайный node_modules или фикстура на 2 ГБ замедляет каждую сборку.
Белый список вместо чёрного: начните с * и верните !go.mod, !go.sum, !cmd/, !internal/, тогда новый файл не попадёт в контекст, пока кто-то не решит иначе.
Побочный эффект для Go: без .git go build не может записать в бинарник vcs.revision; передайте коммит аргументом сборки в -ldflags "-X main.commit=$COMMIT".
**COPY и ADD**: COPY копирует файлы из контекста; ADD умеет ещё скачать URL, клонировать Git-репозиторий и распаковать локальный tar-архив. Используйте COPY, если не нужно именно это, чтобы .tar.gz не распаковался неожиданно.
Go-бинарник содержит машинный код для одной архитектуры процессора, а на Mac с M-чипом docker build по умолчанию собирает образ linux/arm64, который ядро amd64 запустить не может. Соберите обе архитектуры через docker buildx build --platform linux/amd64,linux/arm64 и дайте Go кросс-компилировать вместо эмуляции.
Один тег, несколько образов: мультиплатформенный образ представляет собой индекс (manifest list) с отдельным манифестом на каждую платформу под одним тегом, и каждая нода скачивает манифест своей архитектуры. docker buildx imagetools inspect показывает их список.
Эмуляция: с неизменённым Dockerfile BuildKit выполняет каждый RUN чужой платформы под QEMU, который есть в Docker Desktop, а на Linux-билдер его нужно ставить. Это работает для любого языка, но go build под эмуляцией идёт в разы медленнее.
Кросс-компиляция средствами Go: FROM --platform=$BUILDPLATFORM запускает стадию сборки нативно, а GOOS=$TARGETOS GOARCH=$TARGETARCH заставляет Go выдать бинарник целевой платформы; с CGO_ENABLED=0 дополнительный тулчейн не нужен. Объявляйте ARG TARGETOS TARGETARCH после go mod download, тогда этот слой общий для всех платформ.
cgo ломает этот приём: C-зависимости нужен кросс-компилятор C под каждую платформу (задайте CC или возьмите хелперы tonistiigi/xx), иначе остаётся эмуляция или нативные arm64-раннеры.
Только одна платформа: docker build --platform linux/amd64 быстро решает проблему, если все серверы amd64.
Как это проявляется: контейнер сразу завершается, а под уходит в CrashLoopBackOff с единственной строкой в логах exec format error.
Тег представляет собой подвижное имя в реестре, а latest означает лишь тег по умолчанию, а не «самый новый»: то, на что он указывает, может измениться между двумя pull. Digest (@sha256:...) является хешем самого манифеста образа, поэтому всегда означает ровно те же байты.
**Что ломается с latest**: невозможно сказать, какая сборка запущена, две ноды, скачавшие образ в разное время, запускают разный код, а у «отката на предыдущий latest» нет цели.
В Kubernetes: для образа с тегом :latest или вовсе без тега по умолчанию ставится imagePullPolicy: Always, так что каждый старт пода зависит от реестра. Пуш нового latest не меняет Deployment, поэтому раскатка не начинается, и поды подхватывают новый образ по одному, когда им случится перезапуститься.
Неизменяемые теги для релизов: помечайте каждую сборку SHA коммита или версией (api:9f2c1e4, api:1.4.0), включите неизменяемость тегов в реестре (она есть в ECR, Harbor и Artifact Registry) и продвигайте тот же самый образ из staging в production.
Digest для базовых образов: FROM golang:1.25 незаметно сдвигается с каждым патч-релизом Go и обновлением Debian. FROM golang:1.25.14@sha256:... фиксирует точный образ: тег остаётся для людей, а что скачивать, решает digest. Renovate или Dependabot открывают PR, когда появляется новый digest, так что закрепление не означает отказа от патчей.
У мультиплатформенного тега есть digest индекса: закрепляйте его, а не digest вложенного amd64-манифеста, иначе arm64-ноды получат amd64-образ.
PID 1 занимает скрипт, а Go-бинарник работает как его дочерний процесс, поэтому SIGTERM от kubelet получает sh, который его не пересылает и, будучи PID 1 без обработчика, даже не умирает от него. Go сигнала не видит, и через 30 с всё получает SIGKILL.
PID 1 особенный: ядро доставляет сигнал init-процессу пространства имён, только если тот установил для него обработчик. У sh, ждущего дочерний процесс, обработчика нет, и SIGTERM просто отбрасывается. Go обработчики ставит всегда, поэтому в роли PID 1 сигнал получает.
Исправление: закончить скрипт строкой exec /app/server "$@". exec заменяет shell бинарником в том же PID 1, и сигнал попадает в signal.NotifyContext.
В shell-форме та же ловушка: ENTRYPOINT /app/server --port=8080 запускается через /bin/sh -c; одни shell делают exec единственной команды, другие нет. Используйте exec-форму ENTRYPOINT ["/app/server", "--port=8080"]; на distroless только она вообще работает: /bin/sh там нет.
Симптомы: каждый деплой ждёт полный grace period на каждый под, код выхода 137, а запросы в обработке обрываются, потому что дренаж так и не запустился.
Дочерние процессы: если сервис запускает подпроцессы, добавьте крошечный init (tini или docker run --init), который пересылает сигналы и подбирает зомби.
Без user namespaces UID 0 в контейнере остаётся UID 0 на хосте, которого сдерживают только урезанные capabilities и seccomp, так что взломанный процесс под root отделяет от ноды один баг в ядре или рантайме. Go-бинарнику привилегии не нужны: задайте числовой USER в образе и закрепите это через runAsNonRoot.
**На scratch**: USER 65534:65534 (nobody). Без /etc/passwd работает только числовой UID.
На distroless: берите тег :nonroot (UID 65532) и пишите USER 65532:65532, а не USER nonroot. При runAsNonRoot: true kubelet отказывается запускать пользователя по имени (image has non-numeric user (nonroot), cannot verify user is non-root), если в поде не задан runAsUser.
Порты: слушайте 8080, а не 80: порты ниже 1024 во многих рантаймах требуют CAP_NET_BIND_SERVICE, а Service всё равно может отдавать наружу 80.
Остальное усиление: readOnlyRootFilesystem: true с emptyDir для временных файлов, allowPrivilegeEscalation: false и сброс всех capabilities.
Любой слой образа может прочитать каждый, кто может его скачать, а удаление файла или переопределение переменной в следующем слое не убирает их из предыдущего. Секреты для сборки передаются через secret mounts в BuildKit, а секреты для рантайма приходят от платформы: переменными окружения или смонтированными файлами.
Как они утекают: ENV DB_PASSWORD=... лежит в конфиге образа (docker inspect), ARG, использованный в RUN, виден в docker history, скопированный .env остаётся в своём слое после rm. А образ пушится в registry, кешируется в CI и скачивается на каждую ноду.
При сборке: обычно это токен для приватных модулей. RUN --mount=type=secret,id=netrc,target=/root/.netrc go mod download даёт его только этой команде и не пишет ни в один слой.
В рантайме: Kubernetes Secret, в идеале синхронизированный из Vault или облачного secret manager, подставленный как переменная окружения или файл. Тогда один и тот же образ работает во всех окружениях.
Env или файл: env проще всего, но его наследуют дочерние процессы, он виден в /proc/<pid>/environ и меняется только при рестарте. Смонтированному файлу можно дать более строгие права, и он обновляется на месте при ротации секрета (но не с subPath).
В Go: загружать один раз при старте, не пускать в логи и тексты ошибок и никогда не передавать флагом командной строки: флаги видны в ps и /debug/pprof/cmdline.
Один compose.yaml: сервис собирается из своего Dockerfile, Postgres и Redis берутся из официальных образов, данные лежат в named volume. Простой depends_on ждёт только запуска контейнера Postgres; condition: service_healthy с healthcheck на pg_isready ждёт, пока база начнёт принимать соединения.
Почему «запущен» недостаточно: Postgres нужно несколько секунд на инициализацию, а при первом запуске ещё больше, поэтому сервис, который пингует базу при старте, падает и завершается. При этом первом запуске временный сервер образа слушает только Unix-сокет, так что pg_isready без -h уже отвечает «accepting connections», пока TCP закрыт: проверяйте с -h 127.0.0.1.
Миграции как разовый сервис: сервис migrate применяет схему и завершается; condition: service_completed_successfully запускает API только после его успешного завершения.
Go-сервис всё равно делает ретраи: depends_on работает только во время docker compose up. В production порядок старта никто не гарантирует, а Postgres может перезапуститься под работающим сервисом, поэтому подключайтесь с таймаутом и повторяйте с backoff, а не полагайтесь на порядок.
Адреса приходят из окружения: внутри Compose база доступна как postgres:5432, для go run на хосте как localhost:5432, а бинарник просто читает DATABASE_URL.
Быстрый цикл правок: многие команды запускают в Compose только зависимости (docker compose up -d postgres redis), а сервис через go run, или используют develop.watch, чтобы docker compose watch пересобирал контейнер при изменении файлов.
**HEALTHCHECK относится к Docker**: его используют Compose и docker ps, обычный Docker не перезапускает unhealthy-контейнер, а Kubernetes его игнорирует и полагается на свои пробы.
Внутри контейнера localhost указывает на сам контейнер, а в контейнере api порт 5432 никто не слушает. Compose помещает оба сервиса в одну сеть, где имя каждого сервиса резолвится в его контейнер, поэтому адрес должен быть postgres:5432.
**Почему go run работал**: ports: "5432:5432" публикует Postgres на localhost хоста. Этот проброс существует только для хоста, контейнеры через него не ходят.
Нужен порт контейнера: с ports: "15432:5432" хост подключается к 15432, но контейнеры по-прежнему ходят на postgres:5432, а postgres:15432 получит отказ. Трафику между контейнерами запись ports вообще не нужна.
Держите адрес в конфиге: DATABASE_URL из окружения позволяет одному и тому же бинарнику ходить на localhost под go run и на postgres в Compose; захардкоженный хост рано или поздно приводит именно к этому багу.
База на самом хосте: из контейнера используйте host.docker.internal, его даёт Docker Desktop; на Linux добавьте extra_hosts: ["host.docker.internal:host-gateway"].
Healthcheck тут ни при чём: service_healthy прошёл, потому что Postgres был готов; API просто подключался не к тому хосту.
Нужно поставлять как можно меньше, знать, что внутри, вовремя патчить, доказывать происхождение и ограничивать то, что может запущенный контейнер. Для Go это означает статический бинарник на distroless, сканер и SBOM в CI, базовые образы, закреплённые по digest и часто пересобираемые, подписанные образы и минимальные права в рантайме.
Меньше содержимого
Статический бинарник на distroless/static или scratch не тащит ни shell, ни пакетный менеджер, ни curl: меньше CVE на разбор и нечем воспользоваться атакующему после удалённого выполнения кода. Полная база golang или Debian добавляет сотни пакетов, которые сервис никогда не вызывает.
Знать, что внутри
Trivy, Grype или Docker Scout сверяют пакеты ОС с базами CVE, а поскольку Go-бинарник хранит список своих модулей (тот, что печатает go version -m, и он остаётся после -s -w), проверяют и модули Go, и стандартную библиотеку, даже на scratch.
Они сравнивают версии, поэтому помечают и код, который вы никогда не вызываете; govulncheck -mode=binary проверяет, какие уязвимые функции попали в бинарник (сканируйте сборку без -s: без таблицы символов он выдаёт все уязвимости всех модулей внутри). Валите CI на исправимых HIGH и CRITICAL, остальное разбирайте.
SBOM (--sbom=true у docker buildx build прикрепляет документ SPDX) служит описью, по которой ищут, когда выходит следующая громкая CVE, вместо того чтобы скачивать и пересканировать каждый образ.
Вовремя патчить
Чистый сегодня образ через месяц обрастает CVE. Закрепляйте базовые образы по digest, пусть Renovate их обновляет, и пересобирайте по расписанию даже без изменений кода: CVE в стандартной библиотеке исправляется только пересборкой на более новом Go.
Доказать происхождение
Provenance-аттестации (--provenance=mode=max, SLSA) записывают, из чего и как собран образ. cosign sign подписывает digest, а admission-политика (Kyverno, Sigstore policy-controller) не пускает неподписанные образы.
Ограничить возможности
Помимо пользователя без root: read-only корневая файловая система, все capabilities сброшены, no-new-privileges, seccomp-профиль по умолчанию и никогда --privileged. Никогда не монтируйте в контейнер /var/run/docker.sock: кто управляет демоном Docker, тот root на хосте.
Инструменты приносят снаружи, а не кладут в образ: отладочный контейнер подключается к пространствам имён процессов и сети целевого. В Docker для этого служат --pid и --network со значением container:api или docker debug; в Kubernetes эфемерный контейнер, добавленный командой kubectl debug --target.
Docker
docker run --rm -it --pid=container:api --network=container:api nicolaka/netshoot даёт shell, в котором ps показывает /api как PID 1, а ss, curl localhost:8080 и tcpdump видят собственную сеть сервиса.
Файловая система целевого контейнера лежит в /proc/1/root/. Чтобы её прочитать, нужны те же UID и GID, что у процесса, или --cap-add=SYS_PTRACE, иначе будет Permission denied.
С Docker Desktop docker debug api делает всё это одной командой и работает также с образами и остановленными контейнерами.
Kubernetes
kubectl debug -it pod/api-7d9f --image=busybox:1.37 --target=api --profile=general добавляет эфемерный контейнер (стабилен с 1.25): --target разделяет пространство имён процессов контейнера api, а профиль general добавляет SYS_PTRACE. Для этого нужно отдельное RBAC-право (pods/ephemeralcontainers), и удалить такой контейнер нельзя: он живёт, пока не удалят под.
У пода в CrashLoopBackOff нет запущенного процесса, к которому можно подключиться. Начните с kubectl logs --previous и kubectl describe pod; kubectl debug --copy-to=api-debug запускает копию пода, в которой можно поменять образ или команду.
Часто shell не нужен
pprof и /metrics на админском порту через kubectl port-forward отвечают на большинство вопросов о Go-сервисе.
SIGQUIT (docker kill -s QUIT api) заставляет рантайм Go напечатать стеки всех горутин в stderr, и по ним видно, где завис сервис. После этого процесс завершается с кодом 2, так что делайте это на экземпляре, который всё равно собираетесь заменить.
Это слои одного стека: dockerd собирает и управляет, containerd скачивает образы и запускает контейнеры, runc создаёт процесс с пространствами имён и cgroups. Образы следуют спецификациям OCI, поэтому образ из Docker без изменений работает на containerd или CRI-O: удаление dockershim образов не коснулось.
Стек
docker run идёт в dockerd (API, сети, volumes, сборки через BuildKit), тот передаёт контейнер в containerd (скачивает и распаковывает слои, присматривает за каждым контейнером через shim), а containerd вызывает runc: тот настраивает пространства имён и cgroups, запускает процесс и завершается.
OCI описывает формат образа (манифест, конфиг, слои), API реестра и runtime bundle, который читает runc. Инструменты с обеих сторон совместимы: docker build, Buildah и ko (Go-образ без Dockerfile) создают образы; containerd, CRI-O и Podman их запускают.
Kubernetes после 1.24
kubelet общается с рантаймами через CRI, gRPC API. Docker Engine его так и не реализовал, поэтому kubelet содержал адаптер dockershim; начиная с 1.24 ноды работают напрямую с containerd или CRI-O (для кластеров, которым нужен Docker Engine, есть cri-dockerd от Mirantis).
Не изменилось: Dockerfile, docker build в CI, реестры, образы.
Сломалось всё, что опиралось на демон Docker на ноде: поды, которые монтируют /var/run/docker.sock для сборки образов (используйте BuildKit или Buildah в кластере), агенты, читающие лог-файлы Docker, и docker ps на ноде (используйте crictl ps).
Podman
Без демона: каждый контейнер является дочерним процессом маленького монитора (conmon), а не root-демона, и по умолчанию работает rootless через user namespaces. CLI совместим с Docker, а сверху есть поды и podman kube play для YAML из Kubernetes. Под капотом работают те же OCI-рантаймы, crun или runc.
У Docker тоже есть rootless-режим; настоящая разница заключается в демоне: доступ к сокету Docker равен root на хосте.
Замена рантайма
Через RuntimeClass под может работать на gVisor (runsc) или Kata Containers (микро-VM на под) вместо runc, когда общее ядро хоста слишком рискованно.