CI/CD в разработке игр: что меняют ночные сборки проекта
Настройка автоматической сборки в маленькой команде заняла пять дней: средняя жизнь сломанного коммита упала с 2,5 дней до 9 часов, а релизы стали спокойнее.
Сборка, которая развалилась в пятницу вечером
В прошлом году, в ту пятницу, когда мы должны были отправить демо издателю, единственной машиной, способной собрать билд, был мой рабочий компьютер. Нас было четверо на проекте, и только я знал, как делается сборка. В 19:40 она упала с сообщением Shader error in 'Custom/Water': undeclared identifier '_TimeScale'. Эта строка была закоммичена тремя днями раньше, и никто ничего не заметил, потому что тот вариант шейдера в редакторе вообще не компилировался.
На поиск ушло 25 минут, на исправление и пересборку — ещё 70. Чтобы найти виновное изменение, мне пришлось вручную пройти диапазон из 14 коммитов. Демо ушло в 23:10. Плохо было не опоздание: плохо было то, что три дня никто из нас не знал, собирается ли проект вообще.
Наш CI/CD для разработки игр вырос именно из того вечера. Целью никогда не был красивый pipeline: цель была отвечать на вопрос какой коммит всё сломал за одну ночь, а не за трое суток. Начали с GitHub Actions, потому что репозиторий уже лежал там, а на Jenkins перешли, когда добавили вторую сборочную машину.
Что на самом деле меняет ночная сборка
Единственная задача автоматической сборки — чтобы утром, когда вы садитесь за стол, вас уже ждала рабочая версия вчерашнего кода. Наша запускается в 03:00, заканчивается около 06:20 и роняет в Slack одну строку: хеш коммита, длительность, размер пакета, результат тестов. Два месяца мы слали отчёт подробнее, и я ни разу не видел, чтобы его кто-то открыл, поэтому вернулись к одной строке. Она гоняется и по выходным: найти сломанный репозиторий в понедельник утром гораздо приятнее, чем в пятницу вечером.
Самый осязаемый выигрыш, который я могу измерить, такой: средняя жизнь сломанного коммита упала с 2,5 дней до 9 часов. Чем раньше поймана поломка, тем меньше коммитов успевает лечь сверху — вы читаете один диф вместо того, чтобы запускать git bisect. Стоимость исправления тоже падает, потому что тот, кто писал код, ещё помнит контекст.
Второй выигрыш — на непрограммистской стороне. Раз машина каждое утро выдаёт играбельный exe, фраза «у меня работало» почти исчезла: левел-дизайнер и QA открывают ровно один и тот же билд. Ручная подготовка релизов отнимала у меня около 4 часов в неделю, и эти часы тоже вернулись. Сборка, которую мы отдаём издателю в пятницу, теперь готова вечером в четверг, и на последний день остаётся только написать список изменений.
Сборка из командной строки в Unity и Unreal
Сторона Unity выглядит тривиально, но первая ловушка — флаг -quit. В цепочке -batchmode -nographics -quit -executeMethod редактор может закрыться раньше, чем завершится ваша асинхронная работа, а код возврата при этом всегда 0. Мы убираем -quit и в конце метода вызываем EditorApplication.Exit(code), чтобы ошибка компиляции действительно красила CI в красный. Не менее важно направить лог в stdout через -logFile -: если писать его в файл, ошибка не появится в интерфейсе CI вообще.
Список сцен для сборки мы читаем не из EditorBuildSettings.scenes, а из собственного ScriptableObject BuildProfile. Причина практическая: этот список — ассет, он постоянно давал конфликты слияния, и дважды тестовая сцена уезжала внутри билда, а никто этого не замечал. Тот же ассет несёт define-символы и номер версии, так что информация о том, из чего собран билд, лежит ровно в одном месте.
В Unreal всё идёт через RunUAT.bat BuildCookRun. Полный проход с -clientconfig=Development -cook -stage -pak -archive занимает 48 минут; с -iterativecooking неизменившиеся ассеты пропускаются, и время падает до 11-14 минут. Итеративному куку в ночном прогоне мы не доверяем — только в сборках по pull request. В ночном проходе используется ещё и -nocompileeditor, что экономит дополнительные 6-7 минут.
Perforce, Git LFS и дисциплина кеша
Репозиторий на 40 ГБ — это не то, для чего создавался Git. С Git LFS наши файлы .psd и .fbx превратились в указатели, и всё равно новой машине требовалось 55 минут на первый клон. Sparse checkout плюс lfs.fetchexclude опустили это до 12 минут. Просить художников выкачивать только нужные им папки не сработало: тот, кому надо помнить команду, её не помнит.
Настоящая проблема — блокировки, а не время загрузки. Когда двое трогают один и тот же Environment_Rocks.fbx, никакого слияния не существует: кто-то теряет свою работу. Мы перешли на Perforce не потому, что он технически лучше, а потому, что художники его уже знали, и exclusive checkout там поведение по умолчанию. Лицензии и обслуживание сервера — реальные годовые расходы, но дешёвые на фоне тех рабочих часов, которые мы теряли. Мой порог простой: до 20 ГБ хватает Git LFS, выше — берите Perforce.
По кешу цифры безжалостные: заново собрать чистую папку Library/ на нашем проекте стоит 26 минут, тогда как сама компиляция — 9. Поэтому Library/ остаётся на сборочной машине, но не вслепую: Library/PackageCache вычищается при каждой смене версии Unity, и раз в неделю мы гоняем полностью чистую сборку. Иначе ошибка, которую прятал кеш, вылезет через месяцы, ровно в день релиза. Эту чистую сборку мы перенесли на ночь воскресенья, потому что отдавать те 26 минут в рабочий день не собираемся.
В Unreal ту же роль играет DerivedDataCache, и мы держим его в общей сетевой папке. Компиляция шейдеров вхолодную стоит 38 минут, с прогретым DDC — 4 минуты. Чтение по сети медленнее локального диска, поэтому на машине живёт ещё и второй, локальный слой DDC. Когда папка перевалила за 180 ГБ, мы добавили задачу, вычищающую старые записи: при заполнении диска сборка не падает, она просто тихо становится медленнее, и нам понадобилось две недели, чтобы это заметить.
Smoke-тесты и порог регрессии производительности
Под автоматическими тестами я не имею в виду исчерпывающий набор. Наша сцена SmokeTest запускает игру, заходит из главного меню на первый уровень, проигрывает 30 секунд записанного ввода и корректно завершается. Даже этого хватило, чтобы поймать 6 из 9 поломок сборки за год и сообщить о них в 07:00. Оставшиеся 3 проявлялись только на конкретной видеокарте или на втором уровне; вместо того чтобы раздувать ради них smoke-тест, мы добавили два пункта в утренний чек-лист QA.
Второй порог — производительность. В той же сцене мы собираем времена кадров и смотрим на p99, а не на среднее; цель — 16,6 мс при пороге p99 в 22 мс. Среднее прячет один-единственный рывок в 40 мс, а именно на такой рывок и жалуются игроки. Измерять начинаем после первых 120 кадров, потому что в это окно попадает прогрев шейдеров и раздувает p99 до бессмысленных значений.
Одна сборка выше порога не красит pipeline в красный, нужны две подряд. Хотя машина не занята больше ничем и мы берём медиану из трёх замеров, около 6% шума всё равно остаётся; поставьте порог жёстче — и через месяц команда начнёт игнорировать предупреждения. Артефакт провалившейся сборки мы тоже не удаляем, чтобы его можно было спрофилировать в сравнении с прошлой ночью, а не искать заново. p99 за последние 14 ночей мы храним в обычном CSV-файле, потому что тренд говорит больше, чем любое отдельное число.
Автоматическая отправка в Steam и TestFlight
Раскладка по площадкам — технически самый простой шаг и операционно самый раздражающий. Загрузка в Steam умещается в одну строку, steamcmd +run_app_build app_build.vdf, но если один раз не залогиниться на сборочной машине вручную и не сохранить файл ssfn, который оставляет Steam Guard, pipeline будет вставать на этом шаге каждую ночь. Ночные сборки уходят только в защищённую паролем ветку internal. Загрузка депо на 9 ГБ занимает в среднем 7 минут, а после первого раза по сети идут только изменившиеся куски.
На стороне iOS работает цепочка xcodebuild -exportArchive и затем xcrun altool. Единственное правило, которое здесь действительно важно, — привязать CFBundleVersion к номеру запуска CI; отправите один и тот же номер сборки дважды, и App Store Connect её отклонит, а письмо с ошибкой придёт минут через 20. Обработка в TestFlight занимает от 8 до 20 минут, то есть к утреннему кофе она не успевает. Установить сертификаты и provisioning profile в связку ключей машины и добавить в pipeline шаг security unlock-keychain оказалось самой времяёмкой задачей первой недели.
Вся настройка заняла у одного человека 5 дней: 1 день на сборку из командной строки, 1 день на машину и раннер, 1,5 дня на кеш и контроль версий, 1 день на тесты и полдня на выгрузку в магазины. Держитесь этого порядка. В первую неделю соберите pipeline, который отвечает только на вопрос «собирается ли», и складывает артефакт в папку; smoke-тесты и выгрузку в магазины оставьте на следующие месяцы. Сам pipeline тоже требует обслуживания — у нас это примерно полдня в месяц, — и если не заложить эту статью сразу, за полгода система сгниёт. Никто не строит полную систему в первый день, и половина работающего конвейера дороже целого неработающего.