서로 다른 디스크 사용량 장부와 삭제됐지만 열린 파일 연결을 추적하는 유령 파일시스템 감사관

Linux에서 파일을 지웠는데 용량이 그대로일 때: df·du 차이와 lsof +L1 진단

서버·인프라 Ubuntu 2026년 9월 4일

Linux 서버에서 큰 로그 파일을 삭제했는데도 df -h의 사용량이 줄지 않을 때가 있다. 반면 du로 디렉터리를 더해 보면 문제의 크기만큼 파일이 보이지 않는다. 이 상황에서 파일을 더 지우거나 서버를 바로 재부팅하면 원인은 사라질 수 있지만, 어떤 프로세스가 공간을 붙잡고 있었는지 확인할 기회도 함께 사라진다.

핵심은 dfdu가 같은 질문에 답하는 명령이 아니라는 점이다.

  • df파일시스템 전체의 사용·가용 블록을 보여준다.
  • du는 현재 디렉터리 트리에서 찾아갈 수 있는 파일과 디렉터리의 사용량을 합산한다.
  • 파일 이름이 디렉터리에서 사라져도 프로세스가 파일 디스크립터를 열고 있으면, 그 공간은 프로세스가 닫을 때까지 해제되지 않을 수 있다.

GNU df 설명은 지정한 파일이 속한 파일시스템의 공간을 보고한다고 정의한다. lsof 매뉴얼에서 +L1은 링크 수가 1보다 작은, 즉 연결이 끊겼지만 여전히 열린 파일을 선택한다. Linux의 /proc/<PID>/fd/에는 프로세스가 열고 있는 파일 디스크립터가 링크 형태로 나타난다.

이 글에서는 삭제를 더 진행하기 전에 범위를 고정하고, 열린 삭제 파일을 서비스나 컨테이너까지 추적한 뒤, 가장 작은 영향 범위로 공간을 회수하는 순서를 정리한다. 일반적인 /var/log, /var/lib, Docker 용량 분류부터 필요한 경우에는 Ubuntu 서버 용량 부족을 안전하게 진단하는 순서를 먼저 참고한다.

아래 명령은 대부분 조회용이다. systemctl restart, docker compose restart는 서비스 중단을 일으킬 수 있으므로 실제 서비스명·이중화·점검창·백업 상태를 확인한 뒤 실행한다. 예시의 PID, 마운트 지점, 서비스명은 실제 환경 값으로 바꿔야 한다.

3줄 요약

  • df는 파일시스템의 할당 상태를 보고하고, du는 이름으로 찾아갈 수 있는 파일을 순회해 합산하므로 두 값은 다를 수 있다.
  • sudo lsof -nP +L1로 삭제됐지만 열린 파일을 찾고, PID가 어떤 systemd 서비스나 Docker 컨테이너에 속하는지 먼저 확인한다.
  • 공간 회수는 무조건 재부팅하거나 파일 디스크립터를 직접 비우는 방식보다, 원인을 기록한 뒤 해당 서비스만 정상적으로 로그를 다시 열거나 점검창에서 재시작하는 방식이 안전하다.

먼저 확인할 것: 정말 같은 파일시스템을 비교했는가

df /du /var를 단순히 비교하면 범위부터 다르다. /var가 별도 마운트일 수도 있고, / 아래에 다른 파일시스템이 중첩돼 있을 수도 있다. 먼저 문제가 난 경로가 어느 파일시스템에 속하는지 확인한다.

findmnt -T /
findmnt -T /var
df -hT /
df -hT /var
df -i /

df -hT는 파일시스템 형식과 블록 사용량을 함께 보여준다. df -i는 공간이 아니라 inode 사용량을 확인한다. 작은 파일이 지나치게 많으면 바이트 여유가 있어도 inode가 고갈될 수 있으므로 IUse%도 따로 본다.

그다음 같은 파일시스템 안에서만 상위 디렉터리 사용량을 합산한다.

sudo du -xhd1 / 2>/dev/null | sort -h
sudo du -xhd1 /var 2>/dev/null | sort -h

여기서 -x는 다른 파일시스템으로 넘어가지 않게 한다. -d1은 한 단계 아래까지만 보여준다. df가 가리키는 마운트와 du가 순회하는 범위를 맞춰야 불필요한 오해를 줄일 수 있다.

