Обновили файл, а программа читает старое: что происходит при замене в Linux
Скрипт обновления конфигурации часто считают успешным, если команда завершилась без ошибки, а при повторном чтении по тому же пути обнаруживается новый текст. Но этого недостаточно: другой процесс мог открыть файл заранее, рядом может находиться жёсткая ссылка, а новый файл способен получить другие права доступа.
Чтобы понять, как заменить файл в Linux и какие последствия дают разные способы, я сравнил перезапись на месте, `cp`, `sed -i`, `install` и замену через временный файл с помощью Python. Результаты эксперимента и описание сценария собраны в материале о том, [как работает замена файла в Linux](https://habr.com/ru/articles/1099016/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1099016).
Проверка проводилась в контейнере с Python 3.15.0, GNU sed 4.9 и GNU coreutils 9.7 на файловой системе overlayfs. Команды запускались от обычного пользователя с UID 1002 и маской `umask 022`. В каждом отдельном каталоге находился файл `config` с правами `0640`, его жёсткая ссылка и процесс-читатель, который заранее открыл файл. Чтение начиналось после завершения обновления: одновременной записи и чтения в этом тесте не было.
Что меняется при разных способах обновления
Если программа открывает существующий файл для записи и меняет его содержимое, inode сохраняется. Поэтому новый текст становится виден и при чтении по пути, и через уже открытый дескриптор, и через жёсткую ссылку. При этом такая запись сама по себе не гарантирует, что читатель не столкнётся с частично обновлённым содержимым во время операции.
Похожий результат показал обычный `cp` в существующий файл: данные обновились, но объект файловой системы остался прежним. Права `0640` также сохранились. Иначе ведут себя команды и методы, создающие новый файл и подменяющие им прежний. В тесте так сработали `sed -i`, `install`, `cp --remove-destination` и `os.replace`: новое содержимое появилось по исходному имени, однако открытый ранее дескриптор и жёсткая ссылка продолжили указывать на старый inode.
Именно поэтому атомарная замена файла Linux не означает, что все процессы мгновенно начнут читать новую версию. Переименование меняет соответствие имени файлу, но не переключает уже открытые дескрипторы на другой объект. Если приложение держит конфиг открытым, ему понадобится повторно открыть файл или отдельно обработать сигнал о перезагрузке настроек.
`sed -i` в GNU sed обновляет файл через временный файл, который затем занимает место исходного. В проведённом опыте права `0640` сохранились, однако совпадение режима доступа не означает, что процесс с ранее открытым файлом прочитает новую версию. Обычный `cp` и `cp --remove-destination` тоже не взаимозаменяемы: второй вариант создаёт файл заново, и в данном окружении он получил права `0644`.
Временный файл и права доступа
При реализации замены через Python важно учитывать, как создаётся временный файл. Файл, полученный через `tempfile.mkstemp`, по умолчанию доступен для чтения и записи только создавшему его пользователю. Если сразу передать его в `os.replace`, итоговый конфиг в тесте получил права `0600`, и группа лишилась чтения.
Другой вариант - создать временный файл через `open(..., 'w')`. С учётом `umask 022` он в этом примере получил бы права `0644`, то есть доступ мог бы стать шире, чем у исходного конфига. Чтобы сохранить режим `0640`, перед заменой дескриптор временного файла был настроен вызовом `os.fchmod(fd, 0o640)`. Функция `os.replace` Python после этого подменила имя исходного файла подготовленным временным объектом.
На вопрос, как обновить конфигурационный файл Linux, нет универсального ответа: сначала нужно определить, что именно обязано сохраниться. Если потребители каждый раз открывают файл по имени, схема "создать временную копию, выставить нужные свойства, затем заменить" помогает избежать промежутка, когда по пути нет готового файла. Однако режим доступа следует задавать до замены, а итог проверять отдельно.
Символические ссылки и ограничения
Если `config` - символическая ссылка, поведение замены отличается от записи в целевой файл. Перезапись содержимого через путь меняет файл, на который указывает ссылка, поэтому сама ссылка остаётся на месте. А `os.replace` заменяет объект по имени `config`: в итоге вместо символической ссылки появляется обычный файл, а прежняя цель сохраняет старое содержимое. Это важно учитывать, если конфигурация развёрнута через ссылки на версии файла.
Эксперимент не доказывает, что запись на месте безопаснее атомарной подмены. В нём не проверялось чтение непосредственно во время записи, а также не моделировались отключение питания и аварийное завершение процесса. Не тестировались владелец файла, ACL, расширенные атрибуты, SELinux и сетевые файловые системы. Вызов `fchmod` сохраняет только биты режима и не переносит автоматически остальные свойства исходного объекта.
Для более надёжного обновления обычно готовят файл в том же каталоге, что и целевой: это помогает избежать проблем с заменой между разными файловыми системами. Затем записывают данные, выставляют требуемые права и только после этого выполняют замену имени. Если критична сохранность после сбоя питания, одной подмены имени недостаточно: отдельно продумывают сброс данных на диск и обработку ошибок.
Итоговый выбор зависит от читателей. Перезапись на месте сохраняет inode, поэтому изменения затрагивают открытые дескрипторы и жёсткие ссылки, но не исключает чтение промежуточных данных. Замена временным файлом даёт новую версию по пути, однако старые дескрипторы и жёсткие ссылки остаются связаны с прежним содержимым. Перед тем как применять `sed -i`, `cp` или Python-скрипт, стоит проверить режим доступа, тип ссылки и способ, которым потребитель открывает конфигурацию. Подробное сравнение этих сценариев приведено в разборе [замены файла через cp, sed и Python](https://habr.com/ru/articles/1099016/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1099016).


