再現可能なビルドが先だ
한국어 원문으로 표시합니다.
한 줄 요약
CI 는 빌드를 대신 눌러 주는 로봇이 아니라, 같은 입력이면 언제 어디서 돌려도 같은 산출물이 나오도록 강제하는 재현성 장치다.
왜 이게 필요했나
손으로 빌드하던 시절의 사고는 늘 같은 모양이었다. 내 노트북에서는 되는데 서버에서는 안 되고, 어제 만든 이미지와 오늘 만든 이미지가 다른데 무엇이 달라졌는지 아무도 설명하지 못한다. 원인은 대개 "고정하지 않은 것"에 있다. 의존성 버전을 고정하지 않았거나, 액션과 베이스 이미지를 태그로만 참조했거나, 산출물 이름에 무엇으로 만들었는지가 남아 있지 않다.
태그는 사람이 옮길 수 있는 이름표다. tj-actions/changed-files 공급망 공격에서 공격자는 액션의 태그를 악성 커밋으로 재지정했다. 저장소를 뚫은 게 아니라 이름표만 옮겼는데, 그 태그를 참조하던 수많은 파이프라인이 그대로 악성 코드를 실행했다. 그래서 규칙은 짧다. 태그는 악의적으로 변경될 수 있지만 커밋 SHA 는 변경이 불가능하다. 액션과 이미지는 태그가 아니라 커밋 SHA 또는 다이제스트로 고정한다.
프로덕션에 latest 를 쓰면 두 가지를 동시에 잃는다. 어떤 버전이 배포됐는지 추적이 불가능해지고, 사고가 났을 때 되돌릴 대상 자체가 없다. 그래서 배포에 쓰는 태그는 반드시 불변이어야 한다. 커밋 SHA 나 시맨틱 버전처럼 한 번 정해지면 다른 것을 가리키지 않는 값이어야 한다.
어떻게 동작하나
재현 가능한 빌드는 네 축으로 만든다.
- 입력 고정. 락 파일은 반드시 커밋하고, CI 는 npm ci, --frozen-lockfile, -lockfile=readonly 처럼 락 파일을 갱신하지 않고 적힌 그대로 설치하는 명령을 쓴다. 평범한 install 계열은 락 파일을 슬쩍 갱신해 실행마다 다른 의존성 트리를 만든다.
- 출력 식별. 산출물 이름과 이미지 태그에 커밋마다 바뀌는 불변 값을 넣는다. latest 는 그 위에 덧붙이는 별칭일 뿐, 식별자가 아니다.
- 캐시. 캐시 키에 락 파일 해시를 넣어야 의존성이 바뀔 때 키가 저절로 달라져 자동 무효화된다.
${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}에 restore-keys 접두 체인을 붙이는 형태가 표준이다. 효과적인 캐시 키 전략은 빌드 시간을 50~70% 단축한다. 한 팀의 실측은 평균 CI 빌드가 28분에서 8분으로 71% 줄었다. 다만 캐시 저장소당 한도는 10GB 이고 넘으면 오래된 것부터 축출되므로, 스코프를 커밋 단위로 잘게 쪼개면 캐시끼리 서로를 밀어내 히트율이 오히려 떨어진다. 캐시는 신뢰 경계이기도 하다. Fork PR 이 악성 의존성을 캐시에 주입하면 이후 빌드가 그 캐시를 그대로 쓰는 캐시 포이즈닝이 성립한다. - 실패 처리. 매트릭스 빌드의 fail-fast 기본값은 true 라서 하나가 실패하면 나머지가 취소된다. 빠른 피드백이 중요하면 true, 전체 호환성 확인이 중요하면 false 로 바꾼다. 동시성 제어를 걸지 않으면 연속 Push 시 여러 배포 Job 이 동시에 실행되어 롤백이 복잡해지는 사고가 난다.
현장에서 만나는 모습
장애 회고에서 가장 자주 나오는 문장이 "그때 배포된 게 정확히 뭐였죠"다. 이미지 태그가 latest 하나뿐이면 이 질문에 아무도 답하지 못한다. 반대로 태그에 커밋 해시가 박혀 있으면 레지스트리와 Git 로그만으로 5분 안에 답이 나온다.
숫자로 관리하는 팀은 목표를 이렇게 잡는다. Lead Time 1시간 이하, 배포 빈도 일 10회 이상, 변경 실패율 5% 이하, MTTR 30분 이하, 파이프라인 실행 15분 이하, 커버리지 80% 이상. 이 중 파이프라인 실행 시간이 15분을 넘기 시작하면 사람들이 CI 결과를 기다리지 않고 다른 일을 하러 가면서 피드백 루프가 통째로 무너진다.
같은 입력이면 같은 결과가 나오는 빌드
CI 를 처음 붙이면 "내 컴퓨터에서는 됐는데" 가 "CI 에서는 됐는데" 로 바뀔 뿐이다. 그 자리를 없애는 것이 빌드 자동화의 진짜 목표다.
버전을 고정하지 않으면 어제의 성공이 오늘을 보장하지 않는다. npm install
과 npm ci 의 차이가 여기 있다. 앞의 것은 package.json 의 범위 안에서 최신을
가져오므로 잠금 파일을 갱신해 버린다. 뒤의 것은 잠금 파일과 다르면 아예
실패한다. CI 에서는 실패하는 쪽이 옳다. 파이썬의 pip install -r 도 해시를
적은 요구 파일과 --require-hashes 를 함께 쓸 때만 같은 성질을 얻는다.
바닥 이미지의 태그도 버전이다. FROM python:3.12 는 내일 다른 것을 가리킬
수 있다. 다이제스트로 못 박으면 재현되지만, 보안 갱신을 받지 못하게 된다.
그래서 다이제스트로 고정하고 갱신을 자동화하는 것이 함께 가야 한다.
고정만 하고 갱신을 안 하면 몇 달 뒤 취약점 목록이 길어진다.
캐시는 정확할 때만 이득이다. 열쇠에 잠금 파일의 해시를 넣는다.
key: deps-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: deps-${{ runner.os }}-
열쇠가 너무 헐거우면 낡은 의존성으로 통과하는 빌드가 나온다. 이것은 캐시가 없는 것보다 나쁘다: 실패해야 할 것이 성공하기 때문이다.
빌드 산출물은 한 번만 만든다. 개발계에 배포할 때 한 번 빌드하고 운영계에 배포할 때 또 빌드하면, 시험한 것과 배포한 것이 다른 물건이 된다. 한 번 만들어 저장소에 올리고, 이후 단계는 같은 다이제스트를 승격시킨다. 태그는 사람이 읽기 위한 이름표일 뿐이고, 같은 것임을 보장하는 것은 다이제스트다.
실패한 빌드의 증거를 남긴다. 로그만 남기면 재현하려고 같은 빌드를 다시 돌리게 된다. 시험 보고서, 커버리지, 생성된 설정 파일을 산출물로 올려 두면 실패한 그 순간의 상태를 그대로 볼 수 있다. 다시 돌리면 사라지는 것들이 대개 원인이다.
다음 실습에서 할 것
이 파드에는 Jenkins 도 GitHub Actions 러너도 없다. 그래서 build → test → package 3단계를 셸 스크립트로 직접 만든다. 중요한 것은 벤더가 아니라 메커니즘이기 때문이다. 소스 해시로 이름 붙은 아티팩트를 한 개만 만들고, 같은 입력으로 다시 돌리면 재사용하고, 마지막에는 해시 태그와 latest 를 같은 이미지에 붙여 무엇이 불변 식별자이고 무엇이 움직이는 별칭인지 눈으로 확인한다.