du 결과에서 .git가 크게 보이는 문제라면 열린 삭제 파일과는 별개다. 이 경우에는 .git 대용량 파일 진단과 안전한 정리처럼 Git 객체와 참조를 기준으로 확인한다.

왜 파일을 삭제했는데 공간이 남는가

일반 파일은 디렉터리 항목과 inode, 그리고 실제 데이터 블록으로 구성된다. 실행 중인 프로세스가 로그 파일을 열어 둔 상태에서 rm이나 잘못된 로그 정리 작업이 파일 이름을 제거하면 디렉터리에서 그 파일을 더는 찾을 수 없다. 그래서 du의 디렉터리 순회에는 나타나지 않는다.

그러나 프로세스가 기존 파일 디스크립터를 계속 들고 있으면 커널은 그 열린 파일을 즉시 폐기할 수 없다. 프로세스가 계속 쓰는 서비스라면 이름은 사라졌는데 사용량은 오히려 더 늘어날 수도 있다. 이때 파일시스템 블록은 아직 할당돼 있으므로 df에는 남는다.

이 현상은 df의 버그라고 단정할 일이 아니다. 서로 다른 관점의 결과다.

도구 주로 보는 대상 열린 삭제 파일
df 파일시스템의 전체 블록 할당·가용 상태 공간이 해제되기 전까지 사용량에 포함될 수 있음
du 현재 경로에서 이름으로 순회 가능한 파일 디렉터리 항목이 없어지면 보통 합산하지 못함
lsof +L1 프로세스가 열고 있으며 링크 수가 1 미만인 파일 원인 PID와 파일 디스크립터를 찾는 데 사용

1단계: 변경하지 말고 현재 상태를 기록한다

디스크가 완전히 가득 차기 직전이라도 먼저 최소한의 증거를 남긴다. 출력 전체에 경로·사용자·프로세스 인수가 들어갈 수 있으므로 공개 게시판에 그대로 붙이지 않는다.

date -Is
hostname
df -hT
df -i
findmnt
sudo du -xhd1 / 2>/dev/null | sort -h

Docker를 운영한다면 Docker가 인식하는 사용량도 별도로 기록한다.

docker system df
docker system df -v
docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Image}}'

Docker 공식 문서에서 docker system df는 Docker 데몬이 사용하는 공간을 표시하며, -v는 이미지의 공유·고유 크기와 컨테이너·볼륨 세부 정보를 보여준다. du /var/lib/docker와 숫자가 완전히 같아야 한다는 뜻은 아니다. Docker가 관리하는 논리 객체와 호스트 파일시스템 관점이 다르기 때문이다.

백업이나 복구 절차가 불명확한 상태에서 볼륨이나 데이터 경로를 먼저 건드리지 않는다. Ghost·MySQL처럼 상태를 가진 컨테이너라면 백업 복원 검증과 Docker 실패 포렌식 절차를 먼저 확인한다.

2단계: 삭제됐지만 열린 파일을 찾는다

가장 직접적인 조회는 다음과 같다.

sudo lsof -nP +L1

-nP는 호스트명과 포트명 변환을 생략해 조회 지연과 불필요한 이름 해석을 줄인다. +L1은 링크 수가 1보다 작은 열린 파일만 선택한다. 권한이 부족하면 다른 사용자의 프로세스나 파일이 빠질 수 있으므로 서버 전체를 볼 때는 sudo가 필요할 수 있다.

출력에서 주로 볼 열은 다음과 같다.

의미 확인 포인트
COMMAND 파일을 연 프로세스 이름 어떤 애플리케이션 계열인지
PID 프로세스 ID 서비스·컨테이너 역추적 기준
USER 프로세스 사용자 예상 계정인지
FD 파일 디스크립터 쓰기용인지, 파일 번호가 무엇인지
DEVICE 파일시스템 장치 문제가 난 마운트와 같은 장치인지
SIZE/OFF 파일 크기 또는 오프셋 큰 항목을 우선 조사
NLINK 링크 수 일반적인 열린 삭제 파일은 0으로 보일 수 있음
NAME 기존 경로와 상태 (deleted) 표시 확인

목록이 많으면 원본을 삭제하거나 수정하지 말고 우선 파일로 보존할 수 있다. 다만 이 출력에는 내부 경로와 프로세스 정보가 포함되므로 접근 권한을 제한한다.

