하나의 서비스에 키가 없어 결코 실행되지 않은 기능
한 기능이 출시된 날부터 모든 요청에서 실패했습니다. 4개의 배포 대상 중 하나에 자격 증명이 누락되었고, 로컬 환경에서는 이 문제가 드러나지 않았습니다.
기능이 배포되었고, 그날 이후로 해당 기능에 대한 모든 요청이 503 오류로 실패했습니다. 4일 동안 아무도 알아차리지 못했습니다. 원인은 기능 자체의 버그가 아니었습니다. 동일한 컨테이너 이미지를 공유하는 4개의 배포 대상 중 하나에 API 키가 누락되었고, 로컬 개발 환경에서는 항상 그 키를 제공했기 때문에 아무리 로컬 테스트를 많이 했어도 이를 발견할 수 없었습니다. 다음은 해당 장애의 모습과, 응답 시간만으로 장애 계층을 식별한 이유, 그리고 이를 방지할 수 있었던 체크리스트입니다.
1.4밀리초의 장애가 알려주는 것
장애가 발생한 엔드포인트의 액세스 로그는 다음과 같았습니다.
14:20:07 503 /api/v1/extract/preview 0.001846s
14:20:06 503 /api/v1/extract/preview 0.001463s
14:20:06 503 /api/v1/extract/preview 0.001414s
하루에 19개의 요청, 모두 503 오류, 모두 2밀리초 미만. 이 숫자가 진단의 전부입니다.
엔드포인트는 업로드된 파일 두 개를 파싱하고 언어 모델을 두 번 실행하며, 성공 시 30초에서 60초가 걸립니다. 1.4밀리초 만에 도착한 실패는 이 중 어떤 것도 시도하지 않았습니다. 요청 본문을 읽지 않았습니다. 문 앞에서 거부되었습니다.
응답 시간은 코드 한 줄을 읽기 전에 실패를 계층으로 분류합니다.
| 실패 지연 시간 | 의미 |
|---|---|
| 수 밀리초 미만 | 작업 시작 전, 사전 조건 확인에서 거부됨 |
| 정상 지연 시간과 유사 | 실제 작업 내부에서, 대부분을 수행한 후 실패 |
| 플랫폼 타임아웃 시 | 응답하지 않는 종속성으로 인해 중단됨 |
| 변동성이 매우 큼 | 리소스 경합 또는 비정상 인스턴스 풀 |
성공만큼 오래 걸리는 503 오류는 작업 중간에 종속성에 문제가 생긴 것입니다. 즉시 반환되는 503 오류는 가드 클로즈(guard clause) 때문입니다. 이들은 수정 방법이 다른 별개의 버그이며, 타임스탬프 열은 편집기를 열기 전에 어떤 버그인지 알려줍니다.
이 경우 가드 클로즈는 핸들러 상단에 있는 기능 확인이었습니다.
if s.embedder == nil || s.primaryLLM == nil || s.secondaryLLM == nil {
writeError(w, http.StatusServiceUnavailable, "models are not available")
return
}
그 세 가지 중 하나는 해당 기능이 출시된 날부터 모든 인스턴스, 모든 요청에서 nil이었습니다.
로컬 개발에서 누락된 자격 증명이 숨겨지는 이유
nil인 것은 API 키가 있을 때만 빌드되는 두 번째 언어 모델 클라이언트였습니다.
if cfg.OpenAIKey != "" {
s.secondaryLLM = newClient(cfg.OpenAIKey)
} else {
log.Warn("secondary model disabled: API key not set")
}
그 경고는 4일 동안 모든 인스턴스가 시작될 때마다 출력되었습니다. 서비스가 정상적으로 실행되는 것처럼 보였고, 실제로도 잘 실행되고 있었기 때문에 아무도 시작 로그를 읽지 않았습니다. 애플리케이션의 다른 모든 페이지는 작동했습니다.
이 서비스는 동일한 컨테이너 이미지에서 빌드된 네 가지 배포 대상 중 하나입니다. 즉, 공개 API, 스테이징 사본, 야간 배치 작업, 내부 검토 도구입니다. 그중 세 개에는 키가 있었습니다. 내부 도구에는 없었습니다.
| 배포 대상 | 시크릿 존재 여부 |
|---|---|
| 공개 API | 예 |
| 스테이징 | 예 |
| 야간 배치 작업 | 예 |
| 내부 검토 도구 | 아니요 |
하나의 대상만 달랐던 이유는 일반화할 수 있으므로 언급할 가치가 있습니다. 그 내부 도구는 지금껏 데이터베이스 연결 외에는 아무것도 필요하지 않았습니다. 그것은 편집 버튼이 있는 테이블 뷰어였습니다. 배포 구성에는 데이터베이스 URL과 캐시 비밀번호가 나열되어 있었고, 그 목록은 몇 달 동안 올바른 상태였습니다. 그러다 언어 모델을 호출하는 기능이 그 도구에 추가되면서, 구성 목록의 기저에 있던 가정이 조용히 깨졌습니다. 코드는 변경되었지만, 배포 사양은 변경되지 않았습니다.
로컬 개발 환경에서는 이 문제가 보이지 않았습니다. 개발자 머신에서는 키를 .env 파일이나 공유 구성 로더에서 가져오므로 클라이언트는 항상 생성되고 가드 클로즈는 절대 실행되지 않습니다. 로컬에서 실행한 해당 기능은 모두 성공했습니다. 로컬 서버에 대한 통합 테스트도 성공했습니다. 이 실패는 코드가 새로 필요하게 된 것과 실제 배포 환경이 제공하는 것 사이의 격차에서만 존재했으며, 노트북에서 실행되는 것으로는 그 격차를 볼 수 없습니다.
이것이 바로 자격 증명 드리프트(credential drift)를 일반적인 버그보다 더 까다롭게 만드는 속성입니다. 대부분의 버그는 당신이 살펴보는 환경 중 적어도 하나에서는 실패합니다. 이 버그는 프로덕션 환경을 제외한 모든 곳에서 성공하며, 프로덕션 환경에서는 누군가 특정 화면을 열지 않는 한 조용히 실패합니다.
자신의 메시지를 잃어버린 오류
서버는 알 수 없는 말을 하는 것이 아니었습니다. 다음과 같은 구체적인 문장으로 응답했습니다.
{"detail": "models are not available"}
브라우저에 Request failed (503)이 표시되었습니다.
실패한 응답을 메시지로 변환하는 클라이언트 헬퍼는 세 개의 키를 읽었지만, 서버가 실제로 사용한 키는 그중에 없었습니다:
async errText(r) {
const d = await r.json();
return d.error || d.message || `Request failed (${r.status})`;
}
API는 detail로 표준화되었습니다. 프런트엔드 헬퍼는 다른 컨벤션에 따라 작성되었고 다시 검토되지 않았습니다. 모든 라우트의 모든 오류 본문을 가져와 파싱한 후, 상태 코드만 남기고 버리고 있었습니다. 수정 내용은 식별자 하나입니다:
return d.detail || d.error || d.message || `Request failed (${r.status})`;
이러한 종류의 불일치는 정상 경로에서는 보이지 않으며, 상태 코드를 검증하는 테스트에서도 보이지 않습니다. 누군가 스크린샷을 보고 디버깅하려고 할 때에만 나타나는데, 이때가 바로 그것이 필요한 순간입니다. 오류 경로에서의 계약 불일치는 며칠을 소모하게 만들기 전까지는 아무런 비용이 들지 않습니다.
두 가지 작은 변경으로 나머지 진단을 셀프서비스로 만들었습니다. 가드 절은 이제 세 가지를 한 문장으로 묶는 대신 누락된 기능의 이름을 명시합니다:
func (s *Server) missingCapabilities() []string {
var miss []string
if s.embedder == nil { miss = append(miss, "embeddings") }
if s.primaryLLM == nil { miss = append(miss, "primary model") }
if s.secondaryLLM == nil { miss = append(miss, "secondary model (API key)") }
return miss
}
그리고 거부는 동일한 목록과 함께 로깅되므로, 답은 4일 전에 스크롤되어 지나간 시작 경고에만 있는 것이 아니라 응답 본문과 로그 라인에 있습니다.
외부 종속성 추가 시 체크리스트
복구 자체는 명령어 하나였습니다. 누락된 시크릿을 주입하는 데는 몇 초밖에 걸리지 않았고, 엔드포인트는 첫 시도에 1.4ms 만에 503을 반환하던 것에서 30.5초 만에 200을 반환하게 되었습니다. 흥미로운 부분은 나흘간의 수고를 덜어줄 수 있었던 다섯 가지 확인 사항입니다.
- 이 이미지를 실행하는 모든 배포 대상을 나열하세요. 테스트 중인 대상이 아닙니다. 서비스, 스테이징 복사본, 배치 작업, 내부 도구. 동일한 바이너리는 동일한 새 요구사항을 의미합니다.
- 라이브 서비스뿐만 아니라 생성 스크립트도 업데이트하세요. 실행 중인 서비스에 일회성으로 주입하는 것은 다음에 스크립트로 서비스를 재생성하는 사람에게 보이지 않습니다. 재배포 시 이미지만 교체한다면 주입된 내용은 살아남고, 이것이 바로 아무도 스크립트가 이제 잘못되었다는 것을 알아차리지 못하는 이유입니다.
- 시작 경고 시 크게 실패하게 만들거나, 상태 확인(health check)이 알도록 하세요. 그 외에는 정상적으로 시작되는 서비스의
log.Warn은 신호가 아닙니다. 선언된 기능이 작동할 수 없을 때는 시작을 거부하거나, 다른 사람이 볼 수 있는 곳에 기능 상태를 노출하세요. - 누락된 것의 이름을 반환하세요. 여러 기능을 하나의 메시지 뒤에 묶어두면 그중 하나가 없을 때 진단 전체를 망치게 됩니다.
- 로컬 테스트로는 1단계와 2단계를 검증할 수 없다고 가정하세요. 이것이 불편한 점입니다. 사용자 컴퓨터에는 자격 증명이 있습니다. 거기서 실행할 수 있는 어떤 테스트도 배포 환경에 그것이 있다는 것을 증명하지 못합니다.
1단계와 2단계는 실제로 이런 종류의 실패를 방지하는 단계이며, 어떤 테스트 스위트도 대신해 주지 않는 두 가지입니다. 이는 도구가 아니라 습관입니다.
자주 묻는 질문
자격 증명이 누락되었을 때 서비스 시작을 거부해야 하나요?
이는 해당 의존성이 핵심적인지 선택적인지에 따라 다릅니다. 그것 없이는 전체 서비스가 쓸모없다면, 배포가 눈에 띄게 실패하고 롤백되도록 시작 시점에 빠르게 실패(fail fast)해야 합니다. 만약 그것이 여러 기능 중 하나에만 사용된다면 시작하는 것이 맞지만, 그럴 경우 기능의 상태가 시작 로그 외의 다른 곳에서 보여야 합니다. 정상으로 보이는 성능 저하 서비스가 최악의 경우입니다.
배포 후 스모크 테스트로 이를 발견할 수 있었을까요?
스모크 테스트가 해당 특정 기능을 실행했을 경우에만 가능했을 것입니다. 30초가 걸리는 언어 모델 호출의 경우 매 배포마다 실행하기에는 비용이 많이 듭니다. 더 저렴한 버전은 어떤 선택적 기능이 활성화되어 있는지 보고하는 준비성(readiness) 엔드포인트를 두고, 배포 단계에서 그 보고서를 릴리스가 예상하는 것과 비교하는 것입니다. 동작이 아닌 구성을 테스트하는 것이므로 빠르게 수행할 수 있습니다.
이것이 단지 코드형 인프라(infrastructure as code)의 문제인가요?
선언된 인프라는 도움이 됩니다. 왜냐하면 배포 사양이 그것을 필요로 하는 코드 옆에 위치하고 리뷰어들이 동일한 변경 사항에서 둘 다를 볼 수 있기 때문입니다. 그렇다고 문제가 해결되는 것은 아닙니다. 누군가는 여전히 올바른 대상에 새 시크릿을 추가해야 하며, 잘못된 내용이 담긴 선언적 파일은 잘못된 내용을 안정적으로 배포합니다. 이를 통해 얻는 이점은 실수가 콘솔에만 존재하는 것이 아니라 diff에서 보인다는 것입니다.
이러한 드리프트(drift)의 기존 사례를 어떻게 찾을 수 있나요?
동일한 이미지를 공유하는 배포 대상들을 열거하고, 각 대상의 환경 및 시크릿 목록을 서로 비교(diff)합니다. 차이점이 있다고 해서 무조건 잘못된 것은 아닙니다. 배치 작업은 공개 API보다 적은 것을 필요로 하는 것이 타당하기 때문입니다. 하지만 모든 차이점에는 누군가 설명할 수 있는 이유가 있어야 합니다. 이번 사고의 경우는 아무 이유가 없었습니다. 단지 그것을 필요로 하는 기능보다 더 오래되었을 뿐입니다.
관련 글
캐싱 및 통합으로 Secrets Manager 비용 절감
관리형 시크릿 매니저는 저장된 시크릿과 API 호출 건당 요금을 청구합니다. 단순한 페칭은 두 비용을 모두 폭발적으로 증가시킵니다. 캐싱과 통합을 통해 비용을 절감하는 방법을 소개합니다.
멀티테넌트 SaaS를 에어갭 박스로 분리하기
클라우드 SaaS를 하나의 온프레미스 어플라이언스로 제공하는 것은 배포 방식의 변경이 아닙니다. 이는 적절한 위치에서 테넌트, 크레딧, 관리형 인증 및 모든 클라우드 종속성을 제거하는 것을 의미합니다.
배포 후 캐시 무효화: 무엇을 퍼지하고, 무엇을 하지 말아야 하는가
stale 엣지나 오리진 과부하 없이 매일 배포하세요. 애셋을, 절대 퍼지하지 않는 불변의 해시 키와 경로로 퍼지하는 가변의 HTML로 분류하세요.
Git 히스토리에 커밋되기 전에 비밀을 차단하기
`git`에 커밋된 비밀은 삭제된 후에도 영원히 남습니다. 여기에 커밋 전에 이를 차단하는 계층적 방어 방법과, 비밀이 유출되었을 때 교체하는 방법이 있습니다.
Cloud Run 서비스 대 잡: 하나의 Go 바이너리, 두 개의 진입점
Cloud Run 서비스와 작업은 하나의 Go 이미지와 하나의 빌드를 공유할 수 있습니다. 여기서는 단일 바이너리가 서브 모드와 배치 작업으로 분할되는 방법과 그렇게 하지 말아야 할 경우를 설명합니다.
Declarative Firewall Sync가 규칙을 정리하고 액세스를 차단할 때
방화벽 동기화가 수동으로만 존재하던 규칙을 정리하여 라이브 트래픽을 차단했습니다. 여기에 사후 분석, 근본 원인, 그리고 정리(prune) 작업을 안전하게 만드는 방법이 있습니다.