Дайджесты новостей
Инфографический патентный чертеж механизма атомарной записи файлов в Go через временный файл и вызов POSIX rename против гонки O_TRUNC.

Атомарная запись конфигов в Go: почему renameio надежнее обычной перезаписи

Представь типичную задачу: твой сервис на Go периодически выгружает обновленную конфигурацию или токены авторизации на диск, а фоновый процесс (например, Envoy или агент метрик) следит за файлом через inotify и перечитывает его при изменении. В стандартной библиотеке Go рука сама тянется вызвать знакомую функцию:

err := os.WriteFile("/etc/app/config.json", data, 0644)

Кажется, что всё чисто и надежно. Но однажды в продакшене случается необъяснимый сбой: фоновый сервис падает с ошибкой unexpected end of JSON input. Ты открываешь файл на сервере — там валидный JSON. Ты только что поймал классическое состояние гонки файловой системы (truncate race condition).

Ловушка O_TRUNC: почему файл на миг становится пустым

Под капотом os.WriteFile выполняет системный вызов open с флагами os.O_WRONLY | os.O_CREATE | os.O_TRUNC.

И вот здесь скрывается ловушка:

  1. Флаг ядра Linux O_TRUNC моментально обнуляет длину существующего файла прямо при открытии дескриптора.
  2. Только после этого рантайм Go начинает перекачивать новые байты из оперативной памяти в буфер файловой системы.
  3. Процесс завершается системным вызовом close.

Если в этот микроскопический промежуток параллельный поток или внешний демон попытается прочитать файл, он застанет его пустым (0 байт) или наполовину записанным. Для любого парсера это верный крах.

Механика renameio: надежность через POSIX rename

Чтобы читатель всегда видел либо полностью старую, либо полностью новую версию файла, используют паттерн атомарной подмены. В экосистеме Go эталонным решением стала библиотека от Google — renameio и ее вторая ветка renameio/v2.

Библиотека выполняет строгую последовательность шагов:

  1. Создает временный файл в том же каталоге, где находится целевой файл.
  2. Записывает новые данные во временный файл.
  3. Вызывает системную синхронизацию fsync, принудительно сбрасывая кэш памяти на накопитель.
  4. Выполняет атомарный системный вызов rename(2). Ядро Linux просто подменяет указатель в директории на новый inode. Промежуточного состояния для внешних процессов не существует.
  5. Автоматически удаляет временный файл при любой ошибке.

В нашем разборе того, как устроено тестирование файлов в Go без временных папок через fstest.MapFS, мы говорили об изоляции тестов. Настоящая дисковая изоляция в проде строится именно на такой атомарности.

Посмотрим на код безопасной записи с renameio/v2:

package main

import (
    "log"
    "github.com/google/renameio/v2"
)

func updateConfig(filePath string, payload []byte) error {
    // Атомарно перезаписываем файл с учетом umask процесса
    if err := renameio.WriteFile(filePath, payload, 0644); err != nil {
        log.Printf("Сбой атомарного обновления: %v", err)
        return err
    }
    return nil
}

Для потоковой записи больших объемов данных доступен тип PendingFile:

t, err := renameio.TempFile("", "/etc/app/config.json")
if err != nil {
    return err
}
defer t.Cleanup()

if _, err := t.Write(payload); err != nil {
    return err
}
return t.CloseAtomicallyReplace()

Что изменилось в версии v2 и границы метода

Главное отличие renameio/v2 от первой версии — корректный учет системной маски прав umask. В первой версии WriteFile выставлял права жестко. Версия v2 применяет umask точно так же, как стандартный os.WriteFile. Если требуется старое поведение, достаточно передать опцию renameio.IgnoreUmask().

Границы атомарности и разные файловые системы

Системный вызов rename гарантированно атомарен только в пределах одного смонтированного дискового тома. Если временный файл создается в /tmp, а целевой лежит на отдельном томе /var/data, системный вызов вернет ошибку EXDEV. Всегда держи временный файл рядом с целевым.

От прикладных гонок и пустых конфигов в продакшене связка временного файла и renameio/v2 защищает безупречно.