Как ArgoCD выгнал меня из Nexus: переезд на Harbor, Verdaccio и Athens

История о том, как один artifact-сервер незаметно стал лицензионной ловушкой, и почему я разменял монолитный Nexus на три специализированных open-source сервиса

Я долго смотрел на альтернативы Nexus и честно не понимал, зачем они мне. У меня был один artifact-сервер, который закрывал почти всё: Docker-образы, npm-пакеты, Go-модули, blob’ы лежали в S3. Работало годами, не просило есть. Любая статья про «а вот есть ещё Harbor / Verdaccio / Athens» вызывала ровно одну мысль: зачем городить зоопарк, если Nexus и так всё умеет?

А потом помог случай. Точнее — ArgoCD.

Harbor, Verdaccio и Athens вместо монолитного Nexus

В статье

Предыстория: «Nexus и так всё закрывает»

Sonatype Nexus Repository — добротный комбайн. Один процесс обслуживает десятки форматов репозиториев: docker (и приватный приёмник push’а, и proxy-cache к Docker Hub), npm, go, maven, raw — список длинный. У меня blob’ы Docker-реестра (это сами слои образа — упакованные куски файловой системы, из которых он собирается) уезжали в S3-совместимое хранилище, метаданные жили в самом Nexus, а наружу всё это торчало через обратный прокси под несколькими доменными именами.

Это и есть главная ловушка, которую я тогда не разглядел: один сервис на всё выглядит как простота, а на деле это single point — и по отказу, и, как выяснилось, по лицензии.

Случай, который всё решил

Я включил ArgoCD Image Updater — компонент, который сам следит за реестром и подтягивает свежие теги образов в Git, замыкая GitOps-петлю (желаемое состояние кластера описано в Git, а инструмент сам приводит реальность к нему). Штука удобная, но у неё есть характерная особенность: она периодически опрашивает реестр по каждому отслеживаемому образу. На каждый образ — запросы к /v2/.../tags/list, манифесты и digest’ы (это контрольная сумма sha256, которой образ адресуется однозначно). Чем больше образов и чем чаще интервал опроса — тем плотнее поток запросов в реестр.

И вот тут Nexus сказал «стоп». Я внезапно упёрся в суточный лимит бесплатной редакции: мой трафик к собственному репозиторию доходил до ~120 тысяч запросов в сутки. Дело в том, что Sonatype в редакции Nexus Repository Community Edition ввела жёсткие лимиты бесплатного использования: начиная с версии 3.87.0 это 100 тысяч запросов в сутки и 40 тысяч компонентов (до этого было 200 тысяч и 100 тысяч соответственно). Свой суточный потолок запросов я и перебил.

Я сделал очевидное: снизил частоту опроса у Image Updater и сбил нагрузку до ~40 тысяч запросов в сутки. Возможность добавлять новые компоненты вернулась — формально лимит так и устроен: упал под порог, и push снова открыт. Но осадок остался в прямом смысле: в интерфейсе повис несъёмный баннер с обратным отсчётом — через месяц после инцидента инстанс переходит в режим read-only и остаётся в нём, пока не купишь лицензию. Сам факт разового превышения уже запустил этот таймер, и аккуратное снижение нагрузки задним числом его не сбрасывало.

В пользовательских чатах гуляет обходной приём: на бэкенде PostgreSQL повесить триггер, который постоянно патчит служебные данные и снимает ограничение. Технически рабоче — но это путь «обмани вендора в его же базе», и не мой. Честнее съехать, чем жить на костыле, который отвалится на ближайшем апгрейде схемы.

Интервал опроса у Image Updater — это вообще отдельная история: задаётся он не там, где интуитивно ждёшь, и легко настроить «каждую минуту по всем образам», даже не заметив. Но это уже тема для отдельного разбора.

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

Решение: вместо монолита — три специализированных сервиса

Первым желанием было найти «второй Nexus» — такой же комбайн, но без лимитов. Но чем дольше я смотрел, тем яснее понимал: монолитность как раз и была проблемой. Если разнести форматы по специализированным сервисам, каждый из них окажется проще, прозрачнее и — внезапно — функциональнее в своей нише.

Так монолит распался на три части:

flowchart LR subgraph BEFORE["Было"] N["Nexus Repository
(Community Edition)"] N --> Nd["docker"] N --> Nn["npm"] N --> Ng["go"] N -. blob'ы .-> S3a[("S3")] end subgraph AFTER["Стало"] H["Harbor
docker + proxy-cache + Trivy"] V["Verdaccio
npm proxy-cache"] A["Athens
Go module proxy"] H -. blob'ы .-> S3b[("S3")] A -. кэш .-> S3b end BEFORE -->|"переезд"| AFTER

flowchart LR
    subgraph BEFORE["Было"]
        N["Nexus Repository
(Community Edition)"] N --> Nd["docker"] N --> Nn["npm"] N --> Ng["go"] N -. blob'ы .-> S3a[("S3")] end subgraph AFTER["Стало"] H["Harbor
docker + proxy-cache + Trivy"] V["Verdaccio
npm proxy-cache"] A["Athens
Go module proxy"] H -. blob'ы .-> S3b[("S3")] A -. кэш .-> S3b end BEFORE -->|"переезд"| AFTER
Было: один Nexus на все форматы. Стало: три специализированных сервиса с общим S3
  • Docker-образыHarbor. Нативный для cloud-native мира реестр с proxy-cache, robot-аккаунтами и — главное — встроенным сканером уязвимостей.
  • npm-пакетыVerdaccio. Лёгкий npm-реестр и кэширующий прокси.
  • Go-модулиAthens. Harbor умеет только Docker, Go-модули он не обслуживает — поэтому Go выехал в отдельный сервис.

Дальше — по каждому из них, с теми решениями, которые реально пришлось принять.

Harbor: Docker-образы и proxy-cache

Harbor — это CNCF-проект, де-факто стандартный open-source реестр, родной для Kubernetes-окружений. Развернул я его на отдельной машине из официального docker-compose-инсталлятора: внутри nginx, core, jobservice, встроенные PostgreSQL и Redis, registry и Trivy.

Скажу честно: на фоне «ушёл от монолита» Harbor сам по себе монолитом и выглядит — полдюжины контейнеров, своя БД, свой Redis. Для чистого «приватный registry в homelab» есть кандидаты полегче — например Zot (одиночный OCI-реестр с минимумом зависимостей) или встроенные package-реестры Gitea/Forgejo. Я сознательно выбрал тяжёлый вариант: мне нужны были именно те функции, которых у лёгких нет, — сканер уязвимостей, robot-аккаунты, proxy-cache и политики на проектах. Если такой обвес не нужен — начинать стоит с Zot, а не с Harbor.

Blob’ы — в том же S3. Registry в Harbor умеет S3-backend нативно, так что хранилище образов осталось ровно там же, где было у Nexus. Versioning на бакете я выключил намеренно: Docker-реестр адресует blob’ы по sha256, перезаписи не бывает — versioning только раздул бы метаданные.

storage_service:
  s3:
    accesskey: "${S3_ACCESS_KEY}"
    secretkey: "${S3_SECRET_KEY}"
    region: us-east-1
    regionendpoint: https://s3.internal:9000
    bucket: harbor-registry
    secure: true
    v4auth: true
  redirect:
    disable: true   # Harbor сам отдаёт blob клиенту — лишний хоп, но универсально

Один нюанс, на котором легко споткнуться: сам harbor.yml не разворачивает переменные окруженияprepare-скрипт берёт файл как есть. Плейсхолдеры ${S3_ACCESS_KEY} выше — это для читаемости; на практике подставляйте реальные значения шаблонизатором (envsubst, Ansible, Helm — что у вас в пайплайне), прежде чем отдать файл Harbor.

Сканер уязвимостей из коробки. Вот это для меня оказалось главным неожиданным выигрышем. Harbor поставляется с интегрированным Trivy: каждый образ при push автоматически сканируется на CVE, результаты видны прямо в UI, можно запретить pull уязвимых образов политикой. В Nexus сканирование (IQ Server / Firewall) — это платная функциональность. То есть я ушёл от платного лимита и заодно получил бесплатно то, за что в старом мире пришлось бы доплачивать.

Сохранил внешние имена. У меня было два привычных адреса — приватный приёмник push’а и proxy-cache к Docker Hub. Менять их во всех Dockerfile и CI не хотелось, поэтому обратный прокси (HAProxy) маршрутизирует старые хосты на единый Harbor, переписывая путь под нужный проект:

# /v2/ оставляем как есть — это version-check endpoint, его трогать нельзя.
# Приватный реестр: /v2/<name>/... -> /v2/library/<name>/...
http-request set-path /v2/library%[path,regsub(^/v2/,/)] \
    if host_images !{ path /v2/ } { path_beg /v2/ }

# Proxy-cache: /v2/<name>/... -> /v2/dockerhub-proxy/<name>/...
http-request set-path /v2/dockerhub-proxy%[path,regsub(^/v2/,/)] \
    if host_dockerhub !{ path /v2/ } { path_beg /v2/ }

use_backend bk_harbor if host_harbor || host_images || host_dockerhub

Конфиг намеренно упрощён, чтобы показать идею host-routing’а. В бою есть краевые случаи, которые надо держать в голове: эндпоинт /v2/_catalog и многосегментные имена (team/app/service) под regsub ведут себя не так, как одиночное library/<name>, а /v2/ без хвоста — это version-check, его трогать нельзя (потому и исключаем через !{ path /v2/ }). Если будете повторять — проверьте именно эти пути отдельно.

Proxy-cache к Docker Hub я подключил с токеном бесплатного аккаунта — это поднимает лимит анонимных pull’ов с upstream и спасает CI от 429 Too Many Requests. А robot-аккаунты заменили общий логин: у CI — права push/pull в свой проект, у кластера — только pull.

Миграция образов через skopeo

Образы надо было перенести из старого реестра в Harbor без даунтайма. Инструмент для этого — skopeo: он копирует образы registry-to-registry, не разворачивая их в локальный Docker. Логика простая: получить список репозиториев, по каждому — список тегов, и каждый тег скопировать.

skopeo copy --all --retry-times 3 \
  --src-creds "$SRC_USER:$SRC_PASS" \
  --dest-creds "$DST_USER:$DST_PASS" \
  docker://registry.old.example/library/myapp:1.4.2 \
  docker://registry.example/library/myapp:1.4.2

Два момента, на которых стоит заострить внимание:

  • --all обязателен. Современные образы — это manifest list с несколькими архитектурами (amd64, arm64). Без --all skopeo свернёт multi-arch образ в одну архитектуру той машины, с которой запускаешь копирование. С --all сохраняются все.
  • Миграция — best-effort. Один битый legacy-тег не должен ронять весь перенос. Я гонял копирование в режиме «упало — записали в лог, поехали дальше», а в конце сверял число репозиториев в источнике и в Harbor и разбирал расхождения отдельно.

Приятный бонус S3-backend: blob’ы адресуются по sha256, поэтому при копировании одинаковые слои дедуплицируются — общие базовые образы заливаются один раз.

Proxy-cache к Docker Hub я переносить не стал вовсе: это кэш, он наберётся сам при первых pull’ах. Первый pull чуть медленнее (Harbor тянет с upstream), повторные — быстрые, из S3.

Подробнее про skopeo, перенос образов между реестрами и через архив — в статье про golden builder image и registry.

Verdaccio: npm-прокси

Docker — епархия Harbor, а npm нужно было пристроить отдельно. Тут отлично зашёл Verdaccio: крошечный npm-реестр, который из коробки работает как кэширующий прокси к upstream-реестрам.

Мне нужно было проксировать два источника: публичный registry.npmjs.org и приватный реестр с pro-пакетами (доступ по токену). Verdaccio это решает через uplinks и scope-роутинг:

uplinks:
  npmjs:
    url: https://registry.npmjs.org/
    cache: true
  vendor:
    url: https://npm.vendor.example/
    cache: true
    auth:
      type: bearer
      token_env: VENDOR_NPM_TOKEN   # токен — из env, не в репозитории

packages:
  '@vendor/*':
    access: $anonymous
    publish: $authenticated         # публикация фактически закрыта
    proxy: vendor npmjs             # сперва vendor, при 404 — fallback на npmjs
  '**':
    access: $anonymous
    proxy: npmjs

# egress в интернет — только через исходящий прокси
http_proxy: http://proxy:3128
https_proxy: http://proxy:3128

Регистрацию и публикацию я отключил — это чистый прокси-кэш на чтение, наружу он не торчит, ходят в него только через обратный прокси по TLS. Клиентам остаётся прописать в .npmrc единый registry= и scope для приватных пакетов — старый Nexus-URL выводится из обихода.

Athens: Go-модули

Последний кусок монолита — Go-модули. Они, как и npm, вне зоны Harbor, так что в дело пошёл Athens — кэширующее зеркало Go-модулей с бэкендом на S3.

Здесь логика та же, что у Verdaccio, но с поправкой на специфику Go: Athens хранит кэш модулей в том же MinIO/S3, тянет публичные модули с proxy.golang.org через egress-прокси и проксирует sumdb — то есть проверка контрольных сумм у клиентов продолжает работать, и ставить GOSUMDB=off не нужно.

# Athens (docker-compose, фрагмент окружения)
ATHENS_STORAGE_TYPE=minio
ATHENS_MINIO_ENDPOINT=s3.internal
ATHENS_MINIO_USE_SSL=true
ATHENS_MINIO_BUCKET_NAME=athens
ATHENS_DOWNLOAD_MODE=sync
HTTPS_PROXY=http://proxy:3128        # egress к proxy.golang.org/sumdb
NO_PROXY=.internal,localhost         # внутренний/S3-трафик мимо прокси

# Клиент (dev / CI)
export GOPROXY=https://go.example,direct
export GOPRIVATE=git.internal/*       # приватные модули — мимо кэша, напрямую из VCS

Важная деталь — ,direct в GOPROXY. Если Athens вдруг недоступен, клиент деградирует к прямому скачиванию из VCS, а не падает целиком. Для кэш-сервиса это правильное поведение: кэш ускоряет, но не становится новым single point.

Рефлексия: и я не жалею

Самое забавное в этой истории — что я сопротивлялся переезду именно из-за «простоты» монолита. А оказалось, что монолитный artifact-сервер давал ложное чувство простоты и при этом был сразу и единой точкой отказа, и единой точкой лицензионного давления. Один поворот вентиля у вендора — и вся CI/CD-петля встаёт.

Что я получил, разменяв один сервис на три:

  • Бесплатный сканер уязвимостей в Harbor — раньше это была платная опция.
  • S3-нативность там, где она нужна (Harbor, Athens), без прослойки.
  • Независимость сервисов: упал/обновляется один формат — остальные работают. Нагрузка от ArgoCD на Docker-реестр больше не утягивает за собой npm и Go.
  • Open-source без вендор-лока: ни у кого нет рубильника «плати или до свидания».

Но честно — кое-что и подорожало. Три сервиса вместо одного — это три набора TLS-сертификатов, три апдейт-цикла и три места, куда смотреть при инциденте. Часть боли снимается тем, что Verdaccio и Athens — чистые кэши поверх S3: их состояние восстановимо, и по-настоящему бэкапить нужно только Harbor (его PostgreSQL) и сами S3-бакеты. Но факт остаётся фактом: оперировать тремя сервисами сложнее, чем одним, и это осознанная плата за независимость. Если эта цена для вас выше выгоды — возможно, ваш сценарий как раз тот, где монолит оправдан.

Если решите повторить — не поднимайте всё сразу. Начните с формата, который болит сильнее всего (у меня это был Docker → Harbor): разверните его из официального docker-compose, переключите трафик через обратный прокси и убедитесь, что CI и кластер живы. npm (Verdaccio) и Go (Athens) добавляются потом, по одному — так проще локализовать, если что-то отвалится. Собранные конфиги удобно держать в отдельном репозитории: их легче версионировать и переиспользовать.

Значит ли это, что Nexus плохой и всем надо бежать? Нет. Для небольшого проекта один реестр всё ещё может быть оптимальным выбором — меньше движущихся частей, один бэкап, один UI. Я бы и сам не дёрнулся, если бы не лимит. Просто стоит заранее понимать, где у бесплатной версии стоят счётчики, и не строить весь pipeline в расчёте на то, что их никогда не упрёшь.

И эта история ещё не закончена. Тот же сюжет «бесплатное вчера — платное завтра» разворачивается и в других местах моей инфраструктуры. Свежий пример — MinIO, который заметно урезал публичную/community-поддержку и функциональность бесплатной редакции (вплоть до выпотрошенной веб-консоли). И это явно не последняя такая развилка: модель «open core» живёт ровно до тех пор, пока вендору это выгодно.

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

Обсуждение в Telegram

Присоединиться →

Комментарии