CI가 전부 멈췄고, 저는 원인을 확인하지 않았습니다 — 30분 만에 끝난 대응의 함정
새벽 세 시 무렵부터 제 저장소의 GitHub Actions가 전부 막혔습니다. 새로 연 PR만이 아니라 이미 돌고 있던 워크플로우도, main 브랜치까지 포함해서 전부였습니다. 화면에는 최근 계정 결제가 실패했다는 메시지가 떠 있었습니다.
대응 자체는 30분 만에 끝났습니다. 문제는 그 30분이 너무 매끄러웠다는 데 있었습니다. 저는 에러 메시지를 원인으로 받아들이고 거기서 조사를 멈췄습니다. 그 판단이 왜 틀렸는지는 이 시리즈의 마지막 편에서야 밝혀집니다.
메시지가 곧 원인처럼 보였습니다
결제가 실패했다는 문구를 봤을 때 제 머릿속에 그려진 그림은 단순했습니다. 카드가 만료됐거나 한도에 걸렸고, 그래서 GitHub이 유료 자원 사용을 막았다는 것이었습니다. 실제로 화면이 그렇게 말하고 있었으니 더 파볼 이유를 못 느꼈습니다.
돌이켜 생각해보면 여기서 던졌어야 할 질문이 하나 있었습니다. 나는 무료 사용량 안에서 쓰고 있다고 믿고 있었는데, 그렇다면 애초에 왜 유료 자원을 쓰고 있었나라는 질문입니다. 이 질문을 그날 던졌다면 2주를 아꼈을 것입니다. 다만 그때는 눈앞의 CI를 되살리는 게 급했고, 급한 일이 있으면 이상한 점은 나중으로 미루게 됩니다.
선택지는 둘이었고, 저는 원칙이 있었습니다
길은 두 개였습니다. 결제를 정상화하거나 유료 플랜을 붙여서 뚫는 것이 하나, 제 손으로 CI를 실행할 컴퓨터를 마련하는 것이 다른 하나였습니다.
저는 이 블로그와 여기 얹힌 서비스들을 과금 없이 무료로 운영한다는 원칙을 갖고 있습니다. 수익이 나는 물건이 아니니 고정비를 만들지 않겠다는 것이고, 이 원칙은 이 시리즈 내내 제 판단을 지배합니다. 그래서 저는 두 번째를 골랐습니다.
GitHub Actions에서 이 선택은 self-hosted 러너로 구현됩니다. GitHub이 관리하는 가상 머신 대신, 내가 가진 컴퓨터에 러너 프로그램을 설치해 두면 그 컴퓨터가 작업을 받아 갑니다. 내 전기와 내 하드웨어를 쓰는 대신 GitHub에 내는 돈은 없습니다.
변수 한 줄로 갈아 끼우기
가장 먼저 한 일은 워크플로우가 어디서 돌지를 고정하지 않는 것이었습니다. 실행 대상을 저장소 변수로 뺐습니다.
runs-on: ${{ vars.CI_RUNNER || 'ubuntu-latest' }}
이 한 줄이 좋았던 이유는 변수를 설정하지 않으면 원래 동작 그대로라는 점입니다. 변수가 없으면 기존과 똑같이 GitHub의 우분투 머신에서 돌고, 변수를 채우면 그때부터 제 컴퓨터로 갑니다. 되돌리는 데 코드 수정이 필요 없고, 잘못돼도 원래 자리로 돌아올 뿐입니다. 급한 상황에서 손대는 변경은 이런 성질을 갖는 게 좋다는 생각이 들었습니다.
그다음 개발용 맥북에 러너를 설치하고 상시 실행되도록 등록했습니다. 변수를 채우자 PR의 CI가 제 책상 위에서 돌기 시작했습니다. 여기까지가 30분이었습니다.
한 가지 알아두면 좋은 사실이 있습니다. GitHub Actions에는 hosted가 막히면 self-hosted로 자동으로 넘어가는 기능이 없습니다. 그래서 이런 스위치는 자동 폴백이 아니라 사람이 손으로 넘기는 수동 전환입니다. 이 사실은 나중에 제가 러너를 여러 대로 늘릴 때 설계 전체를 규정하게 됩니다.
기각한 대안 — 운영 서버를 겸용하기
검토했다가 접은 선택지가 하나 있습니다. 이미 돌아가고 있는 운영 서버에 러너를 얹는 것입니다. 새 하드웨어가 필요 없으니 가장 싸 보였습니다. 두 가지 이유로 접었습니다.
첫째는 메모리입니다. 백엔드 테스트 스위트가 6GB 힙을 요구하는데, 운영 컨테이너들은 수백 MB 단위로 빠듯하게 잡혀 있습니다. 테스트가 도는 동안 실제 서비스가 메모리를 두고 다투게 됩니다.
둘째가 더 중요했습니다. CI가 실행하는 것은 아직 검증되지 않은 브랜치의 코드입니다. 그것을 운영 인프라 위에서 돌린다는 것은, 리뷰도 통과하지 않은 코드에 운영 서버의 권한을 주는 것과 같습니다. 비용을 아끼려다 경계를 무너뜨리는 거래라고 판단했습니다.
옮기고 나니 숨어 있던 것들이 드러났습니다
CI를 제 머신으로 옮기는 것으로 끝이 아니었습니다. hosted 러너는 매번 새로 만들어졌다 버려지는 일회용 가상 머신입니다. 그 전제 위에서 짜인 것들이 계속 살아 있는 제 개발 머신에서는 다르게 동작했습니다.
예를 들어 어떤 캐시 액션은 작업이 끝나면 홈 디렉터리의 도구 경로를 정리하는데, 일회용 VM에서는 어차피 통째로 사라질 것이라 무해합니다. 제 맥북에서는 그것이 제가 실제로 쓰는 도구를 지우는 동작이 됩니다. 결국 CI가 쓰는 도구 경로를 작업 폴더 안쪽으로 격리해서, CI가 제 개인 환경을 건드리지 못하게 막아야 했습니다.
전수 점검을 하다 보니 아예 실행조차 못 할 워크플로우도 하나 나왔습니다. 세 가지 운영체제에서 병렬로 도는 빌드가 있었는데, 이건 GitHub이 그 세 종류의 머신을 다 갖고 있어야 성립합니다. 제 self-hosted 환경에는 맥북 한 대뿐이었으니 전환 즉시 영구 실패할 운명이었습니다. 실행되기 전에 감사로 발견해서 고쳤습니다.
이 세 가지 운영체제 병렬 빌드가 이 시리즈의 진범입니다. 다만 그때 저는 그것을 그저 "self-hosted로 옮기면 안 돌아갈 워크플로우" 정도로만 보고 조용히 고쳐두었습니다. 이것이 애초에 저를 여기까지 오게 만든 원인이라는 사실은 2주 뒤에 알게 됩니다.
증상을 끄는 것과 원인을 아는 것
그날 저는 스스로 대응을 잘했다고 생각했습니다. 실제로 나쁘지 않았습니다. 되돌리기 쉬운 변경을 골랐고, 위험한 대안을 이유를 갖고 기각했고, CI는 30분 만에 되살아났습니다.
다만 그 전부가 증상을 끄는 일이었습니다. 저는 CI가 멈춘 것을 고쳤지, CI가 왜 멈췄는지는 끝내 확인하지 않았습니다. 그리고 원인을 모르는 채로 대책을 세우면, 그 대책은 필요 이상으로 커지는 경향이 있습니다. 다음 편에서 저는 집에 있던 노트북 두 대를 마저 켜게 됩니다.
관련 글
집에서 놀던 노트북 두 대를 CI 러너로 만들었습니다 — 라벨로 폴백을 만드는 법
맥북 한 대로는 CI 큐가 밀렸습니다. 방치돼 있던 Proxmox 홈랩 서버와 윈도우 노트북을 러너로 붙이면서, 네이티브 폴백이 없는 GitHub Actions에서 라벨만으로 폴백을 만든 과정을 정리합니다.
다 만들어놓고 전부 껐습니다 — 쿼터를 태운 범인은 macOS 10배 배수였습니다
사용량을 총량으로만 보면 원인이 안 보입니다. 저장소별 SKU로 분해하자 raw 99.4분짜리 macOS 작업이 994분으로 청구돼 쿼터의 절반을 태우고 있었습니다. 2주간 만든 러너 함대는 그래서 전부 껐습니다.
혼자 개발하는데 CI가 왜 필요했나 — 편의 기능인 줄 알았는데 임계 경로였습니다
1인 개발자에게 CI는 사치일까요. 어느 날 CI가 통째로 멈추고 나서야, 그것이 테스트를 대신 돌려주는 편의 기능이 아니라 머지 자체를 막는 임계 경로였다는 것을 알게 됐습니다.