sudo lsof -nP +L1 > "$HOME/lsof-unlinked-$(date +%Y%m%d-%H%M%S).txt"
chmod 600 "$HOME"/lsof-unlinked-*.txt

3단계: PID를 실제 서비스까지 역추적한다

lsof에 PID가 보였다고 곧바로 종료하지 않는다. 먼저 어떤 서비스가 소유하는지 확인한다.

PID='<ACTUAL_PID>'

ps -p "$PID" -o pid,ppid,user,lstart,comm,args
sudo readlink -f "/proc/$PID/exe"
sudo cat "/proc/$PID/cgroup"

Linux 커널 문서에 따르면 /proc에는 PID별 하위 디렉터리가 있고, fd 디렉터리는 그 프로세스가 열고 있는 파일 디스크립터를 담는다. 특정 PID의 링크를 조회하는 명령은 다음과 같다.

sudo ls -l "/proc/$PID/fd"

systemd가 관리하는 프로세스라면 해당 유닛을 찾는다.

systemctl status "$PID"

Docker 컨테이너 내부 프로세스로 의심되면 컨테이너의 호스트 PID와 대조한다.

docker inspect \
  --format '{{.State.Pid}} {{.Name}} {{.State.Status}}' \
  $(docker ps -q)

여기서 목적은 “PID를 죽이는 것”이 아니라 다음 질문에 답하는 것이다.

  1. 어떤 서비스나 컨테이너가 파일을 열고 있는가?
  2. 그 파일은 로그인가, 임시 파일인가, 데이터 파일인가?
  3. 프로세스가 정상적으로 파일을 다시 열 수 있는 방법이 있는가?
  4. 단일 인스턴스를 재시작하면 사용자 영향이 있는가?
  5. 조치 전 백업이나 장애조치가 필요한 상태 저장 서비스인가?

4단계: 가장 작은 영향 범위로 파일을 닫는다

공간은 마지막 열린 참조가 닫혀야 실제로 해제된다. 가장 안전한 방법은 애플리케이션이 지원하는 공식 로그 재열기나 정상 종료 절차를 사용하는 것이다. 서비스마다 지원 신호와 명령이 다르므로 kill -HUP을 모든 프로세스에 공통 해법처럼 사용하면 안 된다.

systemd 서비스라면 설정과 영향 범위를 확인한 뒤 점검창에서 해당 유닛만 재시작한다.

sudo systemctl restart <ACTUAL_UNIT>
sudo systemctl --no-pager --full status <ACTUAL_UNIT>

Docker Compose 서비스라면 프로젝트 디렉터리와 서비스명을 확인하고 대상 하나만 재시작한다.

docker compose config --services
docker compose restart <ACTUAL_SERVICE>
docker compose ps

docker compose restart도 실제 서비스 중단을 만든다. 데이터베이스처럼 연결 복구와 준비 시간이 필요한 서비스는 애플리케이션 영향과 복제·백업 상태를 먼저 확인한다. 네트워크나 Compose 경계를 함께 점검해야 한다면 NPM·Ghost·MySQL 네트워크 분리 구성의 검증 순서를 참고한다.

5단계: 공간 회수와 서비스 정상 여부를 함께 검증한다

재시작 명령이 성공했다는 사실만으로 끝내지 않는다. 같은 열린 삭제 파일이 사라졌는지, df가 회복됐는지, 서비스가 실제 요청을 처리하는지 순서대로 본다.

sudo lsof -nP +L1
df -hT /
df -i /

그다음 서비스 종류에 맞게 검증한다.

sudo systemctl is-active <ACTUAL_UNIT>
sudo journalctl -u <ACTUAL_UNIT> --since '-10 min' --no-pager

Docker Compose라면 다음처럼 상태와 최근 로그 범위를 제한해서 본다.

docker compose ps
docker compose logs --since=10m <ACTUAL_SERVICE>

HTTP 서비스는 내부 헬스체크와 외부 HTTPS를 각각 확인한다. 데이터베이스는 단순 포트 연결뿐 아니라 실제 읽기·쓰기 또는 애플리케이션 쿼리가 정상인지 확인해야 한다.

검증 기준은 다음과 같다.

  • 원인이던 PID 또는 파일 디스크립터가 더 이상 lsof +L1에 나타나지 않는다.
  • 대상 파일시스템의 df 사용량이 예상 방향으로 감소한다.
  • 서비스가 active/running 상태이고 재시작 반복이 없다.
  • 최근 로그에 파일 권한, 로그 경로, DB 복구 관련 새 오류가 없다.
  • 사용자 요청 경로와 백엔드 의존성이 정상이다.

