Главная/Docker и Kubernetes/Урок

18.1. Какие задачи решают Docker и Kubernetes

Цели

После этого материала вы сможете:

  • сформулировать задачу каждого инструмента одним предложением и не путать их;
  • объяснить, почему отказ от dockershim не затронул образы;
  • назвать, что именно связывает Docker и Kubernetes, и проверить это на своём образе;
  • объяснить, что такое CRI и почему он появился;
  • назвать container runtime, применяемые в Kubernetes, и их отношение к Docker;
  • определить, какие ваши навыки переносятся в Kubernetes без изменений.

Предварительные знания

Ключевые термины

ТерминОбъяснение
OCIНабор стандартов: формат образа, среда выполнения, распространение
CRIИнтерфейс Kubernetes к среде выполнения container'ов
dockershimСлой совместимости Kubernetes с Docker, удалённый в 1.24
kubeletАгент Kubernetes на каждом узле
декларативностьОписывается желаемое состояние, а не действия

Теория

Две разные задачи

DockerKubernetes
ЗадачаСобрать образ и запустить container на машинеПоддерживать заданное состояние на кластере
ЕдиницаContainerPod
МодельИмперативная: выполни командуДекларативная: приведи к состоянию
Что происходит при сбоеContainer останавливаетсяСостояние восстанавливается
ОбластьОдна машинаМножество машин

Формулировка «Kubernetes заменяет Docker» неточна, потому что сравнивает инструменты разного уровня.

Точнее так: Docker решает задачу упаковки и запуска; Kubernetes решает задачу поддержания состояния. Второе требует первого, но не отменяет его.

Императивная и декларативная модель

text
Императивно (Docker)               Декларативно (Kubernetes)
──────────────────────             ─────────────────────────
docker run -d --name web ...       apiVersion: apps/v1
                                   kind: Deployment
выполняется один раз               spec:
                                     replicas: 3
если упадёт — останется лежать
                                   система непрерывно приводит
                                   фактическое состояние
                                   к описанному

Практическое различие: docker run --restart unless-stopped перезапускает container при завершении процесса. Kubernetes восстанавливает состояние при чём угодно: узел выключился, pod удалён, реплик стало меньше заданного.

Это не «лучше» — это другой класс задач и другая цена (урок 18.4).

Что связывает: OCI

text
        ┌─────────────────────────────────┐
        │        стандарты OCI            │
        ├─────────────────────────────────┤
        │ Image Specification             │  формат образа
        │ Runtime Specification           │  bundle и config.json
        │ Distribution Specification      │  протокол registry
        └─────────────────────────────────┘
              ▲                    ▲
              │                    │
        docker build          Kubernetes
        docker push           kubelet → CRI → containerd → runc

Образ, собранный docker build, соответствует OCI Image Specification (урок 14.1). Kubernetes скачивает его по Distribution Specification и запускает через Runtime Specification (урок 17.2).

Ни на одном шаге Docker как программа не участвует.

Отсюда главный практический вывод раздела: образы переносятся без изменений. Dockerfile, слои, теги, digest, multi-stage сборка, сканирование, SBOM — всё это работает одинаково.

Что такое CRI и зачем он появился

Kubelet должен запускать container'ы. Ранние версии умели говорить только с Docker — напрямую, специальным кодом внутри kubelet.

Это создавало две проблемы: поддержка других сред выполнения требовала кода в самом Kubernetes, а Docker при этом решал задачи, kubelet не нужные.

Container Runtime Interface — интерфейс gRPC, который kubelet использует вместо прямых вызовов:

text
kubelet ──CRI──► среда выполнения ──► containerd/runc ──► процесс

Любая среда, реализующая CRI, работает с Kubernetes. Код kubelet при этом один для всех.

dockershim: что произошло

Docker Engine не реализует CRI — он появился раньше и говорит на своём API (урок 17.1).

Для совместимости в kubelet держали слой перевода — dockershim:

text
До 1.24:  kubelet → dockershim → Docker Engine → containerd → runc
После:    kubelet → CRI ────────────────────────► containerd → runc

Из цепочки убрали два звена. Docker Engine перестал быть частью пути.

Что изменилосьЧто не изменилось
Kubelet не общается с Docker EngineФормат образов
На узлах Kubernetes Docker не нуженdocker build и docker push
docker ps не покажет pod'ы кластераRegistry и протокол
Диагностика на узлах — через crictlВаши навыки и Dockerfile

Формулировка «Kubernetes отказался от Docker» породила недоразумение: многие решили, что образы docker build перестанут работать.

Они работают. Убран слой совместимости внутри kubelet, а не поддержка формата образов. Формат стандартизован OCI, и его никто не менял.

Среды выполнения в Kubernetes

СредаОсобенностьОтношение к Docker
containerdНаиболее распространённаяТот же, что внутри Docker
CRI-OСоздана специально для KubernetesНезависимая реализация
Docker Engine (через cri-dockerd)Внешний адаптер вместо dockershimТребует установки

Первая строка снимает большую часть недоумения: containerd, работающий на узлах Kubernetes, — тот же самый компонент, который Docker использует у вас на машине.

Разница в том, кто им управляет: dockerd или kubelet через CRI.

Что переносится из ваших навыков

НавыкПереносится
Dockerfile, multi-stage, кэш слоёвПолностью
Размер образа, выбор базовогоПолностью
Пользователь, capabilities, read-onlyПолностью, с другим синтаксисом
Теги, digest, стратегия публикацииПолностью
HealthchecksПонятийно; в Kubernetes три вида проб
Лимиты ресурсовПонятийно; добавляются requests
Логи в stdoutПолностью
Compose-файлыНе переносятся: другая модель
docker run и его флагиНе переносятся напрямую
Сети DockerНе переносятся: другая модель

Восемь пунктов из десяти переносятся. Это и есть ответ на вопрос «не зря ли я учил Docker, если у нас Kubernetes».


Внутренний механизм

Почему образ работает и там и там

Образ — это набор слоёв (архивы tar) и конфигурация (JSON), связанные манифестом. Формат описан OCI Image Specification.

Kubelet через CRI просит среду выполнения скачать образ по имени. Среда обращается к registry по Distribution Specification — тому же протоколу, что использует docker pull (урок 14.1).

Затем среда разворачивает слои и формирует bundle по Runtime Specification, который исполняет runc — тот же, что в Docker (урок 17.2).

Ни одного шага, где формат образа зависел бы от Docker.

Почему docker ps не видит pod'ы

На узле Kubernetes с containerd container'ы создаёт kubelet через CRI. Они находятся в пространстве имён containerd k8s.io, а не moby (урок 17.2).

docker ps обращается к dockerd, который знает только о своём пространстве moby. Container'ы Kubernetes ему не видны — даже если Docker установлен.

Диагностика на узле выполняется через crictl — клиент CRI:

bash
crictl ps
crictl images
crictl logs <id>

Команды намеренно похожи на docker, но обращаются к другому интерфейсу.


Команды и примеры

Образ соответствует стандарту

bash
mkdir -p /tmp/k8s && cd /tmp/k8s
mkdir -p src

cat > src/app.py <<'PY'
"""Приложение, пригодное и для Docker, и для Kubernetes."""
from __future__ import annotations

import http.server
import json
import os
import signal
import sys
import threading

PORT = int(os.environ.get("PORT", "8000"))
_ready = threading.Event()
_stop = threading.Event()


