---
title: "Глобальный логгер: ReplaceGlobals"
description: "Глобальный логгер go-приложения: zap ReplaceGlobals — когда уместен, чем опасен (гонки, тесты) и почему логгер лучше отдавать явно. Разбор с примерами."
course: "Zap: структурное логирование на Go"
url: "https://golangmaster.ru/courses/zap/lessons/21-globalnyj-logger/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 16
---

# Глобальный логгер: ReplaceGlobals

*Курс «Zap: структурное логирование на Go», урок 21 из 25. ~16 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/zap/lessons/21-globalnyj-logger/*

Вызов zap ReplaceGlobals (в коде — zap.ReplaceGlobals) превращает no-op zap.L() в боевой логгер одной строкой — самый быстрый способ достучаться до пакетов, куда зависимость-логгер не дотянута. Ниже — когда глобальный логгер go-проекта оправдан, чем грозит гонка на старте и подмена в тестах, и почему рабочий паттерн звучит скучно: настраиваем в main, отдаём явно.

История с одним и тем же началом: в большой проект подключена библиотека аналитики, из её глубины должны идти записи, но в системе сбора от неё пусто. Код библиотеки безупречен — он пишет через zap.L(), глобальный логгер пакета zap. Ни один тест не падает: вызовы проходят, записи просто никуда не попадают. Причина в том, что глобал по умолчанию — заглушка, а zap.ReplaceGlobals в main никто не вызвал, потому что о договорённости никто не знал. Глобальный логгер go-программы — скрытая зависимость уровня процесса: он либо настроен до первого вызова, либо молча съедает телеметрию. Разберём, как работает пара zap.L() и ReplaceGlobals, где глобал действительно нужен и почему зрелые проекты настраивают его один раз в main, а дальше передают логгер явно через конструкторы.

### zap.L() и zap.S(): что лежит в глобале

В пакете zap живут две глобальные переменные: L для *zap.Logger и S для *zap.SugaredLogger. До первого вызова ReplaceGlobals обе указывают на заглушки: записи уходят в никуда, Sync возвращает nil, паники нет — код работает и молчит. zap.ReplaceGlobals(logger) меняет обе переменные разом и возвращает функцию restore, которая восстанавливает прежние значения. Подмена безопасна для читателей: внутри глобала мьютекс, data race не возникнет — опасность в другом, в порядке вызовов, и об этом ниже. Читать глобал дёшево: zap.L() снимает блокировку на чтение и отдаёт текущее значение. Полезно понимать механику: zap.L() каждый раз возвращает актуального логгера, а вот переменная, в которую вы скопировали zap.L() однажды, остаётся старым логгером навсегда — на этом копировании спотыкаются чаще, чем на самой подмене.

### Минимальная настройка в main

```go
func main() {
    logger, err := zap.NewProduction()
    if err != nil {
        panic(err)
    }
    defer logger.Sync()

    restore := zap.ReplaceGlobals(logger)
    defer restore()

    srv := server.New(":8080", zap.L().Named("server"))
    if err := srv.ListenAndServe(); err != nil {
        zap.L().Fatal("server stopped", zap.Error(err))
    }
}
```

ReplaceGlobals вызывается сразу после сборки: с этого момента zap.L() и zap.S() во всех пакетах пишут в production-конфиг. restore вешается на defer — не столько для этого процесса, сколько для тестов и CLI-подкоманд, где глобал меняют и возвращают. Сервер при этом получает своего Named-потомка явно: глобал здесь страховка для аварийных путей, а не основной канал зависимостей. zap.L().Named("server") читает глобал в момент вызова: сервер фиксирует потомка раз и навсегда, последующие подмены на него не влияют — копия zap.L() в поле структуры замораживает значение, о чём говорит predict ниже.

### Зачем глобальный логгер go-коду: три честных случая

Случай первый — утилита в main-пакете: пять файлов, два флага, DI-цепочка длиной в одну функцию; глобал здесь дешевле контекста, и это нормально. Случай второй — граница с чужим кодом: библиотека пишет через zap.L(), и её сигнатуры вы поменять не можете; тогда пара zap ReplaceGlobals и zap.L() — единственный шов, куда записи библиотеки вообще попадут. Случай третий — аварийные пути, до которых контекст не доезжает: хендлер паники в горутине, обработчик сигналов, финализатор процесса. Во всех трёх глобал — запасной выход, а не архитектура. Признак здорового отношения к глобалу: основной поток приложения передаёт логгер явно — конструкторам, структурам, контексту, — а zap.L() остаётся страховкой, на которую смотрят при разборе падений.

### Проверка: что в глобале до настройки

**Вопрос:** zap.ReplaceGlobals ещё не вызывался. Что вернёт zap.L() и что будет с записью Info?

- nil — первый же вызов метода упадёт с паникой
- ✅ No-op логгер: вызовы проходят, запись исчезает
- Логгер, пишущий JSON в stderr
- Паника: глобал не инициализирован

До подмены в глобале лежит заглушка из zap.NewNop(): методы безопасны, записи никуда не идут. Отсюда коварство бага: незанастроенный глобал не проявляется ни в тестах, ни в рантайме — только отсутствием телеметрии.

### Опасность первая: порядок инициализации

Опасность глобала — не data race: подмена защищена мьютексом, и race detector ругаться не будет. Опасность — в порядке вызовов. init-функции пакетов выполняются до main, и если библиотека пишет в zap.L() из init, на тот момент глобал ещё заглушка. Если ReplaceGlobals зовут в init другого пакета, порядок определяет граф импортов — нечитаемая зависимость, которая меняется от одной перестановки импорта в файле. Горутины, стартовавшие до подмены — фоновая очистка, прогрев кеша, запущенный из инициализатора, — тоже пишут в заглушку, и их записи теряются без следа. Диагноз ставится по одному симптому: записи есть у одних пакетов и нет у других, а перезапуск не меняет ничего. Лечение всегда одно: глобал настраивается в main до запуска горутин, а не в глубине импортов.

### Ловушка init-функции

```go
// package analytics — библиотечный код
package analytics

import "go.uber.org/zap"

func init() {
    // Выполнится ДО main: глобал ещё заглушка.
    zap.L().Info("analytics initialized") // запись потеряна
}
```

Инициализаторы исполняются до main в порядке зависимостей импортов, поэтому ReplaceGlobals из main ещё не был вызван. Запись уходит в no-op без ошибок и предупреждений. Правило для библиотек: не писать в глобал из init вовсе — телеметрия старта библиотека отдаёт при явном вызове конструктора, куда хост передаёт настроенный логгер.

> **Тишина — худший симптом.** No-op глобал не ломает ни один тест: вызовы дёшевы, паник нет, формат вывода не важен. Сломанная телеметрия обнаруживается через недели, когда лог нужен для разбора, а его нет. Если проект обязан использовать глобал, добавьте тест, который проверяет, что zap.L() уже боевой к моменту первого пакета, — одна проверка экономит час разбора.

### Опасность вторая: тесты

Глобал — состояние всего процесса, и тесты делят его между собой. Заменили глобал в одном тесте и не вернули — следующий тест пишет в чужого наблюдателя или молчит. С t.Parallel() картина грустнее: параллельные тесты дерутся за один глобал, и результат зависит от планировщика, то есть флакает. Дисциплина простая: zap.ReplaceGlobals возвращает restore, и defer restore() — обязательная пара к каждому вызову в тесте; а параллельный тест, которому нужен глобал, — повод переписать его на явную передачу логгера, как делает observer из урока про тестирование логов. Прогон таких тестов — обычный go test на локальном Go, никакой обвязки не нужно.

### Тест с restore

```go
func TestGlobalConfigured(t *testing.T) {
    core, logs := observer.New(zapcore.DebugLevel)
    restore := zap.ReplaceGlobals(zap.New(core))
    defer restore()

    doWork() // внутри — zap.L().Warn("degraded")

    if logs.FilterMessageSnippet("degraded").Len() == 0 {
        t.Fatal("expected a warning from doWork")
    }
}
```

Пара ReplaceGlobals и defer restore() в тесте нерушима: как только тест закончится, глобал вернётся к прежнему значению, и соседние тесты ничего не заметят. Проверки те же, что и для обычного логгера: уровень, сообщение, поля. Единственное отличие — код под тест берёт логгера из zap.L(), поэтому наблюдатель попадает внутрь через глобал.

### Что будет, если ReplaceGlobals вызвать дважды

Второй вызов не запрещён и не паникует: он подменяет текущее значение и возвращает restore, возвращающий уже его. Пары вызовов складываются в стек, откаты применяются в обратном порядке — это удобно в CLI с подкомандами: корень настроил глобал, подкоманда подменила логгера своим Named-потомком и вернула всё defer'ом. Две подмены без отката — уже запах дизайна: второй restore вернёт первого логгера, а не заглушку, и цепочка подмен растёт с каждым пакетом, решившим «подстроить глобал под себя». Правило из чек-листа ниже — один вызов на процесс — существует именно потому, что механика позволяет больше.

### Что выведет каждая из двух записей?

```go
early := zap.L()

logger, _ := zap.NewProduction()
_ = zap.ReplaceGlobals(logger)

early.Info("one")
zap.L().Info("two")
```

**Что выведет программа?**

- Обе записи попадут в вывод: глобал один на всех
- Ни одна: запись в глобал до подмены запрещена
- ✅ Только «two»: early осталась старой заглушкой
- Паника на второй строке: повторная подмена запрещена

zap.L() читает актуальное значение глобала на каждый вызов, а переменная early хранит ссылку на заглушку, снятую до подмены. Копирование zap.L() в переменную при старте — типовая ошибка: такую переменную ReplaceGlobals больше не обновит.

### Паттерн: настраиваем в main, отдаём явно

Здоровый проект выглядит так: main собирает конфиг и логгер — пресет или Build из урока про конфиг, — затем делает ReplaceGlobals один раз и дальше передаёт логгера по рукам: конструкторам сервера, репозиториев, посредников. Дети строятся через With и Named; иерархию имён разбирает [урок про With и Named](/courses/zap/lessons/12-with-named/). Глобал при такой схеме остаётся для трёх случаев из начала урока. В тестах же логгер вообще не глобален: код под тест принимает *zap.Logger, и наблюдатель входит через конструктор. Спорное мнение в защиту глобала: в маленьких CLI он экономит больше, чем стоит, — я не люблю глобальные состояния, но жертвовать архитектурой ради пятисот строк утилиты не готов, и zap.L() с ReplaceGlobals в main — разумный компромисс с условием: как только файл растёт, зависимость уходит в конструкторы.

### Чек-лист: включать ли глобал

- main-пакет или CLI-утилита без слоёв — можно, настройка в начале main.
- Библиотека — никогда: принимает *zap.Logger или logr.Logger параметром.
- Один вызов ReplaceGlobals на процесс: больше — повод пересмотреть дизайн.
- Каждый вызов в тестах парный: ReplaceGlobals и defer restore().
- t.Parallel и глобал несовместимы: уберите параллельность или глобал.
- Горутины стартуют только после ReplaceGlobals — иначе их записи потеряны.

### restore: сигнатура и альтернативы

restore — это func() без аргументов и результата: она возвращает те глобальные значения, которые были до подмены, включая заглушку, если глобал ещё не настраивали. Подмена идёт парами — L и S меняются одновременно, рассинхрона не бывает. Если цель была не в подмене логгера, а в смене уровня, глобал трогать не нужно: атомарный уровень из урока про динамические уровни меняет порог на живом процессе без перевязки зависимостей. Подмена нужна там, где меняется само ядро: тест ставит наблюдателя, CI-скрипт перевключает файловый вывод. Правило разделения: уровень — через AtomicLevel, ядро — через ReplaceGlobals, и путать их не стоит.

### Проверка: что возвращает ReplaceGlobals

**Вопрос:** Что возвращает zap.ReplaceGlobals(logger)?

- Новый *zap.Logger для глобала
- Ошибку, если логгер не собран через Build
- ✅ Функцию restore, возвращающую прежние глобалы
- Ничего: подмена необратима

Сигнатура — func(): без аргументов, возвращает прежние значения L и S. Именно поэтому в тестах паттерн выглядит как restore := zap.ReplaceGlobals(...) и defer restore().

> **Библиотека не трогает глобал.** Библиотечный код, который вызывает zap.ReplaceGlobals, ломает хост-приложение: подменяет чужой логгер, чужой конфиг, чужой формат. Если библиотеке нужен логгер, она принимает его параметром; если параметра нет и правка невозможна, остаётся задокументированный zap.L() — но подмену делает хост, не библиотека.

### Соберите настройку глобала

Восстановите вызовы: подмена, возврат, чтение глобала.

```go
func main() {
    logger, _ := zap.NewProduction()
    restore := zap.___(logger)
    defer ___()

    zap.___().Info("started", zap.Int("port", 8080))
}
```

Подсказка: Подмена — ReplaceGlobals, defer возвращает прежнее значение, чтение глобала — zap.L().

Правильные ответы в пропусках: `ReplaceGlobals`, `restore`, `L`

### Соберите порядок настройки

Расставьте строки main по порядку: от сборки логгера до старта сервера.

Правильный порядок строк:

1. `logger, _ := zap.NewProduction()`
2. `restore := zap.ReplaceGlobals(logger)`
3. `defer restore()`
4. `srv := server.New(":8080", zap.L().Named("server"))`
5. `zap.L().Info("listening", zap.String("addr", ":8080"))`
6. `srv.ListenAndServe()`

### Карточки: глобальный логгер

- **zap.ReplaceGlobals** — Подменяет глобальные L и S на логгер и его sugar-версию; возвращает restore для отката.
- **zap.L()** — Читатель глобального логгера: до подмены — no-op заглушка, после — боевой логгер из main.
- **restore()** — Функция из ReplaceGlobals: возвращает прежние глобалы. Обязательная пара к подмене в тестах.
- **No-op глобал** — Заглушка по умолчанию: вызовы дёшевы и безопасны, записи исчезают — телеметрия теряется молча.
- **Логическая гонка** — Порядок вызовов, а не data race: init и горутины до ReplaceGlobals пишут в заглушку.

### Что дальше по курсу

Соседняя проблема — код, который не должен зависеть даже от факта наличия zap, — решается интерфейсом: мост logr и zapr разбирается в следующем уроке, и там подмена в тестах становится ещё проще. Дисциплина тестов глобала опирается на observer из [урока про zaptest и observer](/courses/zap/lessons/20-testirovanie-logov/). Карта всего курса — на [странице курса zap](/courses/zap/); дальше по плану — эксплуатация и итоговый проект, где глобал, наконец, заменяется явной передачей.

## Коротко: что запомнить

- До ReplaceGlobals zap.L() — no-op: вызовы безопасны, записи исчезают.
- ReplaceGlobals меняет L и S разом и возвращает restore для отката.
- Опасность — порядок, а не data race: init и горутины до подмены пишут в заглушку.
- В тестах ReplaceGlobals парный к defer restore(), с t.Parallel() глобал не дружит.
- Библиотека глобал не трогает: принимает логгер параметром.
- Паттерн зрелого проекта: настроили в main один раз, дальше передаём явно.
- Уровень меняет AtomicLevel, ядро — ReplaceGlobals: разные инструменты.

## Домашнее задание

Добавьте в маленький проект глобальный логгер: сборка через пресет, один вызов ReplaceGlobals в main с defer restore(). Напишите тест с observer, который проверяет запись из zap.L(), и убедитесь, что без restore() соседний тест ломается. Затем переведите один пакет с zap.L() на явную передачу логгера через конструктор и сравните, где проверки стали проще. После этого соберите predict-сценарий урока в тест: переменная, снятая до подмены, и zap.L() после — обе пишут Info, и одна запись теряется.

## Вопросы и ответы по уроку

### Насколько безопасна подмена глобала из нескольких горутин?

Внутри zap мьютекс — data race не будет, и это часто вводит в соблазн. Безопасна такая подмена по смыслу только на старте или в тестах: на живом трафике она меняет поведение всех пакетов разом, а это операция уровня main, не рантайма. Чужие горутины о подмене не уведомляются: кто успел снять zap.L() до неё, остался со старым значением.

### Нужно ли вызывать restore() в main?

Практического эффекта нет: restore вернёт заглушку перед самым выходом из процесса. defer там — привычка и образец для тестов, где откат обязателен и без него ломаются соседние проверки.

### Что делать с чужой библиотекой, которая пишет только в zap.L()?

Настроить глобал в main — это её единственный канал, и Named-потомков она не увидит: глобал один. Рабочие пути: договориться о настройке глобала в main, взять форк с параметром-логгером или вынести критичные события из библиотеки в свой код.
