Всем привет!
Меня зовут Федоров Василий, я руковожу группой Mobile.Speed в Aliexpress Россия. Мы стараемся облегчить жизнь разработчиков: пишем утилиты, настраиваем CI, складируем метрики в Grafana — в общем отвечаем почти за все, что влияет на time-to-market и Developer Experience команд. В этой статье я расскажу о том, как мы ускоряем сборку проекта с помощью XCRemoteCache — но обо всем по порядку под катом.
Как вы уже знаете, больше года назад мы запустили собственное приложение для российских пользователей, а еще до этого начали постепенно локализовывать все сервисы. В какой-то момент мы ушли и от китайской CI, переехали в Gitlab-CI, а заодно развернули все что было в монорепо.
Можно долго спорить о достоинствах и недостатках монорепо, но один явный минус все же есть, и в этой статье мы попробуем с ним разобраться. Говорю я о скорости сборки проекта. Мы в качестве менеджера зависимостей используем Cocoapods.
Приложение AliExpress Russia собирается из 97 модулей, в них 795 132 компилируемых строк кода, из них 113 043 — на Swift. И это только наши собственные модули, подключенные как Development pods! Все остальные сторонние зависимости - преимущественно в виде скомпилированных фреймворков — в подсчете не участвовали. А их еще 235.
Уже можно понять, что все это достаточно тяжеловесно и долго компилируется. Чего далеко ходить - оно линкуется 40 секунд!
А сборка на холодную занимает 10-15 минут. Это не считая pod install
, который занимает от 2 до 10 минут в зависимости от разных условий. В итоге и сборка на Merge Requests занимает от 20 до 25 минут.
Хватит это терпеть, — решили мы! 25 минут до получения билда + еще 5 минут на все тесты — это слишком долго, чтобы получить фидбэк по своему мержу. Хотим прийти к сборке + тестам в 15 минут. В идеальных условиях это примерно то время, за которое можно получить аппрув от код-ревьюверов.
И мы начали смотреть в сторону различных реализаций билд-кэшей.
Что мы нашли:
Cocoapods-binary
Buck
GN/Ninja от Google
Bazel
XCRemoteCache от Spotify
Cocoapods-binary отбросили сразу. Его пытались сделать еще в cocoapods 1.5, но так и не довели до ума
Buck тоже решили даже не трогать, т.к. его поддержку уже прекратили. Да и у нас в команде есть несколько человек с печальным опытом работы с ним в прошлых проектах.
GN/Ninja тоже отпал, так как разработчики гугла уже планируют переезжать на Bazel.
У нас остались Bazel и XCRemoteCache. Мы решили пойти сразу в обе стороны, чтобы сравнить показатели двух систем и выбрать для себя лучшую. Но сравнение — это тема отдельной статьи, а сейчас давайте остановимся на второй. Итак!
XCRemoteCache
Не так давно Spotify выложила в паблик свою самописную утилиту по кэшированию артефактов. https://github.com/spotify/XCRemoteCache
Давайте разберемся, что это и с чем его едят.
Но для начала, небольшой список определений.
Кэш/артефакт - .zip архив с объектными файлами, получаемыми в результате компиляции и линковки какого-либо модуля.
Мета-информация - .json файл с информацией о конкретном артефакте. Перечислены:
Коммит, на котором собран артефакт;
Платформа (iphoneos/iphonesimulator/macos/...);
Конфигурация (Debug/Release/...);
Версия икскода;
Название таргета;
Имя файла артефакта;
Перечень файлов-зависимостей таргета;
md5 хэш всех зависимостей и всех собственных файлов таргета.
XCRemoteCache представляет из себя набор исполняемых файлов, которые скачиваются из репозитория или cocoapods-плагином автоматически:
Проверяют валидность текущего кэша;
При совпадении мета-информации всех файлов модуля и всех зависимостей, использование этого кэша;
При несовпадении - fallback на компиляцию/линковку родными утилитами Xcode.
Артефакты однозначно ссылаются на коммит, в котором были созданы. NB! важный момент, стоит запомнить
Давайте разберем чуть подробнее, по шагам.
Сначала этап подготовки xcprepare:
Далее - xcprebuild step (для каждого таргета).
Если кэши были отключены в xcprepare, то этот шаг не добавляется в проект (либо удаляется из него, если он был). Аналогично и с xcpostbuild.
Потом - xcpostbuild step (тоже для каждого таргета)
Полный список файлов XCRemoteCache:
Как видно, среди бинарников есть не только рассмотренные xcprepare, xcprebuild и xcpostbuild. Еще есть обертки над встроенными утилитами:
xcld - обертка над линковщиком ld;
xclibtool - обертка над утилитой libtool, которая собирает библиотеки при помощи ld;
xcswiftc - обертка над компилятором свифта swiftc.
Вы спросите - а где же компилятор для Objective-C? А он есть, только он компилируется из исходников во время xcprepare.
Зачем это нужно? - Для оптимизации. Все дело в том, что все вышеуказанные бинарники вызываются по одному разу для каждого таргета, и нам в принципе не так важно, обращаются ли они к файловой системе или нет. В случае с компиляцией Obj-C файлов это уже приобретает значение, т.к. cc вызывается для каждого файла отдельно. Так что в Spotify решили компилировать xccc для каждого окружения и для каждого коммита отдельно, чтобы "зашить" все переменные внутри обертки, и таким образом оптимизировать обращения к файловой системе.
Дополнительно в комплекте идет удобный плагин для Cocoapods: cocoapods-xcremotecache.
В Readme.md подробно описана инструкция по интеграции в существующий проект, не будем останавливаться на этом подробно. Но заглянем немножко под капот.
В XCRemoteCache есть два режима работы - producer (создает кэши) и consumer (использует кэши)
Начинали мы в тот момент, когда была доступна версия 0.3.3. Решили сразу пойти через плагин для cocoapods, ведь интеграция виделась быстрой и бесшовной:
запускаем
pod install
, XCRemoteCache интегрируется в проект;собираем проект с использованием кэшей;
???
Profit!
Как же мы ошибались...
Пробуем установить. Исправляем все, что мешает
Итак, поехали!
generate_multiple_pod_projects
Включили плагин, сконфигурировали на наш локальный S3, включаем producer mode
.
plugin 'cocoapods-xcremotecache'
git_repo = `git remote get-url --push origin`.gsub(/\s+/, "")
xcrcconfig = {
'enabled' => true,
'cache_addresses' => ['http://localhost:8080/cache'],
'primary_repo' => git_repo,
'cache_commit_history' => 30,
'artifact_maximum_age' => 7,
'final_target' => 'AliexpressRuBuyer',
'xcrc_location' => 'XCRC',
'xccc_file' => '.rc/xccc',
'mode' => 'producer'
}
Тут требуется небольшая ремарка. Мы уже давно используем cocoapods в режиме generate_multiple_pod_projects => true
. Потому что удобно, что у каждого таргета свой отдельный проект, и можно не выходя из икскода пройтись по дереву зависимостей.
С наскока - ничего не работает. Плагин не поддерживает этот режим. Ну то есть проект собирается, только интеграции XCRemoteCache нет ни в один проект Cocoapods.
Залезаем в кишки плагина:
unless installer_context.pods_project.nil?
# Attach XCRemoteCache to Pods targets
installer_context.pods_project.targets.each do |target|
next if target.name.start_with?("Pods-")
next if target.name.end_with?("Tests")
next if exclude_targets.include?(target.name)
enable_xcremotecache(target, 1, xcrc_location, xccc_location, mode, exclude_build_configurations, final_target)
end
Ага. Работает только в обычном режиме cocoapods. Что ж, мы не гордые, добавляем проход по сгенерированным проектам. Применяем обходной маневр:
Оформляем Pull request в главный репо, поехали дальше
Swiftinterface
Снова pod install
.
Завелось!
Создаем кэши, меняем режим на consumer:
'mode' => 'consumer'
Снова pod install
.
Кэши используются, но проект не собирается. Падает с ошибкой missing .swiftinterface file
Все дело в том, что у нас некоторое количество модулей собирается в режиме BUILD_LIBRARY_FOR_DISTRIBUTION
. А в этом режиме создаются .swiftinterface
файлы, которые сейчас не добавляются в артефакты. А раз их нет, но Xcode думает, что они созданы - как раз и возникает упомянутая ошибка. Вообще что это за файлы? Они нужны в тех случаях, когда swift-библиотека распространяется в виде бинарника. А в этом файле содержатся все интерфейсы, чтобы Xcode мог перекомпилировать этот модуль при необходимости.
Добавляем эти файлы в список optional artifacts, оформляем следующий Pull request, идем дальше.
Производим кэши, собираем в режиме consumer - заработало! Ура!
producer-fast
Вчитываясь в документацию, вы обнаружите, что существует удобный режим producer-fast
— это такой гибрид consumer и producer.
Допустим, первые кэши были созданы на каком-то коммите. Через 2-3 коммита мы решили создать новые кэши. Понятно, что поменялось не 100% модулей, а только часть. Так вот этот режим позволяет переиспользовать старые артефакты для нового коммита в тех модулях, в которых ничего не менялось. Но вот незадача. Плагин для cocoapods этот режим не поддерживает.
Исправляем очередным Pull request.
Деинтеграция из проекта
Перевозим producer mode на Gitlab-CI, запускаем по расписанию каждую ночь. На ноутбуке разработчика пробуем собрать все с использованием XCRemoteCache, натыкаемся на пару ошибок в конфигурации. Если во время очередного pod install
плагин решит, что он не может использовать кэши, то он отключает использование бинарников для главного проекта. А для Pod-проектов - нет! Плюс в CFLAGS добавляется пробел перед переопределением. И в процессе нескольких переключений между режимами работы, может возникнуть ситуация с дублированием флагов. А все потому, что Xcode слишком умный, и сносит эти лишние пробелы.
Вносим очередное исправление и отправляем Pull request.
out_of_band_mappings
Также мы обнаружили, что кэши для Development pods, собранные на CI, не могут использоваться, т.к. все исходники Development pods у нас лежат в подпапке ./Modules
относительно корня репо. А Cocoapods создает проекты-прокладки в подпапке ./Pods
. И логика подсчета зависимостей и исходников модуля завязана на пути относительно проектов каждого пода. При сборке с включенным XCRemoteCache ни для одного Development pod не используются кэши, т.к. в мета-информации зависимостей указываются абсолютные пути. А нам нужны относительные пути.
Снова вчитываемся в документацию и находим, что уже есть встроенный механизм для этого, и ничего не нужно дописывать! Добавляем параметр в конфиг (иначе все эти файлы добавляются в список зависимостей с абсолютными путями CI-раннера, которых точно нет на компах разработчиков).
Это специфично для нашего проекта, возможно у вас все будет работать и без этого. Но это будет полезно для тех, кто также работает с модулями через Development Pods.
'out_of_band_mappings' => {"MAIN_REPO_MODULES" => "#{Dir.pwd}/Modules"},
И кэши успешно используются. И даже дебаг работает! Но...
resolve_symlinks
Пробуем создать кэши для сборки на устройство, под архитектуру arm64. Собираем через обычный archive, включаем consumer
и пробуем запустить проект на устройстве. И что? Снова cache miss в бОльшей части модулей. Идем разбираться. DependencyProcessor использует TARGET_BUILD_DIR
для определения списка зависимостей и фильтрации реальных зависимостей от собственных артефактов. Если при обычной сборке TARGET_BUILD_DIR
и BUILT_PRODUCTS_DIR
совпадают, и указывают на одну и ту же папку, то при архивации TARGET_BUILD_DIR
уже указывает на симлинк от BUILT_PRODUCTS_DIR
. Что ж. Резолвим симлинки для корректного определения собственных артефактов, и заодно, раз уж мы залезли в этот класс, исправим слияние артефактов для модулей со схожими названиями. Например, ModuleName
считает своими артефакты от модуля ModuleNameCommon
.
Оформляем очередной Pull request.
XCRemoteCache v0.3.4
В этот момент вышла версия 0.3.4, почти целиком состоящая из наших исправлений:
dependency_cycles
Ну что, наконец-то можно спокойно работать? Как бы не так!
У нас есть модуль с локализованными строками, из которых собираются свифтовые структуры через SwiftGen. А по умолчанию [XCRC] Prebuild step
устанавливается первым в список. Первая сборка на холодную в итоге проходит без ошибок, а вот вторая крашится с Cycle dependencies
, т.к. SwiftGen стоит после Prebuild скрипта, но может менять файлы, от которых зависит Prebuild. В итоге Икскод сходит с ума.
Ставим [XCRC] Prebuild
непосредственно перед Compile sources
, заодно добавим имя таргета в название степа для облегчения поиска и дебага. Оформляем Pull request.
Development pods madness
А теперь-то уже все? Не совсем. Залезаем "в шкуру" разработчика. Делаем pod install
, все прекрасно, кэши используются, КРА-СО-ТА! Меняем что-то в одном из модулей, компилируем, запускаем приложение... И видим, что наши изменения не применились! Как же так? Разбираем логи. В измененном модуле кэши отключаются, все компилируется корректно, а вот главный таргет AliexpressRuBuyer
линкуется из кэша! Не порядок. Начинаем исследовать, и видим, что в списке зависимостей главного таргета нет практически ничего. А "сборная солянка" из подов - Pods-AliexpressRuBuyer.framework
, просто линкуется внутрь. То есть у XCRemoteCache в данный момент просто нет шансов узнать, что что-то изменилось внутри Development pods
.
Ничего не поделаешь - пока добавляем в исключения главный таргет приложения для режима consumer
.
Вот теперь все! Настройка завершена, можно с этим работать.
XCRemoteCache v0.3.5 и v0.3.6
На момент написания статьи уже выпущена версия v0.3.5, в которой была введена возможность отключения vfsoverlay
(добавленная в версии v0.3.4), которая резко увеличила время создания кэшей. В добавок к этому сильно просел cache hit rate
, который мы также зарепортили. С нашей помощью они это починили в версии v0.3.6. Но проблемы с производительностью не решены, поэтому мы пока остаемся на пропатченной версии v0.3.4.
Что в итоге?
Мы получили ускорение сборки, но не на 75%, как заявляли в Spotify, а на 50%. В идеальных условиях, когда кэши максимально свежие.
Реальность. Бессердечная ты с...
Тут бы кричать и радоваться, ведь мы сократили время сборки в два раза! Но суровая реальность такова. Мы накопили некоторую статистику за пару недель использования XCRemoteCache, и вот вам картинка.
Тут необходимо прояснить, что происходит. Producer с нулевым временем - это тестовые прогоны без реального создания кэшей. Всплески Producer в районе 1700-1800 - это тесты версий v0.3.5 и v0.3.6 с vfsoverlay
. Основная масса Producer проходит за 800-900 секунд, основная масса Consumer - за 450-550 секунд.
По сравнению со средним временем сборки без кэшей в 650-750 секунд уже что-то. Сокращение времени сборки примерно на 30%.
Почему же не 75%, и даже не 50%, Карл?
А все потому, что инвалидация кэша в одном модуле приводит к инвалидации кэшей во всех зависимых от него модулей.
Что можно сделать? Выделить публичную часть (читай все публичные .h
файлы и свифтовые интерфейсы) в отдельные модули, и переключить зависимости уже на них. Тогда не будет каскадного перекомпилирования кучи модулей по цепочке зависимостей.
Вывод
Решение от Spotify, конечно, работает. Не так, как они заявляют, но работает. По крайней мере в нашем проекте в текущем состоянии мы не смогли достичь 75% ускорения билда. Предстоит еще много работы и рефакторинга, чтобы получить хотя бы стабильные 50%.
В целом XCRemoteCache пока сыроват, но уже позволяет им пользоваться. Так что подключайтесь, и мы сможем сделать его еще лучше!
Надеюсь, данная статья поможет вам не напороться на наши грабли и обойти подводные камни.
Комментарии и вопросы приветствуются!