class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self) -> None:
        if self.path == "/healthz":
            code, body = 200, {"состояние": "жив"}
        elif self.path == "/readyz":
            ok = _ready.is_set()
            code = 200 if ok else 503
            body = {"состояние": "готов" if ok else "не готов"}
        else:
            code, body = 200, {"сервис": "работает", "uid": os.getuid()}
        payload = json.dumps(body, ensure_ascii=False).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)

    def log_message(self, *args: object) -> None:
        pass


def _on_term(signum: int, _frame: object) -> None:
    print(f"получен сигнал {signum}: закрываем готовность, затем завершаемся",
          flush=True)
    _ready.clear()
    _stop.set()


if __name__ == "__main__":
    signal.signal(signal.SIGTERM, _on_term)
    signal.signal(signal.SIGINT, _on_term)
    server = http.server.ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    _ready.set()
    print(f"готов на порту {PORT}", flush=True)
    _stop.wait()
    server.shutdown()
    sys.exit(0)
PY

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS production
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 PORT=8000
WORKDIR /app
COPY src/ ./src/
RUN useradd --create-home --uid 10001 app && chown -R app:app /app
USER app
EXPOSE 8000
HEALTHCHECK --interval=5s --timeout=3s --retries=3 --start-period=3s \
    CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz')"]
ENTRYPOINT ["python", "-m", "src.app"]
EOF

echo "═══ собираем образ ═══"
docker build -q -t k8s-ready:1 . > /dev/null
echo "  собран"

echo "═══ формат образа ═══"
docker inspect k8s-ready:1 --format '{{.Os}}/{{.Architecture}}' | sed 's/^/  платформа: /'
docker save k8s-ready:1 -o img.tar 2>/dev/null
mkdir -p unpacked && tar -xf img.tar -C unpacked 2>/dev/null
python3 - <<'PY'
import json
from pathlib import Path

root = Path("unpacked")
index = root / "index.json"
if index.exists():
    data = json.loads(index.read_text())
    print(f"  index.json: schemaVersion={data.get('schemaVersion')}")
    for m in data.get("manifests", [])[:2]:
        print(f"    mediaType: {m.get('mediaType')}")
    print("  формат OCI Image Layout")
else:
    manifest = root / "manifest.json"
    if manifest.exists():
        print("  формат Docker Image (совместим с OCI)")
    print("  index.json отсутствует — классический формат save")

blobs = list((root / "blobs").rglob("*")) if (root / "blobs").exists() else []
print(f"  blob-файлов: {len([b for b in blobs if b.is_file()])}")
PY
rm -rf unpacked img.tar

echo "═══ что это значит ═══"
cat <<'TXT'
  Образ описан стандартом OCI Image Specification.
  Kubernetes скачивает его по Distribution Specification —
  тому же протоколу, что docker pull.

  Затем разворачивает слои и формирует bundle
  по Runtime Specification, который исполняет runc.

  Ни на одном шаге Docker как программа не участвует.
  Отсюда: образы переносятся БЕЗ ИЗМЕНЕНИЙ.
TXT

Ожидаемый вывод:

text
═══ собираем образ ═══
  собран
═══ формат образа ═══
  платформа: linux/amd64
  index.json: schemaVersion=2
    mediaType: application/vnd.oci.image.manifest.v1+json
  формат OCI Image Layout
  blob-файлов: 5
═══ что это значит ═══
  Образ описан стандартом OCI Image Specification.
  Kubernetes скачивает его по Distribution Specification —
  тому же протоколу, что docker pull.
  ...

mediaType: application/vnd.oci.image.manifest.v1+json — это и есть стандарт OCI. Не «формат Docker», а общий формат.

Что убрал dockershim

bash
cd /tmp/k8s
cat > dockershim.py <<'PY'
"""Что изменилось при удалении dockershim в Kubernetes 1.24."""
from __future__ import annotations

CHAINS = {
    "До 1.24 (с dockershim)": [
        "kubelet", "dockershim", "Docker Engine", "containerd", "shim", "runc",
    ],
    "После 1.24 (containerd напрямую)": [
        "kubelet", "CRI", "containerd", "shim", "runc",
    ],
    "После 1.24 (CRI-O)": [
        "kubelet", "CRI", "CRI-O", "runc",
    ],
    "У вас на машине (Docker)": [
        "docker CLI", "dockerd", "containerd", "shim", "runc",
    ],
}

CHANGED = [
    ("kubelet общается с Docker Engine", "больше нет"),
    ("Docker нужен на узлах кластера", "не нужен"),
    ("docker ps показывает pod'ы", "не показывает"),
    ("Диагностика на узле", "через crictl вместо docker"),
]

UNCHANGED = [
    ("Формат образов", "OCI Image Specification — не менялся"),
    ("docker build", "работает; образ пригоден для Kubernetes"),
    ("docker push и registry", "Distribution Specification — тот же"),
    ("Dockerfile", "не изменился ни на строку"),
    ("Слои, кэш, multi-stage", "механизм тот же"),
    ("Теги и digest", "те же"),
    ("runc", "тот же исполнитель"),
]


def main() -> None:
    print("  Цепочки выполнения:\n")
    for name, chain in CHAINS.items():
        print(f"    {name}")
        print(f"      {' → '.join(chain)}")
        print(f"      звеньев: {len(chain)}")
        print()

    before = len(CHAINS["До 1.24 (с dockershim)"])
    after = len(CHAINS["После 1.24 (containerd напрямую)"])
    print(f"  Убрано звеньев: {before - after}")
    print()

    print(f"  {'что ИЗМЕНИЛОСЬ':<42} стало")
    print("  " + "─" * 78)
    for what, now in CHANGED:
        print(f"  {what:<42} {now}")

    print()
    print(f"  {'что НЕ изменилось':<42} почему")
    print("  " + "─" * 78)
    for what, why in UNCHANGED:
        print(f"  {what:<42} {why}")

    print()
    print("  Формулировка «Kubernetes отказался от Docker» породила")
    print("  недоразумение: многие решили, что образы docker build")
    print("  перестанут работать. Они работают — убран слой")
    print("  совместимости внутри kubelet, а не поддержка формата.")


if __name__ == "__main__":
    main()
PY

echo "═══ что убрал dockershim ═══"
python3 dockershim.py

Ожидаемый вывод:

text
═══ что убрал dockershim ═══
  Цепочки выполнения:

    До 1.24 (с dockershim)
      kubelet → dockershim → Docker Engine → containerd → shim → runc
      звеньев: 6

    После 1.24 (containerd напрямую)
      kubelet → CRI → containerd → shim → runc
      звеньев: 5

    После 1.24 (CRI-O)
      kubelet → CRI → CRI-O → runc
      звеньев: 4

    У вас на машине (Docker)
      docker CLI → dockerd → containerd → shim → runc
      звеньев: 5

  Убрано звеньев: 1

  что ИЗМЕНИЛОСЬ                             стало
  ──────────────────────────────────────────────────────────────────────────────
  kubelet общается с Docker Engine           больше нет
  Docker нужен на узлах кластера             не нужен
  docker ps показывает pod'ы                 не показывает
  Диагностика на узле                        через crictl вместо docker

  что НЕ изменилось                          почему
  ──────────────────────────────────────────────────────────────────────────────
  Формат образов                             OCI Image Specification — не менялся
  docker build                               работает; образ пригоден для Kubernetes
  docker push и registry                     Distribution Specification — тот же
  Dockerfile                                 не изменился ни на строку
  Слои, кэш, multi-stage                     механизм тот же
  Теги и digest                              те же
  runc                                       тот же исполнитель

  Формулировка «Kubernetes отказался от Docker» породила
  недоразумение: многие решили, что образы docker build
  перестанут работать. Они работают — убран слой
  совместимости внутри kubelet, а не поддержка формата.

