← назад к разделу

Вы собрали образ у себя на ноутбуке — теперь нужно, чтобы он оказался на сервере. Реестр образов — это именно то место, через которое образ переезжает с одной машины на другую, и откуда CI берёт и кладёт образы автоматически.

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

что лежит в реестре и что получит сервер по тегу реестр · ghcr.io/myorg/backend образ A · sha256:9f3c… 240 МБ latest sha-abc1234f снятобраз B · sha256:2c7a…244 МБlatestsha-de56789a оба тега смотрят на один образсборка #41 · коммит abc1234fdocker build, затем docker tag ×2docker push …:sha-abc1234fdocker push …:latestдва тега — один набор слоёвданные не дублируютсяв реестре 240 МБ, а не 480docker push с двумя тегами — один образ в реестре latest снят с A и надет на Bсборка #42 · коммит de56789aтот же Dockerfile, новый коммитdocker push …:sha-de56789adocker push …:latestlatest переехал: A → Bsha-abc1234f остался на Aбайты образа A не изменилисьповторный push перевешивает latest на новый образ latest сейчас = B, вчера был = Aразвёртывание по тегу latestdocker pull …:latestсервер-1 тянул до сборки #42 → Aсервер-2 тянул после неё → Bтег один — образы разныепо тегу не понять, что в продепо latest не откатиться: он переписанодин тег latest — два сервера на разных образах дайджест ведёт к байтам, не к имениразвёртывание по дайджестуdocker pull …@sha256:9f3c…сервер-1 → образ Aсервер-2 → образ Aдайджест — хеш содержимогоповторный push его не переставитCI кладёт дайджест в манифест деплояsha-тег и дайджест не переезжают — деплой по ним

Два тега на одном образе ничего не дублируют — в реестре лежат те же 240 МБ. Но повторный push перевешивает latest на новый образ, и два сервера с одним тегом окажутся на разных образах; неподвижны только sha-тег коммита и дайджест.

Обязательно

Что такое реестр образов

Реестр образов (registry) — это хранилище Docker-образов, доступное по сети. Принцип тот же, что у репозитория git: вы отправляете (push) образ в реестр, а потом скачиваете (pull) его на любой машине — локально, на сервере, в Kubernetes.

Самые распространённые реестры:

  • Docker Hub (hub.docker.com) — публичный реестр по умолчанию. Бесплатный для публичных образов; приватные — ограничены на бесплатном тарифе. Когда вы пишете docker pull nginx, Docker идёт именно туда.
  • GitHub Container Registry (ghcr.io) — реестр от GitHub, удобен, если код уже там. Права управляются через токены GitHub.
  • Приватные реестры — можно поднять собственный (например, через registry:2 или Harbor) или использовать реестр облачного провайдера (Amazon ECR, Google Artifact Registry, Yandex Container Registry).

Все они работают по единому протоколу — OCI Distribution Spec, поэтому команды docker push / docker pull одинаковы вне зависимости от реестра.

Именование образов и теги

Полное имя образа выглядит так:

<реестр>/<пространство-имён>/<имя>:<тег>

Несколько примеров:

Имя образаГде лежит
nginx:1.27Docker Hub, официальный образ
myuser/myapp:latestDocker Hub, образ пользователя
ghcr.io/myorg/backend:v1.4.2GitHub Container Registry
registry.company.ru/team/api:sha-abc1234приватный реестр компании

Если реестр не указан — Docker Hub. Если тег не указан — latest.

Тег — это метка, приклеенная к конкретному образу целиком: к его манифесту, то есть к списку слоёв и настроек. Не к отдельному слою — к набору. Теги изменяемы: latest сегодня и latest через неделю могут указывать на разные образы. Поэтому latest удобен для разработки, но опасен в продакшне.

тег :1.4.2 манифест указывает на конфиг образа настройки, env слой: база temurin 21-jre слой: зависимости jar-библиотеки слой: код классы сервиса второй тег sha-abc1234f тот же манифест 0 новых байт

Тег держится за манифест, а не за отдельный слой; второй тег на тот же манифест добавляет ещё один указатель и ни одного байта данных.

Стратегия тегов

Хаотичное именование — источник головной боли: непонятно, что задеплоено, сложно откатиться, трудно проследить, из какого коммита образ собран.

