Docker Hub rate limits в CI: переносим образы в Harbor и настраиваем proxy-cache
После ноябрьских rate limits Docker Hub наши CI-сборки начали падать с 429. Переносим базовые образы во внутренний Harbor и разбираем подводные камни proxy-cache.
Docker Hub rate limits (ноябрь 2020) стали системной проблемой CI/CD: переход на Harbor proxy-cache как производственное решение
В ноябре 2020 Docker Hub ввёл rate limits, и первые несколько месяцев это ощущалось как неудобство - там пайплайн упал, тут разработчик получил 429 при docker pull. К весне 2021 картина изменилась: у нескольких клиентов CI начал падать регулярно и предсказуемо, особенно в часы пик. Анонимный лимит - 100 pull за 6 часов с одного IP - исчерпывался за час на нагруженных GitLab-runner'ах, которые сидят на одном адресе.
Временных заплаток хватало. Авторизация в Docker Hub прямо в пайплайне снимала остроту - авторизованный free-аккаунт даёт 200 pull за 6 часов. Но это всё равно потолок, credentials расползались по переменным разных проектов, а корень проблемы никуда не девался. Пора было сделать нормально.
Что решаем и как
Задача распадается на две части: базовые образы, которые мы собираем сами (FROM python:3.9-slim, FROM node:14-alpine и так далее), и транзитный трафик - то, что CI тянет из Docker Hub на лету при каждой сборке.
Для базовых образов решение простолинейное: копируем нужные образы в Harbor, прописываем внутренний адрес в Dockerfile. Один раз дёрнуть, больше не зависеть. Для транзитного трафика - proxy-cache, чтобы не переписывать каждый image: в CI-конфигах.
Harbor у нас уже стоял - мы перевели на него Helm-чарты раньше, так что как минимум одна точка входа вместо нескольких уже была.
Переносим базовые образы
Сначала инвентаризация: что реально используется в Dockerfile'ах как FROM. Грепаем по репозиториям:
grep -rh '^FROM ' --include='Dockerfile*' . | \
sort -u | \
grep -v 'FROM scratch' | \
grep -v '^FROM \$'
Получаем список вида python:3.9-slim, node:14-alpine, golang:1.16, nginx:1.19-alpine - штук двадцать уникальных образов, часть с несколькими тегами. Дальше скрипт:
HARBOR="harbor.internal.example.com"
PROJECT="dockerhub-mirror"
while IFS= read -r image; do
docker pull "${image}"
target="${HARBOR}/${PROJECT}/${image}"
docker tag "${image}" "${target}"
docker push "${target}"
done < base-images.txt
После этого меняем FROM в Dockerfile - вместо python:3.9-slim пишем harbor.internal.example.com/dockerhub-mirror/python:3.9-slim. Это ломает образ для локальной разработки снаружи сети, если у разработчика нет доступа к Harbor. Пришлось добавить инструкцию в онбординг - один из тех моментов, которые кажутся очевидными, пока первый новый человек не потратит час на debugging.
Второй момент: теги типа node:14-alpine не вечны. Docker Hub периодически пересобирает эти образы с патчами безопасности. Мы добавили в расписание еженедельный re-pull и re-push базовых образов, чтобы не застревать на старых слоях.
Настраиваем proxy-cache в Harbor
Это интереснее. Harbor с версии 2.1 поддерживает Proxy Cache как отдельный тип проекта: создаёшь проект с endpoint на Docker Hub, и Harbor становится pull-through кешем. Запрос docker pull harbor.internal.example.com/dockerhub-proxy/nginx:1.19 уходит в Harbor, Harbor смотрит у себя в кеше, если нет - тянет с Docker Hub под своими credentials, кеширует, отдаёт. Следующий запрос - из кеша.
Настройка в Harbor:
- Идём в Administration - Registries, добавляем endpoint:
https://hub.docker.com, тип Docker Hub, credentials от аккаунта с подпиской (или хотя бы авторизованного). - Создаём проект
dockerhub-proxy, в настройках выбираем тип Proxy Cache, указываем созданный registry endpoint. - Настраиваем политику очистки - по умолчанию кеш может разрастись до нескольких сотен гигабайт, если тянуть много разных тегов.
В CI меняем prefix:
variables:
DOCKER_HUB_PROXY: harbor.internal.example.com/dockerhub-proxy
build:
image: ${DOCKER_HUB_PROXY}/node:14-alpine
services:
- name: ${DOCKER_HUB_PROXY}/postgres:13
alias: db
Подводные камни
Кеш инвалидируется по тегу, не по дайджесту. Если node:14-alpine обновился на Docker Hub, Harbor об этом не знает - он отдаёт то, что закешировано, до истечения TTL. По умолчанию TTL в Harbor - 7 дней для кешированных манифестов. Для latest-тегов это может быть неприятно: CI тянет образ недельной давности и не подозревает об этом. Мы снизили TTL до 24 часов для проектов, где свежесть базового образа важна.
Анонимный pull к proxy-проекту тоже лимитирован. Harbor сам делает pull с Docker Hub от имени credentials, которые указали в endpoint - это один аккаунт, лимит выше. Но если credentials не указаны или аккаунт анонимный, вы просто перенесли проблему из своего CI в Harbor. Убедитесь что endpoint настроен с авторизацией.
Robot accounts и права. Для CI мы используем robot account в Harbor с правами pull на проект dockerhub-proxy. Для Dockerfile-сборок нужен отдельный robot account с push в dockerhub-mirror. Не смешивать - при ротации credentials легче понять что куда.
Первый pull всегда медленный. Когда образ не в кеше, Harbor тянет его с Docker Hub в момент запроса. Если несколько параллельных job'ов запрашивают один и тот же образ первый раз одновременно - Harbor корректно обрабатывает конкурентные запросы, но ждёт реальный pull. На толстых образах вроде python:3.9 это несколько минут. Это нормально, просто первый прогон после прогрева будет дольше.
Где сейчас
На проектах в managed-сопровождении переход сделали поэтапно за пару недель. Базовые образы в Harbor - сразу, proxy-cache для транзитного трафика - туда же, но с переменной в CI чтобы откатить одним изменением если что-то пойдёт не так.
Ошибки toomanyrequests из CI пропали. Credentials от Docker Hub теперь в одном месте - в Harbor endpoint, а не размазаны по переменным десятков проектов.
Что не решили: образы из других публичных registry - ghcr.io, quay.io, gcr.io. Там своя история с лимитами, и она отдельной задачей висит в бэклоге.
- GitLab 13.10: Dependency Proxy как ответ на Docker Hub rate limits · 13 апреля 2021
- Helm 3 OCI Registry: храним чарты там же, где образы · 18 марта 2021