Вместо вступления

Хочу рассказать про несколько новых возможностей AWS Elastic Container Registry (ECR), о которых, как мне кажеться знают немного.

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

Инцидент

Наши сервисы спокойно жили в 20 регионах AWS - ровно до очередного инцидента в us-east-1. Подробную хронологию(да это было аж в 2021 году, как летит время!) можно почитать в официальном разборе AWS: https://aws.amazon.com/message/12721/. Если коротко: из-за сетевых проблем в ключевом регионе посыпалось много чего. Нас это почти не задело(мы же распределенные!) - кроме одного маленького нюанса: примерно на час ECR API перестал авторизовать запросы из всех регионов кроме us-east-1. А наши образы в тот момент хранились только в us-east-1.

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

Полистав документацию, мы включили Cross-Region Replication во всех регионах. Сработало? Да, надёжность выросла. Но вместе с ней мы получили три новые проблемы.

Гидра

У такого решения есть своя цена. В нашем случае она сложилась из трёх сложностей.

1. Синхронизация

Репозиторий-реплика появляется в регионе только после первого успешного пуша (replication event). То есть старые образы в новых регионах-репликах попросту недоступны, пока нет события типа push в главном репозитории.

Звучит безобидно, но по факту:

  • случайно удалили не тот образ - сам он не восстановится, и это простой в регионе(outage);

  • завели новый регион - он стоит пустой, пока мы не сделаем пуш свежего (или старого) образа в вышестоящий регион, и если нужен ролбек- откатываться некуда, ведь старых версий не было, и это тоже простой в регионе(outage);

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

2. Деньги

Репликация в 20 регионов - это стоимость хранения, умноженная на 20. А количество образов растёт практически экспоненциально.

В качестве лекарства Amazon предлагает lifecycle (cleanup) policy, но правила там довольно грубые: можно «хранить последние N образов» или «удалять всё старше X дней / без тегов» - и почти ничего сверх этого. Представьте: разработчики гоняют хотфикс, проблема решается не сразу - и вот в репозитории уже два десятка новых образов, причём с релизными тегами(проблема у них, понимаешь, воспроизводиться только на проде!). А дальше такая политика спокойно удаляет образ, который крутится в проде - просто потому, что ему не повезло оказаться 21-м по счёту и старше 14 дней.

Неудивительно, что соответствующий запрос на фичу собрал в роадмапе AWS Containers больше 600 голосов. Правда, на реализацию у AWS ушло около пяти лет - почему так долго и почему именно в таком виде, судить не берусь. Нам же оставалось только упражняться в написании достаточно хитрых политик, а для критичных сервисов - и вовсе отключать их.

3. Автоматизация

И, наконец, ложка дёгтя для тех, кто живёт в IaC:

  • Terraform не может применить aws_ecr_lifecycle_policy к репозиторию, которого ещё нет в регионе-реплике.

  • А репозиторий-реплика, как мы помним, появляется только после первого пуша.

В итоге деплой нового сервиса распадается на зависимые шаги: terraform apply для репозитория и его реплик → запуск пайплайна и сборка+пуш билда → ожидание репликации → terraform apply для политик очистки. Костыль, конечно, всегда можно прикрутить - но мы любим избегать нестандартных практик там, где это возможно.

Решение: Декларативный принцип

И вот однажды, обсуждая совсем другую тему - задержки и лимиты при стягивании образов с Docker Hub, - мы снова вернулись к теме репликации и решили присмотреться к Pull-Through Cache.

Ранее эта фича работала только для публичных апстримов (Docker Hub, ECR Public и подобных), и для нашей задачи была бесполезна. Но в 2025 году появилась возможность использовать в качестве апстрима приватный ECR. И тут до нас дошло: вместо того чтобы заранее реплицировать образы во все 20 регионов, надо просто стягивать их по требованию. Это же классическая дилемма проектирования - Push model против Pull model, - правда, раньше нам нечем было эту дилему решить.

Идея была использовать сразу две новые фичи: Pull-Through Cache + Repository Creation Templates.

Пример реализации

1. Pull-Through Cache

Декларативный принцип в чистом виде: больше не нужно пушить всё и везде заранее. Если ноде в каком-то регионе понадобился образ - она его запрашивает, а ECR сам сходит в апстрим, заберёт образ и закэширует его локально. Никто ничего не реплицирует «на всякий случай».

Нам надо просто объяснить ECR в новом регионе (например, eu-central-1): что если кто-то просит образ из нашего приватного us-east-1 реестра - не надо отдавать 404, надо сходить в апстрим и забрать его.

resource "aws_ecr_pull_through_cache_rule" "main"
 {
  ecr_repository_prefix = "ROOT"
  upstream_registry_url = "111111111111.dkr.ecr.us-east-1.amazonaws.com"
}

После этого команда с другим регионом внутри адреса магически работает, даже если репозитория upstream-prod/my-service в eu-central-1 никогда не существовало.

docker pull 111111111111.dkr.ecr.eu-central-1.amazonaws.com/upstream-prod/my-service:v1

Под капотом вместо того чтобы сразу выдать вам ошибку, ECR проверяет свои настройки кэширования и видит правило которое говорит, что за любыми неизвестными образами надо идти в "111111111111.dkr.ecr.us-east-1.amazonaws.com". Причем это работает и с другими аккаунтами(но там понадобиться создать IAM-роль).
Например, если за билды отвечает отдельная команда и она все образы складывает в центральный регион своего аккаунта, то ваш сервис может с помощью этой фичи стянуть этот образ в свой аккаунт и регион.
Интересно, что указывая несуществующий адрес образа с eu-central-1 внутри мы просто говорим ECR, в каком регионе он должен обработать запрос, а не указываем конкретный адрес.

