В скрипте обновления конфига обычно проверяют две вещи: команда завершилась без ошибки и по прежнему пути читается новый текст. Этого мало, если файл уже открыт другим процессом, на него есть жёсткая ссылка или его права нужно сохранить.
Я сравнил несколько способов обновить маленький файл: перезапись на месте, cp, sed -i, install и замену через временный файл. Это синтетический лабораторный опыт на Linux. Проверяемое поведение описано в документации; цель опыта — сравнить последствия нескольких команд в одинаковых условиях.
Стенд
Python 3.15.0, GNU sed 4.9 и GNU coreutils 9.7 в контейнере, файловая система overlayfs. Запуск от обычного пользователя (UID 1002), umask 022. Каждый вариант работал в отдельном каталоге. Исходный файл config имел права 0640 и дополнительную жёсткую ссылку, а один читатель заранее держал его открытым. Сначала выполнялось обновление, и только после его завершения читатель начинал читать. Одновременного чтения и записи в опыте не было.
Что получилось
Способ обновления |
Права после |
inode прежний |
По исходному пути |
Уже открытый дескриптор |
Жёсткая ссылка |
|---|---|---|---|---|---|
|
|
да |
новый |
новый |
новый |
|
|
да |
новый |
новый |
новый |
|
|
нет |
новый |
старый |
старый |
|
|
нет |
новый |
старый |
старый |
|
|
нет |
новый |
старый |
старый |
|
|
нет |
новый |
старый |
старый |
|
|
нет |
новый |
старый |
старый |
|
|
нет |
новый |
старый |
старый |
Таблица делится на две группы. Если inode остаётся прежним, как при записи на месте или обычном cp, новые данные после окончания записи видят все: путь, открытый дескриптор и жёсткая ссылка. Если под именем появляется новый файл (sed -i, install, cp --remove-destination, os.replace), новое видно только по пути. Открытый дескриптор и жёсткая ссылка остаются на прежнем inode со старым текстом.
Механизм документирован. GNU sed для -i пишет результат во временный файл, который потом занимает место исходного. В описании rename(2) сказано, что открытые дескрипторы переименование не затрагивает. sed -i при этом сохранил 0640, поэтому проверка одних прав через stat не показывает, какую версию прочитает процесс с открытым файлом.
Права зависят от того, как создан новый файл. Обычный cp писал в уже существующий файл и сохранил его 0640. С --remove-destination файл создаётся заново и в этом стенде получил 0644. Текст по пути одинаковый, поэтому команды легко принять за взаимозаменяемые, хотя это не так.
Символьные ссылки
Отдельно проверил случай, когда config является символьной ссылкой на настоящий файл:
Способ |
Осталась ссылкой |
Чтение через путь |
Чтение файла-цели |
|---|---|---|---|
запись на месте |
да |
новый |
новый |
|
нет |
новый |
старый |
os.replace заменяет саму ссылку обычным файлом, а файл, на который она указывала, остаётся со старым содержимым.
Короткий repro
Расхождение прав временного файла воспроизводится стандартной библиотекой Python. Для запуска нужен отдельный временный каталог:
import os import stat import tempfile from pathlib import Path os.umask(0o022) with tempfile.TemporaryDirectory() as directory: path = Path(directory) / 'config' path.write_text('old\n') path.chmod(0o640) with path.open() as reader: fd, name = tempfile.mkstemp(dir=directory) with os.fdopen(fd, 'w') as writer: writer.write('new\n') os.replace(name, path) print(oct(stat.S_IMODE(path.stat().st_mode))) print(path.read_text().strip()) print(reader.read().strip())
Программа печатает 0o600, new, old. По документации tempfile файл от mkstemp доступен на чтение и запись только создателю. Для временных файлов это полезно, но после замены конфига с 0640 группа теряет право на чтение. Временный файл, созданный обычным open(..., 'w'), при umask 022 получает 0644, то есть доступ расширяется. В варианте с сохранением режима я перед заменой вызвал os.fchmod(fd, 0o640), и нужные биты остались.
Что проверять в скрипте обновления
Сначала решите, что именно должно сохраниться. Если потребители каждый раз открывают файл по имени, замена через временный файл подходит, но режим временного файла нужно задать до замены, а после неё проверить результат.
Если потребитель держит дескриптор открытым, замена файла по имени новую версию ему не покажет. Нужен механизм приложения: повторное открытие или перезагрузка конфигурации. Жёсткие ссылки после замены сохранят прежний inode. Если заменяемое имя было символьной ссылкой, она исчезнет, а файл-цель останется прежним.
Из таблицы не следует, что обычный cp или запись на месте безопаснее. Опыт не проверял, что увидит читатель во время перезаписи.
Ограничения
Одна машина, overlayfs, обычный пользователь,
umask 022, маленький файл.Не проверялись одновременное чтение во время записи, отключение питания и сбои, владелец файла, ACL, расширенные атрибуты, SELinux и сетевые файловые системы.
fchmodиз примера сохраняет только биты режима. Это не готовый рецепт для production: остальные свойства файла и устойчивость к сбоям для реального инструмента нужно проверять отдельно.Атомарная замена имени не означает, что сохраняются все свойства файла, и не гарантирует сохранность данных после сбоя питания.
Комментарии (2)

tech_post_dev
25.10.2026 13:44На практике эта таблица чаще всего всплывает с bind-mount одного файла в Docker. Монтируешь -v ./nginx.conf:/etc/nginx/nginx.conf, правишь на хосте через sed -i или редактором, который сохраняет через временный файл, и контейнер продолжает видеть старый конфиг: монтирование держится за inode, а не за имя. nginx -s reload при этом честно перечитывает старую версию. Помогает монтировать каталог, а не файл.
Та же развилка у logrotate. copytruncate относится к вашей первой группе: inode прежний, процесс пишет дальше в тот же файл, но строки между копированием и обрезкой можно потерять. create относится ко второй, и без postrotate с сигналом на переоткрытие процесс так и пишет в переименованный лог.
Про символьные ссылки: у GNU sed есть --follow-symlinks, с ним sed -i правит файл-цель, а саму ссылку оставляет. А vim вы не проверяли? У него backupcopy=auto смотрит на жёсткие и символьные ссылки и в таких случаях пишет на месте. Интересно, совпадёт ли это с таблицей на overlayfs.
AnnSerba
Про сохранность после сбоя питания автор честно написал, что не проверял: это как раз то место, где os.replace перестаёт быть атомарным. Чтобы замена пережила обрыв, нужны два fsync — на записанный временный файл до rename и на каталог после. Иначе после краха можно увидеть старое содержимое по новому имени, а на старых ext4 ловили и пустой целевой файл. И оговорка про EXDEV: os.replace атомарен только в пределах одной ФС, поэтому временный файл надо создавать в том же каталоге, что и целевой, иначе перенос из /tmp упадёт.