인프라

어떤 배포 단계에서도 다시 빌드되지 않고 커밋된 빌드 아티팩트

git에서 추적되고 바이너리에 포함된 생성된 파일은 다시 빌드하지 않고도 제공될 수 있습니다.

이 글은 영어 원문을 AI 모델이 번역한 것입니다. 표현이 원문과 다를 수 있습니다. 영어 원문 보기

일부 빌드 결과물은 의도적으로 리포지토리에 커밋됩니다. 컴파일된 스타일시트, 생성된 API 클라이언트, 번들로 묶인 번역 파일. 이 패턴은 파일을 재생성하는 명령이 한 스크립트에 있고 배포 경로는 다른 스크립트를 실행한다는 것을 알아차리기 전까지는 잘 작동합니다. 그러면 새 입력이 리빌드 없이 배포되고, 아무것도 알려주지 않습니다.

제가 겪었던 형태는 다음과 같습니다: CSS 프레임워크 CLI로 생성되어 git에 커밋되고 Go 바이너리에 포함된 스타일시트. 배포 스크립트는 컨테이너 이미지를 빌드했지만 CSS CLI는 전혀 호출하지 않았습니다. 마지막 로컬 빌드 이후에 작성된 새로운 유틸리티 클래스는 그에 해당하는 규칙 없이 배포되었을 것입니다.

격차를 숨기는 설정

세 가지 결정은 개별적으로는 합리적이지만 함께하면 위험합니다.

첫째, 생성된 파일을 커밋합니다. 이는 빌드 속도가 느리거나 런타임 이미지에 없는 툴체인이 필요한 애셋의 경우 흔한 방식입니다. 재현 가능한 서빙을 보장하고 컨테이너의 의존성을 하나 줄일 수 있습니다.

둘째, 바이너리에 포함시킵니다. Go에서는 //go:embed를 사용하며, 이 때문에 파일이 부팅 시에 가져오는 대신 컴파일 시점에 존재해야 합니다.

셋째, 서명, 공증, 데스크톱 아티팩트 업로드와 같은 다른 작업도 수행하는 빌드 스크립트에 재성성 명령을 넣습니다. 해당 스크립트는 자격 증명과 VPN이 필요하므로 CI가 실행하는 것이 아닙니다.

이제 파일을 추적해 봅시다. 파일은 스크립트 A에 의해 생성됩니다. 스크립트 B 실행 중에 컴파일러가 이 파일을 사용합니다. 스크립트 B에서는 마지막 입력 변경 이후 스크립트 A가 실행되었는지 확인하는 절차가 없습니다.

커밋된 애셋 재빌드 주체 1 재빌드 2 포함 3 실행 안 함 로컬 스크립트 커밋됨 애셋 바이너리 배포 경로 // 애셋은 배포의 입력이며, 출력이 아님

실패가 조용한 이유

심각성은 소비자가 누락된 항목에 어떻게 반응하는지에 전적으로 달려 있습니다.

메서드가 누락된 생성된 API 클라이언트는 요란하게 실패합니다. 호출 지점이 컴파일되지 않고, CI는 빨간불이 되며, 병합 전에 수정하게 됩니다. 생성된 데이터베이스 쿼리 코드와 protobuf 스텁도 마찬가지입니다. 타입 시스템이 대신 확인해 줍니다.

스타일시트는 그 반대입니다. CSS에는 정의되지 않은 클래스라는 개념이 없습니다. 일치하는 규칙 없이 class="items-center"를 작성하면 브라우저는 이를 아무것도 아닌 것으로 해석합니다. 요소는 렌더링되고, 페이지는 로드되며, 콘솔 경고는 나타나지 않습니다. 프로덕션에서는 레이아웃이 미묘하게 잘못되고, 마지막으로 CLI를 실행한 머신에서는 올바르게 표시됩니다.

번역 번들은 조회 실패 시 키로 대체될 때 동일하게 동작합니다. 기능 플래그 매니페스트는 알 수 없는 플래그를 false로 읽을 때 동일하게 동작합니다. 각 경우 모두 누락된 항목에 그럴듯한 기본값이 있으며, 바로 이 그럴듯한 기본값이 버그를 조용하게 만듭니다.

