Логи в Go

Go – это язык программирования, который часто используется в backend-разработке. Backend-сервисы иногда принимают в качестве конфигурационного параметра путь к log-файлу. В среде Unix такой подход не считается хорошим дизайном, поскольку не задействует мощь стандартных потоков stdout и stderr, предоставляемых каждой Unix-программе. Стандартные потоки конфигурируются средой выполнения, а приложение о среде выполнения должно знать минимум, полагаясь на общепринятые контракты, предоставляемые средой. Среда выполнения может направить поток в файл, в терминал или в любую другую систему обработки логов. Подробнее об этом можно почитать в публикациях Logs Are Streams, Not Files от ADAM WIGGINS и Logs от The Twelve-Factor App.

Мои backend-сервисы обычно работают под управлением Debian, поэтому запуском сервисов заведует systemd, управление которым осуществляется с помощью systemctl. В файлах конфигурации для systemd (обычно лежат в каталоге /etc/systemd/system) можно указать пути к файлам, куда будут записываться логи:

# /etc/systemd/system/my_cool_service.service
[Unit]
Description=My Cool Service
After=network.target

[Service]
User=cool_services
Group=cool_services
ExecStart=/var/www/my_cool_service /var/www/my_cool_service/config.ini
Restart=always
# лог-файлы (append:path дописывает в конец файла)
StandardOutput=append:/var/log/myapp/myapp.log
StandardError=append:/var/log/myapp/myapp.err

[Install]
WantedBy=multi-user.target

По умолчанию systemd направляет stdout и stderr в systemd-journald, логи можно смотреть через journalctl:

# указываем имя службы
journalctl -u my_cool_service.service
# служб может быть несколько, поддерживаются фильтры
journalctl -u nginx.service -u php-fpm.service --since 10:00 --until 16:00

systemd-journald записывает логи в специальном бинарном формате и принимает запросы на запись через специальный протокол, реализованный в специальной библиотеке с методом sd_journal_send. Таким образом, физически в systemd-мире логи из Go-приложения по умолчанию складируются в бинарные файлы журналов journald.

Рассмотрим теперь варианты, как Go-приложение может передавать данные в stdout и stderr.

Пакет fmt предоставляет функции форматирования и I/O. Например, метод Printf пишет в stdout:

import "fmt"

// Print* пишет в stdout
fmt.Printf("user=%s age=%d\n", user, age)

Метод Fprintf позволяет уже явно указать, в какой поток пишем, например, в stderr:

import (
    "fmt"
    "os"
)

fmt.Fprintf(os.Stderr, "bad status=%d\n", status)

Пакет log предоставляет тип Logger, дефолтный экземпляр которого доступен через метод log.Default и алиасы. Logger по умолчанию пишет в stderr и предлагает ряд удобств:

  • добавляет префикс даты-времени к каждой строке (можно дополнить кастомным);
  • добавляет постфикс завершения строки, если в исходной строке он отсутствует;
  • безопасен для конкурентного использования (несколько goroutine пишут в один лог без перемешивания строк).
package main

import (
    "fmt"
    "log"
)

func main() {
    // нужен \n для перехода на следующую строку, вывод в stdout
    fmt.Print("Hello, world!\n")
    // дефолтный Logger, \n не обязателен, вывод в stderr
    log.Default().Print("Hello, world!")
    // алиас предыдущего примера
    log.Print("Hello, world!")

}

Методы логгера работают «in the manner of» fmt-аналогов, например log.Printf. Logger можно инстанцировать явным образом и писать не в стандартный поток, а в буфер:

package main

import (
    "bytes"
    "fmt"
    "log"
)

func main() {
    var (
        buf bytes.Buffer
        // указываем буфер, константный префикс и вычисляемый префикс
        logger = log.New(&buf, "Logger: ", log.Ldate|log.Ltime|log.Lshortfile)
    )
    // выведет на печать:
    // Logger: 2026/02/10 12:06:19 main.go:18: Hello, world!
    // Logger: 2026/02/10 12:06:19 main.go:19: Hello, world!
    logger.Print("Hello, world!")
    logger.Print("Hello, world!")
    fmt.Print(&buf)
}

Поток вывода можно переопределить, в том числе и для дефолтного Logger:

package main

import (
    "log"
    "os"
)

func main() {
    // выведет в stderr
    log.Print("Hello, world!")
    log.SetOutput(os.Stdout)
    // выведет в stdout
    log.Print("Hello, world!")
}

Пакет log/slog предназначен для структурного логгирования, позволяет разделить логи по severity-уровням и снабдить каждую запись атрибутами в формате ключ-значение. Всякий slog-логгер снабжается обработчиком Handler, в котором формируются правила обработки атрибутов. По умолчанию дефолтный slog-логгер пишет в stderr независимо от уровня severity.

package main

import (
    "log"
    "log/slog"
    "os"
)

func main() {
    
    // выведет в stderr строку: 2026/02/10 12:57:20 INFO Hello, world!
    slog.Info("Hello, world!")
    
    // выведет в stderr строку: 2026/02/10 12:57:20 ERROR Hello, world! user=Yury
    slog.Error("Hello, world!", "user", os.Getenv("USER"))
    
    // выведет в stdout строку: {"time":"2026-02-10T12:57:20.425778+03:00","level":"INFO","msg":"Hello, world!","user":"Yury"}
    jsonLogger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    jsonLogger.Info("Hello, world!", "user", os.Getenv("USER"))

    // выведет в stdout строку: time=2026-02-10T12:57:20.425+03:00 level=INFO msg="Hello, world!" user=Yury
    textLogger := slog.New(slog.NewTextHandler(os.Stdout, nil))
    textLogger.Info("Hello, world!", "user", os.Getenv("USER"))

    // замена стандартного логгера на кастомный
    slog.SetDefault(textLogger)

    // выведет в stdout строку: time=2026-02-10T12:57:20.425+03:00 level=INFO msg="Hello, world!"
    slog.Info("Hello, world!")

    // log.logger тоже будет заменен, выведет в stdout строку ту же строку
    log.Print("Hello, world!")

}

Помимо этого slog позволяет формировать кастомные хэндлеры и настраивать уровни вложенности журналирования. Подробный обзор возможностей можно найти в статье Logging in Go with Slog: The Ultimate Guide за авторством Ayooluwa Isaiah и в русскоязычном переводе Структурированное логирование в Go с помощью Slog от компании Слёрм на Хабре.

Пакет slog по сути предоставляет унифицированный интерфейс для Go-приложений, позволяя менять бекенд логгирования, не затрагивая непосредственно код логов. В упомянутой выше статье показано, как можно в качестве бекенда задействовать пакеты zap и slog-zerolog.

Дополнительно