tempus.build
Почему tempus.build собирает собственный образ раннера, а не берёт ubuntu-latest
Managed-раннер для GitHub Actions обещает одно: вы меняете runs-on, и workflow работает как
раньше. Сильнее всего это обещание зависит от образа раннера — набора языков, инструментов и
переменных окружения, на который опираются шаги ваших workflow. Проще всего взять образ GitHub,
ubuntu-latest (сейчас — Ubuntu 24.04), как есть. Мы так не делаем и собираем
свой образ (открывается в новой вкладке). Разберём, почему, и чем за это платим.
Почему образ GitHub не подходит как есть
ubuntu-latest — это образ виртуальной машины Azure, который собирается Packer’ом из репозитория
actions/runner-images (открывается в новой вкладке). Это не образ контейнера.
Сборочные скрипты рассчитаны на полноценную VM: systemd как PID 1, фоновые сервисы (Docker daemon,
PostgreSQL, MySQL), swap.
Он и большой. На стандартном приватном раннере ubuntu-latest (2 vCPU) мы измерили: корневой раздел
около 77 GB, свободно около 14 GB — эти 14 GB GitHub и указывает
в документации (открывается в новой вкладке). Сам
образ занимает 59–63 GB.
Раннеры tempus.build устроены иначе. Каждый раннер — одноразовый контейнер под управлением Actions
Runner Controller (ARC), каждый под запускается в своей Лёгкая виртуальная машина: сильная граница VM (своё ядро, аппаратная виртуализация), но по весу и скорости старта близка к контейнеру. Kata Containers, а Docker
предоставляет отдельный dind-контейнер рядом с раннером. Такой схеме нужен образ контейнера с
агентом GitHub Actions внутри — этого требует gha-runner-scale-set из ARC. Образ VM в неё не
встраивается, поэтому образ мы собираем сами.
Воспроизводимость и проверяемая цепочка поставки
Это главное техническое отличие. Образ GitHub ставит то, что актуально на момент сборки: пакеты apt
последних версий, установщики через curl | bash, версии из toolset вроде «8.3» без патч-версии.
Две сборки с разницей в день могут отличаться. GitHub публикует отчёт о составе образа, но не
подпись, Software Bill of Materials — машиночитаемый перечень всех компонентов и зависимостей внутри артефакта (образа): что именно внутри и каких версий. Образы tempus.build публикуют SBOM в формате CycloneDX. или Криптографически подписанное свидетельство о происхождении артефакта: чем, из какого исходника и в каком окружении он собран. В SLSA — build provenance attestation., которые потребитель мог бы проверить для самого образа.
Наш образ собран иначе:
- Всё скачиваемое закреплено: точная версия и SHA256/512 либо apt-репозиторий с проверкой ключа. Базовый образ закреплён по digest. Пины обновляет Renovate — отдельными PR, которые проходят ту же проверку, что и любое изменение.
- Каждый опубликованный образ подписан Инструмент проекта Sigstore для подписи и проверки контейнерных образов и артефактов. В keyless-режиме подпись привязана к OIDC-личности и не требует долгоживущих ключей. keyless (GitHub OIDC, привязка к
build.ymlнаrefs/heads/main) и несёт SBOM и Supply-chain Levels for Software Artifacts — отраслевой стандарт уровней защиты цепочки поставки; уровни описывают, насколько надёжно доказано происхождение артефакта.-provenance. - Сканер уязвимостей: проверяет образ на известные CVE в пакетах ОС и зависимостях. В CI используется как гейт, блокирующий публикацию при исправимых HIGH/CRITICAL. блокирует публикацию при HIGH/CRITICAL. Исключения задокументированы в
.trivyignore.yaml(открывается в новой вкладке): у каждого есть обоснование и срок действия.
Проверить, какой образ исполнял ваш код, может кто угодно — команды из
SECURITY.md (открывается в новой вкладке):
IMAGE=ghcr.io/tempusbuild/runner-ubuntu-24.04
DIGEST=$(docker buildx imagetools inspect "${IMAGE}:vYYYYMMDD" --format '{{.Manifest.Digest}}')
cosign verify \
--certificate-identity-regexp '^https://github\.com/tempusbuild/runner-images/\.github/workflows/build\.yml@refs/heads/main$' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"${IMAGE}@${DIGEST}"
gh attestation verify "oci://${IMAGE}@${DIGEST}" --owner tempusbuild
Какой образ исполнял job, видно в логе шага «Set up job»: раннер читает
/imagegeneration/imagedata.json и показывает имя образа, версию vYYYYMMDD, версию агента GitHub
Actions и ссылки на README на коммите сборки и на пакет в ghcr. Подробнее о подписи, SBOM и проверке
CVE — в разборе supply chain раннер-образов.
Совместимость там, где она влияет на скорость
Контракт совместимости — официальный
README образа Ubuntu 24.04 (открывается в новой вкладке)
от GitHub. В образе есть CLI облаков, компиляторы (GCC 12–14, Clang 16–18), браузеры и драйверы к
ним; полный список — в
README образа (открывается в новой вкладке). Но
для скорости важнее не список, а то, найдут ли setup-* actions нужную версию в образе или пойдут
за ней в сеть.
Как setup-* actions находят предустановленный инструмент
Многие workflow начинаются с actions/setup-python, setup-node, setup-go, setup-java и
подобных. Каждый из них решает одно: взять версию из образа или скачать.
- Поиск в toolcache. Официальные actions ищут версию через
@actions/tool-cache(открывается в новой вкладке): каталог$RUNNER_TOOL_CACHE/<инструмент>/<версия>/<архитектура>, рядом с которым лежит файл-маркер<архитектура>.complete. Нет маркера — версии для action нет, даже если Python 3.12 установлен в другом месте. Уsetup-javaсвоя схема имён, напримерJava_Temurin-Hotspot_jdk/<версия>/x64. - Нет в toolcache — значит, загрузка. Не найдя версию, action скачивает её (из манифеста версий, с go.dev, с сайта Adoptium или другого поставщика) и кладёт в toolcache — но только до конца job’а. Наши раннеры одноразовые, поэтому без встроенного toolcache каждый job платил бы за эту загрузку заново.
- Режим раннера меняет поведение. Раннер выставляет
RUNNER_ENVIRONMENT(открывается в новой вкладке) вgithub-hostedилиself-hosted, и часть actions от этого зависит. Раннеры на ARC — всегдаself-hosted, у нас тоже.ruby/setup-ruby, например, на self-hosted раннерах (открывается в новой вкладке) ожидает toolcache в/opt/hostedtoolcache, доступный пользователюrunnerна запись.
Поэтому структура каталогов у нас совпадает с GitHub, и setup-* находят версии локально:
- Python 3.10–3.14, Node 22 и 24, Go 1.25 и 1.26, Ruby 3.2–4.0, PyPy 3.9–3.11;
- Java Temurin 8, 11, 17, 21 и 25;
- .NET SDK 8, 9 и 10 в
/usr/share/dotnet— каталоге, куда ставитactions/setup-dotnet, поэтому он использует их повторно; - PHP 8.3 для
shivammathur/setup-php(см. пример ниже).
Как и на ubuntu-latest, в образе есть кеш архивов популярных actions — раннер не скачивает их на
каждом прогоне. Сохранены и переменные окружения, на которые опираются setup-* и сборки:
ImageOS, RUNNER_TOOL_CACHE, JAVA_HOME_<v>_X64 и другие.
Пример: setup-php
Как это выглядит, когда условие не выполнено, — случай из PHP-проекта одного из клиентов. Шаг
shivammathur/setup-php (открывается в новой вкладке) занимал 62–72 секунды на наших
раннерах против примерно 6 секунд на GitHub, хотя PHP 8.3 в образе уже был.
setup-php видит RUNNER_ENVIRONMENT=self-hosted и переходит в режим self-hosted. В нём он
использует установленный PHP, только если php-config сообщает запрошенную серию, а php-config
поставляется в пакете php8.3-dev. Ещё в этом режиме он ставит через apt вспомогательные пакеты,
среди них apt-transport-https. Ни php-config, ни apt-transport-https в образе не было, поэтому
каждый прогон заново добавлял PPA, выполнял apt-get update по дюжине репозиториев и
переустанавливал PHP.
Исправление — PHP 8.3 из архива Ubuntu (открывается в новой вкладке), как
у GitHub, без сторонних PPA, с полным набором расширений, php8.3-dev и вспомогательными пакетами.
Как мы удерживаем быстрый путь
Совместимость — это не «те же инструменты установлены», а те же условия, которые проверяет каждый action: пути, файлы-маркеры, вспомогательные программы, переменные окружения. И выполняться они должны в том режиме, в котором action работает на самом деле, — у нас это self-hosted.
Поэтому тесты образа (открывается в новой вкладке) проверяют сами
условия, а не только версии: маркер .complete у каждой встроенной версии, ImageOS и
RUNNER_TOOL_CACHE, доступность toolcache на запись. Новый тест для PHP проверяет условия быстрого
пути setup-php: php-config нужной серии и вспомогательные пакеты. Если workflow всё равно уходит с
быстрого пути, мы считаем это ошибкой совместимости и исправляем в образе.
Где мы отличаемся от ubuntu-latest — намеренно
Часть отличий продиктована безопасностью и корректностью. Они описаны в политике включения в README образа:
- Go с истёкшей поддержкой (1.22–1.24) не встроен. Go выпускает исправления только для двух
последних минорных версий, поэтому встроенный toolcache устаревшей версии содержал бы уязвимости
stdlib, которые в образе не исправить.
goпо умолчанию — 1.26; наubuntu-latestпо умолчанию 1.24, поддержка которой закончилась. Старые версииsetup-goставит по запросу. - В старых Python из toolcache обновлён setuptools. Встроенная в них версия содержит уязвимые вложенные копии библиотек; мы обновляем её вместо того, чтобы поставлять как есть.
- Нет systemd-coredump. В контейнере systemd не работает как PID 1, поэтому пакет ничего бы не делал и только добавлял поверхность атаки.
- Больше заголовочных файлов (dev-пакетов) для нативной сборки, чем на
ubuntu-latest: обработка изображений, криптография, БД и ODBC, сжатие, Kafka, BLAS/HDF5, а также protobuf, meson и ccache. Нативные пакеты Python вроде Pillow, mysqlclient, python-ldap, pycurl, pyodbc, PyNaCl и h5py собираются без шагаapt-get install. Оговорка: workflow, которые на это полагаются, не будут работать на GitHub-hosted раннерах без доработки. - Job работает от пользователя
runner, не от root;sudoдоступен, как на GitHub-hosted. В слоях образа,ENVиARGнет секретов. - В образе нет ничего, что зависит от нашей инфраструктуры. Всё специфичное для окружения применяется при запуске, поэтому на раннерах работает тот же образ, что опубликован в ghcr.
Чем мы за это платим
Пробелы в совместимости стоят скорости
Каждое условие, которое проверяет setup-* action, — наша ответственность. Пропущенное условие не
ломает workflow, а замедляет его, как в примере с setup-php, поэтому такие пробелы приходится искать
по времени шагов, а не по ошибкам.
Поддержка — на нас
Обновление пинов и пересчёт SHA — наша работа. Ещё одно обязательство — окно обновления агента GitHub Actions: GitHub перестаёт назначать job’ы (открывается в новой вкладке) раннерам, чья версия устарела более чем на 30 дней после нового релиза. Поэтому версию агента мы обновляем в том же ритме; на момент публикации — 2.338.0.
Размер
Образ весит около 17 GB в сжатом виде (amd64) — для узла, где образа ещё нет, загрузка тяжёлая. Поэтому каждую новую версию образа мы загружаем на узлы с раннерами до того, как переключаем на неё раннеры, и на запуск job’а её размер не влияет.
Итог
Собственный образ — не отказ от совместимости с ubuntu-latest, а другой способ её обеспечить:
образ контейнера вместо образа VM, закреплённые версии вместо плавающих, подпись и SBOM вместо
доверия на слово. Отличия от GitHub перечислены в README образа, а репозиторий открыт — можно
посмотреть, что внутри, собрать образ самостоятельно и проверить подпись.
Если ваш workflow на tempus.build работает медленнее или иначе, чем на GitHub-hosted, — напишите нам или откройте issue в runner-images (открывается в новой вкладке). Как устроена изоляция вокруг образа — на странице «Безопасность и изоляция».