코드베이스 속의 SRE: 타임아웃, 재시도, 코드로 작성한 알림
신뢰성은 저장소에서 결정된다. SRE가 애플리케이션 코드에서 리뷰하는 것: 타임아웃, 재시도, 헬스 체크, 롤아웃, 그리고 코드로 쓴 번 레이트 알림.

서비스 신뢰성의 대부분은 그것이 돌아가는 클러스터가 아니라 저장소에서, 의존성이 느려질 때 각 함수가 어떻게 행동하는지에서 결정된다. 따라서 SRE는 애플리케이션 코드를 리뷰하고 고칠 수 있을 만큼 읽어야 한다. 타임아웃 없는 네트워크 호출, 장애 중에 부하를 세 배로 키울 재시도 루프, 모든 인스턴스를 한꺼번에 서비스에서 빼버릴 헬스 체크를 찾아내야 한다. 이 글은 그 리뷰 목록과 수정 방법이고, SRE가 온전히 소유하는 한 가지 코드로 끝난다. 에러 버짓 번 레이트(burn rate)에 호출하는 알림을 Terraform으로 쓴 것이다.
이 글은 Budget for Failure 시리즈의 3부이자 첫 201 수준 부다. 1부는 역할을 정의했고 2부는 그것에 숫자를 줬다. Application Load Balancer에서 측정한 30일 99.9퍼센트 가용성 SLO. 그 예시가 여기서 이어진다. 코드 샘플은 Python이고, 패턴은 어느 언어에서나 같다.
SRE에게 필요한 코드 역량은 어느 정도인가
팀에서 가장 뛰어난 기능 개발자일 필요는 없다. 세 가지 능력이 필요하다. 요청 경로를 읽는 것. 핸들러에서 출발해 사용자 요청 하나가 만드는 모든 네트워크 호출을 따라가며, 각 호출이 30초 걸리면 무슨 일이 일어나는지 말할 수 있어야 한다. 풀 리퀘스트를 운영 동작의 관점에서 리뷰하는 것. 정확성을 리뷰하는 것과는 다른 읽기다. 그리고 프로덕션 품질의 변경을 직접 쓰는 것. 타임아웃, 메트릭, 롤백 훅. 개발자에게 타임아웃을 추가해 달라는 티켓을 내는 SRE는 문제를 설명한 것이다. 풀 리퀘스트를 보내는 SRE는 문제를 고친 것이다.
모든 네트워크 호출에는 데드라인이 필요하다
애플리케이션 코드에서 가장 흔한 신뢰성 결함은 영원히 기다릴 수 있는 호출이다. Python의 requests 라이브러리에는 기본 타임아웃이 아예 없다. AWS SDK for Python은 connect 60초, read 60초를 기본값으로 쓰고, 그 위에 자동 재시도가 얹힌다. 그 기본값으로 DynamoDB를 호출하는 핸들러가 유휴 타임아웃 60초짜리 로드 밸런서 뒤에 있으면, 사용자가 이미 504를 받은 뒤에도 워커를 몇 분씩 붙들 수 있다.
의존성이 느려지면, 데드라인 없는 호출은 느린 의존성을 죽은 서비스로 바꾼다. 모든 워커가 기다리며 멈춰 있고, 풀은 소진되고, 그 느린 의존성이 전혀 필요 없던 요청들마저 실패한다. 한계를 의도적으로 설정하라.
import boto3
from botocore.config import Config
sdk = Config(
connect_timeout=1,
read_timeout=2,
retries={"mode": "standard", "max_attempts": 3},
)
ddb = boto3.client("dynamodb", config=sdk)read 타임아웃은 딱 떨어지는 숫자가 아니라 의존성의 측정된 지연, 그 99.9번째 백분위수보다 조금 위에서 고른다. 그다음 한계들이 서로 안에 들어맞는지 확인하라. 사용자 대면 예산이 3초라면, 핸들러는 각각 2초가 걸릴 수 있고 3회 시도하는 순차 호출을 세 번 할 수 없다. 바깥 데드라인을 안으로 전달해서, 더는 답을 기다리는 사람이 없을 때 안쪽 호출이 포기하게 해야 한다.
재시도: 장애를 일으키는 수정
재시도는 일시적 실패를 성공으로 바꾸고, 지속적 실패를 더 큰 실패로 바꾼다. 세 가지 규칙이 올바른 쪽에 머물게 한다.
한 계층에서만 재시도하라. 클라이언트가 3회 재시도하고, API가 데이터베이스 호출을 3회 재시도하고, 그 밑에서 SDK가 3회 재시도하면, 사용자 행동 하나가 이미 허덕이는 의존성에 최대 27개의 요청이 된다. 실패에 가장 가까운 계층을 고르고 나머지는 빠르게 실패하게 하라.
지터를 넣어 백오프하라. 고정된 일정으로 재시도하는 클라이언트들은 함께 도착해서 회복 중인 서비스를 다시 쓰러뜨린다. 이에 대한 AWS Architecture Blog의 분석이 표준 참고 자료다. 그 "full jitter" 변형은 0과 지수적으로 커지는 상한 사이의 무작위 시간만큼 잔다.
import random, time
def call_with_retry(fn, attempts=3, base=0.1, cap=2.0, deadline=None):
for attempt in range(attempts):
try:
return fn()
except RetryableError:
if attempt == attempts - 1:
raise
pause = random.uniform(0, min(cap, base * 2 ** attempt))
if deadline and time.monotonic() + pause > deadline:
raise
time.sleep(pause)반복해도 안전한 것만 재시도하라. 타임아웃된 읽기는 안전하다. 타임아웃된 "카드 결제"는 작업이 멱등이 아닌 한 안전하지 않다. 멱등이란 클라이언트가 요청에 고유 키를 보내고 서버가 결과를 그 키 아래 저장해서, 반복 요청이 두 번 결제하는 대신 첫 결과를 돌려주는 것이다. 리뷰에서 쓰기 주변의 재시도는 멱등 키가 어디 있는지 물어볼 신호다.
더 작게 실패하기: 브레이커, 셰딩, 성능 저하
타임아웃과 재시도는 잠깐 아픈 의존성을 다룬다. 다운된 의존성에 대해서는 코드가 시도를 멈출 방법과, 그동안 사용자가 무엇을 볼지에 대한 결정이 필요하다.
- 서킷 브레이커. 실패가 연달아 나면 짧은 간격 동안 의존성 호출을 멈추고 즉시 실패한다. 워커 풀을 보호하고 의존성에 회복할 여지를 준다.
- 로드 셰딩. 서비스가 포화되면 전부 받아서 전부 느리게 처리하는 대신, 초과분을 429나 503으로 일찍 거절한다. 요청의 20퍼센트를 싸고 빠르게 거절하는 것이 100퍼센트를 타임아웃시키는 것보다 낫다.
- 우아한 성능 저하. 의존성마다 하드인지 소프트인지 결정하라. 추천 서비스가 다운되면 상품 페이지는 500을 반환할 것이 아니라 추천 없이 렌더링되어야 한다. 하드에서 소프트로 바뀐 의존성 하나하나가 가용성 곱셈에서 빠져나오고, 4부에서 그 곱셈을 끝까지 한다.
진실을 말하는 헬스 체크
헬스 체크는 코드이고, 두 방향 중 하나로 자주 틀린다. 프로세스가 살아 있기만 하면 200을 반환하는 얕은 체크는 망가진 인스턴스를 로테이션에 남겨 둔다. 모든 인스턴스에서 데이터베이스를 테스트하는 깊은 체크에는 더 나쁜 실패가 있다. 데이터베이스가 잠깐 흔들리면 모든 인스턴스가 같은 순간에 체크에 실패하고, 로드 밸런서에는 건강한 타깃이 하나도 남지 않는다. 그 상태의 Application Load Balancer는 fail open으로 모든 타깃에 어쨌든 라우팅하므로 당신을 구해주지만, 비정상 인스턴스를 교체하도록 설정된 오케스트레이터는 데이터베이스 사건 중에 건강한 플릿을 죽이기 시작한다.
통상의 답은 엔드포인트 두 개다. Liveness는 프로세스가 실행 중이며 재시작하면 안 된다고 말한다. Readiness는 이 인스턴스가 유용한 작업을 할 수 있다고 말하되, 자신에게 로컬인 것만 확인한다. 설정 로드됨, 연결 풀 생성됨, 캐시 데워짐. 공유 의존성은 인스턴스별 헬스 체크가 아니라 SLI를 통해 모니터링한다.
코드에서 SLI를 내보내기
2부의 로드 밸런서 SLI는 상태 코드를 본다. 성공한 체크아웃과 본문에 에러 메시지를 담은 200을 구별할 수 없고, 고객 등급이나 작업별로 나눌 수도 없다. 애플리케이션은 할 수 있다. 각 요청마다 작업, 비즈니스가 정의한 결과, 소요 시간, 트레이스 ID를 담은 구조화된 로그 한 줄을 쓰라. AWS에서는 CloudWatch 임베디드 메트릭 포맷이 추가 API 호출 없이 바로 그 로그 줄을 메트릭으로 바꾼다.
{
"_aws": {
"Timestamp": 1787130000000,
"CloudWatchMetrics": [{
"Namespace": "Checkout",
"Dimensions": [["Operation"]],
"Metrics": [
{ "Name": "Requests", "Unit": "Count" },
{ "Name": "Failures", "Unit": "Count" },
{ "Name": "Duration", "Unit": "Milliseconds" }
]
}]
},
"Operation": "PlaceOrder",
"Requests": 1,
"Failures": 0,
"Duration": 184,
"trace_id": "1-66c2f1d0-3f1b2c4d5e6f7a8b9c0d1e2f",
"customer_tier": "enterprise"
}트레이스 ID나 고객처럼 카디널리티가 높은 필드는 메트릭 디멘션이 아니라 로그 속성으로 두라. 고유한 디멘션 값 하나하나가 따로 과금되는 메트릭이다. 이것이 1부의 서비스 템플릿에 들어가야 할 계측이고, 그래야 개발자가 요청하지 않아도 얻는다.
변경을 안전한 경로로 만들기
2부의 장부는 배포 한 번이 한 달 예산의 31퍼센트를 소비하는 것을 보여줬다. 코드 쪽 방어는 이 목록에서 수익률이 가장 높은 작업이다.
- 자동 롤백을 갖춘 점진적 롤아웃. 새 버전에 트래픽의 10퍼센트를 보내고, 에러 비율과 지연을 이전 버전과 비교하고, 더 나쁘면 사람 없이 롤백한다. CodeDeploy, ECS 배포, Lambda 별칭 모두 CloudWatch 알람에 따라 멈추고 롤백할 수 있다. 그러면 나쁜 릴리스의 비용은 트래픽 10퍼센트의 몇 분이 된다.
- 배포와 릴리스를 분리하라. 코드를 피처 플래그 뒤에 어둡게 내보내고 점진적으로 켜라. 플래그를 끄는 것은 어떤 롤백보다 빠르다.
- 하위 호환되는 스키마 변경. 확장, 마이그레이션, 축소. 새 컬럼을 추가하고, 양쪽에 쓰고, 읽기를 옮긴 다음, 나중 릴리스에서 옛것을 제거한다. 옛 코드와 새 코드가 결코 나란히 돌 수 없게 만드는 마이그레이션은 모든 롤아웃을 장애로, 모든 롤백을 불가능으로 만든다.
코드로 작성한 알림: CPU가 아니라 번 레이트에 호출하라
SRE가 소유하는 마지막 코드는 알림 자체다. 전형적인 AWS 계정에 걸린 알람 세트는 원인에 호출한다. CPU 80퍼센트 초과, 메모리 90 초과, 5xx 카운트 10 초과. 아무 문제 없는 곳에서 시끄럽고(지연 목표를 충족하는 CPU 95퍼센트의 오토스케일링 그룹), 문제가 있는 곳에서 조용하다(모든 호스트 메트릭이 평평한 채 체크아웃의 2퍼센트를 실패시키는 배포). 2부의 SLO는 호출할 더 나은 조건을 준다. 이대로 두면 목표를 놓칠 만큼 빠르게 에러 버짓을 쓰고 있는가? 그 속도가 번 레이트(burn rate)다.
burn rate = observed error ratio / (1 - SLO)
SLO 99.9% -> budget ratio 0.001
error ratio 0.1% -> burn rate 1 -> budget lasts 30 days
error ratio 0.6% -> burn rate 6 -> budget lasts 5 days
error ratio 1.44% -> burn rate 14.4 -> budget lasts 50 hours
error ratio 100% -> burn rate 1000 -> budget lasts 43 minutes알림에는 임계값과 창이 필요하고, 둘이 함께 알림이 울릴 때까지 예산이 얼마나 사라졌는지를 결정한다.
budget consumed at alert = burn rate x alert window / SLO window
14.4 x 1 hour / 720 hours = 2%
6 x 6 hours / 720 hours = 5%
1 x 3 days / 30 days = 10%이 세 행은 Google SRE Workbook의 알림 장이 권장하는 것이고, 30일 SLO에 대한 합리적인 기본값이다. 예산의 2퍼센트가 한 시간 만에 또는 5퍼센트가 여섯 시간 만에 사라지면 호출하고, 10퍼센트가 사흘 만에 사라지면 티켓을 연다. 빠르고 심각한 문제는 빠른 호출을 받는다. 임계값 알람이 결코 잡지 못하는 종류인 느린 누수는 다음 근무일의 티켓을 받는다.
왜 창이 두 개인가
긴 창 하나에는 리셋 문제가 있다. 에러가 10분 동안 15퍼센트로 달리다 멈추면, 한 시간 평균은 이후 한 시간의 대부분 동안 14.4배 임계값 위에 머문다. 알람은 건강한 시스템을 보고 있는 엔지니어에게 계속 울린다.
해결책은 두 번째 짧은 창이 같은 시각에 같은 임계값을 넘을 것을 요구하는 것이다. 워크북의 지침은 짧은 창을 긴 창의 12분의 1로 잡는 것이다. 긴 창은 소비가 유의미하다고 말하고, 짧은 창은 아직 진행 중이라고 말한다. 합치면:
| 조치 | 긴 창 | 짧은 창 | 번 레이트 | 99.9%에서의 에러 비율 | 소비된 예산 |
| 호출 | 1시간 | 5분 | 14.4 | 1.44% | 2% |
| 호출 | 6시간 | 30분 | 6 | 0.6% | 5% |
| 티켓 | 3일 | 6시간 | 1 | 0.1% | 10% |
감지는 필요한 곳에서 빠르다. 전면 장애에서 한 시간 평균은 약 52초 뒤에 1.44퍼센트를 넘고, 5분 창은 이미 넘어 있다. 장애가 끝나면 5분 창은 몇 분 안에 임계값 아래로 떨어지고 호출이 해제된다. 한 시간 창은 여전히 오염되어 있더라도.
Terraform으로 CloudWatch에 만들기
CloudWatch에는 여기 필요한 두 조각이 있다. 원시 카운트 대신 에러 비율에 알람을 걸기 위한 metric math 알람, 그리고 긴 창과 짧은 창을 AND로 묶기 위한 복합 알람. 창마다 메트릭 알람 하나씩, 창 길이와 같은 단일 평가 기간으로, 액션은 붙이지 않는다.
locals {
slo = 0.999
budget = 1 - local.slo
lb = "app/checkout/0123456789abcdef"
windows = {
fast_long = { period = 3600, burn = 14.4 }
fast_short = { period = 300, burn = 14.4 }
slow_long = { period = 21600, burn = 6 }
slow_short = { period = 1800, burn = 6 }
}
elb_metrics = {
req = "RequestCount"
t5xx = "HTTPCode_Target_5XX_Count"
e5xx = "HTTPCode_ELB_5XX_Count"
}
}
resource "aws_cloudwatch_metric_alarm" "burn" {
for_each = local.windows
alarm_name = "checkout-burn-${each.key}"
alarm_description = "Error ratio above ${each.value.burn}x burn over ${each.value.period}s"
comparison_operator = "GreaterThanThreshold"
threshold = each.value.burn * local.budget
evaluation_periods = 1
treat_missing_data = "notBreaching"
metric_query {
id = "ratio"
expression = "(FILL(t5xx, 0) + FILL(e5xx, 0)) / req"
label = "error ratio"
return_data = true
}
dynamic "metric_query" {
for_each = local.elb_metrics
content {
id = metric_query.key
metric {
namespace = "AWS/ApplicationELB"
metric_name = metric_query.value
stat = "Sum"
period = each.value.period
dimensions = { LoadBalancer = local.lb }
}
}
}
}임계값은 번 레이트에 예산 비율을 곱한 것이다. 빠른 쌍은 14.4 x 0.001 = 0.0144, 느린 쌍은 0.006. slo를 0.9995로 바꾸면 모든 임계값이 따라온다. 그다음은 복합 알람, 유일하게 호출을 보내는 것이다.
resource "aws_cloudwatch_composite_alarm" "slo_page" {
alarm_name = "checkout-availability-slo-page"
alarm_description = "Burning 30-day error budget at 14.4x (1h) or 6x (6h). Runbook: https://wiki.example.com/runbooks/checkout-slo"
alarm_rule = join(" OR ", [
"(ALARM(${aws_cloudwatch_metric_alarm.burn["fast_long"].alarm_name}) AND ALARM(${aws_cloudwatch_metric_alarm.burn["fast_short"].alarm_name}))",
"(ALARM(${aws_cloudwatch_metric_alarm.burn["slow_long"].alarm_name}) AND ALARM(${aws_cloudwatch_metric_alarm.burn["slow_short"].alarm_name}))",
])
alarm_actions = [aws_sns_topic.pager.arn]
ok_actions = [aws_sns_topic.pager.arn]
}복합 알람 하나 안의 OR는 억제 문제도 해결한다. 심한 장애는 빠른 쌍과 느린 쌍을 모두 만족시키지만, 둘이 하나의 알람으로 들어가므로 엔지니어는 두 번이 아니라 한 번 호출받는다.
사흘짜리 티켓 단계는 관리형 경로에서 더 쉽다. SLO가 CloudWatch Application Signals에 있다면 번 레이트 룩백 창(최대 10,080분, 즉 7일)을 붙이고, 그것이 발행하는 번 레이트 메트릭에 알람을 건다. AWS의 임계값 공식은 X% x SLO interval length / look-back window이고, 함정이 하나 있다. 14.4는 30일 간격에서만 맞다. 28일 예산의 2퍼센트를 한 시간에 쓰면 13.44다. 임계값은 퍼센트에서 유도하고, 배수를 그대로 베끼지 마라.
프로덕션 준비 리뷰
이것을 모으면, 서비스가 실제 트래픽을 받기 전에 SRE가 개발자와 함께 진행하고 아키텍처가 바뀔 때 반복하는 리뷰가 된다.
- 모든 아웃바운드 호출에 타임아웃이 있고, 그 타임아웃들이 호출자의 데드라인 안에 들어맞는가?
- 재시도 계층이 정확히 하나이고, 백오프와 지터가 있으며, 재시도되는 쓰기는 멱등인가?
- 각 의존성에 대해: 하드인가 소프트인가, 다운됐을 때 사용자는 무엇을 보는가?
- 헬스 체크가 공유 의존성을 피하는가?
- 서비스가 작업별로 요청, 실패, 소요 시간 메트릭을 내보내고 트레이스 ID를 전파하는가?
- SLO와 그 소유자, 그리고 런북 링크와 함께 지명된 교대에 호출하는 번 레이트 알림이 있는가?
- 스키마 변경 없이 릴리스를 5분 안에 롤백할 수 있는가?
- 트래픽이 10배가 되면 어떤 한계에 먼저 닿고, 그다음엔 무슨 일이 일어나는가?
실패 모드와 주시할 것
낮은 트래픽은 번 레이트 산술을 깨뜨린다. 시간당 요청 100건에서 99.9퍼센트 SLO의 14.4배 번은 실패 요청 1.44건이므로, 운 나쁜 타임아웃 두 번이면 누군가 호출받는다. SRE Workbook이 제시하는 선택지는 합성 트래픽, 작은 서비스들을 하나의 SLO로 묶기, 더 낮은 목표, 또는 더 긴 창이다. metric math에서는 IF(req > 200, ratio, 0)으로 볼륨에 게이트를 걸 수 있는데, 게이트 아래에서는 눈이 먼다는 대가가 있다.
누락 데이터는 기본적으로 좋은 소식이 아니다. treat_missing_data = "notBreaching"은 트래픽이 없으면 사라지는 비율에는 맞고, DNS가 망가져서 트래픽이 사라진 경우에는 틀리다. SLO 알람에 RequestCount가 결코 0이면 안 되는 시간대에 0으로 떨어지는지 보는 평범한 알람을 짝지어라.
버킷은 슬라이딩 창이 아니다. 1시간 기간의 CloudWatch 데이터포인트는 버킷이므로, 긴 창 알람은 이상적인 슬라이딩 계산보다 늦게 반응할 수 있다. 짧은 창이 심각한 사건의 감지를 빠르게 유지하고, 느린 번의 타이밍은 근사치로 취급하라.
한 번도 실행되지 않은 복원력 코드. 프로덕션에서 열린 적 없는 서킷 브레이커는 추측이다. 먼저 테스트 환경에서, 그다음 에러 버짓 안에서 프로덕션에서 하는 결함 주입이 폴백 경로가 신뢰를 얻는 방법이다.
위의 모든 것은 플랫폼이 버틴다고 가정한다. 타임아웃과 재시도는 대체로 작동하는 공급자를 상대로 쓰인다. 그 공급자가 실제로 무엇을 약속하는지, 장애 도메인이 어디인지, 공급자가 자체 데이터센터일 때 무엇이 바뀌는지는 4부다.
다음으로 읽을 글
- 이 시리즈의 4부: 클라우드든 온프레미스든: SRE가 서비스 아래에서 점검하는 것.
- 이 시리즈의 2부: SLI, SLO, SLA, 에러 버짓: SRE를 움직이는 숫자들.
- FinOps: 다음 25억 달러 AWS 청구서가 오기 전에 Terraform으로 예산 알림 만들기: 같은 아이디어를 돈에 적용한 것. 최종 청구서가 아니라 지출 속도에 알림을 건다.
- ercan.ai의 Agents on Call, Part 8. Production: Observability, Evals, and the Day It Lies, 온콜을 서는 것이 에이전트일 때 관측 가능성이 어떤 모습인지.
AWS, 클라우드 아키텍처, SRE 실무, CI/CD 파이프라인, Linux 등에 대한 컨설팅은 ercanermis.com에서 시작하라.
참고 자료
- Google SRE Workbook, Chapter 5: Alerting on SLOs
- Google SRE Book, Chapter 22: Addressing Cascading Failures
- AWS Architecture Blog: Exponential Backoff And Jitter
- Amazon Builders' Library: Timeouts, retries, and backoff with jitter
- Amazon Builders' Library: Implementing health checks
- Amazon CloudWatch: Embedded metric format
- Amazon CloudWatch: Service level objectives (SLOs), burn rates and burn rate alarms
- Amazon CloudWatch: Create a composite alarm
Ercan의 다른 글
같은 저자, 다른 영역의 사이트 두 개.