
0. 들어가며
최근 "KSPO 공공데이터 경진대회"를 위한 프로젝트를 진행하던 중에 로컬 환경과 배포 환경에서의 API 응답이 다른 상황을 경험했습니다. 로컬 Postman으로 API 요청을 보냈을 때에는 응답이 정확하게 나왔는데, 배포된 서버에서의 응답은 이전 버전의 API 응답이 오더라구요.
“같은 API를 호출했는데, 왜 로컬 Postman과 배포된 프론트 요청 결과가 서로 다르지…?”
분명히 CI/CD 로그에도 문제가 없고, docker logs 명령어로 확인해도 문제 될 부분도 없고, 로컬 테스트에도 문제가 없었는데, 도대체 왜 응답이 다르지? 어느 부분을 확인해야 할지 감이 잡히지 않았습니다. 그러다가 우연히 EC2 서버 자체 내에서 자동화 배포 스크립트를 실행하다가 발견한 에러 메시지.
failed to extract layer (application/vnd.oci.image.layer.v1.tar+gzip sha256:6eef2e24c9cc554fc012af6cc3933fd8a4363a63eefdc64049f38f75730e9b3b) to overlayfs as "extract-174226526-dYkk sha256:6cbbc706d5b983d0b19d51cad99193582423409aa4b0534cd8b1404616b2cdc9": write /var/lib/containerd/io.containerd.snapshotter.v1.overlayfs/snapshots/485/fs/app/app.jar: no space left on device
처음 보는 에러 메시지에 너무나 당황했습니다. 그럼 이 에러 메시지에 대해서 분석해보고, 해결해 나가는 과정을 한번 작성해 보도록 하겠습니다.
1. 문제 상황
1.1 운영 서버 응답은 수정되기 이전 버전
기존 프로젝트에서는 주간 운동 루틴 추천 카드에서 다음과 같은 정보를 클라이언트에게 보여줍니다.


그러나, "도보 5분"이라는 정보는 잘못된 정보였다. 여기서 "도보 5분"이라는 정보는 프로그램 시설과 가장 가까운 대중교통 플랫폼까지 걸어서 걸리는 시간인데, 유저에게 저 화면을 보여주게 되면 마치 현재 위치에서 프로그램 시설 위치까지 도보 5분이 걸린다는 정보로 해석될 가능성이 다분했습니다.
고민을 하다가 개방된 공공데이터에서 유의미한 정보를 전달할 수가 없어, 하버사인 공식을 사용하여 유저의 현재 위치 정보(위도, 경도)와 프로그램 시설의 위치 정보(위도, 경도)를 활용하여 직선거리를 "반경 ~~ km"와 같이 보여주면 좋을 것 같다는 생각이 들어서 급하게 코드를 변경했습니다.
1-2. 로컬에서의 응답은 수정된 버전


로컬 개발 환경에서 API를 여러 번 호출한 결과는 위 사진과 같이 정상적으로 거리 데이터를 반환하는 것을 확인할 수 있었습니다.
"distanceWalk": "4.63"처럼 모든 응답이 일관되었고, 계산된 distance 값이 정확히 나타났다. 물론, Postman으로 테스트해본 결과도 마찬가지로 정상적으로 잘 나오는 것을 확인했습니다.
즉, main 브랜치로 push한 이후에 CI/CD 과정이 정상적으로 작동했다면, 절대 이렇게 응답이 환경에 따라서 달라질 수가 없는 것이죠. 그러나, 저 "distanceWalk" 데이터 값이 운영 서버에서는 이미 변경된 이전 코드 내용이 반영되었다는 점이 정말 놀라웠습니다.
2. 원인 파악 및 분석
처음엔 다음과 같은 가능성을 의심했습니다.
- Nginx 캐싱 문제인가?
- LLM 프롬프트가 최신 버전으로 반영되지 않은 걸까?
- 프론트 요청이 구버전 서버로 라우팅 되나?
그래서, 강력 새로고침도 해보고, 시크릿 모드에서도 테스트를 해보았지만, 결과는 똑같았습니다. 프론트 코드를 확인해 봐도 큰 문제는 없었고, Github Actions 로그를 봐도 별다른 문제는 보이지 않았습니다. 그러다가 서버 내에 문제가 있을까 싶어 EC2 서버에 있는 "deploy.sh"라는 자동화 배포 스크립트 직접 실행해 보았습니다. 그 결과, 비로소 결정적인 단서를 찾을 수 있었습니다.


도커 이미지 레이어 압축 해제 실패 → 그럼에도 불구하고, 기존 이미지로 컨테이너를 띄어버린 상태
뭔가 서버의 저장 공간이 남아있지 않아, 레이어 추출에 실패했다는 것을 에러 로그를 통해서 확인할 수 있었습니다.
드디어 원인을 찾았습니다..
3. 해결 과정
3.1 디스크 용량 확인
기기의 공간이 더 이상 남아있지 않다고 하니, 용량을 확인해 보도록 하겠습니다. 리눅스에서 저장 공간의 사용현황과 여유상황을 확인하기 위한 명령어는 다음과 같습니다.
df -h

아니나 다를까, 정말 루트 디스크 용량이 100%로 가득 차있었습니다. 남은 용량은 102MB. 도커 레이어 하나도 풀 수 없는 상황이었습니다. 이런 상황이었기 때문에 새롭게 업데이트된 백엔드 서버 이미지를 풀다가 디스크가 꽉 차서 실패하게 된 것이고, 이에 이전 버전의 이미지를 다운받아 실행했기 때문에 API 응답이 정상적이지 않았던 것이죠.
EC2 내부에서는 Docker를 주로 사용했기 때문에 Docker가 사용한 디스크 공간도 같이 확인해보고 싶었습니다.
docker system df

결과는 위 사진과 같습니다. 이상한 점은 "RECLAIMABLE" 값이 음수 값으로 나온다는 점이었습니다. "RECLAIMABLE" 단어는 '회복할 수 있는', '되찾을 수 있는', '재이용할 수 있는'이라는 사전적 의미를 가지는데, 이 값이 음수라는 점이 상당히 신기했습니다. 검색해도 나오지 않아서 GPT에게 물어보았고, 답변은 다음과 같았습니다.
용량이 꽉 찬 상태에서 계속 docker pull 명령어를 사용하다 보니 중간에 실패한 이미지 레이어들이 많이 남아있고, overlayfs snapshot이 깨진 상태여서 계산이 불가능해진 상황이다.
너무 어려웠습니다.... 도커 공부의 필요성도 다시금 느꼈습니다. EC2 서버의 용량이 가득 차있는데, 계속 업데이트된 새로운 이미지를 pull 받으려다 보니 넘치다 못해 오버플로우 현상이 발생한 것이라고 이해했습니다.
3.2 디스크 용량 정리
그러면 디스크 용량을 정리해 봅시다. 일단 모든 컨테이너를 중지하고, Docker 이미지 및 컨테이너를 전부 제거해 보기로 했습니다.
docker compose down
docker system prune -a --volumes

정말 지울 거냐는 물음에 "yes"라고 대답하고, 모든 이미지, 컨테이너, 볼륨을 제거했다. 용량을 확인해 봅시다.

가득 차 있던 루트 디스크 /dev/root 사용률이 100% -> 70%로 개선되었고, 약 4.5GB만큼의 용량을 확보한 것을 확인할 수 있었습니다. 또한, Images 타입의 "RECLAIMABLE" 수치가 음수 값을 가졌었는데, 다시 0B로 회복된 것을 확인할 수 있었습니다. 이 뜻은 깨졌던 이미지 레이어, 손상된 snapshot들이 전부 삭제되었다는 것을 의미합니다.
그럼 이제 깔끔하게 청소했고, 용량도 확보했으니 이미지를 다시 pull 받고, 서버를 클린하게 배포해 보도록 하겠습니다.
3.3 서버 재배포 및 결과 확인
다음 명령어를 활용하여 컨테이너를 다시 띄워보도록 하겠습니다. 여기서 "docker compose up -d --force-recreate" 명령어는 기존 "docker compose up -d"명령어와는 다르게 이미지 설정 변경 여부와는 상관없이 모든 컨테이너를 강제로 삭제하고, 새 컨테이너를 새롭게 생성합니다.
docker compose pull
docker compoes up -d --force-recreate
명령어 실행 이후, 다시 용량을 확인해 볼까요?

아주 기분 좋은 결과가 나왔습니다. "docker system df" 명령어 결과를 보면 Image 4개를 pull 받아서 컨테이너를 띄우면서 약 1.5GB 정도의 용량을 차지하는 것을 확인할 수 있습니다. 이 결과를 바탕으로 이미지를 다운 받기 전 전체 사용가능한 용량과 비교해 보면 Avail 4.5G -> 3.0G로 줄어들고, Use% 70% -> 80%로 증가했다는 사실도 확인할 수 있습니다.
결과적으로 Docker와 서버가 완전히 정상 상태로 돌아왔다는 것을 느낄 수 있었습니다. 그럼 이제 운영 서버에서도 로컬 환경과 같이 API 응답을 잘하는지 확인해 볼까요?