Есть и более продвинутые возможности - например, если один и тот же образ лежит в разных регистри (dockerhub, quay.io) и вы хотите на стороне деплоймента регулировать, откуда загрузить образ в регион, можно использовать ecr_repository_prefix, отличный от ROOT.

Получается, наш кубернетес-деплоймент декларативно говорит, что и как делать с образами ещё до загрузки.

2. Repository Creation Templates

Но это еще не все!
У ленивого создания репозиториев есть свои недостатки: созданный Амазоном «на лету» репозиторий пустой - без шифрования, без тегов, без lifecycle-политик.
Repository Creation Templates закрывают этот пробел. Мы один раз описываем шаблон для префикса (например, upstream-prod/*) - и при первом же обращении по этому пути AWS не только создаёт репозиторий автоматически, но и сразу накатывает на него весь нужный конфиг. Настроил один раз - и забыл.
Теперь говорим AWS: «Каждый раз, когда Pull-Through Cache создаёт репозиторий с префиксом upstream-prod, примени к нему вот этот конфиг» - зашифруй ключом KMS, повесь тег ManagedBy и удаляй всё старше 14 дней.

resource "aws_ecr_repository_creation_template" "template" {
  prefix      = "upstream-prod"
  description = "Auto-config for pull-through cached repos"

  applied_for = ["PULL_THROUGH_CACHE"]   # Обязательный аргумент: к каким действиям применяется шаблон

  custom_role_arn = "arn:aws:iam::111111111111:role/ecr-template"

  encryption_configuration {
    encryption_type = "KMS"
    kms_key         = "arn:aws:kms:eu-central-1:111111111111:key/some-key-123"
  }

  resource_tags = {
    ManagedBy = "Me"
  }

  lifecycle_policy = jsonencode({
    rules = [{
      rulePriority = 1
      description  = "Dumb cleanup"
      selection = {
        tagStatus   = "any"
        countType   = "sinceImagePushed"
        countUnit   = "days"
        countNumber = 14
      }
      action = { type = "expire" }
    }]
  })
}

Внимательный читатель дочитавший до сюда скажет:
 — Падажите! А как это решает изначально описанную проблемму с одним центральным регионом? Да, теперь у вас есть образы и локально, но если в каком‑то регионе сервиса не было, а потом его запустили, то контейнеры уже и не стартанут, ведь регион с апстрим репозиторием отвалился!

Да, это правда, но во‑первых у нас теперь есть требуемые образы во всех регионах, и контейнеры могут скейлиться, а во вторых мы можем оперативно поменять апстрим в `aws_ecr_pull_through_cache_rule.main.upstream_registry_url` если регион прилег надолго, что кстати вот недавно случилось с регионом в ОАЭ(а у нас такой риск был помечен как очень маловероятный!).

Итог

Мы получили систему, которая обслуживает себя сама:

  • Нужен образ в регионе Сан-Паулу? Просто docker pull. Disaster recovery из коробки: образы доступны везде, где они реально нужны, а не там, куда мы заранее догадались их положить.

  • Образ устарел? Политика тихо его удалит. И можно не бояться что продакшен сломаеться!

  • Образ снова понадобился через год? Снова docker pull - и он опять здесь.

  • Zero-touch management. Настроил шаблоны - и забыл; новые сервисы заводятся сами.

  • Платим только за то, что действительно используется в регионе (кэш), а не за копию всего архива × 20.

Больше никаких многократных terraform apply для каждого нового микросервиса в каждом из 20 регионов!

И, пожалуй, главный урок: иногда лучшее решение проблемы синхронизации - это избавиться от синхронизации!

Бонус для тех, кто дочитал:
Автор ужасный зануда, и ему нравится AWS ECR, но ещё больше ему нравится производительность команды AWS ECR. За год, который прошёл с момента написания черновика этой статьи, они выпустили ещё больше крутых фич, которые сделают многим жизнь с образами легче:

  • Archive storage class + архивирование по last-pull-time. Фича которую мы ждали очень долго, я упоминал по ссылке про ишью на GitHub, но реализовали они это через двойное правило сначала правило sinceImagePulled и архивация, потом sinceImageTransitioned и там уже можно указать expired. Они утверждали что метрика sinceImagePulled не надежна, потому они перестраховываються. Ещё нюанс: в архиве образ обязан пролежать минимум 90 дней, прежде чем его можно удалить, что тоже стоит денег.

  • Cross-repository layer sharing (blob mounting) - шаринг слоев между репозиториями, экономия денег.

  • CREATE_ON_PUSH - Автосоздание репозитория на push. Тоже может быть полезно, шаблоны в том числе применяются и на репозиторий созданный так.

  • Pull-through cache: синк referrers - теперь вместе с образом автоматически подтягиваются подписи, SBOM и attestations из апстрима

  • Managed image signing - управляемая подпись образов без своей инфраструктуры подписи

  • Pull-through для Chainguard - Chainguard-реестр как upstream

  • И всякие мелочи типа поддержки IPv6 и новые метрики для подсчета репозиториев и образов

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