lsof +L1이 비어 있으면 확인할 다른 원인

열린 삭제 파일은 흔한 원인이지만 유일한 원인은 아니다. 결과가 비어 있으면 다음 분기로 이동한다.

1. 다른 파일시스템을 섞어 비교했다

findmnt -T <PATH>로 경로별 마운트를 확인하고 du -x로 한 파일시스템에 한정한다. 바인드 마운트나 별도 데이터 볼륨 때문에 눈에 보이는 디렉터리 구조와 실제 파일시스템 경계가 다를 수 있다.

2. inode가 고갈됐다

df -i
sudo find /var -xdev -type f -printf '%h\n' 2>/dev/null \
  | sort | uniq -c | sort -n | tail

바이트가 아니라 파일 수 문제라면 큰 파일을 찾는 접근만으로 해결되지 않는다. 캐시·세션·메일 큐·작은 로그 조각처럼 파일이 폭증한 디렉터리를 애플리케이션의 보존 정책으로 정리해야 한다.

3. 스냅샷이 블록을 보존한다

Btrfs, ZFS, LVM 씬 프로비저닝 등은 스냅샷이 과거 블록을 계속 참조할 수 있다. 파일을 삭제해도 스냅샷 정책 때문에 기대만큼 줄지 않을 수 있다. 파일시스템을 먼저 식별하고 해당 기술의 공식 조회 명령으로 스냅샷을 확인한다. 일반적인 rm이나 Docker prune으로 해결하려 하지 않는다.

4. Docker 이미지·빌드 캐시·컨테이너·볼륨이 실제로 크다

docker system df -v
docker ps -a --size
docker volume ls

이 경우에는 df·du 불일치라기보다 Docker가 관리하는 객체가 실제 공간을 사용하는 문제일 수 있다. Docker 문서상 docker system prune은 중지된 컨테이너, 사용하지 않는 네트워크, dangling 이미지와 빌드 캐시 등을 제거한다. -a--volumes를 붙이면 삭제 범위가 더 넓어진다. 운영 서버에서 다음 명령을 습관적으로 실행하지 않는다.

# 운영 서버에서 확인 없이 실행하지 말 것
docker system prune -af --volumes

먼저 docker system df -v와 객체 목록을 보고, 필요한 이미지·컨테이너·볼륨과 백업 상태를 식별한 뒤 종류별로 좁혀 처리한다.

하면 안 되는 응급조치

/var/lib/docker/overlay2를 직접 지우기

Docker의 저장소 내부는 Docker 데몬이 관리한다. 디렉터리를 직접 삭제하면 이미지·컨테이너 메타데이터와 실제 레이어의 대응 관계를 깨뜨릴 수 있다. du에서 크게 보인다는 이유만으로 하위 파일을 직접 지우지 않는다.

/proc/<PID>/fd/<FD>에 0바이트를 쓰기

인터넷에는 열린 파일 디스크립터를 truncate하거나 셸 리디렉션으로 비우는 방법이 나온다. 이것은 실행 중인 프로세스의 파일 오프셋과 애플리케이션 동작을 고려하지 않은 직접 변경이다. 데이터 파일이나 예상하지 못한 FD를 잘못 지정하면 손상 위험이 있다. 원인 식별과 서비스 정상 재열기·재시작을 우선한다.

원인을 확인하지 않고 kill -9

SIGKILL은 프로세스가 정리 작업을 수행할 기회를 주지 않는다. 연결 종료, 버퍼 flush, 잠금 해제, 데이터베이스 일관성에 영향을 줄 수 있다. 어떤 서비스인지 확인하고 그 서비스의 정상 종료 경로를 사용한다.

서버 전체를 바로 재부팅하기

재부팅하면 열린 파일 디스크립터가 닫혀 공간이 돌아올 수 있지만, 원인 로그와 프로세스 상태가 사라지고 전체 서비스가 중단된다. 디스크가 완전히 고갈돼 다른 조치가 불가능한 비상 상황이 아니라면 PID와 서비스부터 기록한다.

재발 방지: 로그 회전과 보존 한도를 명시한다

