기술 운영 기록

클라우드 Mac CI runner를 안전하게 롤링 업데이트하는 방법

클라우드 Mac CI runner를 안전하게 롤링 업데이트하는 방법

원격 팀은 클라우드 Mac의 CI runner를 상시 가동되는 인프라처럼 운영하는 경우가 많지만, runner 자체도 주기적으로 업데이트해야 합니다. 가장 위험한 방식은 작업이 진행 중인 상태에서 프로그램 디렉터리를 덮어쓰고 재시작하는 것입니다. 빌드 프로세스는 이전 파일을 계속 사용하는데 스케줄링 프로세스는 새 구성 요소를 이미 불러온 상태가 되어, 재현하기 어려운 혼합 상태가 남을 수 있습니다. 더 안전한 방법은 먼저 실행 중인 작업을 모두 자연스럽게 마친 뒤, 변경하지 않는 버전별 디렉터리와 원자적 심볼릭 링크 전환으로 새 버전을 활성화하는 것입니다.

업데이트의 안전 경계부터 정의하기

통제 가능한 업데이트는 최소한 네 가지 조건을 충족해야 합니다. 새 작업을 더 이상 받지 않아야 하고, 현재 작업은 정상적으로 끝나야 하며, 이전 버전과 새 버전이 함께 존재할 수 있어야 하고, 전환에 실패하면 즉시 원래 상태로 복구할 수 있어야 합니다. 여기서 작업을 비운다는 것은 작업 공간을 삭제하거나 빌드를 강제 종료한다는 뜻이 아닙니다. 작업과 작업 사이의 경계에서 runner를 일시 중지한다는 의미입니다.

업데이트 중 가장 중요한 상태는 “서비스가 실행 중인가”가 아니라 “이전 작업이 끝났고 새 작업은 아직 시작되지 않았는가”입니다.

업데이트를 시작하기 전에 현재 버전, runner 프로세스 ID, 실행 중인 작업 식별자와 작업 디렉터리를 기록합니다. 작업에 Xcode 빌드가 포함되어 있다면 xcodebuild, 테스트 프로세스 또는 아카이브 내보내기 하위 프로세스가 남아 있는지도 확인해야 합니다. 최상위 runner만 유휴 상태인지 확인해서는 안 됩니다. runner가 종료된 뒤에도 잘못 작성된 스크립트가 하위 프로세스를 백그라운드에 남길 수 있기 때문입니다.

업데이트 판단 기준은 명확한 규칙으로 작성하는 것이 좋습니다. 현재 작업을 최대 얼마 동안 실행하도록 허용할지, 얼마나 기다린 뒤 수동 점검으로 전환할지, 어떤 스모크 테스트를 통과해야 작업 수신을 재개할지 정해야 합니다. 이러한 규칙은 장애가 발생한 뒤 즉석에서 결정하지 말고, 프로젝트별 작업 시간을 바탕으로 팀이 미리 수립해야 합니다.

프로그램, 설정, 작업 공간 분리하기

runner 프로그램은 버전마다 독립된 디렉터리에 배치하고, 장기적으로 유지할 설정과 빌드 작업 공간은 버전 디렉터리 밖에 두어야 합니다. 간단한 디렉터리 구성은 다음과 같습니다.

/opt/ci-runner/
├── current -> releases/runner-current
├── previous-target
├── drain
├── active.pid
├── config/
├── work/
└── releases/
    ├── runner-old/
    └── runner-current/

config에는 등록 정보와 비공개 설정을 저장하고, 접근 권한은 runner 사용자로 제한해야 합니다. work에는 체크아웃 디렉터리와 임시 빌드 파일을 저장하며, releases에는 프로그램만 둡니다. 토큰을 버전 디렉터리마다 복사해서는 안 되며, 업데이트 패키지가 작업 공간을 덮어쓰게 해서도 안 됩니다.

