MAATRIX / Блог / Почему переименование файла атомарно, а копирование — нет

Почему переименование файла атомарно, а копирование — нет

MAATRIX

Каждый деплой конфига через mv new.conf old.conf работает не потому, что так принято, а потому что у этой команды есть конкретная гарантия на уровне файловой системы. А cp new.conf old.conf такой гарантии не даёт — и если сервер уйдёт в перезагрузку или процесс упадёт ровно в неудачный момент, разница окажется не теоретической, а в виде битого nginx.conf на проде в четыре утра.

Что физически происходит при переименовании

Файл в файловых системах вроде ext4 или XFS — это не «кусок диска с именем», а две отдельные вещи. Есть inode — структура с метаданными (владелец, права, размер, список блоков данных на диске) и номером. И есть запись в каталоге — просто пара «имя → номер inode». Сам файл, его содержимое, при этом не имеет никакого «настоящего» имени — имя существует только в каталоге, который на него ссылается.

Когда вы вызываете rename("a.txt", "b.txt") в пределах одной файловой системы, ядро не трогает содержимое файла вообще. Оно берёт запись каталога a.txt → inode 12345 и переписывает её в b.txt → inode 12345. Если b.txt уже существовал и указывал на какой-то другой inode — старая запись b.txt атомарно заменяется новой, а старый inode (если на него больше никто не ссылается) освобождается отдельно, уже после того, как переименование зафиксировано.

Ключевое здесь — «одна операция». В самой файловой системе rename реализован как единственная транзакция, защищённая журналом (об этом — в статье про журнал файловой системы): либо каталог целиком отражает новое состояние, либо целиком старое. Промежуточного состояния «наполовину переименовано» не существует — ядро физически не может его показать читателю, потому что операция обновления каталога либо попала в журнал и применилась, либо нет.

Стандарт POSIX прямо требует этого поведения: rename() должен быть атомарным относительно других процессов, читающих тот же путь. Это не деталь реализации конкретной ФС, а контракт, на который можно полагаться в любом дистрибутиве с ext4, XFS, Btrfs или ZFS — до тех пор, пока источник и назначение лежат на одном и том же смонтированном разделе.

Почему копирование — это последовательность, а не операция

cp a.txt b.txt (или запись в b.txt из вашего скрипта) выглядит как одна команда в терминале, но на уровне системных вызовов это цепочка независимых шагов:

open("b.txt", O_CREAT|O_WRONLY)
read(a.txt, buf, 4096)
write(b.txt, buf, 4096)
read(a.txt, buf, 4096)
write(b.txt, buf, 4096)
...
close(b.txt)

Каждый read/write — отдельный вызов, между которыми проходит время. Файл размером в десятки мегабайт может копироваться сотнями или тысячами таких итераций. Всё это время файл b.txt уже существует в каталоге и уже виден любому процессу, который его откроет — просто в нём лежит не весь контент, а столько, сколько успело записаться на момент чтения.

Если в середине этой последовательности происходит: обрыв питания, kill -9 процесса, падение диска, OOM killer убивает копирующий процесс, — вы получаете файл b.txt, который существует, у него правильное имя и права, но данные обрываются на случайном байте. Файловая система не считает это ошибкой: с её точки зрения запись прошла успешно ровно настолько, насколько успела пройти. Журналирование в ext4/XFS защищает метаданные и структуру ФС от повреждения, но не гарантирует, что *содержимое* вашего файла окажется цельным — если вам нужно освежить это различие, там же в статье про журнал разобрано, что именно журнал спасает, а что нет.

Важно: сам по себе cp — не «плохая» команда, проблема не в ней, а в том, что copy принципиально не умеет быть одной операцией, потому что копирует данные, а не просто перевешивает указатель. rename() дёшев именно потому, что не трогает данные вообще — он работает с метаданными каталога, это операция порядка микросекунд независимо от размера файла. Copy стоит времени, пропорционального размеру файла, потому что физически перегоняет байты.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Кто видит файл во время операции — и что именно видит

Разница между rename и copy становится ощутимой именно в конкурентном сценарии — когда файл читает другой процесс, пока первый его меняет.

При rename на одной ФС: читатель, открывший файл по старому имени *до* переименования, продолжает читать старый inode — Linux не отзывает уже открытые файловые дескрипторы, они держат ссылку на inode, а не на имя. Читатель, который открывает файл по новому имени *после* того, как rename зафиксирован, увидит новый, полностью записанный inode. Промежуточного «увидел половину» тут нет вообще — потому что операция обновления каталога либо ещё не случилась (виден старый файл), либо уже случилась целиком (виден новый).

При copy в целевой файл: если читатель открывает b.txt в процессе его записи (файл уже создан open(O_CREAT), но write ещё не закончены), он увидит частично записанные данные — ровно то, что физически успело попасть на диск к моменту чтения. Для текстового конфига это может быть, например, файл, оборванный посреди директивы nginx — синтаксически битый. Для JSON — незакрытая скобка. Для бинарника — что угодно, вплоть до сегфолта у процесса, который его загружает.

