Если от вашей системы требуется надёжность, отказоустойчивость и детерминированность, знания системных механизмов — не роскошь, а необходимость.

Работа с файлами в Python кажется простой — open, read, write. Но на практике, особенно в системах с высокими требованиями к отказоустойчивости, стабильности и логированию, за банальными строками кода может скрываться целый мир проблем.

Сегодня разберём, как знание внутренностей Linux может помочь избежать потерь данных и облегчить отладку. Все примеры будут на Python, но применимы к любым языкам, работающим через POSIX-интерфейсы.

1. Буферизация: write() — не значит «записал»

Начнём с простого:

with open("log.txt", "a") as f:
    f.write("Hello\n")

На первый взгляд — всё хорошо. Но этот код не гарантирует, что строка реально ушла на диск. Почему?

Этапы прохождения данных:

  1. Буфер Python (f.write сохраняет строку в user-space).

  2. Вызов flush() передаёт буфер в ядро ОС.

  3. Вызов fsync() / fdatasync() просит ОС сбросить буфер ядра на диск.

Чтобы реально гарантировать сохранение:

with open("log.txt", "a") as f:
    f.write("Hello\n")
    f.flush()
    os.fsync(f.fileno())

Но и это не всегда помогает.

Если на сервере включено write caching на уровне диска/SSD, то fsync() может завершиться успешно, а данные — остаться в кэше контроллера и быть утеряны при сбое питания.

Решение:

  • Использование устройств с power-loss protection.

  • Отключение write cache (hdparm -W 0 /dev/sdX).

  • Использование O_DIRECT или O_SYNC, если готовы пожертвовать производительностью.

Также важно отметить, что O_DIRECT/O_SYNC и flush()/fsync()/fdatasync() ухудшают производительность, и иногда нужно искать баланс между скоростью и надёжностью.

  • fsync()/fdatasync()
    Явный вызов для сброса данных из кэша ядра на диск. fsync(): гарантирует запись данных + метаданных (размер, время модификации). fdatasync(): записывает только данные (если метаданные не критичны).

  • O_SYNC
    Каждый write() ждёт физической записи на диск перед возвратом управления. Гарантирует сохранение данных + метаданных для каждой операции.
    Пример:

    fd = os.open("file.txt", os.O_WRONLY | os.O_SYNC)
    os.write(fd, b"data")  # Блокирует выполнение до записи на диск.
    os.close(fd)
  • O_DIRECT
    Данные пишутся напрямую в устройство, минуя кэш ядра.
    Не гарантирует, что данные попали на физический диск (могут остаться в кэше контроллера). Требует выровненных буферов (адрес памяти, размер блока, смещение в файле кратны 512 байт или 4 KiB).
    Для надёжности после записи всё равно нужен fsync().
    Пример:

    # В Python сложно из-за требований к выравниванию:
    buf = bytearray(4096)  # Выровненный буфер
    buf[:4] = b"data"
    fd = os.open("file.txt", os.O_WRONLY | os.O_DIRECT)
    os.write(fd, buf)
    os.fsync(fd)  # Обязательно!
    os.close(fd)

2. Кто мой враг: логротация и потеря дескриптора

Представим, у вас работает процесс, логирующий в файл:

with open("service.log", "a") as log:
    while True:
        log.write("ping\n")
        log.flush()
        os.fsync(log.fileno())
        time.sleep(1)

Администратор сервера запускает logrotate. Что произойдёт?

  • Старый service.log переименуется.

  • Новый пустой service.log создастся.

  • Процесс продолжает писать в старый (переименованный) файл, потому что дескриптор остался тем же!

Как отследить это?

  • Проверяйте os.stat() и os.fstat() — если inode у файла изменился, лог был заменён.

  • Или используйте watchdog/inotify для мониторинга.

3. Проблема с O_APPEND и конкурентной записью

В многопоточной среде или при нескольких процессах, пишущих в файл, может возникнуть путаница, если не использовать O_APPEND. Режим 'a' в open() автоматически включает O_APPEND.

with open("data.txt", "a") as f:
    f.write("chunk\n")

Но что если вы используете низкоуровневый API (os.open) и забыли os.O_APPEND?

fd = os.open("data.txt", os.O_WRONLY)
os.lseek(fd, 0, os.SEEK_END)
os.write(fd, b"chunk\n")

В многопроцессной среде вызовы lseek и write могут быть неатомарными, и два процесса могут перезаписать друг друга.

Решение:

  • Использовать O_APPEND, чтобы смещение определялось ядром атомарно.

  • Или синхронизировать доступ к файлу (lock-файлы, fcntl.flock).

4. Пропущенные данные при падении процесса

Сценарий:

  • Вы пишете логи.

  • Используете with open(...) и flush/fsync/fdatasync.

  • Но процесс аварийно завершился до закрытия файла.

Проблема: если сбой произошёл до flush() или fsync() — вы теряете строку.

Решение:

  • Оборачивайте запись в лог с try/finally:

f = open("log.txt", "a")
try:
    f.write("data\n")
    f.flush()
    os.fsync(f.fileno())
finally:
    f.close()
  • Или используйте atexit.register() и signal-хендлеры, чтобы корректно завершать процесс.

5. Watchdog и inotify — не панацея

Многие пытаются отслеживать изменения файлов через inotify, но забывают об ограничениях:

  • inotify не работает по сети (NFS).

  • Число слотов ограничено (/proc/sys/fs/inotify/max_user_watches).

  • Некоторые события (например, fsync) не отслеживаются вообще.

В сложных случаях лучше использовать периодическое сравнение mtime/inode/size.

6. Отладка: когда strace спасает

Если данные иногда не пишутся, а код вроде правильный, запускайте процесс под strace:

strace -e trace=write,fsync,open,close -f -tt -o trace.log python myscript.py

Что искать:

  • Есть ли write вообще?

  • Что происходит между write и fsync?

  • Были ли ошибки EIO, ENOSPC и т.п.?

7. Файлы в tmpfs или /dev/shm — быстро, но опасно

Иногда данные записываются в файл, а на диск не попадают, потому что файл был в памяти (например, /tmp на tmpfs).

Проверьте монтирование:

mount | grep /tmp

Решение:

  • Использовать tmp только для кэша.

  • Критичные файлы писать в реальную FS.

Вывод

Использование O_SYNCO_DIRECTfsync() всегда снижает производительность. Важно выбирать метод, исходя из требований к данным:

  • Для журналов транзакций — fsync() после каждой записи.

  • Для временных данных — буферизация без fsync().

Большинство разработчиков живут в мире, где:

  • Один поток пишет в файл.

  • Ошибки редки.

  • Утилиты работают «как всегда».

Но как только вы заходите в зону высоких требований (финансы, аудит, логирование, отказоустойчивость), любые мелочи начинают "стрелять". Там важен каждый байт, каждый системный вызов и каждый дескриптор.

Чем глубже ваше понимание системы, тем легче вам и вашим пользователям.
А значит, вы — тот, кого сложно заменить.

Комментарии (11)


  1. vadimr
    23.05.2025 07:45

    Вообще держать лог открытым – плохая практика. Открыл – дописал строчку – закрыл.


    1. baldr
      23.05.2025 07:45

      Не соглашусь с вами. Открытие-закрытие - это накладные расходы. Писать мы можем часто - 100 строк в секунду или даже чаще, из разных потоков.

      А ещё это может быть не просто текстовый лог, а, например, WAL-файл базы данных, который всё время открыт.


      1. vadimr
        23.05.2025 07:45

        Если вы пишете телеметрию 100 раз в секунду, то для этого лучше воспользоваться каким-нибудь специализированным средством, а не текстовым логом. Текстовый лог такого объёма не имеет практического смысла.

        На мой взгляд, текстовый лог осмыслен тогда, когда его можно читать с консоли в реальном времени.


        1. baldr
          23.05.2025 07:45

          Снова не соглашусь с вами. И nginx тоже не согласится.


          1. vadimr
            23.05.2025 07:45

            По счастью, я далёк от веба, но если вы дисковый кеш будете синхронизировать 100 раз в секунду, то об эффективности вы можете забыть гораздо быстрее, чем при простом переоткрытии файла.


            1. baldr
              23.05.2025 07:45

              Я возражал, в первую очередь, на ваши замечания о целесообразности таких логов и необходимости постоянно открывать-закрывать.

              Синхронизация - да, это отдельная проблема, и это проблема. Надо выбирать - либо скорость, либо консистентность. nginx не очень заботится о том что потеряется пара секунд логов, это проблема админов.

              А вот с базой данных все сложнее. Помню как на меня наорал DBA, когда я спросил почему нельзя ставить Oracle на виртуалку хотя бы в тестовых окружениях.


              1. outlingo
                23.05.2025 07:45

                Любая система виртуализации имеет настройки кэширования, и они безопасны. Небезопасные режимы надо включать руками с приседаниями. Сделано специально с учетом среднего уровня специалистов :-)


  1. baldr
    23.05.2025 07:45

    Про logrotate - проблема описана правильная, но среди решений нет варианта "наорать на администратора и рассказать ему оcopytruncate", хотя в голосовании видно что автор знает об этом.


  1. outlingo
    23.05.2025 07:45

    Если на сервере включено write caching на уровне диска/SSD, то fsync() может завершиться успешно, а данные — остаться в кэше контроллера и быть утеряны при сбое питания

    Неверно. Этот os.fsync() это вызов функции операционной системы, а та при флашинге кэша активно отправляет записи с флагами PRE_FLUSH и FUA (force unit access) который соответственно сбрасывают кэши и пишут мимо кэша. То, что вы написали, бывает только если администратор умышленно стреляет себе в голову.


  1. nv13
    23.05.2025 07:45

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


  1. pae174
    23.05.2025 07:45

    Как отследить это?

    • Проверяйте os.stat() и os.fstat() — если inode у файла изменился, лог был заменён.

    • Или используйте watchdog/inotify для мониторинга.

    Зачем что-то проверять периодически внутри сервиса если есть механизм сигналов. Просто сделайте обработчик сгнала и в нем переоткрывайте логи. Так что если админ в какой-то момент переименует лог-файл то он же и пошлет сигнал на переоткрытие.