운영 서버에서도 API 응답을 정상적으로 하는 것을 확인할 수 있었습니다 :>
4. 재발 방지 방법
기존 방식을 그대로 유지한다면, 서비스가 계속 배포되고 로그와 데이터가 쌓이면서 언젠가는 또 디스크가 100%에 도달할 수밖에 없습니다. 그럼 그때마다 EC2에 직접 SSH로 접속해서 df -h 찍어보고, docker system prune 하고, 로그 지우고… 같은 작업을 반복해야 할까요?
저는 이런 상황에서 항상 “어떻게 하면 사람 손을 최대한 덜 타게 만들까?”, 즉 자동화와 가드레일을 먼저 떠올리게 되었습니다. 그래서 이번 장애를 계기로 아래와 같은 재발 방지 전략을 정리했습니다.
4.1 디스크 모니터링 스크립트 배치
첫 번째는 "터지기 전에 알람을 받는 것"입니다. 디스크 사용량이 100% 되기 전에 90% 일 때, 미리 알람을 받았다면 API 응답 오류 없이 여유 있게 상황을 해결할 수 있었을 것 같습니다. 그럼 어떻게 알람을 받을 수 있을까요?
디스크 사용량이 특정 수치에 도달했을 때, 이를 트리거로 해서 Slack, Discord와 같은 메신저 앱으로 알람을 보내는 식으로 구성할 수 있을 것 같습니다. 간단하게 스크립트를 작성해 본다면 다음과 같은 형태가 될 수 있습니다.
#!/bin/bash
THRESHOLD=80
USAGE=$(df -h | grep "/dev/root" | awk '{print $5}' | sed 's/%//')
if [ "$USAGE" -ge "$THRESHOLD" ]; then
# 여기에 Slack 또는 Discord Webhook 호출 로직 추가
echo "[WARNING] Disk usage is at ${USAGE}% on $(hostname)"
fi
이 스크립트를 cron 명령어로 등록해 주기적으로 체크하다가 특정 용량에 도달한다면, 메신저로 알람을 보내고, 개발자가 이를 확인하여 장애가 나기 전에 쉽게 손볼 수 있을 것입니다.
4.2 Docker prune 자동화
두 번째는 Docker가 차지하는 디스크를 알아서 줄여주는 루틴이 있습니다.
위에서 확인했듯이 용량이 가득 차 있는 상태에서 이미지를 계속 pull 받으면 불완전한 이미지가 배포되면서 원하는 API 응답을 받지 못할 것입니다.
0 3 * * * docker system prune -a -f
이를 방지하기 위해서 마찬가지로 cron 명령어를 통해서 주기적으로 Docker가 차지하는 불필요한 디스크 용량을 정리하면 가득 찰 일이 없겠죠?
4.3 EC2 EBS 볼륨 확장
가장 근본적인 해결 방법이라고 생각합니다. 현재 EC2 서버의 디스크 용량은 15GB인데, 물리적인 용량을 늘리는 방법입니다. 즉, 15GB에서 30GB ~ 50GB로 확장하거나, 별도 볼륨으로 떼어서 관리하는 방법도 좋은 방법이라고 생각합니다.
5. 정리하며
이번 트러블 슈팅을 통해 가장 크게 느낀 점은 문제의 원인이 코드라고 생각했지만, 끝까지 파고들어 보니 전혀 다른 곳에 원인이 있었다는 것입니다. 처음 현상을 마주했을 때는 자연스럽게 다음 항목들부터 의심했습니다.
- LLM 프롬프트가 잘못 전달된 건 아닐까?
- API 응답을 만드는 로직에 버그가 있는 건 아닐까?
- 프론트 요청과 Postman 요청의 차이 때문일까?
하지만 실제 원인은 EC2 서버의 디스크 용량 100% 도달로 인해 Docker 최신 이미지를 정상적으로 Pull 받아오지 못했고, 그 결과 애플리케이션이 최신 업데이트 상황을 반영하지 못한 상태로 실행되고 있었다는 점이었습니다.
CI/CD단계부터 운영 서버 내부까지의 문제는 절대 아니라고 생각했는데, 항상 완벽하다고 믿어서는 안 된다는 교훈을 얻었습니다. 즉, 코드를 자동으로 빌드하고 배포해 주는 CI/CD 과정들이
- 디스크가 충분한지
- 이미지가 정상적으로 extract 되었는지
- 컨테이너 내부 파일이 온전한지
까지 보장해주지는 않는다는 것이죠. 이번 문제를 겪으며, 이전에는 크게 신경 쓰지 않았던 것들이 눈에 들어오기 시작했습니다.
- df -h를 주기적으로 확인하지 않았던 점
- Docker 이미지와 레이어가 어떻게 쌓이는지 깊게 보지 않았던 점
- “배포가 성공했다”는 Github Actions 로그를 너무 쉽게 믿었던 점
- extract 실패가 있어도 컨테이너가 올라올 수 있다는 사실
이제는 배포 로그에서 "no space left on device, failed to extract layer" 같은 메시지가 보이면 어떻게 해결할지 그 방법에 대해서 확실하게 알게 되었고, 디스크 용량까지 신경 써야 한다는 점을 이번 문제를 통해서 알게 되었습니다.
이번 경험은 단순히 하나의 버그를 고친 경험이라기보다, 운영 환경을 바라보는 시야가 한 단계 넓어진 순간이었습니다. 코드를 잘 짜는 것도 중요하지만, 그 코드가 어떤 환경에서, 어떤 상태로 실행되고 있는지를 이해하지 못하면 언젠가는 반드시 비슷한 문제를 다시 마주하게 된다는 사실을 깨달았습니다.
6. Reference
- GPT-5.1과 함께한 트러블 슈팅