Три устойчивые стратегии:

1. Семантическое версионирование (semver)

myapp:1.4.2
myapp:1.4
myapp:1

Понятно людям, поддерживает «плавающие» теги (1.4 → всегда последний патч-релиз). Подходит для библиотек и публичных образов.

2. Git SHA коммита

myapp:sha-abc1234f

Однозначно привязывает образ к исходному коду. Невозможно перезаписать случайно. Рекомендуется как основной тег в CI — всегда знаете, из какого коммита собрано.

3. Комбинированный

myapp:1.4.2          # для релизного тега
myapp:sha-abc1234f   # для точного отслеживания

Можно ставить оба тега на один образ.

Короткая формула: в продакшне никогда не используйте latest — только версионированный или sha-тег.

Тег — подвижный указатель: даже версионный тег можно перезаписать повторным push. Когда нужно гарантировать, что в кластере работает ровно тот образ, который прошёл тесты, ссылаются на дайджест — registry/myapp@sha256:…: это хеш содержимого, он однозначен и не подменяется. CI получает дайджест после push и записывает его в манифест деплоя; тег остаётся для людей, дайджест — для машин.

docker tag, push и pull

Прежде чем отправить образ в реестр — войдите в него:

docker login ghcr.io -u USERNAME --password-stdin <<< "$GITHUB_TOKEN"

Образ тегируется командой docker tag:

# сначала собираем
docker build -t backend:local .

# добавляем «адрес» для реестра
docker tag backend:local ghcr.io/myorg/backend:sha-abc1234f
docker tag backend:local ghcr.io/myorg/backend:v1.4.2

Отправляем:

docker push ghcr.io/myorg/backend:sha-abc1234f
docker push ghcr.io/myorg/backend:v1.4.2

Скачиваем на другой машине:

docker pull ghcr.io/myorg/backend:sha-abc1234f

Один образ (один набор слоёв) может иметь сколько угодно тегов — это дёшево: теги — просто указатели, данные не дублируются.

Сборка и публикация в CI

Ручная сборка и push — это хорошо для экспериментов. В реальном проекте образ собирается автоматически при каждом пуше в основную ветку или при создании тега релиза.

Общая схема конвейера:

код git push запускается CI docker build docker tag docker push развёртывание

Образ собирается один раз и получает тег с хешем коммита. Дальше по цепочке едет именно он — а не «пересоберём для прода».

Пример конфигурации для GitHub Actions (.github/workflows/build.yml):

name: Сборка и публикация образа

on:
  push:
    branches: [main]

env:
  REGISTRY: ghcr.io

jobs:
  build-and-push:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write          # нужно для публикации в ghcr.io

    steps:
      - uses: actions/checkout@v4

      - name: Имя образа строчными буквами
        run: echo "IMAGE_NAME=${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"

      - name: Поднять buildx
        uses: docker/setup-buildx-action@v3

      - name: Войти в GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Собрать образ и опубликовать
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          # кэш слоёв между запусками: без этих строк каждая сборка идёт с нуля
          cache-from: type=gha
          cache-to: type=gha,mode=max
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

Два шага в начале выглядят лишними, но без них этот файл не заработает.

Имя строчными буквами. Реестр ghcr.io принимает имена образов только в нижнем регистре. Если организация называется MyOrg, то ${{ github.repository }} даст MyOrg/backend, и push закончится ошибкой. Конструкция ${GITHUB_REPOSITORY,,} — это средство самой оболочки bash: две запятые переводят строку в нижний регистр.

Шаг setup-buildx-action. Кэш type=gha умеет только отдельный сборщик buildx. Без этого шага сборка идёт стандартным драйвером, который выгружать кэш не умеет, и вы получите Cache export is not supported for the docker driver — причём падение будет на самой сборке, а не на кэше.

Тег latest здесь ставится дополнительно к неизменяемому sha-… — как удобная ссылка «последнее из main» для локальной работы. Разворачивать в прод нужно именно по sha-…: он всегда указывает на один и тот же образ.

Теги не пишут руками: docker/metadata-action

