Что на самом деле убрали: dockershim, а не Docker
Чтобы понять, надо разделить две вещи, которые новички постоянно путают: формат образа и рантайм, который этот образ запускает.
Когда ты делаешь docker build, на выходе - образ в формате OCI (Open Container Initiative). Это открытый стандарт: спецификация на то, как устроены слои файловой системы, манифест, конфиг. Docker его не придумывал в одиночку и не владеет им. Точно так же OCI описывает рантайм - как из распакованного образа сделать живой процесс в namespaces и cgroups. Эталонная реализация низкоуровневого рантайма - runc.
Теперь сам Docker. Docker Engine (демон dockerd) - это не монолит. Под ним давно живет containerd - отдельный демон, который и занимается реальной работой: тянет образы из реестра, распаковывает слои, управляет жизненным циклом контейнеров, дергает runc. dockerd сверху - это удобная обертка: CLI, build, сеть, тома, REST API. То есть containerd Docker уже использует много лет, ты просто этого не видел.
А что такое kubelet? Это агент Kubernetes на каждой ноде, который запускает и следит за контейнерами пода. Чтобы говорить с рантаймом, у Kubernetes есть стандартный интерфейс - CRI (Container Runtime Interface), это grpc-контракт. containerd и CRI-O умеют CRI нативно. А вот dockerd CRI не умел никогда. Поэтому в Kubernetes жил костыль под названием dockershim - прослойка внутри kubelet, которая переводила вызовы CRI в API Docker.
Получалась дикая цепочка: kubelet -> dockershim -> dockerd -> containerd -> runc. Лишние два звена в горячем пути. Команда Kubernetes годами тащила этот код, он усложнял kubelet и тормозил развитие. В 1.24 dockershim удалили. И теперь честный путь короче:
Код: Выделить всё
# Было (до 1.24):
kubelet -> dockershim -> dockerd -> containerd -> runc -> [контейнер]
# Стало (containerd kubernetes напрямую):
kubelet --CRI--> containerd -> runc -> [контейнер]

