728x90

CI/CD 파이프라인을 몇 년 굴려본 사람이라면 "워크플로우 YAML은 코드가 아니라 설정"이라는 착각을 한 번쯤 해봤을 거다. 이번 Wiz의 Snowflake 사례는 그 착각이 얼마나 비싼지 보여준다. GitHub 이슈 제목 한 줄로 Actions 러너에서 임의 명령이 실행됐고, 거기서 새어 나간 토큰으로 Snowflake 사내 Jira에 읽기 접근이 됐다.

1. 무슨 일이 있었나: 이슈 제목 → 러너 셸 → Jira 토큰

Wiz의 자율 에이전트(Red Agent)가 snowflakedb/snowflake-connector-net 리포의 jira_issue.yml 워크플로우에서 스크립트 인젝션을 찾아냈다. 타임라인이 이 사건의 핵심이다.

  • 2026-06-18: PR #1218이 머지되며 취약 패턴이 라이브. 스쿼시 커밋의 co-author에 "Copilot Autofix powered by AI"가 찍혀 있다.
  • 2026-06-23: 5일 만에 Red Agent가 발견·익스플로잇·HackerOne 리포트. Snowflake는 당일 패치.
  • 2026-06-24: Jira 토큰 폐기·로테이션. 감사 로그상 노출 기간 중 접근한 건 Wiz의 IP뿐이었다고 확인.

문제의 변경은 이랬다. 원래 있던 안전한 패턴을 지우고 직접 보간으로 바꿨다.

- env:
-   ISSUE_TITLE: ${{ github.event.issue.title }}
- run: jq -n --arg title "$ISSUE_TITLE" ...

+ run: TITLE=$(echo '${{ github.event.issue.title }}' | sed 's/"/\\"/g' | sed "s/'/\\\'/g")

추가로 "보안 게이트"처럼 생긴 조건도 무력했다. if: (github.event_name == 'issues' || github.event.pull_request.user.login != 'whitesource-for-github-com[bot]') — issues 이벤트에서는 github.event.pull_request가 항상 null이라 뒤쪽 비교는 언제나 참이 된다. 게이트가 아니라 열린 문이었다.

탈취된 토큰은 qa@snowflake.net 계정으로 붙었고, 엔지니어링·보안 컴플라이언스·버그바운티 트래킹 프로젝트까지 읽을 수 있었다. 워크플로우 하나의 시크릿이 조직 전체 이슈 트래커로 이어진 셈이다.

2. 원리: 치환은 셸보다 먼저 일어난다

많은 사람이 ${{ }}를 "셸 변수 같은 것"으로 오해한다. 아니다. GitHub Actions의 표현식은 러너가 스크립트 파일을 만들기 전에 템플릿 단계에서 문자열로 치환된다. 즉 sed로 이스케이프한다는 발상 자체가 순서상 성립하지 않는다. sed는 이미 만들어진 스크립트가 실행될 때 돌아가는데, 그때는 공격자 문자열이 이미 셸 소스코드의 일부다.

로컬에서 그대로 재현해보자. 템플릿 치환을 sed로 흉내 낸다.

$ cat > vuln.tmpl <<'EOF'
TITLE=$(echo '__TITLE__' | sed 's/"/\\"/g')
echo "title is $TITLE"
EOF

$ TITLE_INPUT="'; id; echo '"
$ sed "s|__TITLE__|$TITLE_INPUT|" vuln.tmpl > vuln.sh
$ cat vuln.sh
TITLE=$(echo ''; id; echo '' | sed 's/"/\\"/g')
echo "title is $TITLE"

$ bash vuln.sh
uid=1000(runner) gid=1000(runner) groups=1000(runner)
title is

id가 그냥 실행된다. 러너 위에서는 이 자리에 curl이 들어가고, 프로세스 환경에 로드된 $JIRA_API_TOKEN이 base64로 인코딩돼 외부 도메인으로 나간다. 원문에 실린 페이로드도 정확히 그 구조다.

안전한 패턴은 하나뿐이다. 신뢰할 수 없는 값을 셸 소스코드가 아니라 데이터 채널(환경변수)로 넘기는 것.