В примере выше два тега перечислены вручную, и для одной ветки этого хватает. Как только появляются релизные теги, ветки с задачами и запросы на изменение, список превращается в набор условий — и для этого есть готовый шаг, который считает теги сам:

      - name: Посчитать теги и метки
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-           # sha-1a2b3c4 — всегда
            type=ref,event=branch          # main, feature-x — для ветки
            type=ref,event=pr              # pr-42 — для запроса на изменение
            type=semver,pattern={{version}}       # 1.4.2 — из тега v1.4.2
            type=semver,pattern={{major}}.{{minor}}  # 1.4
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Собрать образ и опубликовать
        uses: docker/build-push-action@v6
        id: build
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

Что это даёт, кроме экономии строк. latest ставится только для основной ветки — самая частая ошибка ручных тегов, когда latest начинает указывать на сборку из ветки с задачей. Из тега v1.4.2 автоматически получаются 1.4.2 и 1.4, то есть работает описанная выше стратегия «двигающийся минорный тег». И тот же шаг заполняет метки OCI (ссылку на исходный код, коммит, версию), о которых говорит статья про промышленные образы.

Развёртывание по дайджесту: где его взять

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

      - name: Показать дайджест
        run: echo "${{ steps.build.outputs.digest }}"
        # sha256:9f8e7d...

      - name: Подставить в манифест и развернуть
        run: |
          kubectl set image deployment/orders \
            orders=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

Обратите внимание на синтаксис: дайджест присоединяется к имени через @, а не через :, и тег при этом не нужен вовсе. Такая ссылка неизменяема по определению — кто бы что ни переставил в реестре, развёрнут будет ровно этот образ.

Если развёртывание идёт не командой, а через репозиторий с манифестами (подход, когда состояние кластера описано в git), то в конвейере тот же дайджест подставляют в файл манифеста и делают коммит — дальше его применяет оператор в кластере. Механика та же, меняется только способ доставки.

Права на образ: почему сервер не может его скачать

Следующий шаг после первой успешной публикации — «образ в реестре есть, а сервер его не тянет». Причина почти всегда в правах, и в ghcr.io она устроена так: пакет по умолчанию приватный, а видимость и доступ настраиваются отдельно от репозитория с кодом.

Три вещи, которые надо сделать.

Дать конвейеру право публиковать. Это строка packages: write в разрешениях задания — без неё push отвечает отказом, хотя вход прошёл успешно.

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

Дать серверу учётные данные. Для приватного образа это docker login на машине или, в Kubernetes, отдельный секрет с доступом к реестру, который указывают в поде (imagePullSecrets). Токен для этого берут узкий: в GitHub — токен с правом только на чтение пакетов, а не личный токен с полными правами. Симптом, по которому это узнаётся сразу: ErrImagePull и в описании пода unauthorized или denied.

И про сам GITHUB_TOKEN из примера: он действует только внутри запуска конвейера и для сервера не годится — на машины его не копируют.

Сканирование в конвейере

Статья про промышленные образы показывает сканеры на локальном образе и обещает встраивание в конвейер. Вот оно, и встраивается оно одним шагом после сборки:

      - name: Проверить образ на уязвимости
        uses: aquasecurity/trivy-action@0.28.0
        with:
          image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
          severity: HIGH,CRITICAL
          ignore-unfixed: true
          exit-code: '1'

Три решения, которые тут принимаются осознанно.

Что считать отказом. exit-code: 1 роняет сборку. Падать стоит на CRITICAL и на исправимых HIGH (ignore-unfixed: true), иначе конвейер встанет на уязвимости, которую никто не может исправить, и его отключат.

Сканировать до публикации или после. Проще после (как выше, по дайджесту уже опубликованного образа): образ доступен всем инструментам. Строже — до: собрать локально без push, проверить, и публиковать только прошедшее. Второй вариант правильнее для внешних реестров, первый достаточен для внутренних.

Кто читает отчёт. Кроме отказа сборки полезно выгружать результат в раздел безопасности репозитория (format: sarif плюс шаг загрузки) — тогда история находок видна и без чтения журналов конвейера.

Рядом с этим шагом обычно ставят ещё два, уже не про безопасность образа, а про доверие к нему: --sbom и --provenance в сборке и подпись cosign по тому же дайджесту.

Очистка реестра

Реестр растёт быстрее, чем кажется: тег на каждый коммит плюс кэш слоёв — и через год это сотни гигабайт, за которые платят или упираются в квоту. Уборка — часть конвейера, а не разовая акция.

