Зачем возиться, если архив работает?
Установка через `curl | tar` прекрасно работает в первый раз. Проблема — во второй. Ничто не сообщает пользователям о новой версии, ничто не обновляет её вместе с остальными пакетами и ничто не удаляет её начисто. Каждый пользователь в итоге придумывает собственный ритуал обновления.
Репозиторий пакетов перекладывает всё это на механику, которая у операционной системы уже есть. `apt-get upgrade` подхватывает вашу новую версию вместе со всем остальным. Удаление — это `apt-get remove`. Происхождение подтверждает GPG-подпись, которую менеджер пакетов проверяет автоматически, и никому не нужно об этом думать.
Цена вопроса — один вечер настройки и небольшая работа на каждый релиз, которую можно заскриптовать. Вот всё целиком.
Что такое репозиторий на самом деле
Оба формата — это просто статические файлы по HTTPS. Ни демона, ни базы данных — именно поэтому CDN раздаёт их так хорошо.
В APT-репозитории есть `pool/` с файлами `.deb` и дерево метаданных `dists/<suite>/`. `Packages` перечисляет каждый пакет с размером, хешами и путём; `Release` перечисляет хеши файлов `Packages`; а подпись покрывает `Release`. Это вся цепочка доверия, и дальше это будет важно.
YUM-репозиторий проще: файлы `.rpm` лежат в каталоге рядом с `repodata/`, где находится `repomd.xml` и сжатые индексы, на которые он ссылается.
Структура репозитория
deb/
dists/stable/
Release InRelease Release.gpg
main/binary-amd64/Packages{,.gz}
main/binary-arm64/Packages{,.gz}
pool/main/y/yourtool/yourtool_1.0.0_amd64.deb
rpm/
yourtool-1.0.0-1.x86_64.rpm
repodata/
repomd.xml repomd.xml.asc
*-primary.xml.gz *-filelists.xml.gz *-other.xml.gz
Создайте ключ подписи
Ключ — единственная часть, которую нельзя переделать между делом: если вы его потеряете, каждому пользователю придётся импортировать новый. Сгенерируйте его один раз, сделайте резервную копию и секретного ключа, и сертификата отзыва, и держите приватную половину подальше от машин, которым она не нужна.
Релизные скрипты подписывают без участия человека, поэтому у ключа подписи репозитория обычно нет пароля: он защищён правами доступа к файлу и тем, что лежит в ограниченном месте — так же, как ключ подписи в CI. Задайте реальный срок действия (пять лет — обычная практика) и запишите дату продления там, где вы её действительно увидите.
cat > key.batch <<'EOF'
%no-protection
Key-Type: RSA
Key-Length: 4096
Key-Usage: sign
Name-Real: example.com Package Signing
Name-Email: packages@example.com
Expire-Date: 5y
%commit
EOF
gpg --batch --gen-key key.batch && rm key.batch
# the public half — this is what users import
gpg --armor --export packages@example.com > example-com.gpg
# BACK THESE UP, offline:
# gpg --export-secret-keys --armor packages@example.com
# ~/.gnupg/openpgp-revocs.d/<fingerprint>.rev
Соберите и подпишите APT-репозиторий
`apt-ftparchive` (из пакета `apt-utils`) генерирует оба файла метаданных. Важны две детали.
Запускайте его из корня репозитория, потому что пути `Filename:`, которые он записывает в `Packages`, отсчитываются от места запуска. Ошибётесь — apt спокойно прочитает метаданные, а затем получит 404 на каждой загрузке.
Затем опубликуйте подпись дважды: `InRelease` — современная встроенная форма, а `Release.gpg` — отсоединённая подпись для старых клиентов. Сделать оба ничего не стоит и снимает целый класс вопросов в поддержку.
cd deb
for arch in amd64 arm64; do
mkdir -p "dists/stable/main/binary-$arch"
apt-ftparchive --arch "$arch" packages pool \
> "dists/stable/main/binary-$arch/Packages"
gzip -9cn "dists/stable/main/binary-$arch/Packages" \
> "dists/stable/main/binary-$arch/Packages.gz"
done
apt-ftparchive \
-o APT::FTPArchive::Release::Origin=example.com \
-o APT::FTPArchive::Release::Suite=stable \
-o APT::FTPArchive::Release::Components=main \
-o APT::FTPArchive::Release::Architectures="amd64 arm64" \
release dists/stable > dists/stable/Release
gpg --batch --yes -abs -o dists/stable/Release.gpg dists/stable/Release
gpg --batch --yes --clearsign -o dists/stable/InRelease dists/stable/Release
RPM нужны две подписи, а не одна
Это тот шаг, на котором спотыкаются почти все, и мы тоже споткнулись. APT и YUM проверяют пакеты принципиально по-разному.
APT — это цепочка: подпись покрывает `Release`, где лежат хеши `Packages`, где лежат хеши каждого `.deb`. Подписали метаданные — пакеты покрыты автоматически; отдельные файлы `.deb` не подписываются вовсе.
У RPM две независимые проверки. `repo_gpgcheck=1` проверяет `repomd.xml` через отсоединённый `repomd.xml.asc`. А `gpgcheck=1` проверяет подпись, встроенную внутрь каждого пакета, — её больше ничто не даёт. Подпишете только метаданные, и dnf остановится с `Package … is not signed / Error: GPG check FAILED`.
Поэтому выполняйте `rpmsign` для каждого `.rpm` — и делайте это до `createrepo_c`, потому что подписывание переписывает файлы, и любая ранее записанная контрольная сумма мгновенно становится неверной.
# rpmsign ships in the 'rpm' package on Debian/Ubuntu.
# rpm hardcodes %__gpg to /usr/bin/gpg2, which Debian/Ubuntu do not ship:
rpmsign --define "_gpg_name packages@example.com" \
--define "__gpg $(command -v gpg)" \
--addsign rpm/*.rpm
# fail loudly rather than publish something unsigned
for r in rpm/*.rpm; do
rpm -qpi "$r" | grep -qi '^Signature *: *(none)' \
&& { echo "UNSIGNED: $r"; exit 1; }
done
# only now build the indexes, then sign repomd.xml
createrepo_c --quiet rpm/
gpg --batch --yes --detach-sign --armor \
-o rpm/repodata/repomd.xml.asc rpm/repodata/repomd.xml
Раздача через CDN — и ловушка
Репозиторий пакетов — почти идеальная нагрузка для CDN: статические файлы, которые скачивают гораздо чаще, чем меняют, причём пользователи со всего мира. Направьте edge на ваш источник, и проблема трафика исчезает.
Ловушка в том, что метаданные репозитория внутренне согласованы. Каждый файл ссылается на хеши других файлов. Кэш, который отдал вам свежий `Release` и устаревший `Packages`, дал вам не слегка устаревший репозиторий, а сломанный, и в тексте ошибки про кэш не будет ни слова.
Вот три сбоя, которые мы поймали за одну публикацию:
• Устаревший `InRelease` при свежем `Packages` — apt сообщает о несовпадении хеша.
• Устаревший `.rpm` — dnf сообщает `Package … is not signed`, потому что подписывание переписало файл, а в кэше лежит старая, неподписанная копия.
• Свежий `repomd.xml` при устаревшем `repomd.xml.asc` — dnf сообщает `Bad PGP signature`. Изменились оба, а очищен был только один.
Отсюда правило: частичная очистка хуже, чем никакой. Очищайте каждый файл, который переписала публикация — точки входа, хешированные индексные файлы в `repodata/` и сами пакеты. Либо задайте метаданным очень короткий TTL, а пакетам позвольте кэшироваться надолго, поскольку их имена меняются с каждой версией.
# entry points
for p in /deb/dists/stable/InRelease \
/deb/dists/stable/Release \
/deb/dists/stable/Release.gpg \
/deb/dists/stable/main/binary-amd64/Packages \
/deb/dists/stable/main/binary-amd64/Packages.gz \
/rpm/repodata/repomd.xml \
/rpm/repodata/repomd.xml.asc; do
cdnctl purge --account "$ACC" --path "$p" --type exact
done
# the hashed indexes and the packages changed too
for f in rpm/repodata/* rpm/*.rpm; do
cdnctl purge --account "$ACC" \
--path "/rpm/$(basename "$f")" --type exact
done
Публикация: как файлы попадают на CDN
Репозиторий — это лишь статические файлы, поэтому «опубликовать» значит положить это дерево каталогов туда, откуда CDN сможет его раздавать. Есть две формы, и какая нужна вам, зависит от того, есть ли у вас уже веб-сервер.
**Если источник уже есть** — любая машина, отдающая HTTPS, — поставьте CDN перед ней и публикуйте, копируя файлы туда. Достаточно `rsync` по SSH; edge заберёт с источника при первом запросе и дальше будет отдавать из кэша. Именно так мы делаем для cdn.com.tr, потому что машина загрузок — та же, что отдаёт сайт.
Предупреждение из опыта: синхронизируйте подкаталоги, а не родительский каталог. `rsync --delete`, нацеленный на каталог, где лежат и другие ваши загрузки, с удовольствием их удалит.
**Если вы вообще не хотите держать источник**, отправляйте файлы прямо в хранилище CDN. С cdn.com.tr вы загружаете дерево, и оно раздаётся с вашего CDN-домена — никакого веб-сервера, который надо ставить, патчить и поддерживать. `cdnctl cp -r` загружает целый каталог, а это ровно та форма, которую имеет репозиторий.
В обоих случаях базовый URL, который ваши пользователи впишут в `sources.list` или в файл `.repo`, — это просто ваш CDN-домен плюс путь публикации. И в обоих случаях завершайте шагом очистки из предыдущего раздела — именно про него забывают.
# A) you have an origin: sync the subdirectories, never the parent
rsync -a --delete dist/repo/deb/ user@origin:/var/www/downloads/deb/
rsync -a --delete dist/repo/rpm/ user@origin:/var/www/downloads/rpm/
rsync -a dist/repo/example-com.gpg user@origin:/var/www/downloads/
# B) no origin: push straight into CDN storage
cdnctl cp -r dist/repo/deb <account_uuid>:/downloads/deb
cdnctl cp -r dist/repo/rpm <account_uuid>:/downloads/rpm
cdnctl files put --file dist/repo/example-com.gpg \
--target-path /downloads/example-com.gpg
# the base URL is simply your CDN hostname + that path:
# deb -> https://cdn.example.com/downloads/deb
# rpm -> https://cdn.example.com/downloads/rpm
Проверьте в чистом контейнере
Не проверяйте на своей машине. Она уже доверяет вашему ключу, может держать кэшированный индекс и с радостью скроет ровно те проблемы, на которые сейчас наткнутся ваши пользователи. Одноразовый контейнер — единственная честная проверка: так мы и нашли и отсутствующую подпись пакета, и сбои из-за устаревшего кэша.
Держите проверку подписи включённой во время тестов. Отключить `gpgcheck`, чтобы «посмотреть, работает ли остальное», — самый известный способ выпустить неподписанный репозиторий.
Совет для стороны Fedora: передавайте `--disablerepo="*" --enablerepo="ваш-репо"`. Иначе dnf будет занят скачиванием собственных метаданных Fedora, и медленный или неудачный запуск не скажет вам о вашем репозитории ничего.
# Debian / Ubuntu
docker run --rm debian:12 sh -c '
apt-get update -qq && apt-get install -y -qq curl gnupg ca-certificates
curl -fsSL https://example.com/example-com.gpg |
gpg --dearmor -o /usr/share/keyrings/example-com.gpg
echo "deb [signed-by=/usr/share/keyrings/example-com.gpg] \
https://example.com/deb stable main" \
> /etc/apt/sources.list.d/example.list
apt-get update && apt-get install -y yourtool && yourtool --version'
# Fedora / RHEL — only your repo, so the test is about your repo
docker run --rm fedora:40 sh -c '
printf "[example]\nbaseurl=https://example.com/rpm\nenabled=1\ngpgcheck=1\nrepo_gpgcheck=1\ngpgkey=https://example.com/example-com.gpg\n" \
> /etc/yum.repos.d/example.repo
dnf --disablerepo="*" --enablerepo="example" -y install yourtool
rpm -qi yourtool | grep Signature'
И ещё: отключите самообновление в пакетах
Если ваш инструмент умеет обновлять себя сам, эта функция теперь конфликтует с менеджером пакетов. Самообновление перезаписывает файл, которым владеет `dpkg` или `rpm`, а их база продолжает указывать на версию, которой уже нет на диске, — и следующее обновление делает что-то неожиданное.
Исправление небольшое: пометьте сборку тем, как она распространялась, и пусть самообновление отходит в сторону, когда установку сделал менеджер пакетов. Прямые загрузки продолжают обновляться как раньше.
// installChannel is overridden at build time for packaged builds.
var installChannel = "direct"
func update() error {
if installChannel != "direct" {
return fmt.Errorf(
"installed via %s — upgrade it with that instead",
installChannel)
}
// ... normal self-update
}
// packaged builds:
// go build -ldflags "-X main.installChannel=deb"
Частые вопросы
Обязательно ли вообще подписывать репозиторий?
Формально нет — apt и dnf можно заставить пропустить проверку. Практически да. Пользователям пришлось бы отключить проверку безопасности, чтобы установить ваше ПО: просить об этом плохо, а приучать к этому ещё хуже. Подпись стоит одного ключа и двух команд.
Почему dnf говорит, что пакет не подписан, если я подписал метаданные?
Потому что RPM проверяет две разные вещи. `repo_gpgcheck` покрывает `repomd.xml`; `gpgcheck` покрывает подпись, встроенную в каждый `.rpm`. Нужен ещё `rpmsign --addsign` для пакетов — и запускать его надо до `createrepo_c`, поскольку подписывание меняет файлы.
Почему apt сообщает о несовпадении хеша сразу после публикации?
Почти всегда из-за слоя кэширования, отдающего смесь старых и новых метаданных. Метаданные репозитория внутренне согласованы, поэтому частичное обновление — это сломанный репозиторий. Очистите каждый файл, переписанный публикацией, или задайте метаданным короткий TTL.
Может ли один репозиторий обслуживать amd64 и arm64?
Да. Для APT сгенерируйте файл `Packages` для каждой архитектуры в одной и той же suite и перечислите обе в `Architectures`. Для YUM положите оба пакета в один каталог — `createrepo_c` сам запишет архитектуру каждого.
Должны ли файлы репозитория лежать в git?
Обычно нет. Пулы пакетов растут с каждым релизом, а git хранит все версии вечно. Собирайте дерево из релизных артефактов и синхронизируйте его на источник, оставляя в git только скрипты и публичный ключ. Сделайте сборку воспроизводимой, чтобы репозиторий можно было пересоздать с нуля в любой момент.