- name: Create Jira issue
  env:
    ISSUE_TITLE: ${{ github.event.issue.title }}   # 여기서만 치환, 값은 env로
    ISSUE_BODY: ${{ github.event.issue.body }}
  run: |
    payload=$(jq -n --arg t "$ISSUE_TITLE" --arg b "$ISSUE_BODY" \
      '{fields:{summary:$t, description:$b}}')
    curl -sS -X POST "$JIRA_BASE_URL/rest/api/2/issue" \
      -u "$JIRA_USER_EMAIL:$JIRA_API_TOKEN" \
      -H 'Content-Type: application/json' --data "$payload"

env:로 바인딩하면 값은 러너의 환경변수 테이블에 들어가고 셸 파서를 거치지 않는다. JSON을 만들 때 문자열 붙이기 대신 jq --arg를 쓰는 것도 같은 이유다. 따옴표·개행·백슬래시를 jq가 알아서 이스케이프해준다. 참고로 "$ISSUE_TITLE"처럼 큰따옴표로 감싸는 것까지 해야 완성이다. 벗겨두면 워드 스플리팅으로 또 샌다.

3. 실무 관점: 시크릿은 왜 거기 있었나, 그리고 흔한 함정

이 사건의 진짜 아픈 지점은 "인젝션이 됐다"가 아니라 "인젝션된 러너에 프로덕션 Jira 토큰이 있었다"다. 트리거 이벤트별 권한 모델을 정리하면 이렇다.

  • pull_request (포크 PR): 베이스 리포의 시크릿에 접근 불가, GITHUB_TOKEN도 읽기 전용. 상대적으로 안전.
  • issues, issue_comment, pull_request_target: 기본 브랜치의 워크플로우 정의가 리포 시크릿과 함께 실행된다. 이슈를 여는 건 누구나 할 수 있으니, 인증 없는 외부인이 시크릿 컨텍스트를 건드릴 수 있는 표면이 열린다.

특히 pull_request_target에서 actions/checkout으로 PR의 head.sha를 체크아웃한 뒤 빌드 스크립트를 돌리는 패턴은 사실상 "남의 코드를 내 시크릿과 함께 실행"이다. 라벨 기반 승인 없이 이 조합을 쓰고 있다면 오늘 당장 확인하는 걸 권한다.

흔한 함정 ①: 에러가 났으니 안전하다는 착각

원문에서 인상적인 대목. Red Agent의 첫 페이로드는 #로 뒷부분을 주석 처리하려다 TITLE=$(의 닫는 괄호까지 먹어버려 실패했다. 러너 로그에는 이런 게 찍힌다.