이렇게 분리하면 업데이트 시 변경되는 것은 current가 가리키는 대상뿐입니다. 이전 버전은 온전하게 유지되므로 다시 다운로드하거나 설치하지 않고도 롤백할 수 있습니다. 이전 버전 정리는 관찰 기간이 끝난 뒤로 미뤄야 하며, 검증을 마친 가장 최근 버전 하나 이상은 반드시 남겨야 합니다.

wrapper로 작업 경계에서 안전하게 비우기

가장 제어하기 쉬운 방식은 runner가 한 번에 작업 하나만 실행하고 종료한 뒤, wrapper가 다음 작업을 받을지 결정하도록 하는 것입니다. 작업 하나만 실행하는 옵션은 runner마다 다를 수 있으므로 실제 실행 명령을 $RUNNER_CMD로 캡슐화한 다음 공통 반복문을 사용합니다.

#!/bin/zsh
set -u

root=/opt/ci-runner
lock="$root/wrapper.lock"

mkdir "$lock" 2>/dev/null || exit 1

cleanup() {
  rm -f "$root/active.pid"
  rmdir "$lock" 2>/dev/null
}

trap cleanup EXIT INT TERM

while [[ ! -e "$root/drain" ]]; do
  "$root/current/bin/$RUNNER_CMD" run --once &
  child=$!
  print -r -- "$child" > "$root/active.pid"

  if wait "$child"; then
    status=0
  else
    status=$?
  fi

  rm -f "$root/active.pid"

  if (( status != 0 )); then
    sleep 10
  fi
done

업데이트를 준비할 때 touch /opt/ci-runner/drain을 실행합니다. wrapper는 현재 하위 프로세스가 끝날 때까지 기다리지만 다음 실행은 시작하지 않습니다. 이후 active.pid를 읽고 kill -0 PID로 프로세스가 여전히 존재하는지 확인합니다. PID 파일이 비어 있다고 해서 시스템이 유휴 상태라는 뜻은 아닙니다. 해당 작업과 연결된 빌드 및 테스트 하위 프로세스도 함께 점검해야 합니다.

runner가 작업 하나를 마친 뒤 종료하는 모드를 지원하지 않는다면, runner가 자체적으로 제공하는 작업 수신 일시 중지 기능을 사용해야 합니다. 일시 중지 이후 작업을 미리 가져오지 않는지도 반드시 검증해야 합니다. 동작을 확신할 수 없다면 격리된 환경에서 장시간 실행되는 작업을 먼저 시뮬레이션하고 작업이 비워지는 과정을 관찰합니다.

업데이트 패키지 검증 후 원자적으로 전환하기

새 버전은 먼저 임시 디렉터리에 압축을 풀고, 검증을 마친 뒤 이름을 바꿔 releases에 배치합니다. 다운로드 패키지의 체크섬은 팀이 신뢰하는 릴리스 기록에서 가져와야 합니다. 검증하지 않은 동일한 압축 파일 옆에서 체크섬을 즉석으로 생성해서는 안 됩니다.

root=/opt/ci-runner
release="$root/releases/runner-next"
archive=/tmp/runner-next.tar.gz
expected="$EXPECTED_SHA256"

actual="$(shasum -a 256 "$archive" | awk '{print $1}')"
[[ "$actual" = "$expected" ]] || exit 2

mkdir -p "$release"
tar -xzf "$archive" -C "$release"
"$release/bin/$RUNNER_CMD" --version || exit 3

old="$(readlink "$root/current")"
print -r -- "$old" > "$root/previous-target"

ln -sfn "$release" "$root/current.next"
mv -fh "$root/current.next" "$root/current"

심볼릭 링크를 전환하기 전에 새 디렉터리의 프로그램이 실행 가능한지, 시스템 아키텍처와 일치하는지, 외부 설정을 읽을 수 있는지 확인해야 합니다. 전환 작업 자체는 최대한 짧게 유지해야 합니다. 압축 해제, 의존성 설치, 네트워크 다운로드를 임계 구간에 포함해서는 안 됩니다. 업데이트 과정에서 설정 마이그레이션도 필요하다면 먼저 임시 파일에 복사하고 형식을 검증한 다음 원자적으로 교체합니다.

압축 해제로 인한 권한 변경 방지하기

압축 파일에는 기존과 다른 소유자나 권한 정보가 포함될 수 있습니다. 전환하기 전에 runner 사용자가 아닌 계정이 프로그램 파일을 수정할 수 없는지 확인해야 합니다. 민감한 설정 파일은 일반적으로 600, 설정 디렉터리는 일반적으로 700으로 지정합니다. 작업 디렉터리의 소유자가 기존 runner 사용자로 유지되는지도 확인해, 새 버전을 처음 실행할 때 뒤늦게 쓰기 오류가 드러나는 일을 방지해야 합니다.

스모크 테스트와 빠른 롤백

전환 직후에는 drain을 삭제하지 않습니다. 먼저 통제된 스모크 작업을 수동으로 한 번 실행해 최소한 다음 항목을 검증합니다. runner가 설정을 읽을 수 있어야 하고, 임시 작업 공간을 만들 수 있어야 하며, 개발 도구를 호출하고 최소 빌드를 완료할 수 있어야 합니다. 또한 산출물을 저장하고 정상적으로 정리한 뒤 종료할 수 있어야 합니다.

로그를 검증할 때는 경로를 중점적으로 확인합니다. 새 프로세스는 current가 가리키는 새 디렉터리에서 시작해야 하지만, 캐시와 설정, 작업 공간은 계속 고정 경로를 사용해야 합니다. 로그에 이전 버전과 새 버전의 경로가 함께 나타난다면 이전 프로세스가 아직 종료되지 않은 것입니다. 이 상태에서는 작업 수신을 재개하면 안 됩니다.

검증에 실패하면 previous-target에서 이전 링크 대상을 읽어 동일한 임시 링크 방식으로 되돌린 뒤 최소 작업을 다시 실행합니다. 새 버전 디렉터리에서 직접 수정과 테스트를 반복해서는 안 됩니다. 그렇게 하면 변경하지 않는 릴리스라는 전제가 깨집니다. 이전 버전이 정상적으로 복구된 것을 확인한 뒤에 실패한 버전을 삭제하고 릴리스 패키지를 다시 만듭니다.

검증을 통과하면 rm -f /opt/ci-runner/drain을 실행하고 프로세스 관리자가 wrapper를 다시 시작하도록 합니다. 마지막으로 실제 작업 하나 이상이 수신되고, 빌드와 산출물 보관을 거쳐 정상적으로 종료되는 전 과정을 관찰합니다. 버전 번호, 전환 결과, 롤백 대상을 업데이트 기록에 남겨야 합니다. runner가 온라인으로 표시되는 순간이 아니라 이 절차까지 모두 끝나야 업데이트가 완료된 것입니다.

자주 묻는 질문

CI runner를 기존 디렉터리에 덮어쓰면 안 되는 이유는 무엇인가요?

실행 중인 작업이 이전 파일과 새 파일을 함께 읽을 수 있고 정상 버전으로 즉시 돌아갈 기준도 사라집니다. 버전별 디렉터리가 이 위험을 분리합니다.

runner를 드레인할 때 실행 중인 빌드를 종료해야 하나요?

아닙니다. 드레인은 다음 작업 수락만 막고 현재 자식 프로세스는 정상 완료시킵니다. 강제 종료 전에는 로그와 프로세스 상태를 먼저 보존해야 합니다.

업데이트 후 최소한 무엇을 검증해야 하나요?

runner 버전과 등록 상태, 작업 공간 쓰기, 최소 빌드, 산출물 보관, 민감한 설정 파일 권한을 확인합니다. 하나라도 실패하면 작업 수락을 멈추고 복구합니다.

MacVPSGo 클라우드 Mac

독립형 Apple Silicon 빌드 머신이 필요하신가요?

일·주·월·분기 단위로 독립형 물리 노드를 대여해 Xcode 빌드, iOS CI, 원격 개발 및 자동화 작업에 활용할 수 있습니다.

구성 선택 및 주문