Когда один и тот же коммит одновременно запускается повторной попыткой Webhook, ручным перезапуском и заданием по расписанию, две сборки Xcode могут с разницей в несколько секунд попасть в одно рабочее пространство. Они не обязательно завершатся с ошибкой сразу: гораздо чаще оба процесса одновременно изменяют DerivedData, перезаписывают каталог архивов и в итоге вызывают нерегулярную ошибку, которую трудно воспроизвести. Для постоянно работающего облачного Mac такие повторные запуски стоит предотвращать заранее — зачастую это важнее, чем обработка единичного сбоя команды.
Сначала определите ресурс, которому действительно нужна взаимная блокировка
Не следует сразу устанавливать глобальную блокировку на весь узел. Область блокировки должна определяться общим объектом записи, а не названием репозитория. Если две задачи совместно используют хотя бы один из следующих ресурсов, они должны использовать один ключ блокировки:
- генерируемые файлы или каталоги зависимостей в одном рабочем пространстве;
- один и тот же путь DerivedData;
- один и тот же каталог архивирования, экспорта или загрузки;
- набор устройств симулятора, доступ к которому возможен только последовательно;
- одну и ту же конечную цель публикации.
И наоборот, если тестовые шарды используют отдельные checkout, DerivedData и наборы устройств симулятора, их можно выполнять параллельно с разными ключами блокировки. Рекомендуется составлять ключ из репозитория, Scheme и действия, например client-release-archive. Не включайте в него только имя ветки: две ветки всё равно могут конкурировать за один каталог архивов.
| Сценарий | Рекомендуемая гранулярность блокировки | Допускается параллельное выполнение |
|---|---|---|
| Архивирование с общим DerivedData | Репозиторий + Scheme + archive | Нет |
| Тестовые шарды с отдельными наборами устройств | Репозиторий + номер шарда | Да |
| Загрузка одноимённых артефактов релиза | Приложение + канал публикации | Нет |
| Статический анализ без записи | Обычно блокировка не нужна | Да |
Блокировка защищает общие побочные эффекты, а не CPU. Попытка обойти необходимую блокировку ради повышения параллелизма обычно лишь превращает время ожидания во время, потраченное на очистку.
Создайте атомарную точку конкуренции с помощью mkdir
Встроенную в macOS команду mkdir можно использовать как простую и надёжную точку конкуренции: когда несколько процессов одновременно пытаются создать один и тот же путь, успешным будет только один. В отличие от подхода «сначала проверить наличие файла, затем записать PID», здесь нет окна гонки между проверкой и записью.
Следующий скрипт создаёт для блокировки случайный токен владельца и обновляет heartbeat каждые 15 секунд. При завершении удалить блокировку сможет только процесс, токен которого по-прежнему совпадает. Это не позволяет старой задаче случайно удалить блокировку новой.
#!/bin/bash
set -euo pipefail
LOCK_ROOT="${HOME}/Library/Caches/ci-locks"
LOCK_KEY="client-release-archive"
LOCK_DIR="${LOCK_ROOT}/${LOCK_KEY}.lock"
TOKEN="$(uuidgen)"
mkdir -p "$LOCK_ROOT"
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
echo "build lock is already held"
test -f "$LOCK_DIR/owner" && cat "$LOCK_DIR/owner"
exit 75
fi
printf 'token=%s
pid=%s
started=%s
' \
"$TOKEN" "$$" "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" > "$LOCK_DIR/owner"
touch "$LOCK_DIR/heartbeat"
heartbeat() {
while true; do
touch "$LOCK_DIR/heartbeat"
sleep 15
done
}
heartbeat &
HEARTBEAT_PID=$!
cleanup() {
kill "$HEARTBEAT_PID" 2>/dev/null || true
if grep -Fq "token=${TOKEN}" "$LOCK_DIR/owner" 2>/dev/null; then
rm -rf "$LOCK_DIR"
fi
}
trap cleanup EXIT
trap 'exit 130' INT TERM HUP
xcodebuild \
-workspace Client.xcworkspace \
-scheme Client \
-configuration Release \
-derivedDataPath "$PWD/.ci/DerivedData" \
archive
Код выхода 75 однозначно указывает, что задача временно не может быть выполнена. На уровне очереди можно настроить отложенный повторный запуск, но нельзя бесконечно перезапускать задачу в течение одной секунды. Все пути должны быть заключены в кавычки, а корневой каталог блокировок следует разместить там, где текущая учётная запись имеет права на запись и где команды очистки репозитория его не удалят.
Не определяйте устаревшую блокировку только по PID
PID-файл полезен для диагностики, но сам по себе не подтверждает владение блокировкой. macOS может повторно использовать PID, а после перезапуска узла число из старого файла может соответствовать совершенно другому процессу. Поэтому процедура очистки должна проверять как минимум три условия:
- Превысило ли время с последнего изменения heartbeat максимальную продолжительность выполнения конвейера;
- Помечена ли задача в очереди как завершённая или превысившая лимит времени;
- Продолжают ли связанные процессы сборки записывать данные в защищаемый каталог.
Устаревший heartbeat означает лишь, что ситуацию нужно проверить, а не то, что блокировку уже можно перехватить. Если сборка приостановилась из-за блокировки дискового ввода-вывода, процесс всё ещё может удерживать файлы. Принудительный запуск второй сборки в этот момент только увеличит масштаб повреждений. Надёжнее поручить очистку отдельной задаче восстановления, а не позволять ожидающей блокировку задаче самостоятельно удалять состояние предшественника.
Добавьте в блокировку наблюдаемые поля
В файле owner можно дополнительно хранить хеш коммита, номер задачи, Scheme и рабочий каталог, но нельзя записывать токены доступа, данные для подписи и другие конфиденциальные значения. При ошибке журнал должен как минимум содержать ключ блокировки, время запуска и номер задачи. Это позволит инженерам отличить обычное ожидание в очереди от аварийного завершения предыдущего запуска.
Разделяйте ожидание, тайм-аут и отмену
Взаимная блокировка не означает, что все последующие задачи должны завершаться с ошибкой. На практике обычно применяют три стратегии очереди:
- архивирование релиза: первая задача сохраняется, последующие ожидают;
- проверка ветки: старая задача для той же ветки отменяется, а последний коммит сохраняется;
- сборка по расписанию: если задача с тем же ключом уже выполняется, текущий запуск пропускается.
Независимо от выбранной стратегии ожидание должно иметь верхний предел. Тайм-аут ожидания завершает только ожидающую задачу и не должен удалять блокировку владельца. При отмене владельца сначала следует отправить обычный сигнал завершения, чтобы trap выполнил очистку. Только если процесс не удаётся завершить, нужно переходить к ручному или отдельному процессу восстановления.
Кроме того, не выполняйте внутри блокировки несвязанную работу. Загрузку кода, проверки без записи и подготовку параметров можно выполнить до получения блокировки. Блокировку следует захватывать только перед этапом, который действительно изменяет общие каталоги. Это сокращает критическую секцию и уменьшает перегрузку очереди.
Проведите проверку отказоустойчивости перед вводом в эксплуатацию
Сначала дважды подряд запустите скрипт в задаче, не связанной с публикацией, и убедитесь, что только один процесс переходит к xcodebuild. Затем по отдельности смоделируйте нормальное завершение, ошибку команды и получение сигнала завершения, каждый раз проверяя, удалён ли каталог блокировки. Наконец, вручную оставьте старую блокировку и убедитесь, что ожидающая задача лишь сообщает о занятости и не пытается самостоятельно её удалить.
При приёмке используйте следующий контрольный список:
- ключ блокировки точно соответствует общему ресурсу;
- получение блокировки основано на одной атомарной операции;
- перед снятием блокировки проверяется случайный токен владельца;
- для
EXIT,INT,TERMиHUPпредусмотрена очистка; - heartbeat используется только для диагностики и сам по себе не инициирует перехват блокировки;
- журнал позволяет определить задачу-владельца, но не содержит конфиденциальных данных;
- тайм-аут ожидания не влияет на выполняющуюся сборку;
- независимые задачи используют разные рабочие пространства и каталоги вывода.
После этих проверок повторные запуски станут понятными событиями ожидания в очереди, а не скрытыми гонками, случайно повреждающими состояние сборки. Если впоследствии потребуется увеличить параллелизм, сначала разделите общие каталоги и цели публикации, а затем уточните ключи блокировок — не удаляйте взаимную блокировку напрямую.
Часто задаваемые вопросы
Почему недостаточно хранить только PID процесса сборки?
PID может быть повторно выдан другому процессу, а файл может остаться после сбоя. Атомарное создание каталога определяет победителя, а случайный токен подтверждает владельца.
Нужно ли блокировать все Xcode-сборки одним глобальным ключом?
Нет. Один ключ нужен только задачам с общими рабочими каталогами, DerivedData, архивами или местом публикации. Полностью изолированные задачи можно выполнять параллельно.
Можно ли удалить блокировку сразу после остановки heartbeat?
Нет. Сначала подтвердите превышение лимита времени и отсутствие процессов, записывающих данные в защищаемый каталог, затем выполните удаление отдельным этапом очистки.
Выбирайте выделенные физические узлы Mac для сборки, разработки и экспериментов
Сравните две конфигурации Mac mini M4, четыре срока аренды и пять доступных узлов, а затем выберите вариант под свой рабочий процесс.