따라서 경험 법칙은 다음과 같습니다. 만약 생성된 결과물을 스키마가 없는 무언가가 소비한다면, 직접 확인할 때까지는 간극이 존재한다고 가정하십시오.

어떤 경로가 다시 빌드하는지 알아보기

이 작업은 약 1분 정도 걸리며 그 답은 종종 불편합니다.

먼저 파일이 무시되지 않고 실제로 추적되고 있는지 확인합니다:

git ls-files --error-unmatch path/to/generated.css
git check-ignore -v path/to/generated.css   # expect no output

그런 다음 배포에 참여하는 모든 파일에서 생성기 명령을 grep합니다.

grep -rn "tailwindcss\|protoc\|sqlc\|openapi-generator" \
  Dockerfile* .github/workflows/ script/ Makefile cloudbuild.yaml 2>/dev/null

제 경우에는 검색 결과가 모두 데스크톱 빌드 스크립트 한 곳에 있었고, Dockerfile과 배포 스크립트에서는 검색 결과가 하나도 없었습니다. 이것이 진단의 전부입니다. 컨테이너 빌드는 리포지토리를 복사하고 컴파일하므로, git에 있는 것이 곧 배포되는 것입니다.

반대의 실수, 즉 파일이 배포 중에 생성되면서도 커밋되어 모든 배포에서 불필요한 diff가 발생하는 경우를 잡아내는 확인 방법이 한 가지 더 있습니다.

git status --porcelain path/to/generated.css   # after a local build

입력 변경 없이 새로 빌드했을 때 파일이 변경된다면, 생성기는 비결정적이며 이를 커밋하는 것은 영원한 싸움이 될 것입니다.

격차를 해소하는 두 가지 방법

둘 다 유효합니다. 런타임 이미지가 툴체인을 호스팅할 수 있는지에 따라 선택하세요.

생성 과정을 배포 경로로 옮기기. 이것이 정석적인 해결책입니다. 생성기를 컨테이너 빌드나 이미지를 생성하는 CI 워크플로에 추가하고, 결과물 커밋을 중단하세요. 그 대가로 빌드 이미지가 무거워지고 고정해야 할 툴체인이 하나 더 늘어납니다. 만약 생성기에 자격 증명이나 서명 키가 필요하다면 이 옵션은 사용할 수 없으며, 바로 이 점 때문에 애초에 격차가 발생한 것입니다.

계속 커밋하되, diff가 비어 있는지 확인하기. 결과물이 반드시 git에 남아 있어야 한다면, 불변 조건을 명시적으로 만드세요. 즉, 일반적인 수정 후에는 생성된 파일이 변경되어서는 안 됩니다. 생성기를 실행하고 dirty tree가 있으면 실패하는 검사를 추가하면, 조용했던 격차가 빨간 빌드로 바뀝니다.

make generate
git diff --exit-code path/to/generated.css \
  || { echo "generated asset is stale, run make generate and commit"; exit 1; }

로컬 훅이 아닌 CI에 넣으세요. 로컬 훅은 바쁜 사람이 건너뛰기 마련인데, 바로 그 사람이 새 클래스를 추가할 가능성이 가장 높은 사람입니다.

둘 다 적합하지 않을 때 알아둘 만한 더 약한 변형이 있습니다. 아티팩트가 현재 지원하는 토큰의 하드코딩된 목록을 유지하고 새 소스를 그 목록과 비교하여 확인하세요. 저는 이것을 검토 단계로 수행했습니다. 즉, 컴파일된 스타일시트에 있는 모든 클래스 이름을 추출하고, 변경 사항으로 추가된 모든 클래스 속성을 추출한 다음, 두 집합의 차이를 비교했습니다. 이를 통해 코드가 배포되기 전에 후보 클래스가 생성된 집합에 없는 것을 발견했습니다. 이는 게이트라기보다는 체크리스트 항목이지만, 기억에 의존하는 것보다는 낫습니다.

이와 같은 형태를 가진 다른 것들

