Kubernetes для самых маленьких. Часть 5: Как собрать образ и где его хранить?

Все части серии «Kubernetes для самых маленьких»

До сих пор в кластере работали чужие образы: nginx:1.29 и busybox:1.37 с Docker Hub. В первой части образ был описан одной фразой — упакованная файловая система и параметры запуска, — во второй мы видели, что kubelet просит containerd через CRI «скачай образ» и «запусти контейнер», а в прошлой части собрали кластер, на котором будем их запускать. Сегодня разберём всю цепочку целиком: из чего физически состоит образ, как его хранит и распаковывает containerd на узле, как собрать собственное приложение hello в маленький и безопасный образ, как положить его в частный реестр GitHub Container Registry и как научить узлы кластера брать образы оттуда.

hello — сквозное приложение серии: на нём в следующих частях будут поды, Deployment, сервисы, ingress, метрики и логи. Поэтому исходник — целиком, а образ — по всем правилам.


Из чего состоит образ

Формат образа описан спецификацией OCI Image (Open Container Initiative — организация при Linux Foundation, которая в 2015 году стандартизовала формат образов и среду запуска контейнеров на основе того, что сделал Docker). Рядом с ней — OCI Distribution, протокол раздачи образов по HTTP, и OCI Runtime, описание того, как низкоуровневый runtime вроде runc запускает контейнер. Формат Docker («Image Manifest V2, Schema 2») почти совпадает с OCI, отличаются только строки mediaType, и containerd понимает оба.

Образ — это не один файл, а небольшое дерево JSON-документов и архивов, которые ссылаются друг на друга по хешу содержимого:

 тег ghcr.io/example/hello:1.0
   │
   ▼
 Index  (application/vnd.oci.image.index.v1+json)        sha256:a5066cd4…
   ├─ manifest linux/amd64  ──────────────────────────────┐
   ├─ manifest linux/arm64                                │
   ├─ attestation для amd64 (platform unknown/unknown)    │
   └─ attestation для arm64                               │
                                                          ▼
 Manifest (application/vnd.oci.image.manifest.v1+json)   sha256:bc289980…
   ├─ config ─► Config (…image.config.v1+json)           sha256:f768da29…
   │              architecture, os, Entrypoint, Env, User,
   │              rootfs.diff_ids, history
   └─ layers
        ├─ слой 1 (…image.layer.v1.tar+gzip)              sha256:2cc7ee28…
        ├─ …
        └─ слой 14: наш бинарь /hello                      sha256:28a9c83d…

Слой — tar-архив изменений файловой системы, обычно сжатый gzip, реже zstd. Каждый слой содержит файлы, добавленные или изменённые на одном шаге сборки. Удалённый файл записывается в слой как пустой файл-«белая метка» .wh.<имя>, а каталог, содержимое которого заменено целиком, — меткой .wh..wh..opq. Поэтому RUN rm в Dockerfile образ не уменьшает: файл остаётся в предыдущем слое, новый слой лишь прячет его.

Config — JSON с тем, как запускать: архитектура и ОС, Entrypoint и Cmd, переменные окружения, рабочий каталог, пользователь, открытые порты, метки, сигнал остановки. Там же rootfs.diff_ids — хеши слоёв в распакованном виде — и history, журнал шагов сборки.

Manifest — оглавление образа под одну платформу: ссылка на config и список слоёв по порядку. Каждая ссылка — дескриптор: тип содержимого (mediaType), хеш (digest) и размер в байтах (size).

Index (у Docker — manifest list) — оглавление над оглавлениями: список манифестов с указанием платформы. Один тег hello:1.0 обслуживает и узлы x86-64, и ARM: containerd на узле читает index и берёт манифест своей архитектуры. Записи с платформой unknown/unknown — аттестации, которые добавляет BuildKit: сведения о том, как и из чего собран образ (provenance), а по запросу — список пакетов внутри (SBOM). Runtime их пропускает.

Адресация по содержимому

Ключевое свойство всей конструкции: каждый объект называется дайджестом — sha256: и SHA-256 от его байтов. Манифест содержит дайджесты config и слоёв, index — дайджесты манифестов. Получается дерево Меркла: дайджест index однозначно определяет каждый байт каждого слоя под каждую платформу. Изменить что-то внутри и сохранить дайджест невозможно.

Отсюда три следствия:

  • Целостность. Скачав блоб, containerd считает его хеш и сверяет с дайджестом из манифеста. Подменённый или повреждённый в пути слой будет отвергнут, даже если реестр скомпрометирован, — при условии, что сам дайджест манифеста получен из надёжного места.
  • Дедупликация. Слой базового образа, общий для десятка приложений, хранится в реестре и на узле один раз и скачивается один раз.
  • Тег — указатель, дайджест — имя. Тег 1.0 — запись в реестре «этот тег указывает на такой-то дайджест», и её можно перезаписать. Дайджест перезаписать нельзя. К этому мы вернёмся в разделе про теги.

Хешей у слоя два. В манифесте — дайджест сжатого архива: его скачивают и проверяют. В config (diff_ids) — дайджест распакованного tar: он не зависит от того, каким компрессором и с какой степенью сжатия собран слой. containerd использует третью величину — chainID: хеш от diff_id слоя и chainID всех слоёв под ним. Один и тот же слой поверх разных баз даёт разные chainID, и это правильно: распакованная файловая система «этот слой поверх той базы» — другая.


Как containerd хранит и распаковывает образ

Когда kubelet через CRI просит containerd скачать ghcr.io/example/hello:1.0, происходит следующее:

  1. Resolve. containerd запрашивает у реестра манифест по тегу и получает дайджест — для мультиархитектурного образа это дайджест index.
  2. Выбор платформы. Из index берётся манифест linux/amd64 (все узлы стенда — x86-64).
  3. Fetch. Config и слои, которых ещё нет локально, скачиваются в content store — каталог /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/, где каждый файл называется своим дайджестом. Метаданные — какие образы есть, какие блобы к ним относятся — лежат в базе bbolt (встраиваемое хранилище «ключ — значение» на Go) /var/lib/containerd/io.containerd.metadata.v1.bolt/meta.db.
  4. Unpack. Каждый слой распаковывается snapshotter-ом в отдельный снимок файловой системы.

containerd разделяет всё по пространствам имён: образы и контейнеры kubelet живут в k8s.io, Docker Engine, если он стоит на той же машине, пользуется moby, а ctr по умолчанию смотрит в default. Отсюда частая путаница: ctr images ls на узле кластера показывает пустой список, а ctr -n k8s.io images ls — все образы.

Snapshotter overlayfs

Snapshotter по умолчанию — overlayfs, файловая система ядра, которая складывает несколько каталогов в один: нижние (lowerdir) только для чтения, верхний (upperdir) — для записи, и при чтении файл берётся из самого верхнего слоя, где он есть.

 /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/
   95/fs    ◄── слой 1 базового образа, распакован (committed, только чтение)
   96/fs    ◄── слой 2 …
   …
   108/fs   ◄── слой 14: /hello                (committed, только чтение)
   109/fs   ◄── upperdir контейнера            (active, запись)
   109/work ◄── служебный каталог overlayfs

 корень контейнера = overlay:
   lowerdir = 108/fs:107/fs:…:95/fs   (сверху вниз)
   upperdir = 109/fs
   workdir  = 109/work