Последняя цепочка показательна: у вас на машине containerd → shim → runcте же три звена, что на узле Kubernetes.

Различается только то, кто ими управляет.

Императивная и декларативная модель

bash
cd /tmp/k8s
cat > models.py <<'PY'
"""Сравнение императивной и декларативной модели на сценариях отказа."""
from __future__ import annotations

SCENARIOS = [
    {
        "событие": "Процесс завершился с ошибкой",
        "docker": "restart policy перезапустит",
        "k8s": "перезапустит container в pod'е",
        "различие": "нет",
    },
    {
        "событие": "Container удалён командой",
        "docker": "останется удалённым",
        "k8s": "pod будет создан заново",
        "различие": "ЕСТЬ",
    },
    {
        "событие": "Узел выключился",
        "docker": "container не работает",
        "k8s": "pod'ы переедут на другой узел",
        "различие": "ЕСТЬ",
    },
    {
        "событие": "Нужно 3 экземпляра, работает 2",
        "docker": "не отслеживается",
        "k8s": "создаст третий",
        "различие": "ЕСТЬ",
    },
    {
        "событие": "Readiness-проба не проходит",
        "docker": "статус unhealthy, и всё",
        "k8s": "трафик перестаёт идти на этот pod",
        "различие": "ЕСТЬ",
    },
    {
        "событие": "Обновление версии образа",
        "docker": "остановить и запустить заново",
        "k8s": "постепенная замена с контролем готовности",
        "различие": "ЕСТЬ",
    },
    {
        "событие": "Приложение вышло за лимит памяти",
        "docker": "OOM kill, перезапуск по политике",
        "k8s": "OOM kill, перезапуск, учёт в событиях",
        "различие": "нет",
    },
]


def main() -> None:
    print(f"  {'событие':<38} {'Docker':<34} {'Kubernetes':<38}")
    print("  " + "─" * 112)
    differ = 0
    for s in SCENARIOS:
        if s["различие"] == "ЕСТЬ":
            differ += 1
        print(f"  {s['событие']:<38} {s['docker']:<34} {s['k8s']:<38}")

    print()
    print(f"  различаются: {differ} из {len(SCENARIOS)}")
    print()
    print("  Общее у совпадающих строк: они про ОДНУ машину и ОДИН container.")
    print("  Различаются те, где нужно ПОДДЕРЖИВАТЬ состояние:")
    print("  число экземпляров, размещение по узлам, направление трафика.")
    print()
    print("  Это и есть граница: Docker запускает, Kubernetes поддерживает.")
    print("  Второе требует первого и не отменяет его.")


if __name__ == "__main__":
    main()
PY

echo "═══ где модели различаются ═══"
python3 models.py

Ожидаемый вывод:

text
═══ где модели различаются ═══
  событие                                Docker                             Kubernetes                            
  ────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  Процесс завершился с ошибкой           restart policy перезапустит        перезапустит container в pod'е        
  Container удалён командой              останется удалённым                pod будет создан заново               
  Узел выключился                        container не работает              pod'ы переедут на другой узел         
  Нужно 3 экземпляра, работает 2         не отслеживается                   создаст третий                        
  Readiness-проба не проходит            статус unhealthy, и всё            трафик перестаёт идти на этот pod     
  Обновление версии образа               остановить и запустить заново      постепенная замена с контролем готовности
  Приложение вышло за лимит памяти       OOM kill, перезапуск по политике   OOM kill, перезапуск, учёт в событиях 

  различаются: 5 из 7

  Общее у совпадающих строк: они про ОДНУ машину и ОДИН container.
  Различаются те, где нужно ПОДДЕРЖИВАТЬ состояние:
  число экземпляров, размещение по узлам, направление трафика.

  Это и есть граница: Docker запускает, Kubernetes поддерживает.
  Второе требует первого и не отменяет его.

Пятая строка — самая практичная. В Docker unhealthy меняет статус и не более того (урок 11.3); в Kubernetes readiness-проба управляет трафиком.

Это и объясняет, почему healthcheck в Compose полезен только там, где на него кто-то опирается.

Что переносится из навыков

bash
cd /tmp/k8s
cat > skills.py <<'PY'
"""Какие навыки работы с Docker переносятся в Kubernetes."""
from __future__ import annotations

import json

SKILLS = [
    ("Dockerfile: инструкции, порядок, кэш", "полностью", "формат тот же"),
    ("Multi-stage сборка", "полностью", "механизм сборщика, не среды выполнения"),
    ("Размер образа, выбор базового", "полностью", "те же соображения"),
    ("USER, capabilities, read-only", "полностью", "другой синтаксис: securityContext"),
    ("Теги и digest, стратегия публикации", "полностью", "тот же registry"),
    ("Логи в stdout", "полностью", "kubectl logs читает так же"),
    ("Обработка SIGTERM", "полностью", "Kubernetes шлёт тот же сигнал"),
    ("Сканирование, SBOM, provenance", "полностью", "работа с образом"),
    ("Healthcheck", "понятийно", "в Kubernetes три пробы вместо одной"),
    ("Лимиты ресурсов", "понятийно", "добавляются requests для планирования"),
    ("Compose-файлы", "НЕ переносятся", "другая модель объектов"),
    ("docker run и его флаги", "НЕ переносятся", "заменяются описанием pod'а"),
    ("Сети Docker", "НЕ переносятся", "плоская сеть кластера, другая модель"),
    ("Volumes Docker", "частично", "понятие есть, устройство иное"),
]


def main() -> None:
    print(f"  {'навык':<40} {'переносится':<16} почему")
    print("  " + "─" * 96)
    counts = {"полностью": 0, "понятийно": 0, "частично": 0, "НЕ переносятся": 0}
    for name, degree, why in SKILLS:
        counts[degree] = counts.get(degree, 0) + 1
        print(f"  {name:<40} {degree:<16} {why}")

    total = len(SKILLS)
    carried = counts["полностью"] + counts["понятийно"]
    print()
    for degree, n in counts.items():
        print(f"  {degree:<16} {n:>2} из {total}")
    print()
    print(f"  переносится полностью или понятийно: {carried} из {total} "
          f"({carried / total * 100:.0f} %)")
    print()
    print("  Не переносится то, что описывает ЗАПУСК: compose-файлы,")
    print("  флаги docker run, модель сетей. Переносится то,")
    print("  что описывает АРТЕФАКТ и его поведение.")
    print()
    print(json.dumps({"всего": total, "переносится": carried},
                     ensure_ascii=False))


if __name__ == "__main__":
    main()
PY

echo "═══ переносимость навыков ═══"
python3 skills.py

Ожидаемый вывод:

text
═══ переносимость навыков ═══
  навык                                    переносится      почему
  ────────────────────────────────────────────────────────────────────────────────────────────────
  Dockerfile: инструкции, порядок, кэш     полностью        формат тот же
  Multi-stage сборка                       полностью        механизм сборщика, не среды выполнения
  Размер образа, выбор базового            полностью        те же соображения
  USER, capabilities, read-only            полностью        другой синтаксис: securityContext
  Теги и digest, стратегия публикации      полностью        тот же registry
  Логи в stdout                            полностью        kubectl logs читает так же
  Обработка SIGTERM                        полностью        Kubernetes шлёт тот же сигнал
  Сканирование, SBOM, provenance           полностью        работа с образом
  Healthcheck                              понятийно        в Kubernetes три пробы вместо одной
  Лимиты ресурсов                          понятийно        добавляются requests для планирования
  Compose-файлы                            НЕ переносятся   другая модель объектов
  docker run и его флаги                   НЕ переносятся   заменяются описанием pod'а
  Сети Docker                              НЕ переносятся   плоская сеть кластера, другая модель
  Volumes Docker                           частично         понятие есть, устройство иное

  полностью         8 из 14
  понятийно         2 из 14
  частично          1 из 14
  НЕ переносятся    3 из 14

  переносится полностью или понятийно: 10 из 14 (71 %)

  Не переносится то, что описывает ЗАПУСК: compose-файлы,
  флаги docker run, модель сетей. Переносится то,
  что описывает АРТЕФАКТ и его поведение.

Проверка образа на пригодность

bash
cd /tmp/k8s
cat > k8s-readiness.sh <<'SH'
#!/usr/bin/env bash
# Проверка образа на пригодность для Kubernetes.
#
# Все проверки выполняются средствами Docker: кластер не нужен.
set -uo pipefail

IMAGE="${1:?укажите образ}"
NAME="k8sready-$$"
issues=0
pass() { printf '  ✓ %s\n' "$1"; }
warn() { printf '  ⚠ %s\n' "$1"; issues=$((issues + 1)); }

cleanup() { docker rm -f "$NAME" > /dev/null 2>&1; }
trap cleanup EXIT INT TERM

printf '\n  Пригодность для Kubernetes: %s\n\n' "$IMAGE"

# 1. Формат образа
os_arch="$(docker inspect "$IMAGE" --format '{{.Os}}/{{.Architecture}}' 2>/dev/null)"
pass "платформа: $os_arch (OCI Image Specification)"

# 2. Пользователь: в Kubernetes задаётся securityContext, но образ должен позволять
user="$(docker inspect "$IMAGE" --format '{{.Config.User}}')"
case "$user" in
    ""|0|root) warn "USER не задан — потребуется runAsUser в securityContext" ;;
    *)         pass "USER: $user — совместимо с runAsNonRoot" ;;
esac

# 3. ENTRYPOINT в exec form: Kubernetes шлёт SIGTERM напрямую процессу
form="$(docker inspect "$IMAGE" --format '{{json .Config.Entrypoint}}' | python3 -c "
import json, sys
v = json.load(sys.stdin) or []
shells = {'/bin/sh', 'sh', '/bin/bash', 'bash'}
print('shell' if len(v) >= 2 and v[0] in shells and v[1] == '-c'
      else ('exec' if v else 'none'))
")"
[ "$form" = "shell" ] \
    && warn "ENTRYPOINT в shell form — SIGTERM не дойдёт при завершении pod'а" \
    || pass "ENTRYPOINT в $form form"

# 4. Логи в stdout
pass "логи: проверяются запуском ниже"

# 5. Эндпоинты проб
docker run -d --name "$NAME" -p 0:8000 "$IMAGE" > /dev/null 2>&1 || {
    warn "container не запустился"; printf '\n  замечаний: %s\n\n' "$issues"; exit 1; }
sleep 4

port="$(docker port "$NAME" 8000/tcp 2>/dev/null | head -1 | sed 's/.*://')"
if [ -n "$port" ]; then
    for path in /healthz /readyz; do
        code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \
            "http://127.0.0.1:$port$path" 2>/dev/null)"
        case "$code" in
            200) pass "эндпоинт $path отвечает 200 — годится для пробы" ;;
            "")  warn "эндпоинт $path недоступен" ;;
            *)   warn "эндпоинт $path вернул $code" ;;
        esac
    done
else
    warn "порт не опубликован"
fi

# 6. Логи попали в stdout
n_lines="$(docker logs "$NAME" 2>&1 | grep -c . || echo 0)"
[ "${n_lines:-0}" -gt 0 ] \
    && pass "логи идут в stdout: $n_lines строк" \
    || warn "логов в stdout нет — kubectl logs будет пуст"

# 7. Обработка SIGTERM
t0="$(python3 -c 'import time; print(time.monotonic())')"
docker stop -t 10 "$NAME" > /dev/null 2>&1
t1="$(python3 -c 'import time; print(time.monotonic())')"
secs="$(python3 -c "print(f'{$t1 - $t0:.1f}')")"
code="$(docker inspect "$NAME" --format '{{.State.ExitCode}}' 2>/dev/null)"
python3 -c "import sys; sys.exit(0 if $secs < 3 else 1)" \
    && pass "SIGTERM обработан за $secs с (код $code)" \
    || warn "SIGTERM не обработан: $secs с — pod будет убит по таймауту"

printf '\n  замечаний: %s\n' "$issues"
printf '  проверки выполнены средствами Docker; кластер не требовался\n\n'
[ "$issues" -gt 0 ] && exit 1
exit 0
SH
chmod +x k8s-readiness.sh

echo "═══ проверка пригодного образа ═══"
./k8s-readiness.sh k8s-ready:1 || true

echo "═══ проверка непригодного ═══"
cat > Dockerfile.bad <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
ENV PORT=8000
WORKDIR /app
COPY src/ ./src/
ENTRYPOINT python -m src.app
EOF
docker build -q -f Dockerfile.bad -t k8s-bad:1 . > /dev/null 2>&1
./k8s-readiness.sh k8s-bad:1 2>&1 | tail -8 || true

docker rmi -f k8s-ready:1 k8s-bad:1 > /dev/null 2>&1
cd /tmp && rm -rf /tmp/k8s

Ожидаемый вывод:

text
═══ проверка пригодного образа ═══

  Пригодность для Kubernetes: k8s-ready:1

  ✓ платформа: linux/amd64 (OCI Image Specification)
  ✓ USER: app — совместимо с runAsNonRoot
  ✓ ENTRYPOINT в exec form
  ✓ логи: проверяются запуском ниже
  ✓ эндпоинт /healthz отвечает 200 — годится для пробы
  ✓ эндпоинт /readyz отвечает 200 — годится для пробы
  ✓ логи идут в stdout: 1 строк
  ✓ SIGTERM обработан за 0.3 с (код 0)

  замечаний: 0
  проверки выполнены средствами Docker; кластер не требовался

═══ проверка непригодного ═══
  ⚠ USER не задан — потребуется runAsUser в securityContext
  ⚠ ENTRYPOINT в shell form — SIGTERM не дойдёт при завершении pod'а
  ✓ эндпоинт /healthz отвечает 200 — годится для пробы
  ✓ эндпоинт /readyz отвечает 200 — годится для пробы
  ✓ логи идут в stdout: 1 строк
  ⚠ SIGTERM не обработан: 10.2 с — pod будет убит по таймауту

  замечаний: 3

Все проверки выполнены средствами Docker — кластер не понадобился.

Строка про SIGTERM важна для Kubernetes даже сильнее, чем для Docker: при завершении pod'а сигнал шлётся процессу, и необработанный SIGTERM означает жёсткое убийство по истечении terminationGracePeriodSeconds.


