Обновил файл, а читатель видит старое: сравнение cp, sed -i и os.replace

В скрипте обновления конфига обычно проверяют две вещи: команда завершилась без ошибки и по прежнему пути читается новый текст. Этого мало, если файл уже открыт другим процессом, на него есть жёсткая ссылка или его права нужно сохранить.
Я сравнил несколько способов обновить маленький файл: перезапись на месте, 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: остальные свойства файла и устойчивость к сбоям для реального инструмента нужно проверять отдельно.Атомарная замена имени не означает, что сохраняются все свойства файла, и не гарантирует сохранность данных после сбоя питания.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.