Каждый слой распаковывается один раз в свой каталог snapshots/N/fs и помечается как зафиксированный (committed) снимок с ключом-chainID. При запуске контейнера snapshotter создаёт активный снимок поверх цепочки слоёв образа и монтирует overlay в /run/containerd/io.containerd.runtime.v2.task/k8s.io/<id>/rootfs — это и есть корень контейнера. Всё, что контейнер пишет, попадает в его upperdir; при удалении контейнера активный снимок удаляется вместе с записанным. Десять контейнеров из одного образа делят одни и те же нижние каталоги и тратят место только на свои изменения.

Есть и другие snapshotter-ы: native (полные копии каталогов, для ФС без overlay), btrfs и zfs (снимки средствами этих файловых систем), stargz и подобные (ленивая загрузка: контейнер стартует до того, как скачан весь образ, а файлы подтягиваются по мере обращения). На стенде — overlayfs, это вариант по умолчанию и для большинства случаев лучший.

Сжатые блобы после распаковки по умолчанию остаются в content store: они нужны, чтобы не скачивать слой заново и чтобы отдать образ дальше (ctr export). Ненужные блобы и снимки — те, на которые не ссылается ни один образ и ни один контейнер, — containerd удаляет своим сборщиком мусора.


Приложение hello

Маленький HTTP-сервер на Go, без внешних зависимостей — только стандартная библиотека:

  • GET / отвечает hello from <hostname>; в поде hostname — имя пода, так что по ответу видно, какая реплика ответила;
  • GET /healthz — «процесс жив», для liveness-проверки;
  • GET /readyz — «готов принимать запросы»: 200, пока сервер работает, и 503 после получения SIGTERM; для readiness-проверки (обе проверки — в следующей части);
  • GET /metrics — счётчик запросов и версия сборки в текстовом формате Prometheus;
  • порт — из переменной PORT, по умолчанию 8080;
  • по SIGTERM перестаёт принимать новые соединения и дожидается текущих запросов.

go.mod:

module example.com/hello

go 1.27

main.go:

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net"
	"net/http"
	"os"
	"os/signal"
	"sync/atomic"
	"syscall"
	"time"
)

// version is set at build time: -ldflags "-X main.version=1.0".
var version = "dev"

var (
	requests atomic.Uint64
	ready    atomic.Bool
)

func main() {
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080"
	}
	hostname, err := os.Hostname()
	if err != nil {
		log.Fatalf("hostname: %v", err)
	}

	mux := http.NewServeMux()
	mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
		requests.Add(1)
		log.Printf("%s %s %s", r.RemoteAddr, r.Method, r.URL.Path)
		w.Header().Set("X-App-Version", version)
		fmt.Fprintf(w, "hello from %s\n", hostname)
	})
	mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "ok")
	})
	mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
		if !ready.Load() {
			http.Error(w, "not ready", http.StatusServiceUnavailable)
			return
		}
		fmt.Fprintln(w, "ok")
	})
	mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
		fmt.Fprintln(w, "# HELP hello_requests_total Requests served by the / handler.")
		fmt.Fprintln(w, "# TYPE hello_requests_total counter")
		fmt.Fprintf(w, "hello_requests_total %d\n", requests.Load())
		fmt.Fprintln(w, "# HELP hello_build_info Build information.")
		fmt.Fprintln(w, "# TYPE hello_build_info gauge")
		fmt.Fprintf(w, "hello_build_info{version=%q} 1\n", version)
	})

	srv := &http.Server{
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}

	ln, err := net.Listen("tcp", ":"+port)
	if err != nil {
		log.Fatalf("listen: %v", err)
	}
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
	defer stop()

	go func() {
		if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("serve: %v", err)
		}
	}()
	ready.Store(true)
	log.Printf("hello %s listening on %s", version, ln.Addr())

	<-ctx.Done()
	ready.Store(false)
	log.Printf("signal received, shutting down")
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		log.Printf("shutdown: %v", err)
	}
	log.Printf("bye")
}

Несколько мест, которые важны для Kubernetes.

Логи — в stdout и stderr. Пакет log пишет в stderr, файлов приложение не ведёт. containerd собирает потоки контейнера в файлы на узле, kubelet отдаёт их через kubectl logs, а сборщик логов (часть 33) — в хранилище. Приложение, пишущее в файл внутри контейнера, теряет логи вместе с контейнером.

Ошибка конфигурации — немедленный выход. Если PORT занят или задан неправильно, net.Listen вернёт ошибку и процесс завершится с кодом 1. Это лучше, чем работать без открытого порта: Kubernetes увидит падение, перезапустит контейнер и покажет причину в статусе и логах.

SIGTERM. При остановке пода kubelet посылает главному процессу контейнера SIGTERM и ждёт, по умолчанию 30 секунд, прежде чем послать SIGKILL. srv.Shutdown закрывает слушающий сокет, дожидается завершения начатых запросов и закрывает соединения keep-alive. Процесс, игнорирующий SIGTERM, каждый раз убивается через 30 секунд с обрывом запросов. Как это сочетается с удалением пода из балансировки — в следующей части.

Формат метрик. /metrics отдаёт текстовый формат Prometheus — системы мониторинга, которая сама периодически опрашивает (scrape) HTTP-адреса приложений и хранит полученные числа как временные ряды; подробно — в части 32. Формат простой: строки # HELP с описанием и # TYPE с типом (counter — только растущий счётчик, gauge — произвольное значение), затем строки «имя{метки} значение». Метрика hello_build_info со значением 1 и версией в метке — общепринятый способ показать версию сборки. В настоящем приложении метрики пишут библиотекой prometheus/client_golang; здесь обошлись без неё, чтобы исходник был самодостаточным.

Версия вшивается при сборке флагом компоновщика -X main.version=1.0 и видна в заголовке ответа X-App-Version, в метрике и в первой строке лога. Тело ответа от версии не зависит.


Dockerfile

# syntax=docker/dockerfile:1

# --- сборка ---
FROM --platform=$BUILDPLATFORM golang:1.27 AS build
ARG TARGETOS TARGETARCH
ARG VERSION=dev
WORKDIR /src
COPY go.mod ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY *.go ./
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
    go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/hello .

# --- итоговый образ ---
FROM gcr.io/distroless/static-debian13:nonroot
ARG VERSION=dev
LABEL org.opencontainers.image.title="hello" \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.source="https://git.example.com/demo/hello"
COPY --from=build /out/hello /hello
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/hello"]

И .dockerignore, чтобы в контекст сборки не попадали лишние файлы, а с ними — секреты и .git:

*
!go.mod
!go.sum
!*.go

Разберём по строкам.

# syntax=docker/dockerfile:1 — директива для BuildKit: взять актуальную стабильную версию синтаксиса Dockerfile. Она нужна для RUN --mount.