Практическое упражнение

Задание. Проверьте образ на пригодность для Kubernetes, не имея кластера.

Требования:

  1. Показать, что образ соответствует OCI Image Specification.
  2. Перечислить, что изменилось при удалении dockershim, и что не изменилось.
  3. Показать цепочки выполнения до и после, посчитав звенья.
  4. Составить перечень навыков с указанием степени переносимости.
  5. Написать проверку пригодности образа и испытать её на непригодном.
  6. Объяснить, почему docker ps не покажет pod'ы на узле Kubernetes.

Подсказки

Подсказка 1

Формат образа виден в index.json после распаковки docker save: mediaType содержит vnd.oci.

Подсказка 2

Пригодность проверяется тем же, чем валидация образа в уроке 15.5, плюс эндпоинты проб.

Подсказка 3

Kubernetes создаёт container'ы в пространстве имён containerd k8s.io, а dockerd знает только moby.

Решение

Показать решение
bash
mkdir -p /tmp/k8slab && cd /tmp/k8slab
mkdir -p src

cat > src/app.py <<'PY'
"""Сервис с эндпоинтами для проб Kubernetes."""
from __future__ import annotations

import http.server
import json
import os
import signal
import sys
import threading
import time

PORT = int(os.environ.get("PORT", "8000"))
START_DELAY = float(os.environ.get("START_DELAY", "0"))
_ready = threading.Event()
_stop = threading.Event()
_started_at = time.monotonic()


class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self) -> None:
        if self.path == "/healthz":
            # liveness: процесс жив и способен отвечать
            code, body = 200, {"состояние": "жив",
                               "uptime": round(time.monotonic() - _started_at, 1)}
        elif self.path == "/readyz":
            # readiness: готов принимать трафик
            ok = _ready.is_set()
            code = 200 if ok else 503
            body = {"состояние": "готов" if ok else "не готов"}
        elif self.path == "/startupz":
            # startup: инициализация завершена
            ok = time.monotonic() - _started_at >= START_DELAY
            code = 200 if ok else 503
            body = {"инициализация": "завершена" if ok else "идёт"}
        else:
            code, body = 200, {"сервис": "работает", "uid": os.getuid(),
                               "pid": os.getpid()}
        payload = json.dumps(body, ensure_ascii=False).encode()
        self.send_response(code)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)

    def log_message(self, *args: object) -> None:
        pass


def _on_term(signum: int, _frame: object) -> None:
    # Порядок важен: сначала закрыть готовность, потом завершаться.
    # В Kubernetes это даёт время убрать pod из балансировки.
    print(f"сигнал {signum}: закрываю готовность", flush=True)
    _ready.clear()
    time.sleep(0.2)
    print("завершаюсь", flush=True)
    _stop.set()


def main() -> int:
    signal.signal(signal.SIGTERM, _on_term)
    signal.signal(signal.SIGINT, _on_term)
    server = http.server.ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
    threading.Thread(target=server.serve_forever, daemon=True).start()
    if START_DELAY:
        time.sleep(START_DELAY)
    _ready.set()
    print(f"готов на порту {PORT}, uid={os.getuid()}", flush=True)
    _stop.wait()
    server.shutdown()
    return 0


if __name__ == "__main__":
    sys.exit(main())
PY

cat > Dockerfile <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS production
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 PORT=8000
WORKDIR /app
COPY src/ ./src/
RUN useradd --create-home --uid 10001 app && chown -R app:app /app
USER app
EXPOSE 8000
HEALTHCHECK --interval=5s --timeout=3s --retries=3 --start-period=3s \
    CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/healthz')"]
ENTRYPOINT ["python", "-m", "src.app"]
EOF

cat > Dockerfile.bad <<'EOF'
# syntax=docker/dockerfile:1
FROM python:3.13-slim
ENV PORT=8000
WORKDIR /app
COPY src/ ./src/
# Ошибка 1: USER не задан
# Ошибка 2: shell form — SIGTERM не дойдёт
# Ошибка 3: нет EXPOSE
ENTRYPOINT python -m src.app
EOF

cat > check-oci.py <<'PY'
"""Проверка соответствия образа стандарту OCI."""
from __future__ import annotations

import json
import subprocess
import sys
import tarfile
import tempfile
from pathlib import Path

OCI_MEDIA_TYPES = {
    "application/vnd.oci.image.manifest.v1+json",
    "application/vnd.oci.image.index.v1+json",
    "application/vnd.oci.image.config.v1+json",
    "application/vnd.oci.image.layer.v1.tar+gzip",
}
DOCKER_MEDIA_TYPES = {
    "application/vnd.docker.distribution.manifest.v2+json",
    "application/vnd.docker.container.image.v1+json",
}


def analyze(image: str) -> dict[str, object]:
    with tempfile.TemporaryDirectory() as tmp:
        tar_path = Path(tmp) / "image.tar"
        subprocess.run(["docker", "save", image, "-o", str(tar_path)],
                       capture_output=True, check=False)
        if not tar_path.exists():
            return {"ошибка": "docker save не удался"}

        unpacked = Path(tmp) / "unpacked"
        unpacked.mkdir()
        try:
            with tarfile.open(tar_path) as tf:
                tf.extractall(unpacked, filter="data")
        except (tarfile.TarError, TypeError):
            with tarfile.open(tar_path) as tf:
                tf.extractall(unpacked)

        result: dict[str, object] = {"формат": "неизвестен", "медиатипы": []}

        index = unpacked / "index.json"
        if index.exists():
            data = json.loads(index.read_text())
            result["формат"] = "OCI Image Layout"
            result["schemaVersion"] = data.get("schemaVersion")
            types = [m.get("mediaType") for m in data.get("manifests", [])]
            result["медиатипы"] = types
            result["oci_медиатипов"] = sum(1 for t in types if t in OCI_MEDIA_TYPES)
        elif (unpacked / "manifest.json").exists():
            result["формат"] = "Docker Image (совместим с OCI)"

        blobs = unpacked / "blobs"
        if blobs.exists():
            result["blob_файлов"] = sum(1 for p in blobs.rglob("*") if p.is_file())

        layout = unpacked / "oci-layout"
        result["oci_layout_есть"] = layout.exists()
        return result


def main(image: str) -> int:
    info = analyze(image)
    print(json.dumps(info, ensure_ascii=False, indent=2))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1] if len(sys.argv) > 1 else "k8slab:good"))
PY

cat > k8s-check.sh <<'SH'
#!/usr/bin/env bash
# Проверка пригодности образа для Kubernetes.
# Кластер не требуется: всё проверяется средствами Docker.
set -uo pipefail

IMAGE="${1:?образ}"
NAME="k8slab-check-$$"
issues=0
pass() { printf '    ✓ %s\n' "$1"; }
warn() { printf '    ⚠ %s\n' "$1"; issues=$((issues + 1)); }

cleanup() { docker rm -f "$NAME" > /dev/null 2>&1; }
trap cleanup EXIT INT TERM

printf '\n  Проверка: %s\n\n' "$IMAGE"

# Статические
user="$(docker inspect "$IMAGE" --format '{{.Config.User}}' 2>/dev/null)"
case "$user" in
    ""|0|root) warn "USER не задан — нужен runAsUser в securityContext" ;;
    *)         pass "USER: $user — совместимо с runAsNonRoot" ;;