Почему docker-образы работают в kubernetes без изменений
Ключевая мысль, которую надо вбить намертво: docker build делает не "docker-образ", а OCI-образ. Слово "docker" в имени - историческое. Контейнерный рантайм в кластере (containerd или CRI-O) точно так же понимает OCI-манифест, те же слои в tar+gzip, тот же конфиг с ENTRYPOINT и переменными окружения.
Проверь сам, что образ из Docker Hub - это OCI:
Код: Выделить всё
docker buildx imagetools inspect nginx:1.27 --format '{{json .Manifest.MediaType}}'
# "application/vnd.oci.image.index.v1+json"
Маленькая оговорка про containerd: для k8s включается CRI-плагин containerd, и образы он хранит в своем сторадже, а не в /var/lib/docker. На ноде это видно так:
Код: Выделить всё
# на k8s-ноде с containerd
crictl images
# IMAGE TAG IMAGE ID SIZE
# registry.k8s.io/pause 3.10 873ed7510279 320kB
# docker.io/library/nginx 1.27 5ef79149e0ec 72.1MB
Как образ из Docker попадает в кластер: реестр и imagePullSecrets
Локально ты привык: docker build, docker run - образ уже на машине, рантайм берет его из локального стора. В кластере так не выйдет: под может приехать на любую из десятков нод, и собранного локально образа там нет. Поэтому канонический поток - через реестр:
- docker build -t registry.example.ru/team/api:1.4.2 .
- docker push registry.example.ru/team/api:1.4.2
- в манифесте пода указываешь image: registry.example.ru/team/api:1.4.2
- kubelet на ноде дергает containerd, тот тянет образ из реестра и запускает
Код: Выделить всё
kubectl create secret docker-registry regcred \
--docker-server=registry.example.ru \
--docker-username=ci-bot \
--docker-password='***' \
--docker-email=ci@example.ru
Код: Выделить всё
# в Pod/Deployment
spec:
imagePullSecrets:
- name: regcred
containers:
- name: api
image: registry.example.ru/team/api:1.4.2
Отдельная грабля - imagePullPolicy. Если тег :latest, политика по умолчанию Always - kubelet лезет в реестр на каждый старт пода. Для фиксированного тега вроде :1.4.2 дефолт - IfNotPresent (есть локально - не качает). В продакшене не используй :latest: непредсказуемо, какой именно образ поднимется, и откат превращается в гадание. Тегируй по версии или по git sha.
compose в kubernetes: kompose и ручное переписывание
Самый частый запрос на этом мосту - как затащить compose в kubernetes. У тебя есть рабочий compose.yaml, и хочется не переписывать сотню строк руками. Тут на сцену выходит kompose - официальный инструмент проекта Kubernetes, который конвертирует Compose в манифесты.
Код: Выделить всё
# актуальная версия на 2026 - v1.38
kompose version
kompose convert -f compose.yaml
# INFO Kubernetes file "api-service.yaml" created
# INFO Kubernetes file "api-deployment.yaml" created
# INFO Kubernetes file "db-deployment.yaml" created
# INFO Kubernetes file "db-persistentvolumeclaim.yaml" created
- каждый service -> Deployment (контроллер, который держит N реплик пода живыми)
- ports/expose -> Service (стабильный внутрикластерный адрес и балансировка на поды)
- volumes (именованный том) -> PersistentVolumeClaim (PVC, заявка на постоянный диск)
- environment -> env прямо в контейнере, а через аннотации можно вытащить в ConfigMap
- restart: always -> дефолтное поведение Deployment, отдельной настройки не требует
Теперь почему стоит хотя бы раз переписать руками, а не только гонять конвертер. Несколько мест, где Compose и Kubernetes мыслят по-разному:
- depends_on. В Compose это "запусти db раньше api". В Kubernetes порядка запуска нет в принципе - поды поднимаются параллельно и переезжают независимо. Поэтому depends_on не конвертируется один-в-один. Правильный путь: readiness-проба на api, чтобы трафик не шел, пока зависимости не готовы, и при необходимости initContainer, который блокирует старт, пока, скажем, не отвечает порт БД. Приложение должно само переживать недоступность зависимости и ретраить - это и есть смена мышления.
- volumes. Бинд-маунт хоста (./src:/app) в кластере смысла не имеет: нет "хоста", под живет на случайной ноде. Именованные тома становятся PVC, а PVC требует динамического провижининга через StorageClass. Конфиги едут не томом, а через ConfigMap/Secret, смонтированные как файлы.
- сеть. В Compose сервисы видят друг друга по имени в общей сети. В k8s роль DNS-имени играет Service: обращение к http://db:5432 работает, только если есть Service с именем db. kompose их создает, но проверь, что имена сошлись.
Код: Выделить всё
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
replicas: 2
selector:
matchLabels: { app: api }
template:
metadata:
labels: { app: api }
spec:
containers:
- name: api
image: registry.example.ru/team/api:1.4.2
ports: [{ containerPort: 8080 }]
readinessProbe:
httpGet: { path: /health, port: 8080 }
initialDelaySeconds: 3
---
apiVersion: v1
kind: Service
metadata:
name: api
spec:
selector: { app: api }
ports: [{ port: 80, targetPort: 8080 }]
Локальная разработка: kind и minikube тянут локальные образы
Раз образы в кластер обычно едут через реестр, встает вопрос: как локально гонять только что собранный образ, не пушая его в интернет на каждый чих? Для этого есть kind (Kubernetes in Docker - кластер из контейнеров) и minikube. Оба умеют забирать образ прямо из твоего локального Docker.
Код: Выделить всё
# kind: грузим локально собранный образ в ноды кластера
docker build -t api:dev .
kind load docker-image api:dev
# minikube: аналогично
minikube image load api:dev
Код: Выделить всё
containers:
- name: api
image: api:dev
imagePullPolicy: Never # бери только локальный, в реестр не ходи
Что меняется в мышлении
Compose - это "запусти вот эти контейнеры на этой машине". Kubernetes - это "поддерживай вот такое желаемое состояние в кластере, а как и где - твоя забота". Поды эфемерны: их убивают, пересоздают, переносят на другие ноды без спроса. Поэтому никаких локальных файлов на хосте, никакого "контейнер с таким IP", никакого порядка запуска. Состояние - только в PVC и внешних хранилищах. Связь - только через Service по DNS-имени. Готовность - только через пробы, а не через depends_on. Когда это уложится в голове, перенос compose в kubernetes перестает быть переводом строчка-в-строчку и становится переосмыслением: ты описываешь не процесс запуска, а инвариант, который кластер обязан держать.
Типичные грабли и антипаттерны
- "Надо срочно переезжать с Docker, его выпилили". Нет. Из kubelet убрали dockershim. Образы и docker build живут.
- Тег :latest в манифестах. Непредсказуемые деплои и невозможность нормального отката. Тегируй по версии/sha.
- Слепое доверие kompose. Сгенерил, kubectl apply, "работает" - а секреты в открытом env, проб нет, лимитов нет. Это не прод, а демо.
- Перенос бинд-маунтов хоста в кластер. На случайной ноде твоего ./src нет. Используй ConfigMap/PVC.
- Расчет на depends_on. Порядка старта в k8s нет. Делай ретраи в приложении и readiness-пробы.
- Локальный образ без imagePullPolicy: Never (или с тегом :latest) в kind/minikube. ErrImagePull на ровном месте.
- Возьми любой свой compose.yaml с двумя сервисами (например api + db с томом). Прогони kompose convert -f compose.yaml и изучи, во что превратился каждый service, ports и volume.
- Найди в выводе, где остались переменные окружения с секретами, и мысленно (или руками) вынеси их в объект Secret.
- Собери образ: docker build -t api:dev . Подними kind-кластер, выполни kind load docker-image api:dev.
- Напиши минимальный Deployment+Service для api с imagePullPolicy: Never, примени kubectl apply -f, проверь kubectl get pods и kubectl logs. Поймай ErrImagePull, если забыл про политику, и почини.
- Сравни: что в compose занимало 4 строки, в k8s заняло два объекта. Выпиши для себя, что именно k8s сделал явным.
- Что конкретно удалили в Kubernetes 1.24 и почему это не сломало образы, собранные через docker build?
- Опиши путь образа из docker build на ноутбуке до запущенного пода в кластере. Где тут imagePullSecrets и зачем?
- Почему depends_on из Compose нельзя перенести в k8s один-в-один и чем его заменяют?
- Ты загрузил локальный образ в minikube, но получаешь ErrImagePull. Две причины и как починить.
Связка docker kubernetes - это не конфликт, а разделение труда: Docker (и buildx) собирает OCI-образы, containerd в кластере их запускает, dockershim как лишнюю прослойку убрали - вот и весь сыр-бор. Образы переносятся без правок, а реальная работа при миграции - не образы, а переписывание compose в манифесты: service в Deployment+Service, volumes в PVC, depends_on в пробы и ретраи. kompose снимет 70-80 процентов рутины, остальное - руками и головой. Дальше - отдельный курс Kubernetes, где разберем поды, контроллеры, сеть и хранилища вглубь. Этот урок - мост: ты уже понимаешь, как твой контейнерный мир стыкуется с оркестрацией.