Представь типичную задачу: твой сервис на 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.
И вот здесь скрывается ловушка:
- Флаг ядра Linux
O_TRUNCмоментально обнуляет длину существующего файла прямо при открытии дескриптора. - Только после этого рантайм Go начинает перекачивать новые байты из оперативной памяти в буфер файловой системы.
- Процесс завершается системным вызовом
close.
Если в этот микроскопический промежуток параллельный поток или внешний демон попытается прочитать файл, он застанет его пустым (0 байт) или наполовину записанным. Для любого парсера это верный крах.
Механика renameio: надежность через POSIX rename
Чтобы читатель всегда видел либо полностью старую, либо полностью новую версию файла, используют паттерн атомарной подмены. В экосистеме Go эталонным решением стала библиотека от Google — renameio и ее вторая ветка renameio/v2.
Библиотека выполняет строгую последовательность шагов:
- Создает временный файл в том же каталоге, где находится целевой файл.
- Записывает новые данные во временный файл.
- Вызывает системную синхронизацию
fsync, принудительно сбрасывая кэш памяти на накопитель. - Выполняет атомарный системный вызов
rename(2). Ядро Linux просто подменяет указатель в директории на новыйinode. Промежуточного состояния для внешних процессов не существует. - Автоматически удаляет временный файл при любой ошибке.
В нашем разборе того, как устроено тестирование файлов в 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 защищает безупречно.