esac

form="$(docker inspect "$IMAGE" --format '{{json .Config.Entrypoint}}' 2>/dev/null \
    | python3 -c "
import json, sys
v = json.load(sys.stdin) or []
shells = {'/bin/sh','sh','/bin/bash','bash'}
print('shell' if len(v) >= 2 and v[0] in shells and v[1] == '-c'
      else ('exec' if v else 'none'))
")"
[ "$form" = "shell" ] \
    && warn "ENTRYPOINT в shell form — SIGTERM не дойдёт до процесса" \
    || pass "ENTRYPOINT в $form form"

ports="$(docker inspect "$IMAGE" \
    --format '{{range $p, $_ := .Config.ExposedPorts}}{{$p}} {{end}}' 2>/dev/null)"
[ -n "$ports" ] && pass "EXPOSE: $ports" || warn "EXPOSE не задан — не критично, но полезно"

# Динамические
docker run -d --name "$NAME" -p 0:8000 "$IMAGE" > /dev/null 2>&1 || {
    warn "container не запустился"
    printf '\n  замечаний: %s\n\n' "$issues"
    exit 1
}
sleep 4

port="$(docker port "$NAME" 8000/tcp 2>/dev/null | head -1 | sed 's/.*://')"
if [ -n "$port" ]; then
    for path in /healthz /readyz /startupz; do
        code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 \
            "http://127.0.0.1:$port$path" 2>/dev/null)"
        case "$code" in
            200) pass "$path → 200: годится для пробы" ;;
            "")  warn "$path недоступен" ;;
            *)   warn "$path → $code" ;;
        esac
    done
else
    warn "порт 8000 не опубликован"
fi

logs="$(docker logs "$NAME" 2>&1 | grep -c . || echo 0)"
[ "${logs:-0}" -gt 0 ] \
    && pass "логи в stdout: $logs строк — kubectl logs их увидит" \
    || warn "логов в stdout нет"

t0="$(python3 -c 'import time; print(time.monotonic())')"
docker stop -t 10 "$NAME" > /dev/null 2>&1
t1="$(python3 -c 'import time; print(time.monotonic())')"
secs="$(python3 -c "print(f'{$t1 - $t0:.1f}')")"
code="$(docker inspect "$NAME" --format '{{.State.ExitCode}}' 2>/dev/null)"
if python3 -c "import sys; sys.exit(0 if $secs < 3 else 1)"; then
    pass "SIGTERM обработан за $secs с (код $code)"
else
    warn "SIGTERM не обработан: $secs с — pod убьют по terminationGracePeriodSeconds"
fi

printf '\n  замечаний: %s\n\n' "$issues"
[ "$issues" -gt 0 ] && exit 1
exit 0
SH
chmod +x k8s-check.sh

fail=0
ok()  { printf '  ✓ %s\n' "$1"; }
bad() { printf '  ✗ %s\n' "$1"; fail=1; }

printf '\n═══ Подготовка ═══\n'
docker build -q -t k8slab:good . > /dev/null 2>&1 && printf '    k8slab:good собран\n'
docker build -q -f Dockerfile.bad -t k8slab:bad . > /dev/null 2>&1 \
    && printf '    k8slab:bad собран (три внесённые ошибки)\n'

printf '\n═══ Требование 1: соответствие OCI ═══\n'
python3 check-oci.py k8slab:good > oci.json 2>&1
python3 - <<'PY'
import json
from pathlib import Path

d = json.loads(Path("oci.json").read_text())
print(f"    формат: {d.get('формат')}")
print(f"    oci-layout присутствует: {d.get('oci_layout_есть')}")
print(f"    blob-файлов: {d.get('blob_файлов', '—')}")
types = d.get("медиатипы") or []
if types:
    print("    медиатипы манифестов:")
    for t in types:
        mark = "OCI" if "vnd.oci" in t else "Docker"
        print(f"      [{mark}] {t}")
PY
fmt="$(python3 -c "
import json
from pathlib import Path
print(json.loads(Path('oci.json').read_text()).get('формат', ''))")"
printf '\n    Формат описан стандартом, а не Docker ом.\n'
printf '    Kubernetes скачивает образ по Distribution Specification\n'
printf '    и запускает по Runtime Specification — через runc.\n'
case "$fmt" in
    *OCI*) ok "образ соответствует OCI: переносится без изменений" ;;
    *) bad "формат: $fmt" ;;
esac

printf '\n═══ Требования 2-3: dockershim ═══\n'
python3 - <<'PY'
CHAINS = {
    "До 1.24 (dockershim)": ["kubelet", "dockershim", "Docker Engine",
                             "containerd", "shim", "runc"],
    "После 1.24 (containerd)": ["kubelet", "CRI", "containerd", "shim", "runc"],
    "После 1.24 (CRI-O)": ["kubelet", "CRI", "CRI-O", "runc"],
    "На вашей машине": ["docker CLI", "dockerd", "containerd", "shim", "runc"],
}
for name, chain in CHAINS.items():
    print(f"    {name} — звеньев {len(chain)}")
    print(f"      {' → '.join(chain)}")

before = len(CHAINS["До 1.24 (dockershim)"])
after = len(CHAINS["После 1.24 (containerd)"])
print(f"\n    убрано звеньев: {before - after}")

CHANGED = [
    "kubelet больше не общается с Docker Engine",
    "Docker не нужен на узлах кластера",
    "docker ps не покажет pod'ы",
    "диагностика на узле — через crictl",
]
UNCHANGED = [
    "формат образов (OCI Image Specification)",
    "docker build и docker push",
    "протокол registry (Distribution Specification)",
    "Dockerfile — ни строки не изменилось",
    "слои, кэш, multi-stage",
    "теги и digest",
    "runc — тот же исполнитель",
]
print("\n    ИЗМЕНИЛОСЬ:")
for item in CHANGED:
    print(f"      · {item}")
print("\n    НЕ ИЗМЕНИЛОСЬ:")
for item in UNCHANGED:
    print(f"      · {item}")
print(f"\n    изменений: {len(CHANGED)}, неизменного: {len(UNCHANGED)}")
print()
print("    Обратите внимание на последнюю цепочку: у вас на машине")
print("    containerd → shim → runc — ТЕ ЖЕ три звена, что на узле")
print("    Kubernetes. Различается только то, кто ими управляет.")
PY
ok "цепочки посчитаны; разделено изменившееся и неизменное"

printf '\n═══ Требование 4: переносимость навыков ═══\n'
python3 - <<'PY'
import json

SKILLS = [
    ("Dockerfile, порядок, кэш", "полностью"),
    ("Multi-stage сборка", "полностью"),
    ("Размер образа, базовый образ", "полностью"),
    ("USER, capabilities, read-only", "полностью"),
    ("Теги, digest, публикация", "полностью"),
    ("Логи в stdout", "полностью"),
    ("Обработка SIGTERM", "полностью"),
    ("Сканирование, SBOM", "полностью"),
    ("Healthcheck", "понятийно"),
    ("Лимиты ресурсов", "понятийно"),
    ("Volumes", "частично"),
    ("Compose-файлы", "НЕ переносятся"),
    ("Флаги docker run", "НЕ переносятся"),
    ("Сети Docker", "НЕ переносятся"),
]
counts: dict[str, int] = {}
print(f"    {'навык':<34} переносится")
print("    " + "─" * 54)
for name, degree in SKILLS:
    counts[degree] = counts.get(degree, 0) + 1
    print(f"    {name:<34} {degree}")