Правила, которые обычно работают: образы с тегом sha-… хранить месяц, pr-… — удалять после закрытия запроса, релизные (1.4.2) — хранить всегда, кэш сборки — неделю. В GitHub это делает готовый шаг по расписанию, в Yandex Container Registry и Harbor — встроенная политика жизненного цикла образов, и её достаточно настроить один раз.

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

В реальном проекте рядом обычно идёт следующий шаг — обновление манифеста Kubernetes или вызов kubectl rollout restart, но это уже выходит за пределы Docker как инструмента сборки.

Один образ на все среды

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

Соблазн пересобрать выглядит естественно: у тестовой среды другой адрес базы, другой уровень журналирования, другой ключ. Кажется логичным собрать «образ для теста» и «образ для прода». Так делать нельзя, и вот почему.

Пересобранный образ — другой образ. Между двумя сборками изменилось хотя бы содержимое базового образа (21-jre обновляется), версии подтянутых зависимостей, а иногда и код — если кто-то успел смержить. Проверили на тесте одно, в прод поехало другое, и разница невидима: тег тот же, а дайджест другой.

Смысл тестирования исчезает. Все проверки — интеграционные тесты, нагрузочные, ручная приёмка — относятся к конкретному артефакту. Если в прод едет другой артефакт, результаты проверок к нему не относятся, сколько бы их ни было.

Отладка становится гаданием. «На тесте работает, в проде нет» невозможно разобрать, пока нет уверенности, что запущено одно и то же.

Как тогда быть с различиями между средами. Ответ один и старый: различия живут вне образа. Адреса, пароли, ключи, уровни журналирования, включённые возможности — переменные окружения, файлы настроек, монтируемые извне, секреты из хранилища. Внутри образа остаётся только то, что одинаково везде: код, зависимости, среда выполнения. Это и есть принцип «конфигурация отдельно от кода» из «двенадцати факторов», и в контейнерах он не пожелание, а условие работоспособности схемы.

Практически это выглядит так: конвейер собрал образ с тегом sha-1a2b3c4, опубликовал, дальше по этому же дайджесту он разворачивается в тестовую среду, потом в предпромышленную, потом в промышленную — одна ссылка, три развёртывания, разные наборы переменных. Продвижение можно оформить и тегами (app@sha256:… → staging, → production), но тегами именно помечают уже существующий образ, а не собирают новый.

Проверить, что правило соблюдается, можно одним вопросом к своему конвейеру: сколько раз в нём встречается шаг сборки образа? Если больше одного (например, отдельная задача «собрать для прода»), значит в прод едет непроверенное.

Образ помнит, на каком процессоре его собрали

Про это узнают одинаково: собрали образ на ноутбуке с процессором Apple, отправили в реестр, запустили на сервере — и контейнер падает со строчкой exec format error. Внутри лежат машинные команды для другой архитектуры.

У образа есть архитектура, как у обычной программы. Ноутбуки Apple и многие облачные машины — это arm64, а большинство серверов — amd64 (он же x86_64). Образ, собранный под одну, на другой не запустится.

Лечится двумя способами. Первый — собирать в конвейере: раннеры GitHub Actions работают на amd64, и образ сразу получается «серверный». Второй — собрать сразу под обе архитектуры, тогда реестр отдаст каждой машине подходящий вариант:

      - name: Собрать образ и опубликовать
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          platforms: linux/amd64,linux/arm64

Локально то же самое делается флагом: docker build --platform linux/amd64 -t myapp:1.0 . — полезно, когда собираете с ноутбука, а запускать будете на сервере.

Многоэтапная сборка в CI

Для многоэтапной сборки CI ничего особенного делать не должен — docker build сам разберётся, и в реестр уйдёт только финальный лёгкий образ без JDK и сборочного хозяйства: 300–350 МБ вместо 700–900. Как устроить Dockerfile, чтобы зависимости кэшировались отдельно от кода, разобрано в статье про многоэтапную сборку.

Дополнительно: при первом чтении можно пропустить

Глубже: реестры в российских условиях: зеркала, прокси и свой реестррасширенное