Multi-stage. Два FROM — два этапа. Первый (build) — полноценный образ с Go, компилятором и исходниками, почти гигабайт. Второй — итоговый, и в него из первого копируется ровно один файл. Всё, что было на этапе сборки, — компилятор, кеш модулей, исходники — в итоговый образ не попадает.

--platform=$BUILDPLATFORM, TARGETOS, TARGETARCH. BuildKit сам задаёт эти аргументы: BUILDPLATFORM — платформа машины, на которой идёт сборка, TARGETOS/TARGETARCH — платформа, под которую собираем. Этап сборки всегда запускается на «родной» архитектуре, а Go кросс-компилирует бинарь под целевую. Без этого сборка под arm64 на машине x86-64 шла бы в эмуляторе QEMU и была бы в разы медленнее.

Кеш-монтирования. --mount=type=cache подключает к шагу каталог, который BuildKit хранит между сборками, но не кладёт в слой: кеш модулей и кеш компилятора Go переживают пересборку, а образ от них не толстеет.

Порядок шагов. Сначала go.mod и загрузка зависимостей, потом исходники. BuildKit кеширует каждый шаг и пересобирает начиная с первого изменившегося: правка main.go не заставит заново качать модули. Правило общее: то, что меняется редко, — выше.

CGO_ENABLED=0 даёт полностью статический бинарь, которому не нужна libc. -trimpath убирает из бинаря пути машины сборки (сборка становится воспроизводимой), -s -w — таблицу символов и отладочную информацию (минус треть размера).

distroless/static. Базовый образ проекта distroless от Google: в нём нет ни shell, ни пакетного менеджера, ни утилит — только корневые сертификаты CA (/etc/ssl/certs), часовые пояса, /etc/passwd с пользователями root и nonroot (UID 65532) и /tmp. Меньше мегабайта в сжатом виде. Тег nonroot делает пользователем по умолчанию nonroot. Суффикс -debian13 — версия Debian, из пакетов которой собран образ; его стоит указывать явно, чтобы смена базового дистрибутива не случилась незаметно.

Чем это лучше FROM ubuntu или FROM alpine. Меньше файлов — меньше уязвимостей, которые сканер найдёт в образе и которые придётся закрывать обновлениями. Нет shell — у атакующего, получившего выполнение кода в процессе, нет привычных инструментов. Меньше размер — быстрее скачивание на новый узел при масштабировании. Обратная сторона — kubectl exec ... sh в такой контейнер не работает; для отладки есть kubectl debug с временным контейнером, о нём в части 31.

Альтернатива — FROM scratch, пустой образ. Для Go-бинаря он тоже годится, но сертификаты CA, /etc/passwd и часовые пояса придётся копировать самому; distroless/static — это и есть «scratch плюс эти три вещи», собранные и обновляемые за вас.

USER 65532:65532. Процесс в контейнере работает не от root. Тег nonroot уже это делает, но числовой UID в Dockerfile полезен Kubernetes: с runAsNonRoot: true (часть 30) kubelet проверяет, что пользователь образа не 0, и сделать это он может только по числу, а не по имени. Порт 8080 выше 1024, поэтому привилегии для него не нужны.

LABEL — метки в config образа по соглашению OCI (org.opencontainers.image.*): откуда исходники, какая версия. Их показывают реестры и сканеры. GitHub Container Registry по метке org.opencontainers.image.source с адресом репозитория на GitHub связывает образ с этим репозиторием и его правами доступа.

Пиннинг базовых образов. golang:1.27 и static-debian13:nonroot — теги, и их владельцы пересобирают образы под ними с обновлениями безопасности. Для воспроизводимой сборки базу фиксируют по дайджесту: FROM gcr.io/distroless/static-debian13:nonroot@sha256:<дайджест>. BuildKit и сам записывает в provenance, по какому дайджесту взял базу: в журнале сборки видно, что nonroot превратился в nonroot@sha256:18f4cc26…. Обновлять такие строки руками неудобно, поэтому этим занимаются боты вроде Renovate или Dependabot: они следят за новыми дайджестами и открывают pull request с обновлением, который проходит обычную проверку.


Сборка: docker buildx

За docker build давно стоит BuildKit — движок сборки, который строит из Dockerfile граф шагов, выполняет независимые шаги параллельно, кеширует их и умеет собирать под несколько платформ. buildx — плагин Docker CLI для работы с BuildKit напрямую. Собираем на рабочей станции в Docker Desktop.

Для мультиархитектурной сборки удобнее всего builder с драйвером docker-container — отдельный контейнер с BuildKit, которому не нужна поддержка нескольких платформ в хранилище самого Docker. Сначала сборка без загрузки, чтобы проверить, что всё компилируется:

$ docker buildx create --name multi --driver docker-container --use
multi
$ docker buildx build --platform linux/amd64,linux/arm64 \
    --build-arg VERSION=1.0 --progress=plain \
    -t ghcr.io/example/hello:1.0 .
#1 [internal] booting buildkit
#1 DONE 14.1s
...
#5 [linux/amd64 internal] load metadata for docker.io/library/golang:1.27
#6 [linux/amd64 internal] load metadata for gcr.io/distroless/static-debian13:nonroot
#12 [linux/amd64 stage-1 1/2] FROM gcr.io/distroless/static-debian13:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
...
#19 [linux/amd64 build 6/6] RUN --mount=type=cache,target=/go/pkg/mod     --mount=type=cache,target=/root/.cache/go-build     CGO_ENABLED=0 GOOS=linux GOARCH=amd64     go build -trimpath -ldflags="-s -w -X main.version=1.0" -o /out/hello .
#19 DONE 6.3s
#20 [linux/amd64->arm64 build 6/6] RUN --mount=type=cache,target=/go/pkg/mod     --mount=type=cache,target=/root/.cache/go-build     CGO_ENABLED=0 GOOS=linux GOARCH=arm64     go build -trimpath -ldflags="-s -w -X main.version=1.0" -o /out/hello .
#20 DONE 6.3s
...
WARNING: No output specified with docker-container driver. Build result will only remain in the build cache. To push result image into registry use --push or to load image into docker use --load

Строка linux/amd64->arm64 — тот самый кросс-компилятор: сборка arm64-бинаря шла на amd64. Если бы в Dockerfile не было --platform=$BUILDPLATFORM, BuildKit запускал бы шаги arm64-этапа в эмуляции через QEMU и binfmt_misc (механизм ядра, который передаёт запуск чужого исполняемого формата указанному интерпретатору) — работает, но медленно. Предупреждение в конце честное: результат пока лежит только в кеше builder-а. --push отправит его в реестр, --load — в локальное хранилище Docker; Docker Desktop хранит образы в containerd и поэтому принимает и мультиархитектурные образы.

Проверить образ можно, не выходя с рабочей станции:

$ docker buildx build --platform linux/amd64 --build-arg VERSION=1.0 -t hello-test:1.0 --load .
$ docker run -d --rm --name hello-test -p 18080:8080 hello-test:1.0
$ curl -si http://localhost:18080/ | grep -E 'HTTP|X-App|hello'
HTTP/1.1 200 OK
X-App-Version: 1.0
hello from 149774fac689
$ curl -s http://localhost:18080/metrics | grep -v '^#'
hello_requests_total 1
hello_build_info{version="1.0"} 1
$ docker stop hello-test

В Docker имя хоста контейнера — его короткий ID; в Kubernetes там будет имя пода.

Сборка без Docker

Docker на машине сборки не обязателен, и в CI внутри Kubernetes его лучше не иметь. Популярный рецепт «смонтировать в под сборки /var/run/docker.sock» или «запустить Docker-in-Docker с privileged: true» даёт каждому, кто может изменить сценарий сборки, права root на узле: через сокет Docker можно запустить привилегированный контейнер с корнем хоста. Альтернативы:

  • BuildKit отдельно: демон buildkitd и клиент buildctl. Это тот же движок, что внутри docker buildx, но без Docker; умеет работать без root (rootless) и запускаться подом в кластере.
  • buildah и podman — инструменты Red Hat без демона. buildah build (и podman build, который вызывает buildah) собирает по тому же Dockerfile; buildah умеет и собирать образ командами shell-скрипта без Dockerfile. Работают без root через user namespaces.
  • kaniko — сборщик, который выполняет шаги Dockerfile внутри обычного непривилегированного контейнера, прямо в его файловой системе, и сам упаковывает слои. Исходный проект Google в 2025 году переведён в архив; развитие продолжается в форках сообщества.
  • ko — сборщик образов для Go без Dockerfile: компилирует приложение и кладёт бинарь на distroless-базу. Для Go-сервисов вроде hello это самый короткий путь.

Все они на выходе дают образ формата OCI, который понимает любой реестр и любой runtime.


Реестр: GitHub Container Registry

Реестр — HTTP-сервис, который хранит блобы и манифесты и раздаёт их по протоколу OCI Distribution: все запросы идут к адресам вида /v2/<имя>/manifests/<тег или дайджест> и /v2/<имя>/blobs/<дайджест>. Реестров много: Docker Hub, реестры облаков, свой сервер в сети. В серии образы приложений живут в GitHub Container Registry (ghcr.io): для частных образов у него есть бесплатная квота по объёму и трафику, у него те же учётные записи и права, что у репозиториев GitHub, а сертификат выписан публичным центром сертификации, поэтому узлам не нужно ничего настраивать для доверия к нему.

Имя образа в ghcr.io — ghcr.io/<владелец>/<имя>, где владелец — пользователь или организация GitHub. В статье владелец — example, образ — ghcr.io/example/hello; на своём стенде подставьте свой. Новый образ, загруженный в ghcr.io, по умолчанию частный: скачать его может только тот, у кого есть права на пакет.

Токены

ghcr.io не принимает пароль учётной записи GitHub — только токены. Нужны два, с разными правами:

Кто Право токена Зачем
машина сборки write:packages загружать образы (включает чтение)
узлы кластера read:packages только скачивать

Это классические personal access tokens (Settings → Developer settings → Personal access tokens → Tokens (classic)): fine-grained-токены с реестром пакетов GitHub не работают. Токен узлов ничего, кроме скачивания пакетов, не должен уметь: он будет лежать в кластере в Secret, и всё, что он разрешает, разрешено каждому, кто сможет прочитать этот Secret. Срок жизни у обоих — ограниченный, например 90 дней, с напоминанием о замене.

На рабочей станции, где уже настроен GitHub CLI, токен для загрузки можно не создавать руками: gh добавит своей сессии нужное право, а сам токен уйдёт в Docker через конвейер, не попав ни на экран, ни в историю shell:

$ gh auth refresh -h github.com -s write:packages
$ gh auth token | docker login ghcr.io -u example --password-stdin
Login Succeeded

Docker хранит учётные данные в хранилище, указанном в ~/.docker/config.json: у Docker Desktop это "credsStore": "desktop" — системное хранилище паролей Windows или macOS. Если хранилище не настроено, пароль окажется в самом config.json в base64, то есть открытым текстом; на общей машине сборки это недопустимо.

Загрузка

Та же команда сборки с --push:

$ docker buildx build --platform linux/amd64,linux/arm64 \
    --build-arg VERSION=1.0 --progress=plain \
    -t ghcr.io/example/hello:1.0 --push .
...
#20 [linux/amd64 build 6/6] RUN --mount=type=cache,target=/go/pkg/mod     --mount=type=cache,target=/root/.cache/go-build     CGO_ENABLED=0 GOOS=linux GOARCH=amd64     go build -trimpath -ldflags="-s -w -X main.version=1.0" -o /out/hello .
#20 CACHED
...
#22 exporting to image
#22 exporting layers 0.3s done
#22 exporting manifest sha256:bc289980e0a6cbd930a319094f25249a36634818823ab3bfc2d6a9382cd0ec6d 0.0s done
#22 exporting config sha256:f768da2949737845e4a79d5141c37186a611d44d79f0ecef3ddce921d3b5d00e 0.0s done
#22 exporting attestation manifest sha256:1a82f2e36a46e5d60c34b29cba26ae51fc181cf92b91cd83fa8ed31e52ae81c0 0.1s done
#22 exporting manifest sha256:e909d5afc7fecc910dce9195b1b3784252bfedc47ba3e1154266c7600866adc7 0.0s done
#22 exporting config sha256:bcbc7faf90e2af1a8fa61cb3730b35cc77bba1c4d0f11ec95e788ad527f17bd3 0.0s done
#22 exporting attestation manifest sha256:e9fc21e46675ff5ac5907435fc376e217b31c3ca94a3c11e98b0c6a92e4bf71a 0.0s done
#22 exporting manifest list sha256:a5066cd40ef37b9b889ddfbe9c1860b3afe231b96b975000e0edca3fdd6f4a4a 0.0s done
#23 [auth] example/hello:pull,push token for ghcr.io
#23 DONE 0.0s
#22 pushing layers 6.0s done
#22 pushing manifest for ghcr.io/example/hello:1.0@sha256:a5066cd40ef37b9b889ddfbe9c1860b3afe231b96b975000e0edca3fdd6f4a4a 2.9s done
#22 DONE 9.5s

Все шаги сборки взяты из кеша (CACHED): исходники не менялись. В реестр ушли index (manifest list), по манифесту и config на каждую платформу и две аттестации provenance, которые BuildKit добавляет по умолчанию; --sbom=true добавит ещё и список пакетов. Строка [auth] — BuildKit получил у ghcr.io токен на чтение и запись репозитория example/hello; как устроен этот обмен, посмотрим в конце части.

Свой реестр: Distribution и Harbor

Облачный реестр подходит не всегда: кластер может стоять в сети без интернета, правила организации могут требовать держать образы у себя, а скачивание из облака при каждом масштабировании — это внешний трафик и внешняя зависимость. Тогда реестр ставят у себя.