total = len(SKILLS)
carried = counts.get("полностью", 0) + counts.get("понятийно", 0)
print()
for degree, n in counts.items():
    print(f"    {degree:<16} {n:>2}")
print(f"\n    переносится полностью или понятийно: {carried} из {total} "
      f"({carried / total * 100:.0f} %)")
print()
print("    Не переносится то, что описывает ЗАПУСК.")
print("    Переносится то, что описывает АРТЕФАКТ и его поведение.")
print(json.dumps({"всего": total, "переносится": carried}, ensure_ascii=False))
PY
carried="$(python3 -c "
print(11)")"
ok "перечень составлен: большинство навыков переносится"

printf '\n═══ Требование 5: проверка пригодности ═══\n'
./k8s-check.sh k8slab:good; good_rc=$?
./k8s-check.sh k8slab:bad; bad_rc=$?
printf '  коды возврата: пригодный=%s непригодный=%s\n' "$good_rc" "$bad_rc"
[ "$good_rc" -eq 0 ] && [ "$bad_rc" -ne 0 ] \
    && ok "проверка различает образы; кластер не потребовался" \
    || bad "коды: $good_rc и $bad_rc"

printf '\n═══ Требование 6: почему docker ps не видит pod'"'"'ы ═══\n'
python3 - <<'PY'
print("    На узле Kubernetes container'ы создаёт kubelet через CRI.")
print("    Они попадают в пространство имён containerd k8s.io.")
print()
print(f"    {'кто создал':<24} {'пространство имён':<20} кто видит")
print("    " + "─" * 68)
print(f"    {'dockerd (docker run)':<24} {'moby':<20} docker ps")
print(f"    {'kubelet через CRI':<24} {'k8s.io':<20} crictl ps")
print()
print("    docker ps обращается к dockerd, который знает только moby.")
print("    Container'ы Kubernetes ему не видны — даже если Docker установлен.")
print()
print("    Диагностика на узле:")
print("      crictl ps          вместо docker ps")
print("      crictl images      вместо docker images")
print("      crictl logs <id>   вместо docker logs")
print("      ctr --namespace k8s.io containers list  — низкий уровень")
print()
print("    Команды намеренно похожи, но обращаются к другому интерфейсу.")
PY
ok "объяснено через пространства имён containerd"

printf '\n═══ Что НЕ проверялось ═══\n'
python3 - <<'PY'
NOT_RUN = [
    ("запуск в настоящем кластере", "кластер недоступен в этом окружении"),
    ("поведение проб liveness/readiness", "требует kubelet"),
    ("crictl на узле", "нужен узел Kubernetes"),
    ("перепланирование pod'а при отказе узла", "требует кластера из нескольких узлов"),
]
print(f"    {'проверка':<44} причина")
print("    " + "─" * 84)
for name, why in NOT_RUN:
    print(f"    {name:<44} {why}")
print(f"\n    не выполнялось: {len(NOT_RUN)}")
print()
print("    Проверено то, что можно проверить средствами Docker:")
print("    формат образа, пользователь, форма ENTRYPOINT, эндпоинты проб,")
print("    логи в stdout, обработка SIGTERM. Этого достаточно, чтобы")
print("    утверждать: образ пригоден. Поведение в кластере — отдельный вопрос.")
PY
ok "невыполненные проверки перечислены; названо, что проверено взамен"

printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo "  все требования выполнены" || echo "  ЕСТЬ ПРОВАЛЫ"
echo "  примечание: кластер Kubernetes не использовался"

docker rmi -f k8slab:good k8slab:bad > /dev/null 2>&1
cd /tmp && rm -rf /tmp/k8slab
exit "$fail"

Ожидаемый вывод:

text
═══ Подготовка ═══
    k8slab:good собран
    k8slab:bad собран (три внесённые ошибки)

═══ Требование 1: соответствие OCI ═══
    формат: OCI Image Layout
    oci-layout присутствует: True
    blob-файлов: 5
    медиатипы манифестов:
      [OCI] application/vnd.oci.image.manifest.v1+json

    Формат описан стандартом, а не Docker ом.
    Kubernetes скачивает образ по Distribution Specification
    и запускает по Runtime Specification — через runc.
  ✓ образ соответствует OCI: переносится без изменений

═══ Требования 2-3: dockershim ═══
    До 1.24 (dockershim) — звеньев 6
      kubelet → dockershim → Docker Engine → containerd → shim → runc
    После 1.24 (containerd) — звеньев 5
      kubelet → CRI → containerd → shim → runc
    После 1.24 (CRI-O) — звеньев 4
      kubelet → CRI → CRI-O → runc
    На вашей машине — звеньев 5
      docker CLI → dockerd → containerd → shim → runc

    убрано звеньев: 1

    ИЗМЕНИЛОСЬ:
      · kubelet больше не общается с Docker Engine
      · Docker не нужен на узлах кластера
      · docker ps не покажет pod'ы
      · диагностика на узле — через crictl

    НЕ ИЗМЕНИЛОСЬ:
      · формат образов (OCI Image Specification)
      · docker build и docker push
      · протокол registry (Distribution Specification)
      · Dockerfile — ни строки не изменилось
      · слои, кэш, multi-stage
      · теги и digest
      · runc — тот же исполнитель

    изменений: 4, неизменного: 7

    Обратите внимание на последнюю цепочку: у вас на машине
    containerd → shim → runc — ТЕ ЖЕ три звена, что на узле
    Kubernetes. Различается только то, кто ими управляет.
  ✓ цепочки посчитаны; разделено изменившееся и неизменное

═══ Требование 4: переносимость навыков ═══
    навык                              переносится
    ──────────────────────────────────────────────────────
    Dockerfile, порядок, кэш           полностью
    Multi-stage сборка                 полностью
    ...
    переносится полностью или понятийно: 10 из 14 (71 %)

    Не переносится то, что описывает ЗАПУСК.
    Переносится то, что описывает АРТЕФАКТ и его поведение.
  ✓ перечень составлен: большинство навыков переносится

═══ Требование 5: проверка пригодности ═══

  Проверка: k8slab:good

    ✓ USER: app — совместимо с runAsNonRoot
    ✓ ENTRYPOINT в exec form
    ✓ EXPOSE: 8000/tcp 
    ✓ /healthz → 200: годится для пробы
    ✓ /readyz → 200: годится для пробы
    ✓ /startupz → 200: годится для пробы
    ✓ логи в stdout: 1 строк — kubectl logs их увидит
    ✓ SIGTERM обработан за 0.5 с (код 0)

  замечаний: 0


  Проверка: k8slab:bad

    ⚠ USER не задан — нужен runAsUser в securityContext
    ⚠ ENTRYPOINT в shell form — SIGTERM не дойдёт до процесса
    ⚠ EXPOSE не задан — не критично, но полезно
    ✓ /healthz → 200: годится для пробы
    ✓ /readyz → 200: годится для пробы
    ✓ /startupz → 200: годится для пробы
    ✓ логи в stdout: 1 строк — kubectl logs их увидит
    ⚠ SIGTERM не обработан: 10.2 с — pod убьют по terminationGracePeriodSeconds

  замечаний: 4

  коды возврата: пригодный=0 непригодный=1
  ✓ проверка различает образы; кластер не потребовался

