- PVSM.RU - https://www.pvsm.ru -
Всем привет!
Меня зовут Федоров Василий, я руковожу группой Mobile.Speed в Aliexpress Россия. Мы стараемся облегчить жизнь разработчиков: пишем утилиты, настраиваем CI, складируем метрики в Grafana — в общем отвечаем почти за все, что влияет на time-to-market и Developer Experience команд. В этой статье я расскажу о том, как мы ускоряем сборку проекта с помощью XCRemoteCache — но обо всем по порядку под катом.
Как вы уже знаете, больше года назад мы запустили собственное приложение для российских пользователей [1], а еще до этого начали постепенно локализовывать все сервисы. В какой-то момент мы ушли и от китайской 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. Мы решили пойти сразу в обе стороны, чтобы сравнить показатели двух систем и выбрать для себя лучшую. Но сравнение — это тема отдельной статьи, а сейчас давайте остановимся на второй. Итак!
Не так давно Spotify выложила в паблик [2] свою самописную утилиту по кэшированию артефактов. https://github.com/spotify/XCRemoteCache [3]
Давайте разберемся, что это и с чем его едят.
Но для начала, небольшой список определений.
Кэш/артефакт - .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 [4] подробно описана инструкция по интеграции в существующий проект, не будем останавливаться на этом подробно. Но заглянем немножко под капот.
В XCRemoteCache есть два режима работы - producer (создает кэши) и consumer (использует кэши)
Начинали мы в тот момент, когда была доступна версия 0.3.3. Решили сразу пойти через плагин для cocoapods, ведь интеграция виделась быстрой и бесшовной:
запускаем pod install
, XCRemoteCache интегрируется в проект;
собираем проект с использованием кэшей;
???
Profit!
Как же мы ошибались...
Итак, поехали!
Включили плагин, сконфигурировали на наш локальный 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 [5] в главный репо, поехали дальше
Снова pod install
.
Завелось!
Создаем кэши, меняем режим на consumer:
'mode' => 'consumer'
Снова pod install
.
Кэши используются, но проект не собирается. Падает с ошибкой missing .swiftinterface file
Все дело в том, что у нас некоторое количество модулей собирается в режиме BUILD_LIBRARY_FOR_DISTRIBUTION
. А в этом режиме создаются .swiftinterface
файлы, которые сейчас не добавляются в артефакты. А раз их нет, но Xcode думает, что они созданы - как раз и возникает упомянутая ошибка. Вообще что это за файлы? Они нужны в тех случаях, когда swift-библиотека распространяется в виде бинарника. А в этом файле содержатся все интерфейсы, чтобы Xcode мог перекомпилировать этот модуль при необходимости.
Добавляем эти файлы в список optional artifacts, оформляем следующий Pull request [6], идем дальше.
Производим кэши, собираем в режиме consumer - заработало! Ура!
Вчитываясь в документацию, вы обнаружите, что существует удобный режим producer-fast
— это такой гибрид consumer и producer.
Допустим, первые кэши были созданы на каком-то коммите. Через 2-3 коммита мы решили создать новые кэши. Понятно, что поменялось не 100% модулей, а только часть. Так вот этот режим позволяет переиспользовать старые артефакты для нового коммита в тех модулях, в которых ничего не менялось. Но вот незадача. Плагин для cocoapods этот режим не поддерживает.
Исправляем очередным Pull request [7].
Перевозим producer mode на Gitlab-CI, запускаем по расписанию каждую ночь. На ноутбуке разработчика пробуем собрать все с использованием XCRemoteCache, натыкаемся на пару ошибок в конфигурации. Если во время очередного pod install
плагин решит, что он не может использовать кэши, то он отключает использование бинарников для главного проекта. А для Pod-проектов - нет! Плюс в CFLAGS добавляется пробел перед переопределением. И в процессе нескольких переключений между режимами работы, может возникнуть ситуация с дублированием флагов. А все потому, что Xcode слишком умный, и сносит эти лишние пробелы.
Вносим очередное исправление и отправляем Pull request [8].
Также мы обнаружили, что кэши для Development pods, собранные на CI, не могут использоваться, т.к. все исходники Development pods у нас лежат в подпапке ./Modules
относительно корня репо. А Cocoapods создает проекты-прокладки в подпапке ./Pods
. И логика подсчета зависимостей и исходников модуля завязана на пути относительно проектов каждого пода. При сборке с включенным XCRemoteCache ни для одного Development pod не используются кэши, т.к. в мета-информации зависимостей указываются абсолютные пути. А нам нужны относительные пути.
Снова вчитываемся в документацию и находим, что уже есть встроенный механизм для этого, и ничего не нужно дописывать! Добавляем параметр в конфиг (иначе все эти файлы добавляются в список зависимостей с абсолютными путями CI-раннера, которых точно нет на компах разработчиков).
Это специфично для нашего проекта, возможно у вас все будет работать и без этого. Но это будет полезно для тех, кто также работает с модулями через Development Pods.
'out_of_band_mappings' => {"MAIN_REPO_MODULES" => "#{Dir.pwd}/Modules"},
И кэши успешно используются. И даже дебаг работает! Но...
Пробуем создать кэши для сборки на устройство, под архитектуру 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 [9].
В этот момент вышла версия 0.3.4, почти целиком состоящая из наших исправлений:
Ну что, наконец-то можно спокойно работать? Как бы не так!
У нас есть модуль с локализованными строками, из которых собираются свифтовые структуры через SwiftGen. А по умолчанию [XCRC] Prebuild step
устанавливается первым в список. Первая сборка на холодную в итоге проходит без ошибок, а вот вторая крашится с Cycle dependencies
, т.к. SwiftGen стоит после Prebuild скрипта, но может менять файлы, от которых зависит Prebuild. В итоге Икскод сходит с ума.
Ставим [XCRC] Prebuild
непосредственно перед Compile sources
, заодно добавим имя таргета в название степа для облегчения поиска и дебага. Оформляем Pull request [10].
А теперь-то уже все? Не совсем. Залезаем "в шкуру" разработчика. Делаем pod install
, все прекрасно, кэши используются, КРА-СО-ТА! Меняем что-то в одном из модулей, компилируем, запускаем приложение... И видим, что наши изменения не применились! Как же так? Разбираем логи. В измененном модуле кэши отключаются, все компилируется корректно, а вот главный таргет AliexpressRuBuyer
линкуется из кэша! Не порядок. Начинаем исследовать, и видим, что в списке зависимостей главного таргета нет практически ничего. А "сборная солянка" из подов - Pods-AliexpressRuBuyer.framework
, просто линкуется внутрь. То есть у XCRemoteCache в данный момент просто нет шансов узнать, что что-то изменилось внутри Development pods
.
Ничего не поделаешь - пока добавляем в исключения главный таргет приложения для режима consumer
.
Вот теперь все! Настройка завершена, можно с этим работать.
На момент написания статьи уже выпущена версия 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%.
А все потому, что инвалидация кэша в одном модуле приводит к инвалидации кэшей во всех зависимых от него модулей.
Что можно сделать? Выделить публичную часть (читай все публичные .h
файлы и свифтовые интерфейсы) в отдельные модули, и переключить зависимости уже на них. Тогда не будет каскадного перекомпилирования кучи модулей по цепочке зависимостей.
Решение от Spotify, конечно, работает. Не так, как они заявляют, но работает. По крайней мере в нашем проекте в текущем состоянии мы не смогли достичь 75% ускорения билда. Предстоит еще много работы и рефакторинга, чтобы получить хотя бы стабильные 50%.
В целом XCRemoteCache пока сыроват, но уже позволяет им пользоваться. Так что подключайтесь, и мы сможем сделать его еще лучше!
Надеюсь, данная статья поможет вам не напороться на наши грабли и обойти подводные камни.
Комментарии и вопросы приветствуются!
Автор: VasVF
Источник [11]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/ios/372492
Ссылки в тексте:
[1] собственное приложение для российских пользователей: https://habr.com/ru/company/aliexpress_russia/blog/563290/
[2] Spotify выложила в паблик: https://engineering.atspotify.com/2021/11/introducing-xcremotecache-the-ios-remote-caching-tool-that-cut-our-clean-build-times-by-70/
[3] https://github.com/spotify/XCRemoteCache: https://github.com/spotify/XCRemoteCache
[4] Readme.md: http://Readme.md
[5] Pull request: https://github.com/spotify/XCRemoteCache/pull/57
[6] Pull request: https://github.com/spotify/XCRemoteCache/pull/56
[7] Pull request: https://github.com/spotify/XCRemoteCache/pull/63
[8] Pull request: https://github.com/spotify/XCRemoteCache/pull/66
[9] Pull request: https://github.com/spotify/XCRemoteCache/pull/67
[10] Pull request: https://github.com/spotify/XCRemoteCache/pull/74
[11] Источник: https://habr.com/ru/post/653311/?utm_source=habrahabr&utm_medium=rss&utm_campaign=653311
Нажмите здесь для печати.