어느 날 갑자기 SSH도 안 되고 웹서버도 응답이 없었다. 홈서버 겸 여러 서비스를 올려둔 Oracle Cloud 인스턴스였는데, 딱 봐도 “다운됐다”는 느낌은 왔지만 정확히 뭐가 어떻게 죽었는지 알 수가 없었다. 이 글은 그 문제를 처음부터 끝까지 파고든 기록이다.
1. 진짜 다운된 게 맞는지부터 확인
SSH가 안 된다고 무조건 서버가 죽은 건 아니다. 방화벽 문제일 수도 있고, 네트워크 경로 문제일 수도 있다. 그래서 제일 먼저 한 건 클라우드 콘솔 접속이었다.
- 인스턴스 상태 확인: 콘솔에서 인스턴스가
RUNNING상태인지STOPPED인지부터 봤다. Always Free 인스턴스는 사용률이 낮으면 경고 없이 회수(reclaim)되는 경우도 있어서 이 가능성부터 배제해야 했다. - Monitoring 탭 확인: 결과는 예상 밖이었다. CPU는 평소 수준이었는데 Memory Utilization 그래프가 100%에 딱 붙은 채 계속 유지되고 있었다. 여기서 감이 왔다 — 메모
- 리 문제다.

2. 원인을 찾아 로그를 뒤지다
메모리가 문제라는 심증은 섰지만, 정확히 뭐가 어떻게 죽었는지는 또 다른 얘기였다.
첫 번째 삽질: dmesg에 아무것도 없다
일반적으로 리눅스에서 메모리가 부족하면 OOM(Out Of Memory) killer가 개입해서 프로세스를 강제 종료하고, 그 과정을 dmesg에 로그로 남긴다.
bash
sudo dmesg -T | grep -i "out of memory"
sudo dmesg -T | grep -i "killed process"
그런데 결과가 없었다. 이유는 간단했다 — 서버가 죽은 뒤 이미 재부팅을 해버렸고, dmesg는 현재 부팅 세션의 커널 로그만 보여준다. 죽기 직전 로그는 이미 다 날아간 뒤였다.
두 번째 시도: 이전 부팅 세션 로그 뒤지기
journalctl은 이전 부팅 세션의 로그도 남아있으면 조회가 가능하다.
bash
journalctl --list-boots
sudo journalctl -k -b -1 | grep -i -E "oom|memory|killed"
역시 OOM 관련 로그는 없었다. 하지만 grep으로 특정 키워드만 걸러보는 대신, 죽기 직전 시간대의 전체 로그를 그냥 쭉 훑어보기로 했다.
bash
sudo journalctl -b -1 --since "특정시각" --until "특정시각"
여기서 결정적인 단서가 나왔다.
3. 진짜 원인: OOM Killer가 아니라 커널 자체가 멈췄다
로그를 보니 두 가지가 눈에 띄었다.
- 약 5분간 로그가 완전히 끊김 — 정상적인 시스템이라면 이 시간에도 cron이든 방화벽 로그든 뭔가는 찍히는데, 이 구간엔 아무것도 없었다. 커널 자체가 응답 불능(hang) 상태였다는 뜻이다.
- 끊기기 직전 마지막 로그:
rtkit-daemon: The canary thread is apparently starving.— 이건 시스템이 스케줄링을 정상적으로 처리 못 할 정도로 리소스 압박이 심했다는 신호다.
여기서 퍼즐이 맞춰졌다. 이 서버엔 스왑(swap)이 전혀 설정되어 있지 않았다. 스왑이 없는 상태에서 메모리가 100%에 도달하면, 커널은 회수할 수 있는 메모리(스왑 가능한 익명 메모리)가 없으니 메모리 회수 루프에 갇혀버린다. OOM killer의 킬 기준에는 못 미치니 프로세스를 죽이지도 못하고, 그렇다고 정상 동작도 못 하는 어중간한 상태 — 결국 시스템 전체가 얼어붙은(hang) 것이다. SSH도 웹서버도 동시에 안 됐던 이유가 여기 있었다. 특정 서비스 하나가 죽은 게 아니라 커널 레벨에서 시스템 전체가 멈춰버린 상황이었다.
4. 범인 컨테이너를 찾아라 (실패기)
원인 메커니즘은 파악했지만, 정작 어떤 프로세스가 메모리를 그렇게 먹었는지는 여전히 미궁이었다. docker stats로 실시간 모니터링을 해뒀다면 바로 알 수 있었겠지만, 사고 당시엔 그런 로깅이 전혀 없었다.
재부팅 후 컨테이너들을 확인해보니 이 서버 하나에 꽤 많은 서비스가 올라가 있었다 — 웹서버, 데이터베이스 여러 개, 파일 공유 서비스, 원격 데스크톱 환경까지. 문제는 재부팅 직후라 컨테이너들이 전부 방금 막 시작된 상태였고, “사고 당시 어떤 컨테이너가 폭주했는지”는 이미 증거가 사라진 뒤였다.
여기서 배운 교훈: 사고가 터지고 나서 로깅을 시작하면 이미 늦다. 그래서 향후 재발 시엔 바로 범인을 잡을 수 있도록 컨테이너별 + 호스트 프로세스별 메모리 사용량을 1분 간격으로 기록하는 스크립트를 cron에 등록했다.
또 하나 확인해보니, 떠있는 컨테이너 전부 메모리 제한(mem_limit)이 걸려있지 않았다. 즉 어느 컨테이너든 이론적으로는 호스트 메모리를 무한정 잠식할 수 있는 구조였다는 것도 함께 드러났다.
5. 재발 방지 조치
원인과 기여 요인이 어느 정도 파악된 뒤, 다음 조치들을 순서대로 적용했다.
① 스왑 추가 (가장 핵심적인 조치)
bash
sudo fallocate -l 4G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
이번 사고의 직접 원인이 “스왑 없이 메모리 100% → 커널 회수 루프에 갇혀 hang”이었으니, 스왑을 걸어두는 것만으로도 최소한 시스템이 완전히 얼어붙는 대신 스와핑으로 숨 쉴 틈을 벌 수 있게 됐다. 최악의 경우에도 OOM killer가 정상적으로 개입해서 특정 프로세스만 죽고 시스템 자체는 살아있는 형태로 넘어갈 가능성이 훨씬 높아졌다.
② journald 로그 영구 저장 설정
사고 조사할 때 제일 답답했던 게 재부팅하자마자 결정적 로그가 날아간 것이었다. 원인을 보니 journald의 기본 저장 설정이 auto로 되어있어서, 로그 저장용 디렉토리가 없으면 메모리에만 로그를 쌓다가 재부팅 시 소실되는 구조였다.
bash
sudo sed -i 's/#Storage=auto/Storage=persistent/' /etc/systemd/journald.conf
sudo systemctl restart systemd-journald
이제부터는 재부팅해도 이전 부팅 세션의 커널 로그가 디스크에 남아있어서, 다음에 또 문제가 생기면 훨씬 수월하게 원인을 추적할 수 있다.
③ 컨테이너 + 호스트 메모리 상시 로깅
bash
#!/bin/bash
LOG=/home/user/docker_mem.log
echo "=== $(date '+%Y-%m-%d %H:%M:%S') ===" >> $LOG
docker stats --no-stream --format "{{.Name}}\t{{.MemUsage}}\t{{.MemPerc}}" >> $LOG
echo "--- host top mem ---" >> $LOG
ps aux --sort=-%mem | head -10 >> $LOG
free -h >> $LOG
크론으로 1분마다 실행해서 컨테이너별 메모리 사용량뿐 아니라, Docker 밖에서 돌아가는 호스트 프로세스(원격 데스크톱 관련 프로세스, DB 데몬 등)까지 같이 기록하게 만들었다. 다음에 또 이상 징후가 생기면 시계열로 어느 프로세스가 우상향했는지 바로 확인 가능하다.
로그가 무한정 쌓이는 걸 막기 위해 logrotate도 같이 걸어뒀다.
/home/user/docker_mem.log {
weekly
rotate 4
compress
missingok
notifempty
}
④ 안 쓰는 서비스 정리
점검하다 보니 한동안 안 쓰던 컨테이너 하나가 그냥 계속 떠서 메모리를 1GB 가까이 먹고 있는 게 눈에 띄었다. 당장 필요 없는 서비스면 미련 없이 정리하는 것도 중요하다는 걸 새삼 느꼈다.
bash
docker compose down
⑤ 메모리 임계치 초과 시 이메일 알림
앞으로는 문제가 터지기 전에 미리 알 수 있도록 두 겹으로 알림을 걸었다.
클라우드 콘솔 알람: Monitoring 화면에서 메모리 사용률이 특정 임계치(85%)를 넘으면 알림 서비스로 이메일이 오도록 설정. 이 방법은 인프라 레벨에서 감지하는 거라, 서버가 이번처럼 완전히 hang되어도 알림이 온다는 게 장점이다.
서버 내부 스크립트: msmtp를 설치해서 Gmail SMTP로 직접 메일을 보낼 수 있게 구성했다.
bash
sudo apt install msmtp msmtp-mta -y
~/.msmtprc에 Gmail 앱 비밀번호(일반 비밀번호 아님, 반드시 앱 비밀번호 발급 필요)를 설정하고 나서, 메모리 사용률을 체크하는 스크립트를 만들었다.
bash
#!/bin/bash
THRESHOLD=85
USED=$(free | awk '/Mem:/ {printf("%.0f", $3/$2 * 100)}')
FLAG=/tmp/mem_alert_sent
EMAIL="본인 이메일 주소"
if [ "$USED" -ge "$THRESHOLD" ]; then
if [ ! -f "$FLAG" ]; then
{
echo "Subject: 서버 메모리 ${USED}% 사용중"
echo ""
echo "$(date)"
docker stats --no-stream --format '{{.Name}}: {{.MemUsage}} ({{.MemPerc}})'
} | msmtp "$EMAIL"
touch "$FLAG"
fi
else
rm -f "$FLAG"
fi
플래그 파일로 중복 발송을 막아서, 임계치를 넘어선 “그 순간”에만 메일 1통이 오고 이후엔 정상화될 때까지 조용하도록 만들었다. 크론으로 5분마다 체크하도록 등록했다.
마무리하며
이번 사고를 정리하면 이렇다.
| 항목 | 내용 |
|---|---|
| 증상 | SSH·웹서버 모두 응답 없음 |
| 표면적 원인 | 메모리 사용률 100% 지속 |
| 근본 원인 | 스왑 부재 → 메모리 100% 도달 시 커널이 회수 루프에 갇혀 시스템 전체 hang |
| 기여 요인 | 컨테이너 전부 메모리 제한 없음, 사고 시점 메모리 사용 로깅 부재 |
| 조치 | 스왑 4GB 추가, journald 영구 저장, 메모리 상시 로깅, 안 쓰는 서비스 정리, 임계치 알림 설정 |
가장 크게 느낀 건, 평소엔 잘 돌아가던 시스템도 로깅이 없으면 사고 순간 “블랙박스”가 되어버린다는 점이다. 스왑처럼 당연히 있어야 할 안전장치를 놓치고 있었던 것도 문제였지만, 그보다 더 아쉬웠던 건 사고가 터진 순간의 증거가 하나도 안 남아있었다는 것이었다. 이번 기회에 로깅과 알림 체계를 갖춰뒀으니, 다음엔 같은 방식으로는 당하지 않을 거라 믿는다.