
이 프로젝트에서 가장 많이 배운 건 코드를 짤 때가 아니라 뭔가 터졌을 때였다.
4편은 운영 중에 실제로 발생한 사고들을 시간 순서 없이, 기억나는 것들 위주로 정리한 글이다.
비용 절감을 위해 인프라를 내렸다가 다시 올리는 작업을 했다. KDS를 삭제하고 재생성하면 Firehose 연결이 자동으로 붙을 줄 알았다.
아니었다.
Firehose는 KDS가 삭제되는 순간 SUSPENDED 상태로 전환되고, 이후 새 KDS가 생겨도 자동으로 reconnect되지 않는다. 직접 다시 연결해줘야 한다. 이걸 몰라서 Bronze 적재가 0 rows인 채로 한참 돌았다.
Lambda도 같은 문제가 있었다. Alert KDS에 연결된 Lambda event source mapping이 Disabled 상태로 고착된다.
# Disabled 항목 확인
aws lambda list-event-source-mappings \
--event-source-arn <alert-kds-arn>
# 재활성화
aws lambda update-event-source-mapping \
--uuid <mapping-uuid> \
--enabled
이걸 발견한 건 Flink가 Alert KDS에 17건을 보냈는데 Slack 알림이 하나도 안 왔을 때였다. 30분 동안 운영자 화면에서 아무 알림도 없었던 것. Lambda가 KDS를 읽지 않고 있었다.
지금은 down.sh / up.sh 스크립트에 이 재활성화 스텝을 명시적으로 포함했다. 까먹을 수 없게.