Docker 공식 문서에 따르면 기본 json-file 로깅 드라이버는 로그 회전이 기본 적용되지 않아 출력이 많은 컨테이너에서 디스크 고갈로 이어질 수 있다. Docker는 일반적인 환경에서 자동 회전을 제공하는 local 드라이버를 권장한다.

Compose 서비스별로 보존 한도를 명시하는 예시는 다음과 같다.

services:
  app:
    image: your-image:<PINNED_TAG>
    logging:
      driver: local
      options:
        max-size: "20m"
        max-file: "5"

기존 json-file을 유지해야 한다면 회전 옵션을 명시한다.

services:
  app:
    image: your-image:<PINNED_TAG>
    logging:
      driver: json-file
      options:
        max-size: "10m"
        max-file: "3"

Docker 문서에서 log-opts 값은 문자열로 지정해야 하며, 데몬 기본값 변경은 기존 컨테이너에 자동 적용되지 않는다. Compose 설정을 바꿀 때도 최종 모델을 확인하고 필요한 컨테이너를 계획적으로 재생성한 뒤 실제 드라이버를 검증한다.

docker compose config
docker inspect \
  --format '{{.Name}} {{.HostConfig.LogConfig.Type}} {{json .HostConfig.LogConfig.Config}}' \
  <ACTUAL_CONTAINER>

systemd journal도 사용량과 보존 정책을 확인한다.

journalctl --disk-usage
grep -R --no-filename -E '^(SystemMaxUse|SystemKeepFree|MaxRetentionSec)=' \
  /etc/systemd/journald.conf /etc/systemd/journald.conf.d/*.conf 2>/dev/null

journalctl --vacuum-size=--vacuum-time=은 오래된 archived journal을 대상으로 정리한다. 운영 보존 요구와 장애 분석 기간을 먼저 정한 뒤 사용하며, 명령을 매번 수동으로 실행하는 것보다 journald.conf의 한도를 명시적으로 설계하는 편이 재발 방지에 가깝다.

운영 체크리스트

  • [ ] df -hTfindmnt -T로 문제 파일시스템을 특정했다.
  • [ ] df -i로 inode 고갈을 분리했다.
  • [ ] du -x로 다른 마운트를 제외하고 비교했다.
  • [ ] lsof -nP +L1 출력을 변경 전에 보존했다.
  • [ ] PID를 systemd 유닛 또는 Docker 컨테이너까지 역추적했다.
  • [ ] 열린 대상이 로그인지 데이터 파일인지 확인했다.
  • [ ] 점검창·백업·사용자 영향을 확인했다.
  • [ ] 애플리케이션 공식 로그 재열기 또는 정상 재시작 경로를 사용했다.
  • [ ] 조치 뒤 lsof, df, 서비스 상태, 최근 로그, 사용자 요청을 다시 확인했다.
  • [ ] Docker 또는 journald 로그 보존 한도를 설정했다.
  • [ ] /var/lib/docker, /proc/<PID>/fd, 볼륨 데이터를 직접 삭제하지 않았다.

마무리

파일을 삭제했는데 df가 줄지 않고 du에서는 파일이 보이지 않는다면, 더 많은 파일을 지우기 전에 열린 삭제 파일을 의심해야 한다. df는 파일시스템의 블록 상태를 보고하고, du는 현재 이름으로 찾아갈 수 있는 파일을 합산하기 때문에 두 결과가 달라질 수 있다.

안전한 해결 순서는 단순하다.

  1. 같은 파일시스템을 비교한다.
  2. lsof +L1로 열린 삭제 파일을 찾는다.
  3. PID를 서비스나 컨테이너까지 역추적한다.
  4. 원인과 영향 범위를 기록한다.
  5. 해당 서비스만 정상 절차로 파일을 다시 열거나 재시작한다.
  6. df, lsof, 서비스 상태와 실제 요청을 함께 재검증한다.

이 순서를 지키면 재부팅이나 광범위한 삭제로 증상을 가리는 대신, 왜 공간이 회수되지 않았는지 설명 가능한 상태로 복구할 수 있다.

공식 문서

태그

mLog

8년 이상 풀스택 개발자로 일하고 있습니다. Spring Boot, PostgreSQL, Redis, Vue·TypeScript와 Docker·Linux 서버를 다루며, 직접 운영하고 해결한 내용을 공식 문서와 실행 결과를 바탕으로 정리합니다.