OCI 서버가 갑자기 죽었다 — 원인 진단부터 재발 방지까지

어느 날 갑자기 SSH도 안 되고 웹서버도 응답이 없었다. 홈서버 겸 여러 서비스를 올려둔 Oracle Cloud 인스턴스였는데, 딱 봐도 “다운됐다”는 느낌은 왔지만 정확히 뭐가 어떻게 죽었는지 알 수가 없었다. 이 글은 그 문제를 처음부터 끝까지 파고든 기록이다.

1. 진짜 다운된 게 맞는지부터 확인

SSH가 안 된다고 무조건 서버가 죽은 건 아니다. 방화벽 문제일 수도 있고, 네트워크 경로 문제일 수도 있다. 그래서 제일 먼저 한 건 클라우드 콘솔 접속이었다.

  • 인스턴스 상태 확인: 콘솔에서 인스턴스가 RUNNING 상태인지 STOPPED인지부터 봤다. Always Free 인스턴스는 사용률이 낮으면 경고 없이 회수(reclaim)되는 경우도 있어서 이 가능성부터 배제해야 했다.
  • Monitoring 탭 확인: 결과는 예상 밖이었다. CPU는 평소 수준이었는데 Memory Utilization 그래프가 100%에 딱 붙은 채 계속 유지되고 있었다. 여기서 감이 왔다 — 메모
  • 리 문제다.
image

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가 아니라 커널 자체가 멈췄다

로그를 보니 두 가지가 눈에 띄었다.

  1. 약 5분간 로그가 완전히 끊김 — 정상적인 시스템이라면 이 시간에도 cron이든 방화벽 로그든 뭔가는 찍히는데, 이 구간엔 아무것도 없었다. 커널 자체가 응답 불능(hang) 상태였다는 뜻이다.
  2. 끊기기 직전 마지막 로그: 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 영구 저장, 메모리 상시 로깅, 안 쓰는 서비스 정리, 임계치 알림 설정

가장 크게 느낀 건, 평소엔 잘 돌아가던 시스템도 로깅이 없으면 사고 순간 “블랙박스”가 되어버린다는 점이다. 스왑처럼 당연히 있어야 할 안전장치를 놓치고 있었던 것도 문제였지만, 그보다 더 아쉬웠던 건 사고가 터진 순간의 증거가 하나도 안 남아있었다는 것이었다. 이번 기회에 로깅과 알림 체계를 갖춰뒀으니, 다음엔 같은 방식으로는 당하지 않을 거라 믿는다.