가장 오래 헤맨 사고다. 진단에만 1시간 넘게 걸렸다.
Grafana Anomaly Timeline 패널에서 COUNT(DISTINCT robot_id)를 5분 슬라이딩 윈도우로 집계하고 있었다. 어느 날 값이 갑자기 절반 수준으로 떨어졌다. 480, 490, 510... 1,000 근처가 아니라 딱 절반이었다.
처음엔 Generator가 절반만 데이터를 보내는 줄 알았다. CloudWatch KDS IncomingRecords를 보니 정상이었다.
그 다음엔 Athena 쿼리 문제인 줄 알았다. 직접 돌려보니 정상이었다.
실제 원인은 Firehose의 multi-shard flush stagger였다.
KDS 2개 Shard가 있을 때, 각 Shard의 Firehose flush 타이밍이 약 15초씩 어긋난다. Shard 1이 12:00:00에 flush하고, Shard 2가 12:00:15에 flush한다. PartitionKey(robot_id) 해시 기준으로 Shard가 나뉘기 때문에, 5분 슬라이딩 윈도우 안에 두 Shard의 데이터가 완전히 겹치지 않는 타이밍이 생긴다.
결국 5분 윈도우 안에 Shard 1 로봇만 잡히는 순간 → COUNT(DISTINCT robot_id) ≈ 500. 딱 절반이 나왔던 이유가 MD5 해시 분포가 Shard 경계와 거의 정확히 50:50으로 나뉘었기 때문이었다.
해결은 간단했다. 윈도우를 Firehose buffer 시간 + 여유 마진 이상으로 늘렸다.
Firehose buffer: 300초 (5분)
Shard 수: 2
→ 최소 윈도우: 300 + (2 × 60) = 420초 (7분)
→ 실제 적용: 10분 윈도우
근데 이걸 파악하기 전까지 "Generator가 일부 로봇을 샘플링하는 게 아닐까?", "S3 파티션이 일부만 쓰이는 건 아닐까?" 등 전혀 다른 방향을 한참 들여다봤다. 결정적인 단서는 항상 딱 절반이 나온다는 거였다.
초반에 빠르게 테스트하려고 Slack Webhook URL을 코드에 직접 넣었다. GitHub에 올라갔다. Public repo였다.
Slack이 자동으로 탐지해서 Webhook을 즉시 폐기했다. 다행히 Slack 쪽에서 먼저 막아줬지만 찜찜했다.
이후 모든 민감값은 AWS Secrets Manager에 저장하고 Lambda에서 런타임 조회로만 쓴다. Terraform에서도 TF_VAR_slack_webhook_url 환경변수로 주입하던 방식을 버리고 Secrets Manager에서 직접 read하는 방식으로 바꿨다.
data "aws_secretsmanager_secret_version" "slack_webhook" {
secret_id = "/robot-telemetry/slack-webhook-url"
}
resource "aws_lambda_function" "alert_handler" {
environment {
variables = {
SLACK_WEBHOOK_URL = data.aws_secretsmanager_secret_version.slack_webhook.secret_string
}
}
}
환경변수로 주입하면 export를 한 번만 빼먹어도 CHANGEME 같은 기본값이 prod에 조용히 들어간다. Secrets Manager에서 직접 read하면 이 패턴 자체가 없어진다.
비용 절감을 위해 EKS 워크로드를 전부 0으로 줄이는 셧다운 루틴을 만들었다. Pod을 전부 0으로 줄이면 노드가 없어지고, Karpenter가 EC2를 반납한다.
근데 ALB가 살아있었다.
Pod을 0으로 줄여도 kubectl delete ingress를 안 하면 ALB Controller가 ALB를 그대로 유지한다. AWS가 2024년 2월부터 public IPv4 주소를 attached/unattached 무관하게 과금하기 시작했다.
ALB 3개 × public IP 2개씩 × $0.005/h × 720h = 월 $21.6 누수.
셧다운 루틴에 Ingress 삭제 + ALB 완전 삭제 확인을 추가했다.
# Ingress 삭제
kubectl delete ingress --all -A
# ALB가 완전히 사라질 때까지 대기
until [ "$(aws elbv2 describe-load-balancers \
--query 'length(LoadBalancers)' --output text)" = "0" ]; do
echo "ALB 삭제 대기 중..."
sleep 30
done
echo "ALB 완전 삭제 확인"
Pod만 0으로 줄이고 ALB가 살아있으면 노드는 사라지는데 public IP는 계속 과금되는 상황이 된다. 셧다운 루틴은 ALB까지 확인하고 끝내야 한다.
dt = D-1 하드코딩API 서버에서 캐시를 갱신할 때 이렇게 썼었다.
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
query = f"SELECT * FROM gold WHERE dt = '{yesterday}'"
평소엔 괜찮다. 근데 비용 절감을 위해 인프라를 내린 날 밤에 Airflow ETL이 돌지 않으면? gold 테이블에 어제 파티션이 없다. 캐시 갱신 시 빈 DataFrame이 올라가고, AI 채팅은 "데이터를 찾을 수 없습니다"만 반환하게 된다. 자연 회복이 안 된다.
다음날 인프라를 다시 올려도, 오늘 ETL이 돌기 전까지 어제 파티션은 없는 채로 유지된다.
# 수정: 최근 N일 안에서 MAX(dt) 사용
query = """
SELECT * FROM gold
WHERE dt = (
SELECT MAX(dt) FROM gold
WHERE dt >= DATE_FORMAT(DATE_ADD(NOW(), INTERVAL -7 DAY), '%Y-%m-%d')
)
"""
7일 안에서 가장 최신 파티션을 쓴다. ETL이 하루 빠져도 이틀 전 데이터라도 보여준다. 빈 응답보다 낫다.
Airflow에서 SageMaker 학습 job을 실행할 때 이런 에러가 났다.
HeadBucket Error: An error occurred (403) when calling the HeadBucket operation
Bucket: sagemaker-eu-west-1-123456789012
SageMaker SDK가 자동으로 sagemaker-{region}-{account} 버킷을 기본 버킷으로 쓰려 하는데, Airflow의 IRSA에는 그 버킷 접근 권한이 없었다.
# ❌ 기본 버킷 사용 (IRSA 권한 없음)
sagemaker.Session()
# ✅ 명시적 버킷 지정
sagemaker.Session(default_bucket="robot-telemetry-bucket")
이것 말고도 source_dir 상대경로 문제도 있었다. Airflow worker pod의 현재 디렉토리가 /opt/airflow라서, DAG 파일 기준 상대경로가 맞지 않았다.
# ❌ 상대경로 (worker pod CWD 기준)
source_dir="src/ml"
# ✅ 절대경로 (__file__ 기준 파생)
source_dir=str(Path(__file__).parent.parent / "src" / "ml")
SageMaker 관련 문제가 많았던 이유 중 하나가 Airflow worker pod에 SageMaker SDK를 전역으로 설치했던 거다. SageMaker SDK가 313MB라서, webserver/scheduler pod이 시작될 때마다 pip install이 돌았다. 콜드스타트 6분 → CrashLoop.
worker pod에만 격리해서 설치하는 방식으로 바꿨다.
# helm/airflow-values.yaml
workers:
env:
- name: _PIP_ADDITIONAL_REQUIREMENTS
value: "sagemaker boto3" # worker만
# webserver/scheduler에는 해당 없음
이 프로젝트를 운영하면서 비용을 꽤 줄였다.
| 항목 | 전 | 후 | 절감 |
|---|---|---|---|
| KDS Shard | 4개 ($144) | 2개 ($72) | $72 |
| Athena 스캔 | 풀스캔 | Partition Projection | ~$280 |
| EKS 노드 | On-Demand | Spot (Karpenter) | ~$150 |
| S3 Bronze | 90일 Standard | Glacier 전환 | ~$104 |
| 합계 | ~$606/월 |
KDS Shard를 4개에서 2개로 줄인 건, 실제 트래픽을 측정해보니 1,000 rec/sec가 항상 나오는 게 아니었기 때문이다. 피크를 커버할 수 있는 최소한으로 줄였다. (나중에 1개까지 줄이는 실험도 했는데, 그게 위에 나온 Firehose flush stagger 사고로 이어졌다.)
Partition Projection이 가장 효과가 컸다. DDL에 파티션 범위를 수식으로 정의해두면 Athena가 S3 메타데이터 조회 없이 스캔할 파티션을 바로 계산한다.
TBLPROPERTIES (
'projection.enabled' = 'true',
'projection.dt.type' = 'date',
'projection.dt.range' = '2026-01-01,NOW',
'projection.dt.format' = 'yyyy-MM-dd',
'projection.dt.interval' = '1',
'projection.dt.interval.unit' = 'DAYS'
)

이 파이프라인을 만들면서 직접 써본 것들이 PRISM 해커톤에서 그대로 살아났다.
학습 프로젝트로 시작했지만, 결국 해커톤 MVP의 기술적 토대가 됐다. 처음부터 대규모를 목표로 설계하지 않고, 작게 만들고 부딪히면서 키워나가는 방식이 맞았다고 생각한다.