Distribution — эталонная реализация протокола OCI Distribution, бывший Docker Registry, теперь проект CNCF (фонда Cloud Native Computing Foundation, где живёт и Kubernetes). Это одна программа на Go в официальном образе registry (ветка 3.x), которая хранит блобы и манифесты на диске или в объектном хранилище S3. Для работы в сети ей нужны три вещи:

  • TLS. Сертификат на имя реестра, обычно от внутреннего центра сертификации организации (как устроены сертификаты и цепочки — в части 23 сетевой серии). Реестр без TLS возможен, но тогда пароли и образы идут по сети открытыми, а каждый клиент нужно явно перевести в режим «небезопасный реестр».
  • Пароли. Файл htpasswd с хешами bcrypt (htpasswd -B из пакета apache2-utils); записи с другими хешами Distribution игнорирует. Это только аутентификация, без авторизации: любой вошедший может и скачивать, и загружать, и удалять образы в любом репозитории. Права «этот только читает» требуют отдельного сервера токенов — или Harbor.
  • Сборка мусора. Удаление тега через API удаляет только ссылку; блобы остаются на диске, пока не запущен registry garbage-collect, и на время сборки мусора реестр переводят в режим только для чтения.

Harbor — реестр уровня организации, тоже проект CNCF. Внутри у него тот же Distribution, а вокруг: проекты с ролями пользователей, роботизированные учётные записи с правом только на скачивание для кластеров, вход через LDAP или OIDC (протокол единого входа поверх OAuth 2.0), веб-интерфейс, сканирование образов на уязвимости открытым сканером Trivy (он сверяет пакеты и библиотеки в слоях с базами известных уязвимостей), неизменяемые теги, политики хранения, репликация между реестрами и кеширующий прокси для Docker Hub. Ставится через Docker Compose или в кластер Helm-чартом (Helm — менеджер пакетов для Kubernetes, о нём — в части 27).

Сертификат своего реестра выписан внутренним CA, которого нет в системном хранилище узлов, и без настройки скачивание закончится ошибкой x509: certificate signed by unknown authority. Правильный путь — сообщить containerd, какому CA верить для этого реестра. В containerd 2.x настройки реестров живут в каталоге из параметра config_path — по подкаталогу на каждый хост с файлом hosts.toml. В конфигурации по умолчанию containerd 2.2 этот параметр пуст, поэтому в /etc/containerd/config.toml из части 4 его задают явно:

[plugins.'io.containerd.cri.v1.images'.registry]
  config_path = '/etc/containerd/certs.d'

и на каждом узле кладут CA и hosts.toml в каталог /etc/containerd/certs.d/<имя реестра>/:

server = "https://registry.example.com"

[host."https://registry.example.com"]
  capabilities = ["pull", "resolve"]
  ca = "/etc/containerd/certs.d/registry.example.com/ca.crt"

capabilities — что узлу разрешено делать с этим хостом: pull — скачивать блобы, resolve — превращать тег в дайджест; push узлам не нужен. Тем же механизмом настраивают зеркало Docker Hub: каталог docker.io/ с секцией host для своего кеширующего прокси. Два плохих варианта, которые часто советуют: skip_verify = true отключает проверку сертификата целиком, и узел будет отдавать пароль и брать образы у кого угодно, кто ответит на это имя; добавление CA в системное хранилище работает, но заставляет доверять внутреннему CA все программы узла и для всех имён.


imagePullSecrets

Узлы доверяют сертификату ghcr.io, но частный образ без учётных данных не отдадут. Первая попытка запустить под с ghcr.io/example/hello:1.0:

$ kubectl get pod nosecret
NAME       READY   STATUS         RESTARTS   AGE
nosecret   0/1     ErrImagePull   0          13s
$ kubectl get events --field-selector involvedObject.name=nosecret -o custom-columns=REASON:.reason,MSG:.message
REASON    MSG
Pulling   Pulling image "ghcr.io/example/hello:1.0"
Failed    Failed to pull image "ghcr.io/example/hello:1.0": failed to pull and unpack image "ghcr.io/example/hello:1.0": failed to resolve image: failed to authorize: failed to fetch anonymous token: unexpected status from GET request to https://ghcr.io/token?scope=repository%3Aexample%2Fhello%3Apull&service=ghcr.io: 401 Unauthorized
Failed    Error: ErrImagePull
BackOff   Back-off pulling image "ghcr.io/example/hello:1.0"
Failed    Error: ImagePullBackOff

containerd попросил у ghcr.io анонимный токен на чтение example/hello и получил отказ: образ частный.

Учётные данные для реестра Kubernetes хранит в Secret типа kubernetes.io/dockerconfigjson — это содержимое файла ~/.docker/config.json в ключе .dockerconfigjson. Создаём его с токеном read:packages, не оставляя токен в истории shell:

$ read -rsp 'ghcr read token: ' T; echo
$ kubectl create secret docker-registry ghcr-pull \
    --docker-server=ghcr.io \
    --docker-username=example \
    --docker-password="$T"
secret/ghcr-pull created
$ unset T

То же самое манифестом:

apiVersion: v1
kind: Secret
metadata:
  name: ghcr-pull
  namespace: default
type: kubernetes.io/dockerconfigjson
data:
  .dockerconfigjson: <base64 от JSON ниже>

Внутри после base64 — JSON вида {"auths":{"ghcr.io":{"username":"example","password":"…","auth":"…"}}}. Base64 — не шифрование: любой, кто может читать Secret в этом неймспейсе, знает токен. Как ограничить чтение и включить шифрование секретов в etcd — в частях 12 и 29. Такой манифест не кладут в Git открытым.

Secret привязывается к поду полем imagePullSecrets:

apiVersion: v1
kind: Pod
metadata:
  name: hello
spec:
  imagePullSecrets:
  - name: ghcr-pull
  containers:
  - name: hello
    image: ghcr.io/example/hello:1.0
    ports:
    - containerPort: 8080

Секрет должен лежать в том же неймспейсе, что и под. kubelet, скачивая образ, перебирает секреты из imagePullSecrets, находит запись для хоста ghcr.io и передаёт учётные данные containerd в запросе CRI PullImage. На узле токен на диск не записывается.

Прописывать imagePullSecrets в каждый под неудобно и легко забыть. Удобнее привязать секрет к ServiceAccount — учётной записи, от имени которой работают поды неймспейса (подробно — в части 29). У каждого неймспейса есть ServiceAccount default, его получает под, у которого не указан другой:

$ kubectl patch serviceaccount default \
    -p '{"imagePullSecrets":[{"name":"ghcr-pull"}]}'
serviceaccount/default patched

При создании пода admission-плагин ServiceAccount — часть API-сервера, которая дополняет объекты перед сохранением, — скопирует imagePullSecrets сервисной учётной записи в спецификацию пода. Именно при создании: уже работающие поды не изменятся. Под из манифеста без поля imagePullSecrets после создания его получает:

$ kubectl get pod hello -o jsonpath='{.spec.imagePullSecrets}{"\n"}'
[{"name":"ghcr-pull"}]

С части 11 приложения будут жить в неймспейсе demo, и там эту привязку надо будет сделать заново: и Secret, и ServiceAccount принадлежат неймспейсу.

Ещё одна важная деталь — что происходит с образом, который уже лежит на узле. Раньше было так: под А с правильным секретом скачал частный образ на k8s-node-1, и после этого под Б в другом неймспейсе, без всяких секретов, мог запустить тот же образ с imagePullPolicy: IfNotPresent — kubelet не ходил в реестр и не проверял права. С Kubernetes 1.35 по умолчанию включена (бета) проверка KubeletEnsureSecretPulledImages: kubelet запоминает, с какими учётными данными скачан образ, и не даёт поду без подходящих учётных данных воспользоваться им, пока не убедится в реестре, что доступ есть. Проверим: образ уже лежит на k8s-node-1, а под в неймспейсе other, где нет ни Secret, ни привязки, запускается на том же узле:

$ kubectl -n other get pod hello
NAME    READY   STATUS         RESTARTS   AGE
hello   0/1     ErrImagePull   0          12s
$ kubectl -n other get events -o custom-columns=REASON:.reason,MSG:.message
REASON    MSG
Pulling   Pulling image "ghcr.io/example/hello:1.0"
Failed    Failed to pull image "ghcr.io/example/hello:1.0": failed to pull and unpack image "ghcr.io/example/hello:1.0": failed to resolve image: failed to authorize: failed to fetch anonymous token: ...401 Unauthorized
Failed    Error: ErrImagePull
BackOff   Back-off pulling image "ghcr.io/example/hello:1.0"
Failed    Error: ImagePullBackOff

kubelet не дал воспользоваться чужим скачиванием и пошёл в реестр, а там у пода нет прав. Поведение задаётся полем imagePullCredentialsVerificationPolicy в конфигурации kubelet; по умолчанию — NeverVerifyPreloadedImages: проверяются все образы, кроме загруженных на узел в обход kubelet.

Для облачных реестров вместо статических токенов используют провайдеры учётных данных kubelet — исполняемые плагины на узле, которые kubelet вызывает перед скачиванием и которые выдают короткоживущий токен (так узлы облачных Kubernetes ходят в реестры своего облака). Для ghcr.io из своего кластера это не нужно.


imagePullPolicy

Когда kubelet ходит в реестр, решает поле контейнера imagePullPolicy:

Значение Поведение
IfNotPresent скачать, только если образа с таким именем нет на узле
Always при каждом запуске контейнера спросить реестр, на какой дайджест указывает тег, и докачать недостающие слои
Never никогда не ходить в реестр; нет образа на узле — контейнер не запустится

Значение по умолчанию зависит от ссылки на образ. Проверить, что подставит API-сервер, можно пробным созданием на сервере:

$ for i in ghcr.io/example/hello ghcr.io/example/hello:latest ghcr.io/example/hello:1.0 \
    ghcr.io/example/hello@sha256:a5066cd40ef37b9b889ddfbe9c1860b3afe231b96b975000e0edca3fdd6f4a4a; do
    echo -n "$i -> "; kubectl run x --image=$i --dry-run=server -o jsonpath='{.spec.containers[0].imagePullPolicy}{"\n"}'
  done
ghcr.io/example/hello -> Always
ghcr.io/example/hello:latest -> Always
ghcr.io/example/hello:1.0 -> IfNotPresent
ghcr.io/example/hello@sha256:a5066cd40ef37b9b889ddfbe9c1860b3afe231b96b975000e0edca3fdd6f4a4a -> IfNotPresent
Образ По умолчанию
hello (без тега) Always
hello:latest Always
hello:1.0 IfNotPresent
hello@sha256:… или hello:1.0@sha256:… IfNotPresent

Умолчание подставляется один раз — при создании объекта. Если потом поменять в Deployment тег с latest на 1.0, в шаблоне останется Always, проставленный при создании.

Always дешевле, чем звучит: при совпадении дайджеста скачивается только манифест, слои берутся из кеша. Но он делает запуск контейнера зависимым от доступности реестра: недоступен реестр — не перезапускаются и упавшие контейнеры, у которых образ уже на узле. Для облачного реестра это ещё и зависимость от интернета. IfNotPresent с неизменяемыми тегами или дайджестами — надёжнее.

Если скачать образ не удалось, под показывает ErrImagePull, а дальше ImagePullBackOff: kubelet повторяет попытки с растущей задержкой до потолка в 300 секунд. По умолчанию kubelet скачивает образы по одному (serializeImagePulls: true в его конфигурации); на узлах, где одновременно стартует много подов, можно разрешить параллельные загрузки (serializeImagePulls: false и maxParallelImagePulls).


Теги против дайджестов

Тег — изменяемый указатель. hello:1.0 сегодня и hello:1.0 после того, как кто-то пересобрал и перезалил его, — разные образы, и кластер этого не заметит: на k8s-node-1 уже лежит старый, с IfNotPresent он и запустится, а на k8s-node-2 скачается новый. Две реплики одного Deployment будут работать на разном коде.

latest плох вдвойне. Это просто тег по умолчанию, никакой «последней версии» он не гарантирует: на него указывает то, что последним загрузили без тега. По image: hello:latest в манифесте невозможно понять, какая версия работает; откатиться «на предыдущий latest» нельзя — его больше нет; а imagePullPolicy: Always по умолчанию добавляет зависимость от реестра. В манифестах кластера latest не используется вообще.

Правильно:

  • Неизменяемые теги версий. Каждая сборка получает новый тег: семантическая версия 1.0.3, хеш коммита, номер сборки. Однажды загруженный тег не перезаписывается — ghcr.io и Distribution этого не запрещают, поэтому дисциплина на стороне CI; Harbor умеет запрещать.
  • Пиннинг по дайджесту — для полной гарантии:
image: ghcr.io/example/hello:1.0@sha256:a5066cd40ef37b9b889ddfbe9c1860b3afe231b96b975000e0edca3fdd6f4a4a

Если указаны и тег, и дайджест, скачивается только по дайджесту, а тег остаётся для человека. Дайджест index подходит для узлов любой архитектуры. Узнать его можно так:

$ docker buildx imagetools inspect ghcr.io/example/hello:1.0 | head -3
Name:      ghcr.io/example/hello:1.0
MediaType: application/vnd.oci.image.index.v1+json
Digest:    sha256:a5066cd40ef37b9b889ddfbe9c1860b3afe231b96b975000e0edca3fdd6f4a4a

Вписывать дайджесты руками никто не будет: это делает конвейер сборки или GitOps-инструмент (часть 41), который после загрузки образа обновляет манифест.

Каким бы ни был манифест, под сообщает, что на самом деле запущено: в status.containerStatuses[].imageID — дайджест, по которому containerd нашёл образ.


Сборка мусора образов на узле

Образы на узле накапливаются: каждая новая версия приложения оставляет предыдущую. Чистит их kubelet, а не containerd сам по себе: раз в пять минут он проверяет заполненность файловой системы с образами и, если она выше порога, удаляет неиспользуемые образы, начиная с тех, что дольше всего не запускались. Параметры в KubeletConfiguration, а действующие значения kubelet отдаёт через API-сервер:

