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, происходит следующее:
- Resolve. containerd запрашивает у реестра манифест по тегу и получает дайджест — для мультиархитектурного образа это дайджест index.
- Выбор платформы. Из index берётся манифест
linux/amd64(все узлы стенда — x86-64). - 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. - 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, который мы запустили для проверки, — пока просто способ скачать образ; что такое под на самом деле, мы ещё не разбирали.
Источники
- OCI Image Specification и OCI Distribution Specification — формат образа и протокол реестра.
- containerd: путь содержимого от реестра до снимка, snapshotter-ы, настройка реестров через hosts.toml.
- Docker: справочник Dockerfile, multi-stage, сборка под несколько платформ, кеш-монтирования,
docker login; BuildKit, buildah, ko. - distroless — базовые образы без shell и пакетного менеджера.
- GitHub: работа с Container Registry и personal access tokens.
- Kubernetes: образы (
imagePullPolicy, проверка учётных данных для уже скачанных образов), скачивание из частного реестра, провайдеры учётных данных kubelet, сборка мусора образов, конфигурация kubelet. - Distribution и Harbor — свой реестр.
Дальше — Pod: зачем Kubernetes запускает поды, а не контейнеры, что делает pause-контейнер, init- и sidecar-контейнеры, проверки liveness, readiness и startup, перезапуски и CrashLoopBackOff, корректное завершение по SIGTERM.
Оставить комментарий могут только зарегистрированные пользователи.
Войдите на сайт или зарегистрируйтесь, чтобы оставить комментарий.