Файлы и ввод-вывод в Go: чек-лист перед продакшеном
Работа с файлами в Go выглядит простой — до первого инцидента на проде. Разбираем типичные ошибки ввода-вывода, которые проходят код-ревью, но ломают сервис под нагрузкой, и собираем чек-лист для проверки перед деплоем.

Почему файловый ввод-вывод в Go обманчиво прост
Стандартная библиотека Go делает работу с файлами лаконичной: os.ReadFile, os.WriteFile, пара строк — и данные на диске. Проблема в том, что эти удобные обёртки прячут десяток решений, которые в проде принимаются за вас — и не всегда правильно. Файл, записанный «почти целиком» после аварийного завершения, — это не теоретическая угроза, а реальный кейс, знакомый любому, кто хотя бы раз терял конфиг или базу SQLite при внезапной перезагрузке.
Ниже — практический чек-лист. Каждый пункт — конкретная ошибка, цена вопроса и способ её закрыть. Всё для Go 1.22+, но большинство приёмов работает начиная с 1.16, где появились os.ReadFile и io/fs.
Чек-лист: 10 пунктов перед деплоем
1. Закрытие файлов: defer — не серебряная пуля
Классический паттерн defer f.Close() после os.Open решает проблему утечки дескрипторов ровно до момента, когда вы пишете в файл. При записи Close сбрасывает буфер ОС и может вернуть ошибку (диск полон, NFS-таймаут). Если вы проигнорировали возврат — данные потеряны молча.
f, err := os.Create("report.csv")
if err != nil {
return fmt.Errorf("создание файла: %w", err)
}
// Страховочный defer на случай паники или раннего return
defer f.Close()
// ... запись данных ...
// Явное закрытие с проверкой ошибки
if err := f.Close(); err != nil {
return fmt.Errorf("финализация записи: %w", err)
}
Двойной вызов Close безопасен: повторный вернёт ошибку, но не панику. Страховочный defer нужен, чтобы дескриптор не утёк, если функция завершится до явного закрытия.
2. Права доступа: 0666 — не то, что вы хотите
os.Create задаёт режим 0666. После применения umask (обычно 0022) файл получает 0644 — читаем всем. Для конфигов с секретами, PID-файлов, приватных ключей это неприемлемо. Используйте os.OpenFile с явными флагами:
f, err := os.OpenFile(
"app.secret",
os.O_WRONLY|os.O_CREATE|os.O_TRUNC,
0600, // только владелец читает и пишет
)
Чек: ls -la в staging перед релизом. Автоматизируйте проверку прав в CI, если сервис создаёт файлы с секретами.
3. Атомарная запись: write-rename вместо write-in-place
Запись напрямую в файл, который одновременно читают другие процессы, даёт им частичные данные — или пустой файл, если вы используете O_TRUNC. Паттерн «запись во временный файл + переименование» атомарен на уровне файловой системы (в пределах одного раздела):
import (
"fmt"
"os"
"path/filepath"
)
func atomicWrite(target string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(target)
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return fmt.Errorf("создание временного файла: %w", err)
}
tmpName := tmp.Name()
// Удаляем временный файл при ошибке
defer func() {
if tmpName != "" {
os.Remove(tmpName)
}
}()
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("запись: %w", err)
}
// fsync гарантирует, что данные на диске, а не в буфере ОС
if err := tmp.Sync(); err != nil {
tmp.Close()
return fmt.Errorf("fsync: %w", err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("закрытие: %w", err)
}
if err := os.Chmod(tmpName, perm); err != nil {
return fmt.Errorf("chmod: %w", err)
}
if err := os.Rename(tmpName, target); err != nil {
return fmt.Errorf("rename: %w", err)
}
tmpName = "" // rename прошёл — не удаляем в defer
return nil
}
Ограничение: os.Rename не работает между разными файловыми системами (вернёт EXDEV). Убедитесь, что временный файл создаётся в той же директории, что и целевой.
4. Fsync: данные в буфере — не данные на диске
f.Sync() (обёртка над fsync) — единственный способ гарантировать, что ОС сбросила данные из своего кеша на физический носитель. Без него при сбое питания файл может оказаться пустым или усечённым. Для конфигов, WAL-файлов и всего, что «не должно пропасть», вызов обязателен.
Цена: fsync медленный, на HDD — единицы миллисекунд, на NVMe — десятки микросекунд. Не вызывайте его на каждую строку лога — это убьёт пропускную способность.
5. Большие файлы: не читайте всё в память
os.ReadFile загружает файл целиком в []byte. Для файла в 2 ГБ это 2 ГБ оперативной памяти плюс аллокации при росте слайса. Стриминг через bufio.Scanner или io.Reader — стандартный выход:
f, err := os.Open("access.log")
if err != nil {
return err
}
defer f.Close()
scanner := bufio.NewScanner(f)
// Дефолтный буфер — 64 КБ; для длинных строк увеличиваем
scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Bytes() // без аллокации строки
// обработка line
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("чтение: %w", err)
}
Важно: bufio.Scanner по умолчанию ограничивает длину строки 64 КБ и вернёт bufio.ErrTooLong, если строка длиннее. Метод Buffer позволяет поднять лимит.
6. Обработка ошибок: оборачивайте контекстом
Голое return err из файловой операции порождает в логах сообщения вроде "permission denied" без указания пути. Оборачивайте через fmt.Errorf("чтение %s: %w", path, err). Проверка типа ошибки — через errors.Is:
errors.Is(err, os.ErrNotExist)— файл не найден;errors.Is(err, os.ErrPermission)— нет доступа;errors.Is(err, os.ErrExist)— файл уже есть (приO_EXCL).
Не сравнивайте строки ошибок — они зависят от ОС и локали.
7. Таймауты для сетевых файловых систем
NFS, CIFS, FUSE-маунты S3 — операция чтения или записи может зависнуть на минуты. Стандартный os.Open не принимает контекст. Решение — выносить файловые операции в горутину с context.WithTimeout и каналом результата. Альтернатива — deadline на уровне монтирования (timeo, retrans для NFS).
8. Символьные ссылки и path traversal
Если путь к файлу приходит от пользователя (загрузка, API), убедитесь, что после filepath.Clean и filepath.Rel путь не выходит за пределы разрешённой директории. filepath.EvalSymlinks разрешает символьные ссылки — вызывайте его до проверки префикса.
9. Временные файлы: не забудьте убрать
os.CreateTemp создаёт файл, но не удаляет его. В долгоживущем сервисе без очистки /tmp забивается за дни. Шаблон:
tmp, err := os.CreateTemp("", "upload-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
defer tmp.Close()
Порядок defer важен: сначала удаление (выполнится последним), потом закрытие (выполнится первым). На Windows удаление открытого файла невозможно — там порядок обратный, и нужно закрыть до удаления.
10. Лимит открытых дескрипторов
По умолчанию Linux выдаёт процессу 1024 файловых дескриптора (soft limit). Сервис, который держит открытыми соединения к БД, файлы логов, загрузки пользователей и пайпы к дочерним процессам, упирается в этот лимит быстрее, чем кажется. Симптом — "too many open files". Проверяйте:
ulimit -n— текущий лимит;ls /proc/<pid>/fd | wc -l— сколько дескрипторов открыто прямо сейчас;- в systemd-юните:
LimitNOFILE=65536.
Сводная таблица: что проверить и зачем
| Пункт | Симптом на проде | Как проверить |
|---|---|---|
| Ошибка Close при записи | Потеря данных, пустые файлы | grep по коду: defer f.Close() после Create/OpenFile |
| Права 0666 | Утечка секретов | stat на создаваемые файлы в staging |
| Нет атомарной записи | Частичные конфиги, битые JSON | Убить процесс во время записи, проверить файл |
| Нет fsync | Пустой файл после сбоя питания | Сценарий с sync; echo 1 > /proc/sys/vm/drop_caches |
| ReadFile на большом файле | OOM kill | Мониторинг RSS, тест на файле > RAM |
| Голые ошибки | Невнятные логи | Линтер errcheck + ревью fmt.Errorf |
| Нет таймаута на NFS | Зависшая горутина, рост числа горутин | pprof goroutine при NFS-лаге |
| Path traversal | Чтение /etc/passwd через API | Фаззинг путей с ../ и симлинками |
| Утечка tmp-файлов | Диск забит, 500-ки | du -sh /tmp через неделю работы |
| Лимит дескрипторов | "too many open files" | ls /proc/<pid>/fd | wc -l под нагрузкой |
Файловый ввод-вывод — одна из немногих областей, где ошибка не воспроизводится в тестах, но стабильно стреляет в проде. Тесты бегут на быстром SSD с запасом RAM и дескрипторов. Прод — это NFS, контейнер с лимитом памяти и внезапный SIGKILL от OOM killer.
Инструменты для автоматической проверки
Ручной чек-лист хорош, но забывается на третьем спринте. Что можно автоматизировать:
- errcheck — ловит непроверенные ошибки, включая
f.Close(); - gosec — обнаруживает path traversal и небезопасные права;
- golangci-lint — агрегатор, включает оба линтера выше.
Добавьте в CI-пайплайн и не ведите споров на ревью о том, проверена ли ошибка — пусть это делает машина.
Сноска: fsync — системный вызов, который просит ОС записать все буферизированные данные файла на физический носитель. В Go доступен как метод (*os.File).Sync(). Не путать с sync.Mutex из пакета sync — это совершенно разные вещи.
Что делать дальше
Пройдите по чек-листу на текущем проекте: grep -rn 'defer.*\.Close()' . покажет все defer-закрытия, а grep -rn 'os.ReadFile' . — все места, где файл читается целиком. Это два самых частых источника инцидентов. Дальше — настройте golangci-lint с включёнными errcheck и gosec, чтобы новые ошибки не проходили в main. Если сервис работает с пользовательскими путями — проведите фаззинг через go test -fuzz: стандартный фаззер Go (начиная с 1.18) отлично ищет path traversal без сторонних инструментов.
Тендерный специалист 44-ФЗ / 223-ФЗ: Разбор PDF-документации закупки, поиск «заточек» под поставщика, чек-лист заявки.
Спросить за 20 ₽Источники: Пакет os — стандартная библиотека Go, Пакет bufio — стандартная библиотека Go
Частые вопросы
Зачем вызывать Close явно, если уже стоит defer f.Close()?
Работает ли атомарная запись через Rename на Windows?
Нужен ли fsync для каждой записи в лог-файл?
Что если у меня Go версии ниже 1.16 — os.ReadFile недоступен?
Как отловить утечку дескрипторов до того, как упадёт прод?
Что будет, если не обработать ошибку fsync?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.