Youngkwang YangEnglish

shutil.move와 os.replace의 차이

Python

이슈를 재현해보다가 재밌는 이슈를 발견했다. Pip에서 whl1 캐시 파일을 쓰는 도중에 디스크가 가득 차 코드가 중단됐지만, 불완전하게 쓰인 파일이 남아 캐시 파일로 참조되는 문제가 이슈로 올라왔다. pip#9964

pip는 whl 캐시를 만들 때, shutil.move를 사용하고 있는데, 위 이슈와 동일한 상황을 재현해보자.

# (1mb로 제한된 디스크, 다른 파일 시스템)
>>> import shutil, os
>>> os.path.getsize("distribution.whl")
10000000

# /mnt/small/ 1mb로 설정된 캐시 디스크에 10mb distribution.whl 파일을 옮긴다.
>>> shutil.move("distribution.whl", "/mnt/small/distribution.whl")
Traceback (most recent call last):
  ...
  File "/usr/local/lib/python3.12/shutil.py", line 867, in move
    copy_function(src, real_dst)
  File "/usr/local/lib/python3.12/shutil.py", line 475, in copy2
    copyfile(src, dst, follow_symlinks=follow_symlinks)
  ...
OSError: [Errno 28] No space left on device: 'distribution.whl' -> '/mnt/small/distribution.whl'

>>> os.path.getsize("/mnt/small/distribution.whl")
1048576

코드는 중단되었지만 1mb 사이즈의 파일이 남았다. 왜 파일이 정리되지 않았는지 확인해보자. (코드 참고: cpython - shutil.py)

shutil.move(src, dst)
└─ os.rename(src, dst)      # 시도
   ├─ 성공                   # 같은 파일 시스템, 원자적
   └─ OSError               # 다른 파일 시스템일 경우
      ├─ copy2(src, dst)    # 여기서 I/O 도중 중단되면 dst에 불완전한 파일이 남는다
      └─ os.unlink(src)

shutil.moveos.rename을 먼저 호출하고, 실패하면 copy2로 폴백한다. copy2가 복사하는 도중에 프로세스 종료나 디스크 부족으로 멈추면 불완전한 파일이 남는다. 이 파일을 다른 프로세스가 동시에 읽는다면, 복사가 끝나기 전에 불완전한 파일을 캐시로 인식할 수도 있다.

그럼 불완전한 파일을 남기지 않으려면 어떻게 해야 할까? 먼저 os.replace의 동작을 확인해보자.

>>> os.replace("distribution.whl", "/mnt/small/distribution.whl")
Traceback (most recent call last):
  ...
OSError: [Errno 18] Invalid cross-device link: 'distribution.whl' -> '/mnt/small/distribution.whl'
>>> os.listdir("/mnt/small")
[]

os.replace 호출은 실패했지만 /mnt/small에는 아무 파일도 남지 않았다. os.replace는 같은 파일 시스템 안에서는 원자적으로 교체하고2, 파일 시스템이 다르면 복사로 폴백하지 않고 예외를 발생시킨다.

shutil.move는 파일 시스템이 달라도 복사를 통해 이동을 계속하지만, os.replace는 원자적으로 교체할 수 없으면 오류를 낸다. 파일 시스템을 넘어 이동해야 하면 shutil.move를 쓰고, 같은 파일 시스템에서 최종 경로를 원자적으로 바꿔야 하면 os.replace를 쓴다.

shutil.moveos.replace
같은 파일 시스템os.rename으로 원자적 이동원자적 교체
다른 파일 시스템복사 후 원본 삭제EXDEV로 실패
실패했을 때 목적지복사 도중 실패하면 불완전한 파일이 남을 수 있음교체 전 상태가 유지됨

파일 시스템이 다른 상황에서 불완전한 파일을 남기지 않으려면, 목적지에 임시 이름으로 먼저 복사하고, 복사가 끝나면 os.replace로 최종 이름으로 바꾼 뒤 원본 파일을 지우면 된다.

from tempfile import TemporaryDirectory

with TemporaryDirectory(dir="/mnt/small") as temp_dir:
    tmp = os.path.join(temp_dir, "distribution.whl")
    shutil.copy2("distribution.whl", tmp)
    os.replace(tmp, "/mnt/small/distribution.whl")

Footnotes

  1. Wheel은 .whl 확장자를 사용하는 ZIP 기반의 Python 패키지 배포 형식이다. 복사 중간에 파일이 잘리면 정상적인 Wheel로 읽을 수 없다. Wheel 명세

  2. os.replace로 파일을 교체하면 다른 프로세스는 절반만 바뀐 파일을 읽는 일 없이, 기존 파일이나 새 파일 중 하나만 읽게 된다. os.replace 문서