이 패턴은 "배포의 입력물이지만 다른 것의 출력물인 아티팩트"입니다. 일단 그렇게 이름을 붙이면 여러 곳에서 나타납니다.

프레임워크 CLI가 런타임 이미지에 없을 때의 컴파일된 스타일시트와 JS 번들. OpenAPI 사양에서 생성된 API 클라이언트, 특히 사양이 다른 리포지토리에 있을 경우. SQL 생성기의 데이터베이스 쿼리 코드. Protobuf 및 gRPC 스텁. 컴파일된 번역 카탈로그. 임베디드 마이그레이션 번들. 벤더링된(Vendored) 의존성 트리. Terraform 잠금 파일.

"시끄러운" 것들, 즉 누락된 항목이 컴파일을 중단시키는 경우는 대부분 저절로 해결됩니다. "조용한" 것들, 즉 스타일시트, 번역 카탈로그, 플래그 매니페스트, 아이콘 스프라이트 시트와 같이 조회 실패 시 합리적인 대체(fallback) 수단이 있는 모든 것은 의도적으로 감사할 가치가 있습니다.

주목할 만한 두 번째 신호가 있습니다. 만약 생성된 파일이 바이너리에 임베드되기도 한다면, 소비자는 런타임에 새로운 사본을 가져올 수 없으므로 캐시 제거(purge)나 설정 재로딩으로 그 차이를 임시방편으로 메울 수 없습니다. 임베딩은 비상 탈출구를 없애는 것입니다.

FAQ

생성된 파일의 커밋을 중단해야 하나요? 이러한 종류의 버그는 제거되지만, 툴체인이 이미지 안으로 이동하여 빌드가 더 느려지고 무거워집니다. 널리 사용 가능한 도구로 몇 초 만에 빌드되는 애셋이라면, 네, 배포 중에 생성하세요. 자격 증명, 네이티브 컴파일 또는 느린 툴체인이 필요한 경우에는 커밋하고 CI에서 오래된 파일인지 확인하는 것이 합리적인 절충안입니다.

pre-commit 훅으로 충분한가요? 아니요. 훅은 로컬 환경에만 적용되고, 건너뛸 수 있으며, 누군가 새로 리포지토리를 클론할 때 쉽게 유실될 수 있습니다. 속도를 위해 훅을 사용해도 좋지만, 최종적인 검사는 아무도 우회할 수 없는 곳에서 실행되어야 합니다.

클래스 이름에 오타가 있는 것이 아니라 실제로 누락된 것인지 어떻게 알 수 있나요? 생성된 아티팩트에서 식별자를 추출하여 정확한 토큰을 검색하세요. 여기서 이스케이프 처리가 문제가 될 수 있습니다. 예를 들어 py-2.5라는 유틸리티는 컴파일된 CSS에서 .py-2\.5로 나타날 수 있는데, .py-2.5를 단순하게 grep으로 검색하면 누락된 것으로 보고됩니다. 이스케이프 처리되지 않은 형식과 일치하는지 확인하거나, 두 형식 모두를 확인하세요.

변경 사항이 없는 트리에서 diff 검사가 계속 실패하면 어떻게 하나요? 그렇다면 생성기가 결정적으로 동작하지 않는 것입니다. 일반적인 원인으로는 내장된 타임스탬프, 버전 배너, 또는 파일 시스템에 따라 달라지는 파일 순서 등이 있습니다. 해당 검사를 블로킹으로 만들기 전에 버전을 고정하고, 배너를 제거하거나, 입력 파일을 정렬하세요. 불안정한 게이트는 일주일 내로 비활성화되기 마련입니다.

이것이 lock 파일에도 적용되나요? 부분적으로 그렇습니다. lock 파일도 커밋되는 아티팩트이지만, 일반적으로 패키지 관리자가 설치 중에 lock 파일을 검증하기 때문에 오래된 lock 파일은 명확한 오류를 내며 실패합니다. lock 파일과 관련된 위험은 그 반대입니다. 배포 시 조용히 lock 파일을 다시 생성하여 아무도 검토하지 않은 의존성 버전을 배포하는 것입니다.

관련 글