开发环境过了、生产却不过的真正原因
한국어 원문으로 표시합니다.
한 줄 요약
환경마다 다시 빌드하면 환경마다 다른 것이 나옵니다. 한 번 빌드한 아티팩트를 그대로 승격시키는 것이 이 문제의 유일한 근본 해결입니다.
왜 이게 필요했나
파이프라인이 이렇게 생긴 곳이 아주 많습니다.
dev 브랜치 → 빌드 → dev 배포
stage 브랜치 → 빌드 → stage 배포
main 브랜치 → 빌드 → prod 배포
세 번 빌드합니다. 소스가 같으니 결과도 같을 거라고 생각하지만, 그렇지 않습니다. 빌드 사이에 이런 것들이 달라집니다.
- 베이스 이미지 태그가 움직였다 (
python:3.12가 어제와 다르다) npm install이 트랜지티브 의존성의 새 패치를 가져왔다- apt 저장소의 패키지 버전이 올라갔다
- 빌드 머신의 툴체인 버전이 다르다
그래서 dev 에서 통과한 테스트가 prod 이미지에서는 의미가 없습니다. 테스트한 것과 배포한 것이 다른 물건이기 때문입니다.
빌드는 한 번, 승격은 여러 번
소스 커밋 → 빌드 1회 → 아티팩트(불변) ─┬→ dev 배포 → 테스트
├→ stage 배포 → 검증
└→ prod 배포
핵심은 아티팩트가 불변이라는 것입니다. dev 에서 검증한 그 바이트가 prod 로 갑니다. 그러면 "환경 때문에 다르다" 는 변수가 사라지고, 남는 차이는 설정과 데이터뿐입니다 — 그건 우리가 통제할 수 있습니다.
승격은 재빌드가 아니라 참조를 바꾸는 일입니다.
# gitops/prod/kustomization.yaml
images:
- name: registry/backend
newTag: v0820-1500 # ← 이 한 줄을 고치는 커밋이 배포다
LabHub 자체가 이 방식으로 돕니다. 이미지를 한 번 굽고, dev 오버레이의 태그를 올려 검증하고, 통과하면 prod 오버레이의 같은 태그로 올립니다. 배포 기록이 곧 git 로그입니다.
설정은 아티팩트 밖에 둔다
같은 아티팩트를 여러 환경에 쓰려면 환경별로 달라지는 것을 밖으로 빼야 합니다.
| 항목 | 이미지 안 | 이미지 밖 |
|---|---|---|
| 애플리케이션 코드 | ✅ | |
| 런타임·라이브러리 | ✅ | |
| DB 주소, 외부 API URL | ✅ 환경변수/ConfigMap | |
| 자격증명 | ✅ Secret | |
| 로그 레벨, 기능 플래그 | ✅ 설정 |
이미지 안에 application-prod.yml 을 굽는 순간 그 이미지는 prod 전용이
되고, 승격 모델이 깨집니다.
무엇으로 식별하는가
아티팩트에는 되짚을 수 있는 이름이 필요합니다.
- 커밋 SHA — 가장 정확합니다.
backend:a1b2c3d. 어떤 소스에서 나왔는지 한 번에 압니다. - 시맨틱 버전 — 사람이 읽기 좋습니다. 릴리스에 붙입니다.
- 다이제스트 —
@sha256:.... 태그는 옮겨질 수 있지만 다이제스트는 절대 변하지 않습니다. 정말 확실해야 하는 곳(prod)에서는 이걸 씁니다.
latest 는 승격 모델에서 쓰면 안 됩니다. 어느 시점의 latest 인지 알 수
없어서 롤백할 대상을 특정하지 못합니다.
게이트는 승격 사이에 놓는다
빌드 → [단위 테스트] → dev → [통합 테스트] → stage → [수동 승인] → prod
각 화살표 앞의 대괄호가 게이트입니다. 게이트는 통과/차단을 종료코드로 말해야 합니다. 로그에 "실패" 라고 찍고 exit 0 을 반환하면 파이프라인은 그냥 지나갑니다 — 실제로 흔한 버그입니다.
롤백은 승격의 역방향이다
승격이 태그를 올리는 커밋이면, 롤백은 이전 태그로 되돌리는 커밋입니다. 재빌드도, 긴급 패치도 필요 없습니다. 이전 아티팩트는 레지스트리에 그대로 있으니까요.
이게 가능하려면 아티팩트를 지우지 말아야 합니다. 보존 정책을 짤 때 "최근 N개" 만 남기면 그보다 오래된 버전으로는 못 돌아갑니다.
승격이 실제로 어긋나는 자리
"한 번 빌드하고 승격만 한다" 는 원칙은 단순한데, 지키다 보면 세 곳에서 새어 나간다.
태그는 움직인다. myapp:v1.2.3 을 승격했는데 누군가 같은 태그로 다시 밀면,
어제 검증한 것과 오늘 배포되는 것이 다른 물건이다. 다이제스트로 배포하고
태그는 사람이 읽는 이름표로만 쓴다. 레지스트리에 태그 불변 설정이 있으면
함께 켠다.
docker buildx imagetools inspect myapp:v1.2.3 --format '{{.Manifest.Digest}}'
환경별 빌드가 조용히 되살아난다. --build-arg ENV=prod 같은 것이 하나라도
들어가면 그 순간 개발계에서 시험한 이미지와 운영 이미지가 다른 것이 된다.
차이는 실행 시점의 설정으로만 두어야 한다. 빌드 인자에 환경 이름이 보이면
그것이 신호다.
설정이 이미지 안으로 들어간다. 편의를 위해 기본 설정 파일을 이미지에 넣어 두면, 그 값이 언젠가 운영에서 쓰인다. 설정은 밖에서 주입하고, 없으면 기동에 실패하도록 만드는 편이 안전하다. 기본값으로 조용히 도는 것이 가장 나쁘다.
게이트는 승격 사이에 두되, 같은 것을 두 번 재지 않는다. 단위 시험은 빌드에서 한 번, 통합 시험은 개발계 승격 뒤 한 번, 부하 시험은 스테이징 승격 뒤 한 번. 운영 승격 앞에서 단위 시험을 다시 돌리는 파이프라인이 흔한데, 그것은 시간만 쓰고 아무것도 새로 알려 주지 않는다.
되돌리기는 옛 다이제스트를 다시 승격하는 일이어야 한다. 되돌리려고 다시 빌드하면, 그사이 바뀐 의존성 때문에 되돌린 것이 예전과 다른 물건이 된다. 그래서 옛 아티팩트를 지우지 않고 최소 몇 세대는 보관한다.
무엇이 어디에 있는지 기록한다. 각 환경에 지금 어느 다이제스트가 있는지가 한 화면에 보이지 않으면, 장애 때 "운영에 뭐가 떠 있나" 를 알아내는 데 시간을 쓴다.
현장에서 만나는 모습
- "스테이지에서는 됐는데요" → 재빌드해서 다른 물건이 나갔다.
- 롤백하려니 이전 이미지가 없다 → 보존 정책이 너무 짧다.
- prod 만 다른 설정 파일이 이미지에 들어 있다 → 승격이 애초에 불가능.
다음 확인에서 볼 것
이어지는 실습에서 이 글의 주장을 손으로 확인합니다. 커밋에서 판 이름을 짓고, 개발계에 해당하는 저장소에 산출물을 한 번만 만든 뒤, 태그가 아니라 다이제스트로 지목해 스테이징과 운영으로 옮기고, 태그가 다른 내용을 가리키게 된 순간을 잡아내고, 어느 환경에 어느 다이제스트가 있는지를 한 파일로 남깁니다. 실습 뒤의 퀴즈에서는 빌드와 승격의 차이, 불변 다이제스트, 외부 설정과 보존 정책이 롤백 가능성에 미치는 영향을 판단합니다.