Перейти к содержимому

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 (открывается в новой вкладке). Как устроена изоляция вокруг образа — на странице «Безопасность и изоляция».

← в блог