Отдельно стоит проговорить: rename атомарен ровно в пределах одной файловой системы (одного смонтированного раздела). Если исходный и целевой путь находятся на разных ФС — например, /tmp смонтирован как tmpfs, а /etc на корневом разделе — ядро не может просто переставить указатель inode между двумя разными файловыми системами, потому что у каждой ФС свои структуры метаданных. В этом случае mv под капотом делает copy + delete, и все гарантии атомарности исчезают. Это частая скрытая грабля: скрипт деплоя годами работал на одном разделе, а после реорганизации дисков (вынесли /tmp на отдельный tmpfs) mv незаметно перестал быть атомарным.

Паттерн atomic write: пишем во временный файл, потом переименовываем

Из разницы между rename и copy напрямую следует классический безопасный способ обновить файл, который кто-то может читать в любой момент — включая сам процесс, который его перезапускает. Паттерн простой:

  1. Создать временный файл в той же директории (важно — в той же файловой системе), например config.json.tmp.$$.
  2. Записать в него полное новое содержимое.
  3. Вызвать fsync() на файловый дескриптор, чтобы данные реально ушли на диск, а не повисли в кеше страниц ядра — иначе fsync может отчитаться об успехе раньше, чем данные реально легли на носитель, если диск сам кеширует запись «оптимистично».
  4. Вызвать rename(tmp, target) — атомарно подменить целевой файл новым.
# bash-вариант паттерна для конфига
cat > /etc/app/config.json.tmp <<EOF
{ "version": 2, "debug": false }
EOF
sync -- /etc/app/config.json.tmp   # упрощённо; в реальном коде — fsync() на дескриптор
mv /etc/app/config.json.tmp /etc/app/config.json
# python-вариант с явным fsync перед rename
import os, tempfile

def atomic_write(path: str, data: bytes) -> None:
    directory = os.path.dirname(path) or "."
    fd, tmp_path = tempfile.mkstemp(dir=directory)
    try:
        with os.fdopen(fd, "wb") as f:
            f.write(data)
            f.flush()
            os.fsync(f.fileno())
        os.rename(tmp_path, path)  # атомарная подмена
    except Exception:
        os.unlink(tmp_path)
        raise

Результат этого паттерна: любой процесс, который в произвольный момент откроет config.json, увидит либо старую полную версию, либо новую полную версию — но никогда середину. Даже если сервер перезагрузится ровно между шагом 3 и шагом 4, вы потеряете только временный файл — целевой останется в исходном, полностью рабочем состоянии, потому что rename ещё не случился.

Это тот же принцип, на котором держится журналирование самой файловой системы, только применённый на уровне приложения: вместо изменения файла «на месте» вы готовите полную новую версию отдельно и подменяете её одной неделимой операцией.

Где паттерн ломается, если сделать его неаккуратно

Паттерн простой, но у него есть условия, без которых атомарность превращается в иллюзию.

Временный файл должен быть в той же ФС, что и целевой. Если писать .tmp в /tmp (часто отдельный tmpfs), а переименовывать в /etc, rename() вернёт EXDEV («invalid cross-device link») — либо упадёт с ошибкой, либо (если вы используете высокоуровневую обёртку вроде shutil.move в Python) молча откатится на copy+delete, теряя все гарантии. Решение — всегда создавать временный файл рядом с целевым: /etc/app/config.json.tmp, а не /tmp/config.json.tmp.

fsync нужен и на файл, и иногда на директорию. rename() гарантирует атомарность видимости, но не гарантирует, что содержимое временного файла уже физически на диске к моменту переименования — если пропустить fsync(), при обрыве питания можно получить целевой файл с нулевой длиной или мусором. Для строгих гарантий (например, в БД) дополнительно делают fsync() на дескриптор родительской директории после rename — иначе сама запись каталога может не пережить сбой на некоторых конфигурациях.

Права и владелец временного файла — не те, что у целевого. mkstemp() создаёт файл с правами 0600 и текущим владельцем процесса. Если целевой файл должен принадлежать www-data и иметь 0644, после rename он унаследует права временного файла, а не старые права цели — это частая причина, почему после «безопасного» атомарного деплоя конфига сервис вдруг не может его прочитать. Нужно явно выставлять chmod/chown на временный файл до rename.

Симлинки — отдельный случай. Если целевой путь — символическая ссылка, rename() подменит саму ссылку (это тоже атомарно), а не файл, на который она указывает. Иногда это ровно то, что нужно (атомарное переключение «текущей» версии релиза через симлинк — паттерн, знакомый по Capistrano и подобным деплой-тулам), но если ожидали обновить содержимое цели, а не саму ссылку, результат удивит.