/home/runner/work/_temp/8f2c1a3e-....sh: line 3: unexpected EOF while looking for matching `)'
/home/runner/work/_temp/8f2c1a3e-....sh: line 5: syntax error: unexpected end of file
##[error]Process completed with exit code 2.

실무에서 이 로그를 보면 대부분 "이상한 제목 때문에 잡 깨졌네, 이슈 제목 좀 정상적으로 써주세요"로 끝난다. 하지만 이건 인젝션이 막힌 신호가 아니라 인젝션이 성립한다는 신호다. 공격자 입력이 셸 파서에 도달했다는 증거니까. Red Agent는 이 에러를 읽고 페이로드를 ' ; curl ... ; echo ' 형태로 바꿔 바로 성공했다. 사람 개입 없이. CI 로그에서 syntax error: unexpected end of file이 뜨는 잡이 있다면 인젝션 관점으로 한 번 더 봐야 한다.

흔한 함정 ②: env로 옮겼는데 여전히 새는 경우

흔한 반쪽짜리 수정들이다.

# 나쁨: env로 옮겼지만 다시 표현식으로 씀
env:
  ISSUE_TITLE: ${{ github.event.issue.title }}
run: echo "${{ env.ISSUE_TITLE }}"   # 결국 템플릿 치환 → 동일 취약

# 나쁨: 문자열 결합으로 JSON 조립
run: curl -d "{\"summary\":\"$ISSUE_TITLE\"}" ...   # 따옴표 하나에 JSON 붕괴

# 나쁨: actions/github-script 안의 백틱 템플릿
script: console.log(`${{ github.event.issue.title }}`)  # JS 컨텍스트 인젝션

규칙은 단순하다. ${{ }}env:, with: 블록에서만 쓰고 run:/script: 본문에는 등장시키지 않는다.

정적 탐지 붙이기

사람 리뷰에 기대지 말고 파이프라인에 넣자. 먼저 조직 리포에서 후보를 훑는 grep 한 줄.

$ grep -rnE '\$\{\{ *github\.event\.(issue|pull_request|comment|review)\.' \
    .github/workflows/
.github/workflows/jira_issue.yml:31:        TITLE=$(echo '${{ github.event.issue.title }}' | sed ...)
.github/workflows/pr-label.yml:18:          echo "branch=${{ github.event.pull_request.head.ref }}"

그 다음 actionlint로 정식 검사. 이 도구는 신뢰할 수 없는 입력이 인라인 스크립트에 들어간 걸 별도 룰로 잡아준다.

$ actionlint .github/workflows/jira_issue.yml
.github/workflows/jira_issue.yml:31:24: "github.event.issue.title" is potentially untrusted.
  avoid using it directly in inline scripts. instead, pass it through an environment
  variable. see https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions
  for more details [expression]
  |
31|         TITLE=$(echo '${{ github.event.issue.title }}' | sed ...)
  |                        ^~~~~~~~~~~~~~~~~~~~~~~~~~~~

(출력 포맷은 버전에 따라 다를 수 있으니 실제 실행 결과 기준으로 확인하자.) GitHub Actions 전용 정적 분석 도구로 zizmor도 있고, pull_request_target + 체크아웃 조합 같은 패턴을 함께 잡아준다. 어느 쪽이든 PR 체크로 걸어서 병합 차단까지 연결해야 의미가 있다. 리포트만 쌓이는 스캐너는 이번 케이스를 못 막는다.

Copilot Autofix를 어떻게 다뤄야 하나

원문 업데이트 노트를 보면, Copilot이 코드를 작성했는지는 불분명하고 최소한 머지된 PR을 확인하고 "이상 없음"으로 통과시킨 co-author였다. 여기서 배울 건 "AI가 나쁘다"가 아니라 두 가지다.

첫째, AI 도구는 왜 이 패턴을 썼는지에 대한 히스토리 컨텍스트가 없다. env: + jq --arg는 인젝션을 막으려고 일부러 선택한 구조인데, 그 의도가 코드에 주석으로 남아 있지 않으면 "더 간결한" 문자열 보간으로 되돌아갈 수 있다. 보안 목적의 구조에는 이유를 주석으로 박아두고, CODEOWNERS로 .github/workflows/**를 보안/플랫폼 팀 필수 리뷰로 묶는 게 현실적인 방어다.

둘째, AI가 리뷰했다는 사실이 게이트를 대체하면 안 된다. AI 리뷰는 사람 리뷰와 같은 등급이지 정적 분석·정책 검사 위의 등급이 아니다. AI가 생성했든 사람이 짰든 동일한 스캐너를 통과해야 한다.

4. 정리: 오늘 확인할 체크리스트

한 줄 요약 — run: 블록 안의 ${{ github.event.* }}는 전부 원격 코드 실행 후보다. 그리고 그 러너에 붙어 있는 시크릿의 권한이 곧 사고의 폭발 반경이다.

  1. .github/workflows/ 전체를 grep + actionlint로 훑고, run:/script: 안의 표현식은 전부 env: 바인딩으로 이동.
  2. JSON을 만드는 곳은 문자열 결합 금지, jq --arg/--argjson으로 통일.
  3. issues, issue_comment, pull_request_target 트리거 워크플로우 목록을 뽑아 각각 "외부인이 트리거 가능한가 / 시크릿이 필요한가"를 표로 정리. 필요 없는 시크릿은 잡 단위로 분리해 격리.
  4. 워크플로우마다 permissions:를 명시(기본은 contents: read). 조직 설정에서 GITHUB_TOKEN 기본 권한을 read-only로.
  5. 장기 API 토큰은 최소 권한 서비스 계정으로. 클라우드 접근은 OIDC 페더레이션으로 단명 크리덴셜 전환. Jira처럼 OIDC가 안 되는 SaaS는 프로젝트 스코프 제한 + 로테이션 주기 단축.
  6. CI 로그에서 syntax error: unexpected end of file 같은 셸 파싱 에러가 뜨는 잡은 "입력 이상"이 아니라 "인젝션 표면"으로 분류해 조사.

가장 무서운 숫자는 5일이다. 취약 코드가 라이브된 지 닷새 만에 자율 에이전트가 찾아내 익스플로잇까지 끝냈다. 분기별 보안 점검, 반기 토큰 로테이션 같은 사이클은 이 속도를 못 따라간다. 워크플로우 변경은 머지 전에 자동으로 막고, 시크릿은 짧게 가져가는 것 말고는 답이 없다.

참고 자료

728x90
728x90

1. 무슨 일이 있었나 — 그리고 왜 남 얘기가 아닌가

GitHub Actions와 GitHub Pages가 6시간 넘게 대규모 장애·성능 저하를 겪었다. 증상은 대략 이렇게 정리된다.

  • 워크플로우 실행 실패와 지연, 큐 적체
  • 웹훅 처리량이 정상 대비 약 15% 수준까지 떨어져 push/PR 기반 CI 트리거 자체가 안 걸림
  • 자체 호스팅(셀프호스티드) 러너까지 작업 할당이 마비

세 번째 항목이 핵심이다. "우리는 러너를 우리 EC2/온프렘에 띄워놨으니 GitHub 죽어도 빌드는 돌겠지"라고 믿고 있던 팀들이 그날 같이 멈췄다. 나도 예전 팀에서 비용 절감 목적으로 셀프호스티드로 옮기면서 은근히 "가용성도 같이 좋아지겠네"라고 생각했었는데, 그건 착각이었다.

Hacker News 논의에서 나온 배경 설명도 흥미롭다. AI 코딩 에이전트의 자동 커밋·PR·백그라운드 폴링이 폭증하면서 시스템 용량 한계를 넘었다는 분석, 그리고 GitHub COO 발언 인용으로 Actions 주간 사용량이 2023년 5억 분에서 2026년 21억 분으로 늘었다는 수치가 언급됐다. Azure 인프라 이관 과정의 병목과 컨트롤 플레인 집중 구조에 대한 비판도 함께 나왔다. 다만 이건 커뮤니티 분석이고, 공식 포스트모템 기준의 근본 원인은 GitHub 상태 페이지/인시던트 리포트 확인이 필요하다.

어쨌든 실무자 입장에서 결론은 하나다. CI/CD가 외부 SaaS 단일 컨트롤 플레인에 물려 있으면, 그건 우리 배포 파이프라인의 SPOF다. 이 글에서는 왜 셀프호스티드 러너까지 죽는지 구조를 뜯어보고, 우리 파이프라인의 SPOF를 진단하는 방법과 장애 중에도 배포를 이어가는 현실적 우회 전략을 정리한다.

2. 셀프호스티드 러너가 같이 죽는 이유 — 컨트롤 플레인 의존 구조

오해부터 풀자. 셀프호스티드 러너는 "내 서버에서 도는 빌드 머신"이지 "독립적인 CI 시스템"이 아니다. 러너 프로세스가 하는 일을 순서대로 보면 이렇다.

  1. 러너가 GitHub의 Actions 서비스에 롱폴링으로 붙어서 대기한다 (아웃바운드 HTTPS)
  2. 이벤트(push/PR/schedule)가 발생하면 GitHub 쪽 스케줄러가 잡을 큐에 넣고 러너에 할당한다
  3. 러너가 잡 메시지를 받아 워크플로우 YAML을 해석하고 스텝을 실행한다
  4. 로그 스트림, 스텝 상태, 아티팩트 업로드를 계속 GitHub API로 올린다

1~2번이 전부 GitHub 컨트롤 플레인이다. 비유하자면 셀프호스티드 러너는 내 차고에 세워둔 차인데 시동 키는 본사에 있는 구조다. 차는 멀쩡한데 키 발급 서버가 죽으면 그냥 주차장 장식이다. 웹훅 처리량이 15%로 떨어졌다는 건 애초에 "출발 지시" 자체가 안 나갔다는 뜻이고, 스케줄러가 흔들리면 지시가 나가도 러너에 배정이 안 된다.

러너가 살아 있는지, 컨트롤 플레인과 붙어 있는지는 러너 호스트에서 바로 확인할 수 있다.

# 러너 서비스 상태와 최근 로그
$ sudo systemctl status actions.runner.myorg-myrepo.runner-01
● actions.runner.myorg-myrepo.runner-01.service - GitHub Actions Runner
     Active: active (running) since Tue 2026-02-10 09:12:03 KST; 3h 41min ago

$ tail -n 5 ~/actions-runner/_diag/Runner_*.log
[INFO] Listening for Jobs
[WARN] Retrying connection to the server. Attempt 3
[ERR ] GitHub Actions is temporarily unavailable. Retrying in 30 seconds.

여기서 Listening for Jobs가 찍혀 있는데도 잡이 안 내려오면 러너 문제가 아니라 상류 문제다. 이때 러너를 재시작하는 건 아무 소용이 없다(오히려 등록 API까지 흔들리면 재등록이 안 돼서 상황이 더 나빠진다). 장애 중 흔한 자해 행위 1순위가 "일단 러너 재시작"이다.

상태를 스크립트로 물어볼 때는 GitHub 상태 API를 쓰는 게 정확하다.

$ curl -s https://www.githubstatus.com/api/v2/components.json \
  | jq -r '.components[] | select(.name|test("Actions|Pages|Webhooks")) 
           | "\(.name)\t\(.status)"'
Actions         major_outage
Webhooks        degraded_performance
Pages           partial_outage

이 한 줄을 슬랙 알림에 붙여두면, 장애 때 "우리 코드가 문제인가?"로 낭비하는 30분을 아낄 수 있다. 실제로 장애 초기에 팀 채널에 가장 많이 올라오는 질문이 "제 PR만 안 도는 건가요?"다.

3. 우리 파이프라인의 SPOF 진단과 내구성 설계

3-1. SPOF 체크리스트

배포 경로를 단계별로 쪼개서, 각 단계가 GitHub에 얼마나 물려 있는지 표로 그려보길 권한다. 실제로 그려보면 생각보다 GitHub 의존이 촘촘하다.

  • 트리거: push/PR 웹훅. → GitHub 의존 100%. 웹훅이 안 오면 아무것도 시작 안 됨
  • 소스: actions/checkout이 github.com에서 clone. → 미러 레포가 없으면 의존 100%
  • 빌드 캐시: actions/cache는 GitHub 캐시 서비스. → 캐시 미스 시 빌드 시간 폭증
  • 아티팩트: upload/download-artifact, GHCR(ghcr.io). → GHCR도 GitHub 도메인
  • 시크릿: Repository/Org Secrets. → 여기가 진짜 위험. GitHub이 죽으면 수동 배포 시 시크릿 값을 꺼낼 방법이 없다
  • 배포 승인: Environments의 required reviewers. → GitHub UI 의존

이 중 우선순위를 딱 하나만 고르라면 시크릿이다. 빌드는 로컬에서 어떻게든 돌리지만, DB 접속 정보나 배포 키가 GitHub Secrets에만 있으면 긴급 배포 자체가 불가능하다. Secrets는 쓰기 전용이라 UI에서 값을 다시 볼 수도 없다. Vault, AWS Secrets Manager, GCP Secret Manager 중 아무거나 SoT(Source of Truth)로 두고 GitHub Secrets는 거기서 동기화된 사본으로 취급하는 게 맞다.

3-2. 아티팩트·레지스트리 이중화

컨테이너 이미지를 GHCR에만 올리고 있다면, 롤백조차 못 하는 상황이 생긴다. 푸시를 두 곳으로 나누는 건 몇 줄이면 된다.

- name: Build and push (multi-registry)
  uses: docker/build-push-action@v6
  with:
    push: true
    tags: |
      ghcr.io/${{ github.repository }}:${{ github.sha }}
      ${{ secrets.ECR_REGISTRY }}/myapp:${{ github.sha }}

비용은 스토리지 두 벌 + 푸시 트래픽 정도. 롤백 가능성을 사는 값으로는 싸다. 여기서 트레이드오프는 태그 정합성인데, 한쪽 푸시만 성공하고 다른 쪽이 실패하는 부분 성공 케이스를 어떻게 처리할지 정해둬야 한다. 우리는 "ECR 푸시 실패는 파이프라인 실패"로 잡고, 대신 재시도 3회를 걸어뒀다.

3-3. 백업 파이프라인은 "존재"가 아니라 "훈련"이 중요하다

GitLab CI나 Jenkins를 백업으로 두는 팀은 꽤 있는데, 1년에 한 번도 안 돌려본 백업 파이프라인은 장애 때 반드시 깨져 있다. 크리덴셜 만료, 러너 이미지 노후화, 빌드 스크립트 드리프트가 쌓인다. 최소한 주 1회 스케줄로 백업 파이프라인에서 빌드까지만이라도 돌려 녹슬지 않게 해야 한다. 배포까지는 안 해도 된다. 빌드가 성공하는지만 보면 드리프트의 90%는 잡힌다.

원문에서 언급된 Forgejo, GitLab, Woodpecker CI, Depot 같은 대안 이관 움직임도 같은 맥락이다. 다만 전면 이관은 러너 관리·시크릿·권한 체계를 통째로 옮기는 일이라 만만치 않다. 현실적으로는 완전 이관보다 핵심 서비스만 이중 파이프라인이 비용 대비 효율이 좋다고 본다.

3-4. 흔한 함정 — 실제로 만나는 에러들

함정 1: 재시도를 걸었는데 큐만 더 막는다. 장애 중 if: failure()로 재실행을 자동화해두면, 복구 직후 몰린 잡이 한꺼번에 터지면서 동시성 한도를 넘긴다. 그때 나오는 에러가 이거다.

The job was not started because the runner group does not have 
enough capacity. Waiting for a runner to pick up this job...

##[error]The self-hosted runner: runner-01 lost communication with the server. 
Verify the machine is running and has a healthy network connection. 
Anything in your workflow that terminates the runner process, 
starves it for CPU/Memory, or blocks its network access can cause this error.

lost communication with the server 메시지가 특히 헷갈린다. 문구만 보면 우리 머신 네트워크 문제 같은데, 컨트롤 플레인 장애 때도 똑같이 뜬다. 러너 호스트에서 ping, curl https://api.github.com이 멀쩡한지부터 확인하고, 멀쩡하면 상류 장애로 판단하고 손을 떼는 게 낫다.

함정 2: 웹훅 유실은 조용히 지나간다. 웹훅 처리량이 15%로 떨어졌다는 건, 나머지 85%의 push가 실패 표시도 없이 그냥 CI가 안 돈다는 뜻이다. PR 화면에 체크가 안 뜨는 게 아니라 아예 아무 체크도 안 붙는다. 브랜치 보호에서 required status check을 걸어놨다면 머지가 전부 막히고, 안 걸어놨다면 테스트 없이 머지된 커밋이 흘러 들어간다. 후자가 더 무섭다. 복구 후에 반드시 해당 시간대 머지 커밋을 훑고 워크플로우를 수동 재실행해야 한다.

# 장애 시간대 이후 커밋에 워크플로우 수동 재실행
$ gh run list --branch main --limit 5
STATUS  NAME       WORKFLOW  BRANCH  EVENT  ID
X       fix: cache CI        main    push   1029384756
-       chore: bump CI       main    push   1029384700

$ gh workflow run ci.yml --ref main -f reason="post-incident replay"
✓ Created workflow_dispatch event for ci.yml at main

이걸 하려면 워크플로우에 workflow_dispatch: 트리거가 미리 들어 있어야 한다. 없으면 장애 중에 트리거를 추가하는 커밋을 푸시해야 하는데, 그 푸시의 웹훅도 안 갈 수 있다. 지금 당장 모든 배포 워크플로우에 workflow_dispatch를 넣어두자. 비용 0, 효과 확실.

함정 3: Pages를 상태 페이지로 쓰고 있다. 의외로 많다. 서비스 장애 공지 페이지를 GitHub Pages로 호스팅해두면, 이번처럼 Actions와 Pages가 동시에 죽을 때 공지를 띄울 수단이 사라진다. 상태 페이지는 반드시 본 서비스와 다른 사업자에 둬야 한다.

3-5. 장애 중 배포를 이어가는 현실적 우회

순서를 정해두면 당황하지 않는다. 우리 팀 런북은 대략 이 순서다.

  1. 판단(5분): githubstatus API 확인 → 상류 장애면 개별 디버깅 중단, 채널 공지
  2. 동결: 급하지 않은 배포는 전부 홀드. 장애 중 배포는 롤백 수단도 같이 죽어 있다는 걸 잊지 말 것
  3. 핫픽스만 우회: 이미 레지스트리에 올라간 이미지가 있으면 그 태그로 배포. 없으면 로컬 빌드 → 백업 레지스트리 푸시 → 배포 도구(ArgoCD/Helm 등)로 직접 배포
  4. 기록: 우회 배포한 커밋 SHA와 이미지 태그를 채널에 남긴다. 복구 후 정규 파이프라인과 상태를 맞추기 위한 필수 정보다
  5. 복구 후 정합성 확인: 우회 배포분을 정규 파이프라인으로 재빌드해 SHA가 일치하는지 검증

3번에서 로컬 빌드로 배포할 때 가장 자주 사고 나는 게 재현성이다. 평소 CI 러너 이미지와 로컬 환경이 다르면 결과물이 달라진다. 그래서 빌드 환경을 컨테이너로 고정해두는 게 장애 때 빛을 본다. docker run --rm -v $PWD:/src build-env:pinned make build 정도로 CI와 동일한 이미지에서 빌드할 수 있게 만들어두자.

5번의 정합성 확인을 빼먹으면, 몇 주 뒤에 "왜 프로덕션 이미지가 main 브랜치와 다르지?"라는 미스터리로 돌아온다. 실제로 겪어봤고, 원인 찾는 데 하루 날렸다.

4. 정리 — 한 줄 요약과 적용 우선순위

한 줄 요약: 셀프호스티드 러너는 데이터 플레인만 우리 것이고 컨트롤 플레인은 여전히 GitHub이다. 그래서 GitHub이 죽으면 우리 러너도 같이 죽는다.

전면 이중화를 당장 하라는 얘기는 아니다. 팀 규모와 배포 빈도에 따라 필요한 수준이 다르다. 투자 대비 효과 순으로 정리하면 이렇다.

  • 모든 팀 — 오늘 당장(1시간 내): 배포 워크플로우에 workflow_dispatch 추가, githubstatus API 슬랙 알림, 상태 페이지를 Pages 밖으로 이전
  • 배포가 주 1회 이상인 팀 — 이번 스프린트: 시크릿 SoT를 외부 시크릿 매니저로 이전, 컨테이너 레지스트리 이중 푸시, 장애 런북 문서화
  • 배포 중단이 곧 매출 손실인 팀 — 분기 과제: 백업 파이프라인 구축 + 주 1회 자동 검증, 빌드 환경 컨테이너 고정, 소스 미러 레포

반대로 안 해도 되는 것도 짚자. 사내 배포용 툴이나 주 1회 미만 배포하는 레포까지 이중화하는 건 관리 비용만 늘린다. "6시간 멈춰도 되는 것"과 "30분도 안 되는 것"을 먼저 분류하는 게 첫 작업이다.

그리고 이번 장애의 배경으로 지목된 AI 에이전트발 트래픽 폭증은, 사실이라면 앞으로도 반복될 가능성이 높은 종류의 문제다. 우리 조직도 Copilot이나 각종 에이전트를 붙이면서 CI 실행 횟수가 알게 모르게 늘고 있는지, 한 번쯤 사용량을 뽑아볼 만하다. 남의 부하 문제가 아니라 우리도 그 부하의 일부일 수 있다.

참고 자료

728x90

+ Recent posts