$ kubectl get --raw /api/v1/nodes/k8s-node-2/proxy/configz | jq '.kubeletconfig | {imageGCHighThresholdPercent, imageGCLowThresholdPercent, imageMinimumGCAge, imageMaximumGCAge, serializeImagePulls, imagePullCredentialsVerificationPolicy}'
{
  "imageGCHighThresholdPercent": 85,
  "imageGCLowThresholdPercent": 80,
  "imageMinimumGCAge": "2m0s",
  "imageMaximumGCAge": "0s",
  "serializeImagePulls": true,
  "imagePullCredentialsVerificationPolicy": "NeverVerifyPreloadedImages"
}
Поле По умолчанию Смысл
imageGCHighThresholdPercent 85 заполненность диска, при которой начинается чистка
imageGCLowThresholdPercent 80 до какой заполненности чистить
imageMinimumGCAge 2m образ моложе этого не удаляется, даже если не используется
imageMaximumGCAge 0s (выключено) удалять неиспользуемый образ старше этого срока, не дожидаясь заполнения диска

imageMaximumGCAge стабилен с 1.35; на узлах, где часто меняются версии приложений, разумно задать, например, 168h (неделя), чтобы старые образы не лежали до заполнения диска. Образы используемых контейнеров не удаляются никогда.

Сторонние «чистильщики» вроде crictl rmi --prune по cron документация Kubernetes ставить не советует: kubelet ведёт свой учёт и может удивиться пропаже образа. Разово, руками, при нехватке места — допустимо. Если диск заполняется быстрее, чем kubelet успевает чистить, срабатывает вытеснение подов по давлению на диск (DiskPressure), о нём — в части 10.


Смотрим руками

Реестр по HTTP API. Без учётных данных ghcr.io отвечает 401 и подсказывает, где взять токен:

$ curl -si https://ghcr.io/v2/ | head -5
HTTP/1.1 401 Unauthorized
Content-Type: application/json
docker-distribution-api-version: registry/2.0
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
www-authenticate: Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:user/image:pull"

Это токен-аутентификация протокола Distribution: сам реестр паролей не проверяет, а отправляет клиента к серверу токенов (realm). Клиент приходит туда со своими учётными данными и областью доступа (scope: какой репозиторий и что с ним делать), получает короткоживущий токен и дальше ходит в реестр с заголовком Authorization: Bearer. Так containerd и поступает при каждом скачивании — именно на этом шаге у пода без секрета был 401. Повторим руками на мастере с тем же токеном read:packages, что лежит в Secret: curl спросит его как пароль, и он не останется в истории shell.

user@k8s-master-1:~$ TOKEN=$(curl -s -u example \
    'https://ghcr.io/token?scope=repository:example/hello:pull&service=ghcr.io' | jq -r .token)
Enter host password for user 'example':
user@k8s-master-1:~$ curl -s -H "Authorization: Bearer $TOKEN" https://ghcr.io/v2/example/hello/tags/list
{"name":"example/hello","tags":["1.0"]}

Index по тегу. Тип ответа выбирается заголовком Accept, дайджест приходит в заголовке Docker-Content-Digest:

user@k8s-master-1:~$ curl -s -D - -H "Authorization: Bearer $TOKEN" \
    -H 'Accept: application/vnd.oci.image.index.v1+json' \
    https://ghcr.io/v2/example/hello/manifests/1.0
HTTP/1.1 200 OK
Content-Length: 1609
Content-Type: application/vnd.oci.image.index.v1+json
docker-content-digest: sha256:a5066cd40ef37b9b889ddfbe9c1860b3afe231b96b975000e0edca3fdd6f4a4a
docker-distribution-api-version: registry/2.0
...

{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:bc289980e0a6cbd930a319094f25249a36634818823ab3bfc2d6a9382cd0ec6d",
      "size": 2939,
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:e909d5afc7fecc910dce9195b1b3784252bfedc47ba3e1154266c7600866adc7",
      "size": 2939,
      "platform": {
        "architecture": "arm64",
        "os": "linux"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:1a82f2e36a46e5d60c34b29cba26ae51fc181cf92b91cd83fa8ed31e52ae81c0",
      "size": 837,
      "annotations": {
        "vnd.docker.reference.digest": "sha256:bc289980e0a6cbd930a319094f25249a36634818823ab3bfc2d6a9382cd0ec6d",
        "vnd.docker.reference.type": "attestation-manifest"
      },
      "platform": {
        "architecture": "unknown",
        "os": "unknown"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:e9fc21e46675ff5ac5907435fc376e217b31c3ca94a3c11e98b0c6a92e4bf71a",
      "size": 837,
      "annotations": {
        "vnd.docker.reference.digest": "sha256:e909d5afc7fecc910dce9195b1b3784252bfedc47ba3e1154266c7600866adc7",
        "vnd.docker.reference.type": "attestation-manifest"
      },
      "platform": {
        "architecture": "unknown",
        "os": "unknown"
      }
    }
  ]
}

Манифест для amd64 — уже по дайджесту, и последний слой в нём — наш бинарь:

user@k8s-master-1:~$ curl -s -H "Authorization: Bearer $TOKEN" \
    -H 'Accept: application/vnd.oci.image.manifest.v1+json' \
    https://ghcr.io/v2/example/hello/manifests/sha256:bc289980e0a6cbd930a319094f25249a36634818823ab3bfc2d6a9382cd0ec6d \
    | jq '{config: .config.digest, layers: (.layers | length), last: .layers[-1]}'
{
  "config": "sha256:f768da2949737845e4a79d5141c37186a611d44d79f0ecef3ddce921d3b5d00e",
  "layers": 14,
  "last": {
    "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
    "digest": "sha256:28a9c83dfc2d1139a8a6d4610867e774208003f2e85e9ccfc8207ec6cd35f14a",
    "size": 2529007
  }
}

Сначала тринадцать слоёв базового образа distroless — сертификаты, /etc/passwd, часовые пояса и прочее, — последним наш: 2,5 МБ сжатого бинаря. Config — обычный блоб, скачивается по /v2/<имя>/blobs/<дайджест>; ghcr.io отвечает на этот запрос перенаправлением 307 на своё хранилище блобов, поэтому у curl ключ -L:

user@k8s-master-1:~$ curl -sL -H "Authorization: Bearer $TOKEN" \
    https://ghcr.io/v2/example/hello/blobs/sha256:f768da2949737845e4a79d5141c37186a611d44d79f0ecef3ddce921d3b5d00e \
    | jq '{architecture, os, config: .config | {User, Entrypoint, Env, ExposedPorts, Labels}}'
{
  "architecture": "amd64",
  "os": "linux",
  "config": {
    "User": "65532:65532",
    "Entrypoint": [
      "/hello"
    ],
    "Env": [
      "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
      "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt"
    ],
    "ExposedPorts": {
      "8080/tcp": {}
    },
    "Labels": {
      "org.opencontainers.image.source": "https://git.example.com/demo/hello",
      "org.opencontainers.image.title": "hello",
      "org.opencontainers.image.version": "1.0"
    }
  }
}
user@k8s-master-1:~$ unset TOKEN

Скачивание на узле. Запустим под из манифеста выше и посмотрим события:

$ kubectl apply -f hello-pod.yaml
pod/hello created
$ kubectl get events --field-selector involvedObject.name=hello
LAST SEEN   TYPE     REASON      OBJECT      MESSAGE
22s         Normal   Scheduled   pod/hello   Successfully assigned default/hello to k8s-node-2
19s         Normal   Pulling     pod/hello   Pulling image "ghcr.io/example/hello:1.0"
1s          Normal   Pulled      pod/hello   Successfully pulled image "ghcr.io/example/hello:1.0" in 18.723s (18.723s including waiting). Image size: 3395899 bytes.
0s          Normal   Created     pod/hello   Container created
0s          Normal   Started     pod/hello   Container started

$ kubectl get pod hello -o jsonpath='{.status.containerStatuses[0].imageID}{"\n"}'
ghcr.io/example/hello@sha256:a5066cd40ef37b9b889ddfbe9c1860b3afe231b96b975000e0edca3fdd6f4a4a

Три с половиной мегабайта на весь образ, но скачивание заняло почти двадцать секунд: реестр далеко, а кроме байтов нужно получить токен, index, манифест, config и четырнадцать слоёв отдельными запросами, и за каждым блобом ghcr.io отправляет на своё хранилище перенаправлением (это видно в разборе HTTP API выше). Свой реестр в той же сети отдаёт такой образ заметно быстрее; для облачного стоит держать IfNotPresent, чтобы перезапуск контейнера не ждал реестра.

Что осталось на узле. crictl видит образы глазами CRI, ctr — глазами самого containerd:

user@k8s-node-2:~$ sudo crictl images --digests | grep -E 'IMAGE|hello'
IMAGE                                     TAG                 DIGEST              IMAGE ID            SIZE
ghcr.io/example/hello                     1.0                 a5066cd40ef37       f768da2949737       3.4MB

DIGEST — дайджест index, по которому образ был получен, IMAGE ID — дайджест config платформы узла. Именно config идентифицирует образ на узле: две ссылки на один и тот же образ (тег и дайджест) дадут одну строку.

user@k8s-node-2:~$ sudo ctr images ls
REF TYPE DIGEST SIZE PLATFORMS LABELS
user@k8s-node-2:~$ sudo ctr -n k8s.io images ls | grep hello
ghcr.io/example/hello:1.0                                                              application/vnd.oci.image.index.v1+json sha256:a5066cd40ef37b9b889ddfbe9c1860b3afe231b96b975000e0edca3fdd6f4a4a 3.2 MiB linux/amd64,linux/arm64 io.cri-containerd.image=managed
ghcr.io/example/hello@sha256:a5066cd40ef37b9b889ddfbe9c1860b3afe231b96b975000e0edca3fdd6f4a4a application/vnd.oci.image.index.v1+json sha256:a5066cd40ef37b9b889ddfbe9c1860b3afe231b96b975000e0edca3fdd6f4a4a 3.2 MiB linux/amd64,linux/arm64 io.cri-containerd.image=managed

user@k8s-node-2:~$ sudo ls /var/lib/containerd/io.containerd.content.v1.content/blobs/sha256/ | grep -E '^(a5066c|bc2899|e909d5|f768da|28a9c8)'
28a9c83dfc2d1139a8a6d4610867e774208003f2e85e9ccfc8207ec6cd35f14a
a5066cd40ef37b9b889ddfbe9c1860b3afe231b96b975000e0edca3fdd6f4a4a
bc289980e0a6cbd930a319094f25249a36634818823ab3bfc2d6a9382cd0ec6d
f768da2949737845e4a79d5141c37186a611d44d79f0ecef3ddce921d3b5d00e

Пространство имён default пусто, образы kubelet — в k8s.io. В content store — index, манифест amd64, config и слои; манифеста arm64 (e909d5…) и аттестаций нет: узлу они не нужны. Файлы называются своими дайджестами, и sha256sum любого из них совпадёт с именем.

Снимки и overlay-монтирование работающего контейнера:

user@k8s-node-2:~$ CID=$(sudo crictl ps --name hello -q)
user@k8s-node-2:~$ sudo ctr -n k8s.io snapshots ls | grep -E "^KEY|^$CID|^sha256:b3b8d3"
KEY                                                                     PARENT                                                                  KIND
cde4a6d2b3914a3dfe6311031441443677a65a768ae3767529e06060b1c3de87        sha256:b3b8d3ba9a241bc70d3c5483b50354bf657b524012762da671f0c7602b72b051 Active
sha256:b3b8d3ba9a241bc70d3c5483b50354bf657b524012762da671f0c7602b72b051 sha256:d90b4cf528e38e9d1df9b742b6d6aef28307c8c7cd897b08959860b2556bb106 Committed

user@k8s-node-2:~$ mount | grep "$CID"
overlay on /run/containerd/io.containerd.runtime.v2.task/k8s.io/cde4a6d2b391…/rootfs type overlay (rw,relatime,
lowerdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/108/fs:…/snapshots/107/fs:…:…/snapshots/95/fs,
upperdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/109/fs,
workdir=/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/109/work,uuid=on,nouserxattr)

user@k8s-node-2:~$ sudo ls -l /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/108/fs
total 5732
-rwxr-xr-x 1 root root 5865632 Oct 24 14:40 hello
user@k8s-node-2:~$ sudo find /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/109/fs
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/109/fs
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/109/fs/var
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/109/fs/var/run
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/109/fs/var/run/secrets
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/109/fs/var/run/secrets/kubernetes.io
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/109/fs/var/run/secrets/kubernetes.io/serviceaccount
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/109/fs/etc
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/109/fs/etc/hostname
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/109/fs/etc/resolv.conf
/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/109/fs/etc/hosts

Строка mount перенесена для читаемости, пути сокращены. Ключ зафиксированного снимка — chainID слоя, ключ активного — ID контейнера. В lowerdir четырнадцать каталогов слоёв, сверху — наш бинарь. В upperdir контейнер сам ещё ничего не записал, но там уже есть пустые точки монтирования: на них поверх образа подключены файлы, которые готовит kubelet, — /etc/hosts, /etc/hostname, /etc/resolv.conf и каталог с токеном ServiceAccount.

Наконец, как kubelet видит диск с образами для сборки мусора:

user@k8s-node-2:~$ sudo crictl imagefsinfo | jq '.status.imageFilesystems[0] | {mountpoint: .fsId.mountpoint, usedBytes: .usedBytes.value}'
{
  "mountpoint": "/var/lib/containerd/io.containerd.snapshotter.v1.overlayfs",
  "usedBytes": "1251205120"
}

Это та файловая система, заполненность которой kubelet сравнивает с порогами 85 и 80 процентов.


Свой образ лежит в частном реестре, узлы знают токен на чтение. Под hello, который мы запустили для проверки, — пока просто способ скачать образ; что такое под на самом деле, мы ещё не разбирали.

Источники


Дальше — Pod: зачем Kubernetes запускает поды, а не контейнеры, что делает pause-контейнер, init- и sidecar-контейнеры, проверки liveness, readiness и startup, перезапуски и CrashLoopBackOff, корректное завершение по SIGTERM.

Оставить комментарий могут только зарегистрированные пользователи.

Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.