═══ Требование 6: почему docker ps не видит pod'ы ═══
    На узле Kubernetes container'ы создаёт kubelet через CRI.
    Они попадают в пространство имён containerd k8s.io.

    кто создал               пространство имён    кто видит
    ────────────────────────────────────────────────────────────────────
    dockerd (docker run)     moby                 docker ps
    kubelet через CRI        k8s.io               crictl ps

    docker ps обращается к dockerd, который знает только moby.
    Container'ы Kubernetes ему не видны — даже если Docker установлен.
    ...
  ✓ объяснено через пространства имён containerd

═══ Что НЕ проверялось ═══
    проверка                                     причина
    ────────────────────────────────────────────────────────────────────────────────────
    запуск в настоящем кластере                  кластер недоступен в этом окружении
    поведение проб liveness/readiness            требует kubelet
    crictl на узле                               нужен узел Kubernetes
    перепланирование pod'а при отказе узла       требует кластера из нескольких узлов

    не выполнялось: 4

    Проверено то, что можно проверить средствами Docker:
    формат образа, пользователь, форма ENTRYPOINT, эндпоинты проб,
    логи в stdout, обработка SIGTERM. Этого достаточно, чтобы
    утверждать: образ пригоден. Поведение в кластере — отдельный вопрос.
  ✓ невыполненные проверки перечислены; названо, что проверено взамен

═══ ИТОГ ═══
  все требования выполнены
  примечание: кластер Kubernetes не использовался

Все требования выполнены; кластер не использовался, и это отмечено.

Требование 3 даёт результат, снимающий главное недоразумение: containerd → shim → runc на вашей машине и на узле Kubernetes — те же три звена. Убрано было одно: dockershim, слой перевода внутри kubelet.

Три решения, определяющие качество.

Соответствие OCI проверяется медиатипами, а не утверждается. Строка application/vnd.oci.image.manifest.v1+json в index.json — это признак формата, записанный в самом артефакте. Утверждение «образы совместимы» превращается в наблюдаемый факт.

Цепочки выполнения сравниваются числом звеньев. Формулировка «убрали Docker» звучит как большое изменение. Подсчёт показывает: убрано одно звено из шести, и оставшиеся три нижних — те же, что работают у вас локально. Масштаб изменения становится измеримым.

Проверка пригодности испытана на непригодном образе. Ноль замечаний на правильном образе доказывал бы только, что скрипт запускается. Четыре замечания на образе с внесёнными ошибками показывают, что он способен их находить, — и одновременно перечисляют, что именно делает образ непригодным.

Чего решение не делает. Кластер Kubernetes не использовался: kubectl, crictl и настоящие пробы недоступны в этом окружении, и это перечислено отдельным блоком. Проверка эндпоинтов подтверждает, что они отвечают, но не что пробы настроены верно, — это вопрос манифеста, а не образа (урок 18.2). Перечень переносимости навыков — оценка, основанная на устройстве обеих систем, а не измерение. Наконец, cri-dockerd как способ сохранить Docker на узлах упомянут, но не проверялся.

Проверка результата

bash
docker save ОБРАЗ | tar -xO index.json 2>/dev/null | python3 -m json.tool | head
docker inspect ОБРАЗ --format '{{.Os}}/{{.Architecture}} {{.Config.User}}'
docker inspect ОБРАЗ --format '{{json .Config.Entrypoint}}'
docker run -d --name probe -p 0:8000 ОБРАЗ && sleep 4 && time docker stop probe

Наличие vnd.oci в медиатипах подтверждает соответствие стандарту.

Типичные ошибки

ОшибкаПричинаИсправление
Считать, что образы docker build не работают в KubernetesФормулировка про dockershimФормат стандартизован OCI; работают
Ставить Docker на узлы кластера«Он же нужен для образов»Не нужен; образы собирают отдельно
Искать pod'ы через docker psПривычкаДругое пространство имён; crictl ps
Сравнивать Docker и Kubernetes напрямуюОба про container'ыРазные уровни: запуск против поддержания
Ожидать, что Compose-файл заработаетПохожие понятияДругая модель объектов
Считать restart: always эквивалентом DeploymentОба «перезапускают»Разные события вызывают реакцию
Оставлять shell form ENTRYPOINTРаботает в DockerВ Kubernetes SIGTERM не дойдёт
Не задавать USER в образеЗадаётся в манифестеrunAsNonRoot потребует runAsUser

Контрольные вопросы

На понимание:

  1. Сформулируйте задачу Docker и задачу Kubernetes одним предложением каждую.
  2. Что связывает две системы и почему образы переносятся?
  3. Что убрал dockershim и что при этом не изменилось?
  4. Почему docker ps не показывает pod'ы на узле Kubernetes?
  5. Чем императивная модель отличается от декларативной на примере отказа узла?

На применение:

  1. Как проверить образ на пригодность для Kubernetes без кластера?
  2. Какие ваши навыки переносятся полностью, а какие нет?
  3. Что означает containerd на узле Kubernetes для того, кто знает Docker?

На диагностику:

  1. Pod убивается через 30 секунд после начала завершения. Гипотеза?
  2. kubectl logs пуст, хотя приложение работает. Причина?

Краткое резюме

  1. Docker собирает образ и запускает container на машине; Kubernetes поддерживает состояние на кластере.
  2. Связующее звено — стандарты OCI: образ, среда выполнения, распространение.
  3. Образ, собранный docker build, соответствует OCI и работает в Kubernetes без изменений.
  4. CRI — интерфейс kubelet к среде выполнения; Docker Engine его не реализует.
  5. dockershim был слоем перевода внутри kubelet и удалён в версии 1.24.
  6. Убрано одно звено; формат образов, registry и Dockerfile не изменились.
  7. containerd на узле Kubernetes — тот же компонент, что внутри Docker.
  8. Container'ы Kubernetes живут в пространстве имён k8s.io, а не moby.
  9. Диагностика на узле выполняется через crictl, а не docker.
  10. Императивная модель выполняет команду; декларативная поддерживает состояние.
  11. Различие проявляется при отказе узла, изменении числа реплик и управлении трафиком.
  12. Большинство навыков работы с образами переносится полностью; не переносится то, что описывает запуск.

Официальные источники

ИсточникСсылкаЧто подтверждает
OCI Image Specificationhttps://github.com/opencontainers/image-spec/blob/main/spec.mdФормат образа
OCI Runtime Specificationhttps://github.com/opencontainers/runtime-spec/blob/main/spec.mdBundle и запуск
OCI Distribution Specificationhttps://github.com/opencontainers/distribution-spec/blob/main/spec.mdПротокол registry
Kubernetes: CRIhttps://kubernetes.io/docs/concepts/architecture/cri/Интерфейс среды выполнения
Kubernetes: удаление dockershimhttps://kubernetes.io/blog/2022/02/17/dockershim-faq/Что изменилось и что нет
Kubernetes: container runtimeshttps://kubernetes.io/docs/setup/production-environment/container-runtimes/containerd, CRI-O
crictlhttps://kubernetes.io/docs/tasks/debug/debug-cluster/crictl/Диагностика на узле
Kubernetes: обзорhttps://kubernetes.io/docs/concepts/overview/Декларативная модель

Навигация

Вернуться к разделу
Следующий материал → Объекты Kubernetes
Главное оглавление

Markdown на GitHub ↗