На сетевых ФС гарантии слабее. NFS, особенно старых версий, не всегда обеспечивает ту же строгую атомарность rename, что локальная ext4/XFS — при параллельной записи с двух клиентов возможны собственные грабли, отдельные от темы этой статьи. Если конфиги лежат на NFS-шаре, паттерн atomic write стоит проверять отдельно, а не переносить гарантии локальной ФС автоматически.

Практика: деплой конфигов и обновление файлов на сервере

На практике этот механизм — не абстракция, а конкретная причина, почему одни деплой-скрипты переживают обрыв соединения посреди выката, а другие оставляют сервис с половиной конфига.

Типичные места, где стоит сознательно применять atomic write:

  • Конфиги веб-серверов и приложений. nginx, systemd unit-файлы, конфиги приложений — при деплое пишите во временный файл рядом и переименовывайте, а не редактируйте на месте построчно (sed -i без --follow-symlinks тоже, кстати, под капотом создаёт временный файл и делает rename — это не случайно).
  • TLS-сертификаты. Обновление сертификата «на месте» в момент, когда nginx или другой процесс делает SSL_CTX reload, — классический сценарий гонки. Certbot и большинство ACME-клиентов сами используют atomic write для этой причины.
  • Файлы состояния и lock-файлы. Любой файл, который читают параллельно с тем, как его пишут — счётчики, кеши на диске, состояние очереди — выигрывает от того же паттерна.
  • Скрипты автоматического обновления (CI/CD, gitops-пайплайны). Если раскладка конфигов идёт через Ansible/gitops-подход, стоит убедиться, что используемый модуль сам гарантирует атомарность записи (большинство современных — ansible.builtin.copy, например, — делают именно так под капотом), а не полагаться на «повезёт, если процесс не упадёт посередине». Подробнее о том, как выстроить такой конвейер для конфигов сервера, — в статье про CI/CD для конфигов через gitops-подход.

Отдельно стоит упомянуть частую практическую ошибку: редактирование прод-конфига прямо в редакторе через SSH (vim /etc/nginx/nginx.conf, сохранение, потом systemctl reload). Большинство редакторов по умолчанию тоже пишут во временный файл и делают rename при сохранении — это тот же паттерн, — но если редактор настроен на запись «на месте» (некоторые режимы vim с backupcopy=yes) или файл открыт несколькими процессами одновременно, гарантия исчезает. Явное использование atomic write в скрипте деплоя убирает зависимость от настроек редактора конкретного инженера. Разбор похожего класса проблем — когда конфиг физически цел, но применение всё равно не срабатывает — есть в статье о том, почему nginx не видит изменения конфига.

Ещё один нюанс, который стоит проговорить честно: atomic write защищает от «половины файла», но не защищает от логической ошибки в новом содержимом. Если вы атомарно подменили nginx.conf на синтаксически битый (но полностью записанный) файл, rename пройдёт успешно, а nginx -t или systemctl reload всё равно упадёт — просто по другой причине, не связанной с обрывом записи. Поэтому в реальных пайплайнах atomic write комбинируют с проверкой (nginx -t перед reload) и возможностью отката — сама по себе атомарность переименования решает только один класс проблем, не все.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Работает ли mv в Linux всегда как атомарный rename?

Только если источник и назначение на одной смонтированной файловой системе. Если пути на разных разделах или один из них — сетевой ресурс, mv откатывается на copy + delete, и атомарность теряется. Проверить легко: stat -c %d /path/to/source и stat -c %d /path/to/target-dir — если номер устройства совпадает, вы на одной ФС.

Даёт ли rename() гарантию, что данные физически на диске?

Нет, сама по себе — нет. rename гарантирует атомарность *видимости* новой версии другим процессам, но чтобы данные пережили обрыв питания, перед rename нужен явный fsync() на записанный временный файл (а для совсем строгих гарантий — ещё и на родительскую директорию после rename).

Можно ли использовать этот же паттерн для больших файлов — образов дисков, бэкапов на десятки гигабайт?

Технически да, но подумайте о цене: временная копия занимает дополнительное место на диске на время операции, а сама запись линейна по размеру. Для очень больших файлов имеет смысл заранее оценить, хватит ли свободного места под двойной объём на время операции, прежде чем встраивать паттерн в скрипт бэкапа.

Что если целевой файл открыт другим процессом в момент rename — это опасно?

Нет, это как раз безопасный случай. Процесс, уже открывший файл по старому пути, продолжит читать старое содержимое через удерживаемый файловый дескриптор (в Unix открытый fd держит inode, а не имя), пока сам не переоткроет файл. Именно поэтому демоны вроде nginx требуют явного reload, а не подхватывают новый конфиг «сам по себе» — им нужно заново открыть файл по пути, чтобы увидеть новую версию.

А как насчёт rename() для директорий, а не файлов?

Тот же принцип атомарности действует и для каталогов, если оба пути на одной ФС и целевой каталог либо не существует, либо пуст. Это то, на чём держатся паттерны вроде «собрать новый релиз в release.tmp/, потом одним rename подменить current/» — вариант того же приёма на уровне целого дерева файлов, а не одного файла.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →