무슨 일이 있었나
메트로놈들, 행동대장 API, 운영 봇 같은 컨테이너 10여 개를 4GB 메모리 인스턴스 한 대에 올려 쓰고 있었습니다. 그날 새벽 node 프로세스 하나가 메모리를 밀어 올렸고, OOM 킬러가 프로세스들을 반복해서 죽이다가 인스턴스 자체가 상태 검사 실패에 빠졌습니다. 원인은 단순했습니다. 메모리는 빠듯한데 스왑이 없었습니다. 완충 지대가 없으니 순간 피크가 곧장 시스템 전체의 죽음으로 이어진 겁니다.
복구는 싱거웠습니다. 원격 재부팅 한 번이면 됐고, 컨테이너들은 restart: unless-stopped 정책 덕에 스스로 돌아왔습니다. 여기서 끝냈다면 운이 나빴던 날로 남았을 겁니다. 하지만 이 장애의 진짜 문제는 OOM이 아니라, 몇 시간 동안 아무도 몰랐다는 사실이었습니다.
스왑이라는 완충 지대
# 4GB 스왑파일 추가, 재부팅에도 유지되도록 fstab 등록
sudo fallocate -l 4G /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile
# 스왑은 비상구로만. 평소에는 램을 쓰도록 성향을 낮춘다
echo "vm.swappiness=10" | sudo tee /etc/sysctl.d/99-swappiness.conf스왑은 성능 해결책이 아니라 관측할 시간을 버는 장치라고 생각합니다. 메모리 피크가 와도 즉사 대신 느려짐으로 강등되고, 그 사이에 경보가 사람을 부를 수 있습니다. swappiness를 10으로 낮춘 건 반대 방향의 걱정 때문입니다. 스왑이 일상이 되면 그건 그것대로 성능 장애입니다.
죽기 전에 우는 경보
운영 봇에 리소스 모니터를 붙였습니다. 5분마다 /proc/meminfo를 읽어서 메모리 90%, 스왑 60% 임계를 넘으면 Discord로 경고합니다. 만들면서 배운 디테일이 하나 있는데, 임계 경보에는 히스테리시스가 필요합니다. 89%와 91%를 오가는 밤에 경보 수십 개가 쌓이면 사람은 경보를 꺼버립니다. 경계를 넘을 때 한 번만 울고, 충분히 내려와야 다시 무장하게 했습니다.
복구의 자동화
그래도 죽는 날은 옵니다. 그래서 복구를 세 겹으로 쌓았습니다.
1단 CloudWatch: 인스턴스 상태 검사 실패 3분 지속 -> 자동 재부팅
2단 CloudWatch: 시스템 상태 검사 실패 2분 지속 -> 인스턴스 복구
3단 운영 봇: 부팅 후 uptime이 15분 미만이면 "재부팅 감지" 통지3단이 이 구조에서 제일 마음에 드는 부분입니다. 박스가 죽으면 어떻게 알리지, 라는 문제는 죽은 박스가 알림을 못 보낸다는 모순을 안고 있습니다. 순서를 뒤집으면 풀립니다. CloudWatch가 박스를 살리고, 살아난 봇이 자기 uptime을 보고 방금 재부팅됐다고 말하게 하면 됩니다. SNS도 Lambda도 필요 없었습니다.
남은 정직한 사실
이 구조는 4GB 한 대에 서비스를 몰아넣은 비용 최적화의 대가이기도 합니다. 서비스별 메모리 상한이나 인스턴스 분리 같은 더 근본적인 처방도 알고 있고, 트래픽이 그 비용을 정당화하는 시점에 집행할 계획입니다. 그 전까지는 같은 장애가 와도 몇 분 안에, 사람 없이 끝납니다. 장애 대응의 결과물은 복구가 아니라 다음 장애의 시나리오라고 믿게 된 사건이었습니다.