Логи в 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.