docker pull postgres:17 на сервере отвечает ошибкой, а на ноутбуке вчера работал. Список реестров выше устроен так, будто Docker Hub доступен всегда, а с 2024 года прямые обращения к нему из российских сетей блокируются со стороны самого Docker Hub, и это первое, что ломается при развёртывании. Вторая проблема существовала и раньше: у Docker Hub лимит на анонимные pull с одного адреса, а за NAT офиса или облака адрес общий на всех, и в разгар дня сборки падают с toomanyrequests.

Три способа, по нарастающей надёжности. Зеркало. В /etc/docker/daemon.json указывают registry-mirrors со списком зеркал, и Docker ходит за образами Docker Hub туда; публичные зеркала есть у российских облаков и у Google (mirror.gcr.io), и они кэшируют популярные образы. Это чинит pull на машине без правки образов и Dockerfile, но зеркало не гарантирует наличие редкого образа и не покрывает другие реестры: quay.io (Keycloak), gcr.io (distroless), ghcr.io.

Прокси-реестр. Nexus или Harbor внутри компании настраивают как сквозной кэш: он тянет образ из внешнего реестра при первом запросе и хранит у себя, а Dockerfile и конвейеры ссылаются на него: FROM registry.corp/dockerhub/eclipse-temurin:21-jre. Так покрывают все внешние реестры, снимают лимиты и получают образы, которые не пропадут, если внешний источник закроется, ценой ещё одного сервиса в эксплуатации.

Свой реестр для своих образов нужен в любом случае: собранные образы кладут в реестр облака, где стоит кластер, Yandex Container Registry (cr.yandex/<id>/app:1.4.2, вход через yc container registry configure-docker), реестры VK Cloud и Selectel, или в Harbor у себя. Вместе с ним заводят правило: базовые образы, от которых зависят сборки, перетегированы в свой реестр (docker pull, docker tag, docker push один раз, дальше FROM cr.yandex/<id>/base/temurin:21-jre), и внешние реестры в сборке не участвуют вовсе. Дайджест в FROM закрепляет, что перетегированный образ тот самый.

Что проверить на новой машине или в новом конвейере: docker pull базового образа из того реестра, что в Dockerfile; настроен ли registry-mirrors или прокси; есть ли учётные данные для своего реестра в CI (секрет, а не файл config.json в репозитории); и куда ходит Testcontainers, у него для этого своя настройка префикса реестра, о чём статья про основы.

Коротко

  • Реестр образов — хранилище, через которое образ переезжает между машинами; самые популярные: Docker Hub, GitHub Container Registry, приватные реестры облачных провайдеров.
  • Полное имя образа: <реестр>/<пространство-имён>/<имя>:<тег>; без реестра — Docker Hub, без тега — latest. docker tag добавляет новый указатель на уже собранный образ; данные не копируются.
  • В продакшне используйте конкретный тег (semver или sha коммита), а не latest — иначе непонятно, что именно задеплоено. Теги считает docker/metadata-action: sha-… всегда, latest только для основной ветки, 1.4.2 и 1.4 из релизного тега, он же заполняет метки OCI.
  • Конвейер CI: build → tag → push; следующий шаг — развёртывание (Kubernetes, systemd, Compose на сервере).
  • В GitHub Actions GITHUB_TOKEN достаточно для публикации в ghcr.io — дополнительные секреты не нужны. В ghcr.io пакет по умолчанию приватный: конвейеру нужно packages: write, серверу — вход или секрет реестра с правом только на чтение, иначе ErrImagePull с unauthorized.
  • Docker Hub из России и с общего адреса ненадёжен: registry-mirrors в daemon.json, прокси-реестр Nexus или Harbor для всех внешних источников, свой реестр в облаке для своих образов и перетегированные базовые образы с дайджестом.
  • Дайджест для развёртывания берут из вывода шага сборки (steps.build.outputs.digest) и подставляют через @, а не через :.
  • Сканирование встраивают шагом после сборки с порогом CRITICAL плюс исправимые HIGH и ignore-unfixed; рядом ставят SBOM, провенанс и подпись по дайджесту.
  • Реестру нужна политика уборки (sha-… месяц, pr-… после закрытия, релизы навсегда), и она обязана исключать то, на что ссылаются живые среды.
  • Образ собирают один раз и продвигают по средам по дайджесту: пересборка для прода отправляет туда непроверенный артефакт, а различия сред живут в переменных, не в образе.

Что почитать дальше