Разработка
Ускорение CI сборок в Xcode с кэшированием слотов
Далее следует полный технический анализ решения, которое я в итоге реализовал, которое я называю кэшированием с подогревом слотов.
Два месяца назад я начал экономить примерно 4000 долларов в месяц, используя собственные Mac mini для CI в Sidecar. Впервые у меня появилась надежная Continuous Integration для Sidecar.
В течение следующих двух месяцев я органично расширил тестовое покрытие, увеличив количество рабочих процессов с нуля до 91. Каждое приложение Clutch Engineering имело свой собственный рабочий процесс сборки и тестирования. Каждый пакет Swift имел свой собственный рабочий процесс тестирования. И все рабочие процессы использовали фильтрацию paths GitHub Actions для определения времени их выполнения.
Как и следовало ожидать, даже с тремя машинами общее время выполнения запроса на слияние (pull request) в конечном итоге выросло до 15–30 минут на один запрос. Только Sidecar требует около 12 минут для сборки из чистого состояния, и в зависимости от количества пакетов, нуждающихся в тестировании, общее время CI может быстро увеличиваться.
В этом месяце это стало критической проблемой. Я твердо убежден, что если вы не получаете значимую обратную связь в течение пяти минут, вы подвергаетесь серьезному риску переключения контекста, о котором потом пожалеете. В этот момент пул-реквесты становятся скорее узким местом, снижающем производительность, чем подстраховкой.
Поэтому на прошлой неделе я сел и, после нескольких неудачных экспериментов, нашел решение, которое теперь обеспечивает в среднем 2.5 минуты на сквозную проверку перед отправкой. Оно включает в себя полные сборки Sidecar и все затронутые тесты. Это примерно десятикратное улучшение.
Я прошел через множество трудностей, чтобы прийти к этому, и нигде не был описан подход, который я в итоге выбрал. Я подозреваю, что это связано с тем, что относительно немногие используют собственную инфраструктуру CI на macOS, что является обязательным требованием для этой конфигурации.
Далее следует полный технический анализ решения, которое я в итоге реализовал, которое я называю кэшированием с подогревом слотов.
Как я сюда попал: органический рост до 91 рабочего процесса
Краткий обзор октября.
В Sidecar были тесты, но их запуск на размещенных в GitHub macOS-раннерах обошелся бы в тысячи долларов в месяц. Получалась кривая мотивация: чем больше тестов, тем выше затраты. В результате я в основном избегал CI, запускал тесты вручную и надеялся ничего не упустить.
Затем я настроил собственные Mac mini. За одну ночь затраты на CI фактически упали до нуля; электроэнергия плюс амортизированные затраты на оборудование составляют примерно 70 долларов в месяц. Наконец-то я смог добавлять тесты, не беспокоясь о счетах.
Я начал с малого: один тест, затем другой, затем еще несколько.
Для каждого пакета был создан свой собственный файл рабочего процесса:
AlgorithmKit-tests.ymlJourneyLoggingKit-tests.ymlIdentifierRegistry-tests.yml- и так далее
Каждый рабочий процесс включал обнаружение изменений, поэтому он запускался только при необходимости. Сначала это работало хорошо; покрытие кода улучшилось, уверенность возросла, и я продолжал добавлять тесты.
В итоге: 91 рабочий процесс.
Это не было преднамеренным проектным решением. Это было постепенное улучшение, которое накапливалось со временем. Каждый отдельный шаг имел смысл, но совокупное улучшение — нет.
Проблема: быстрое оборудование, медленная сборка
У меня было три моих Mac mini, на которых рабочие процессы выполнялись параллельно. GitHub Actions распределяли работу между ними; если внести изменения в десять пакетов, одновременно запускалось десять или более рабочих процессов по всему кластеру.
Даже при таком параллелизме пул-реквесты по-прежнему занимали минимум 12–20 минут, а часто и 15–30 минут, когда выполнялся другой запрос на слияние и конкурировал за ресурсы.
Почему так медленно, несмотря на параллелизм?
Только сборка приложения Sidecar занимает около 12 минут. Это устанавливает жесткий предел; никакой параллелизм не сможет снизить общее время CI ниже этого значения.
Но более глубокая неэффективность заключалась в следующем:
- Каждый рабочий процесс начинался с «холодного»
DerivedData - Изменение одной строки в
IdentifierRegistryмогло запустить более 20 рабочих процессов, каждый из которых перестраивал его независимо - Отсутствовало совместное использование кэша между рабочими процессами
- Разные машины означали многократную перестройку одних и тех же зависимостей.
Поиск решения для кэширования
Мне нужен был способ кэшировать артефакты сборки Xcode.
К сожалению, кэширование Xcode капризно.
DerivedData хорошо работает локально, но не предназначен для CI. Как я обнаружил на собственном горьком опыте, нельзя просто скопировать каталог DerivedData в новое место и ожидать, что Xcode будет его использовать — путь сборки заложен в артефактах. Xcode 26 представляет новую систему кэширования, но на практике она оказалась ненадежной для ускорения итеративных сборок в нескольких целевых контекстах.
Совместное использование артефактов кэша между приложениями и пакетами Swift
График зависимостей Sidecar включает сотни пакетов и целей Swift. В Sidecar, CANStudio и ELMCheck содержится значительное количество общего кода.
Поэтому было особенно неприятно обнаружить, что Xcode не использует общие артефакты сборки между целями приложений и пакетами Swift.
Собрать Sidecar, а затем собрать тестовую цель пакета Swift, которая зависит от того же кода, и Xcode без проблем перекомпилирует всё — это происходит даже если обе сборки указывают на один и тот же DerivedData.
Я провел ряд экспериментов, чтобы понять масштаб проблемы:
| Сценарий | Общий кэш | Примечание |
|---|---|---|
| Проект Xcode → Проект Xcode | ✅ Да | CANStudio → ELMCheck: 28 с → 18 с (36% быстрее) |
| Пакет Swift → Пакет Swift | ❌ Нет | Каждый со своим .swiftpm/xcode контекстом |
| Проект Xcode → Пакет Swift | ❌ Нет | Различные контексты рабочего пространства |
Добавьте к этому тот факт, что вы не можете просто добавить тесты зависимостей пакетов Swift в план тестирования приложения без ручного добавления каждого пакета в проект Xcode. И в итоге вы потратите много времени на CI. Самым болезненным открытием стало то, что артефакты даже не использовались совместно между пакетами Swift.
Это подтвердило две основные проблемы:
- Сборка приложения не ускоряет тестирование пакетов Swift
- Пакеты Swift не обмениваются артефактами сборки друг с другом
Как минимум, мне понадобятся отдельные кэши для целей приложения и пакетов Swift. Последнее особенно проблематично, поскольку подразумевает дублирование артефактов сборки для каждого узла в графе зависимостей.
Решение проблемы кэширования между пакетами с помощью зонтичного пакета
Чтобы решить проблему кэширования между пакетами, я задал очевидный, но радикальный вопрос: что если бы существовал только один пакет Swift?
Это было бы ужасно для повседневной разработки, но что если бы этот пакет генерировался динамически в рамках CI?
При использовании одного пакета Swift существует один контекст DerivedData. Для тестов требуется один проход сборки и одна копия всех артефактов.
Для этого я использую скрипт Python, вызываемый следующим образом:
python3 .github/scripts/generate_umbrella_package.py \ --platform ios \ --output packages/Package.swift
Генератор:
- Запускает команду
swift package dump-packageдля всех 89 пакетов параллельно - Фильтрует по поддержке платформы
- Проверяет зависимости; исключает только внутренние цели и сторонние пакеты
- Генерирует файл
Package.swift, специфичный для платформы
Важное замечание: видимость API.
В рамках зонтичного пакета все пакеты становятся видимыми друг для друга. Тесты могут случайно получить доступ к API, которые не должны быть доступны.
Здесь важна сборка приложений.
Приложения собираются с использованием реального графа зависимостей, а не зонтичного. Если тест допускает утечку внутреннего API, сборка приложения завершается с ошибкой.
Поэтому каждый слот поддерживает два кэша:
app-cache: собирает приложения, используя правильный граф зависимостей, и обеспечивает соблюдение границ APIpackage-cache: собирает зонтичный пакет и обеспечивает совместное использование артефактов между тестами
Такой подход с двумя кэшами сохраняет корректность, обеспечивая при этом производительность.
Реализация повторного использования кэша
Вооружившись более четким пониманием поведения Xcode, я попробовал три подхода.
1. Кэш GitHub Actions
Первым очевидным вариантом был actions/cache.
К сожалению, DerivedData занимал примерно 20 ГБ. Загрузка и скачивание этого кэша при каждом запуске были бы медленнее, чем пересборка с нуля, особенно при домашнем интернет-соединении, уже занятом загрузкой данных маршрутизации для Clutch Engineering.
2. Копирование «золотого» кэша
Далее я попытался хранить предварительно собранный «золотой» кэш на каждой машине и копировать его в каждый каталог рабочего процесса.
Это не сработало, потому что Xcode закладывает абсолютные пути сборки в DerivedData. Скопируйте кэш в новый путь, и Xcode полностью его проигнорирует.
3. Фиксированные пути с выделяемыми слотами
Решение, которое сработало, оказалось обманчиво простым: хранить DerivedData по фиксированным путям и выполнять сборку рабочих процессов на месте.
Никакого копирования; никакого перемещения. Просто используйте одни и те же каталоги.
Это то, что я называю кэшем на основе слотов (slot-based cache).
Кэш на основе слотов
При использовании фиксированных путей как единственного жизнеспособного подхода, оставшейся проблемой оставалась параллельная обработка.
Структура каталогов
/opt/ci-cache/
slots/
ios/
0/
app-cache/
package-cache/
lock
affinity
1/
…
macos/
0/
…
watchos/
0/
…
Почему слоты вместо кэшей для каждого PR?
Потому что DerivedData встраивает свой путь сборки во множество файлов. Его перемещение потребовало бы перезаписи бинарных артефактов. Такой подход был бы крайне ненадежным и хрупким.
Слоты полностью исключают это.
Специализация по платформам
Sidecar, CANStudio и ELMCheck ориентированы на iOS, macOS и watchOS. Каждая платформа требует отдельных артефактов сборки.
Вместо того чтобы каждая машина собирала каждую платформу, я специализировал:
- Mac mini M4 Pro: iOS
- Mac mini M4: macOS
- MacBook Pro M1 Max: watchOS
Преимущества:
- Каждая машина поддерживает кэши ровно для одной платформы, минимизируя объем кэша на каждой машине
- Производительность предсказуема, при необходимости я могу легко изменить специализацию машины
- Параллелизм естественным образом обеспечивается разделением платформ
Получение слота
- Поступает запрос на слияние (PR)
- Рабочий процесс проверяет файлы соответствия, чтобы найти слот, последний раз использованный этим запросом на слияние
- Если слот заблокирован, он пробует следующий
- Он получает свободный слот и создает файл блокировки
- Сборка выполняется с использованием кэша слота
- По завершении снимается блокировка
Соответствие имеет решающее значение. Принудительная отправка в один и тот же пул-реквест обеспечивает отличное повторное использование кэша.
Каждый PR «загрязняет» свой слот, но это похоже на обычную локальную разработку. После слияний отдельный рабочий процесс повторно прогревает кэши из основного репозитория, чтобы поддерживать их близко к основному.
Запуск только затронутых тестов
Ранее я полагался на фильтрацию на уровне рабочих процессов GitHub. С единым консолидированным рабочим процессом мне потребовался новый подход.
Карта зависимостей
Теперь монорепозиторий поддерживает кэшированный граф зависимостей, сопоставляющий пакеты с их зависимостями и зависимыми компонентами:
{
"IdentifierRegistry": {
"dependencies": [],
"dependents": ["GarageKit", "VehicleKit"]
}
}
Алгоритм обнаружения
Скрипт: detect_affected_packages.py
- git diff для поиска измененных файлов
- Сопоставление файлов с пакетами
- Поиск зависимостей
- Вычисление транзитивного замыкания
- Определение затронутых приложений
- Вывод тестовых целей и сборок приложений
Пример
- Изменено:
IdentifierRegistry/Sources/Registry.swift - Затронутые пакеты: 12
- Затронутые приложения: все три
- Выполнено: 12 тестовых целей и 3 сборки приложений
- Пропущено: 61 несвязанная тестовая цель
В сочетании с «теплыми» кэшами большинство запросов на слияние тестируют менее 20% кодовой базы, сохраняя при этом полную уверенность.
Результаты
До: 15–30 минут на PR.
После: в среднем ~2.5 минуты за последние 96 запусков Улучшение: ~10×
Типичный расклад:
- Получение слота: менее одной секунды
- Сборка приложения, теплый кэш: около одной минуты
- Затронутые тесты: около одной минуты
- Всего: 2–3 минуты
Настоящая победа: поддержание потока
Существует жесткий порог потери продуктивности — около пяти минут.
Более пяти минут:
- Вы переключаетесь между задачами
- Вы теряете темп
- Исправление ошибок занимает больше времени
Менее пяти минут:
- Вы остаетесь вовлеченным
- Ошибки исправляются немедленно
- Множество итераций за час
Сокращение времени CI с 15–30 минут до примерно 2.5 минут не только ускорило CI, но и устранило целое препятствие для моей способности эффективно итерировать Sidecar.
P.S. Почему не Bazel или Buck?
Bazel и Buck хорошо решают этот класс задач, но за свою цену.
Я хочу оставаться в экосистеме Apple:
- Первоклассная поддержка Xcode
- Предварительный просмотр SwiftUI
- Нативная отладка и профилирование
Bazel и Buck вводят системы параллельной сборки, дублирование конфигурации и постоянное изменение инструментов. Для моих целей правильным компромиссом стало повышение производительности самого Xcode.
-
Аналитика магазинов4 недели назад
Мобильный рынок Ближнего Востока: исследование Bidease и Sensor Tower выявляет драйверы роста
-
Видео и подкасты для разработчиков4 недели назад
Разбор кода: iOS-приложение для управления личными финансами на Swift. Часть 1
-
Новости4 недели назад
Видео и подкасты о мобильной разработке 2025.47
-
Разработка4 недели назад
100 уроков о том, как я довёл своё приложение до продажи за семизначную сумму

