---
title: "Итоговый проект: логгер для CLI-сервиса"
description: "Итоговый zerolog проект: логгер CLI-сервиса — LOG_LEVEL и ParseLevel, ConsoleWriter в деве, JSON с lumberjack и MultiLevelWriter в проде, тест с буфером."
course: "Zerolog в Go: быстрое JSON-логирование"
url: "https://golangmaster.ru/courses/zerolog/lessons/25-itogovyj-proekt/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 24
---

# Итоговый проект: логгер для CLI-сервиса

*Курс «Zerolog в Go: быстрое JSON-логирование», урок 25 из 25. ~24 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/zerolog/lessons/25-itogovyj-proekt/*

Итоговый zerolog проект собирает логгер сервиса из кирпичей, разобранных за двадцать четыре урока: конфигурация по окружению, порог, формат вывода, ротация, сублоггеры, тесты. Нового здесь мало, и это честно: на финале проверяется умение складывать знакомое в работающий сервис, а не знание свежих функций. Проект — логгер для CLI-сервиса: воркер или демон, который стартует из консоли, читает APP_ENV и LOG_LEVEL, в деве рисует человекочитаемые строки, в проде пишет JSON в stdout и в файл с ротацией, а при SIGINT аккуратно закрывает всё, что открыл. Суть проекта совпадает с планом курса: конфиг, уровни, файл плюс stdout, тесты и сборка. Сборка умещается в три куска: конструктор NewLogger(cfg), main с сигналами и тест конструктора с буфером. Каждое решение помечено уроком-источником, так что непонятный шов открывается заново за минуту, а полный код переносится в свой проект почти без правок и доращивается по чек-листу в конце урока.

Три файла проекта держатся на одном правиле: решение о судьбе writer-ов живёт в одном месте. Конструктор NewLogger принимает структуру Config, возвращает zerolog.Logger и функцию done — она сбросит файловый буфер и закроет журнал при выходе. main не знает, как устроен вывод: он получает логгер зависимостью и раздаёт его компонентам. Тест подставляет в Config.Stdout буфер и проверяет контракт события без глобальных подмен. По дороге встретятся два живых места: stdout, который читает кто-то ещё, и дисциплина закрытия, которую zerolog не берёт на себя. Порядок такой: конфигурация, writer по окружению, сублоггеры, сигналы и Sync, тест, чек-лист сборки.

### Конфигурация: LOG_LEVEL, дефолт и ParseLevel

Конфигурация начинается со структуры Config: поля Env, LogLevel, LogFile, Stdout и три числа для ротации. Правило из [урока про окружение](/courses/zerolog/lessons/16-konfiguraciya-okrug/) одно на все окружения: чтение, дефолт, парсинг, применение, подтверждение. Дефолт Info ставится до ParseLevel: os.Getenv возвращает пустую строку и для неустановленной переменной, и для пустой, а ParseLevel на пустой строке отвечает NoLevel без ошибки — это порог, который глушит Info, Warn и Error молча. Мусорная строка ведёт себя иначе: опечатка ifno не совпадёт ни с одним именем уровня и вернёт ошибку, и на старте она обязана валить процесс, а не проглатываться. В проекте конструктор не зовёт Fatal сам: NewLogger возвращает ошибку, и решение о судьбе процесса принимает main — библиотечный код, который сам выходит из процесса, отнимает у вызывающего выбор. Подтверждающая запись с принятым порогом закрывает утренние споры о том, какой уровень реально вступил в силу.

### NewLogger: конструктор с выбором writer-а

```go
package logger

import (
        "fmt"
        "io"
        "os"

        "github.com/rs/zerolog"
        "gopkg.in/natefinch/lumberjack.v2"
)

type Config struct {
        Env        string    // development | production
        LogLevel   string    // LOG_LEVEL: info, debug, warn...
        LogFile    string    // путь к журналу
        Stdout     io.Writer // nil — os.Stdout; тесты подставляют буфер
        MaxSize    int       // мегабайт до ротации
        MaxBackups int
        MaxAge     int // дней
}

func NewLogger(cfg Config) (zerolog.Logger, func(), error) {
        out := cfg.Stdout
        if out == nil {
                out = os.Stdout
        }

        lvl, err := zerolog.ParseLevel(cfg.LogLevel)
        if err != nil {
                return zerolog.Nop(), nil, err
        }
        zerolog.SetGlobalLevel(lvl)

        if cfg.Env == "development" {
                lg := zerolog.New(zerolog.ConsoleWriter{
                        Out:        out,
                        TimeFormat: "15:04:05",
                }).With().Timestamp().Logger()
                return lg, func() { syncFile(out) }, nil
        }

        lumber := &lumberjack.Logger{
                Filename:   cfg.LogFile,
                MaxSize:    cfg.MaxSize,
                MaxBackups: cfg.MaxBackups,
                MaxAge:     cfg.MaxAge,
                Compress:   true,
        }
        lg := zerolog.New(zerolog.MultiLevelWriter(out, lumber)).
                With().Timestamp().Logger()

        return lg, func() {
                syncFile(out)
                if err := lumber.Close(); err != nil {
                        fmt.Fprintln(os.Stderr, "close log file:", err)
                }
        }, nil
}

func syncFile(w io.Writer) {
        if f, ok := w.(*os.File); ok {
                f.Sync() // у буфера Sync нет: assertion отфильтрует
        }
}
```

Конструктор возвращает логгер вместе с функцией done: владелец writer-ов — NewLogger, поэтому закрывает их он сам. В деве — ConsoleWriter с TimeFormat, в проде — MultiLevelWriter, дублирующий каждое событие в stdout и в lumberjack. Порог применяется SetGlobalLevel до ветки формата. Метода Sync у zerolog.Logger в 1.34.0 не существует — файловый буфер сбрасывает os.File.Sync через type assertion, а закрытие файла делает lumber.Close.

> **Живое место: stdout, который читает кто-то ещё.** Сервис-воркер печатал прогресс fmt.Println-ом в stdout и туда же писал JSON-логи: в терминале разработчика всё выглядело живым, а скрипт дежурного, собиравший stdout в таблицу, падал на строке баннера и на JSON-записи разом. Если ваш CLI-сервис — утилита, чей stdout является интерфейсом программы, то логам в этом потоке не место: уводите их в stderr или в файл через Config.Stdout. Если сервис фоновый и ничего человеческого не печатает, то пара stdout плюс файл из MultiLevelWriter — легальный tee для сборщика. Правило одно: заранее назовите, кто читает каждый из двух потоков, и не смешивайте.

### Writer по окружению: консоль в деве, tee в проде

Формат и порог меняются вместе — правило из урока про окружение, и NewLogger держит их в одной функции. Дев-ветка отдаёт ConsoleWriter: цветные строки key=value, время в формате 15:04:05, полный поток на Debug. Прод-ветка строит пару writer-ов: JSON едет в stdout, откуда его забирает сборщик, и одновременно в файл через lumberjack — ротация из [урока про lumberjack](/courses/zerolog/lessons/09-rotaciya-lumberjack/). Числа ротации задаются явно и не остаются нулями: MaxSize — мегабайты до деления, MaxBackups — сколько кусков хранить, MaxAge — предельный возраст бэкапов в днях, Compress — gzip на переименованных файлах. Нулевые дефолты lumberjack означают «хранить всё вечно», и на долгоживущем сервисе это тихая дорога к полному диску; бюджет места считается заранее — примерно MaxSize, умноженный на MaxBackups плюс один. MultiLevelWriter принимает обычные io.Writer и заворачивает их в LevelWriterAdapter, так что писать можно в оба потока, не задумываясь, кто из них умеет WriteLevel.

### Сублоггеры на модули и глобальный порог

Логгер сервиса один, а модулей несколько: worker, scheduler, api. Копия с предзаданным полем module собирается из базового логгера одним With — механика сублоггеров: lg.With().Str(module, worker).Logger(). Копия дешёвая, собирается один раз при старте и передаётся компоненте зависимостью: воркер получает логгер в своём конструкторе, а не достаёт глобальный из пакета log — так тест без глобальной подмены добирается до сервиса, а поле module подписывает все записи модуля. Порог при этом один на процесс: SetGlobalLevel атомарен и применяется конструктором. Точечный порог на копию даёт метод Level, но два порога в одном сервисе путают дежурного — заводите второй только под осознанный сценарий вроде тихого healthcheck, где события сами по себе шумнее записей сервиса. Имя module выбирайте из числа каталогов проекта, а не по вкусу: фильтр module равно worker должен найти в ленте всё, что написал воркер, от старта до выключения, и это тот же контракт имён, что у полей access-лога. Подтверждающая запись при старте пишет zerolog.GlobalLevel(): принятый порог виден в первой строке ленты, и спор «а какой уровень реально встал» закрывается без дебаггера.

### main: сигналы, выключение и Sync

```go
package main

import (
        "context"
        "fmt"
        "os"
        "os/signal"
        "syscall"

        "example.com/myservice/internal/logger"
)

func main() {
        cfg := logger.Config{
                Env:        envOr("APP_ENV", "development"),
                LogLevel:   envOr("LOG_LEVEL", "info"),
                LogFile:    "logs/service.log",
                MaxSize:    10, // мегабайт
                MaxBackups: 5,
                MaxAge:     30, // дней
        }

        lg, done, err := logger.NewLogger(cfg)
        if err != nil {
                fmt.Fprintln(os.Stderr, "log config:", err)
                os.Exit(1) // мусор в LOG_LEVEL: старт не имеет смысла
        }
        defer done()

        lg.Info().Str("level", cfg.LogLevel).Msg("logger ready")

        worker := NewWorker(lg.With().Str("module", "worker").Logger())
        go worker.Run()

        ctx, stop := signal.NotifyContext(context.Background(),
                syscall.SIGINT, syscall.SIGTERM)
        defer stop()

        <-ctx.Done()
        lg.Warn().Str("module", "main").Msg("SIGINT: выключаемся")
        worker.Stop()
}

func envOr(key, def string) string {
        if v := os.Getenv(key); v != "" {
                return v
        }
        return def
}
```

main не знает, как устроен вывод: он получает логгер и функцию done зависимостью. Ошибка конфигурации уходит в stderr с os.Exit(1) — до этой точки ни один файл не открыт, так что закрывать нечего, и пропуск defer-а безвреден. signal.NotifyContext превращает SIGINT и SIGTERM в отмену контекста: разблокированный канал ctx.Done() запускает выключение, worker.Stop останавливает работу, и на выходе defer done синхронизирует stdout и закрывает журнал.

Слова про закрытие здесь честные, потому что у zerolog.Logger в 1.34.0 нет ни Sync, ни Close: среди публичных методов в log.go — Output, With, Level, Sample, Hook и события, а единственный Sync в поставке — SyncWriter из writer.go, который синхронизирует вызовы Write мьютексом и к fsync отношения не имеет. Сброс и закрытие делает владелец writer-а: os.File.Sync() руками и lumber.Close() внутри done. Нюанс, который легко пропустить: у MultiLevelWriter есть собственный Close, закрывающий все подписчики, реализующие io.Closer, по порядку, — но при первой ошибке остальные не закрываются вовсе, и файл журнала остаётся открытым до конца процесса. Поэтому проект закрывает каждого подписчика руками и не полагается на общий Close. Цена вопроса измеряется хвостом ленты: без Sync последние строки могут остаться в буфере libc и исчезнуть при обрыве питания, а без Close дескриптор журнала остаётся у процесса до самого конца — на файле это почти безвредно, но дисциплина едина и для сетевого writer-а, где забытый сокет дороже. В тестах эта дисциплина ничего не стоит: done подставляется в defer и выполняется на выходе из функции автоматически.

### Тест конструктора: буфер и порог

Тест логгера повторяет приём из [урока про тесты логов](/courses/zerolog/lessons/20-testy-logov/): writer подменяется буфером, событие разбирается в карту, проверки идут по ключам. Здесь подмена даже проще, чем в двадцатом уроке: поле Config.Stdout принимает io.Writer, и тест кладёт туда буфер, не трогая os.Stdout глобальной подменой. Порог проверяется тем же прогоном: конструктор поднял глобальный уровень до Info, событие Debug отсекается в should() ещё до создания записи, и в буфере обязана остаться ровно одна строка — так тест ловит сломанный порог, а не только поля. Числа приходят из json.Unmarshal во float64: сравнение с float64(8080) — не педантизм, а условие зелёного теста. Тест получает логгер конструктором, а не собирает его сам, поэтому проверяется и вся ветка прод-конфига: порог, tee из двух writer-ов, разбор записи из первого потока. Журнальная часть конфига тоже накрыта: LogFile указывает в t.TempDir(), и lumberjack создаёт журнал во временном каталоге, не разбрасывая файлы по системе.

### TestNewLogger: уровень, поля и порог

```go
package logger

import (
        "bytes"
        "encoding/json"
        "path/filepath"
        "testing"
)

func TestNewLoggerProd(t *testing.T) {
        var buf bytes.Buffer

        cfg := Config{
                Env:      "production",
                LogLevel: "info",
                LogFile:  filepath.Join(t.TempDir(), "service.log"),
                Stdout:   &buf, // события в буфер, а не в stdout теста
        }

        lg, done, err := NewLogger(cfg)
        if err != nil {
                t.Fatal(err)
        }
        defer done()

        lg.Warn().Str("module", "api").Int("port", 8080).Msg("started")
        lg.Debug().Msg("hidden by threshold")

        var m map[string]any
        if err := json.Unmarshal(buf.Bytes(), &m); err != nil {
                t.Fatal(err)
        }
        if m["level"] != "warn" {
                t.Fatalf("level: %v", m["level"])
        }
        if m["module"] != "api" || m["port"] != float64(8080) {
                t.Fatalf("fields: %v / %v", m["module"], m["port"])
        }
        if lines := bytes.Count(buf.Bytes(), []byte("\n")); lines != 1 {
                t.Fatalf("events: %d, want 1", lines)
        }
}
```

Буфер подставлен в Config.Stdout — конструктор тестируется как обычная функция, без глобальной подмены log.Logger. Warn-событие уезжает в буфер одной JSON-строкой, Debug отсечён порогом Info, который конструктор установил SetGlobalLevel: счётчик строк ловит лишние записи. Разбор идёт json.Unmarshal в карту: порт приходит float64, уровень и модуль — строками. LogFile указывает в t.TempDir(), поэтому lumberjack пишет журнал во временный каталог, который тест удалит сам.

### Кирпичи проекта и их уроки

| Кирпич | Урок курса | Роль в проекте |
| --- | --- | --- |
| Config, ParseLevel, SetGlobalLevel | 16 — конфигурация | порог из окружения, дефолт Info, мусор валит старт |
| ConsoleWriter с Out и TimeFormat | 05 — консольный вывод | человекочитаемый формат в деве |
| lumberjack.Logger | 09 — ротация | MaxSize, MaxBackups, MaxAge, Compress — жизнь журнала |
| MultiLevelWriter | 08 — несколько writer-ов | stdout и файл одновременно |
| With().Str(module, ...) | 10 — сублоггеры | копия на модуль с полем во всех записях |
| буфер и json.Unmarshal | 20 — тесты логов | контракт события: уровень, поля, число строк |

### Чек-лист сборки перед выкаткой

- дефолт Info подставлен до ParseLevel: пустая строка окружения — не NoLevel
- ошибка ParseLevel валид старт: сообщение в stderr и os.Exit, без тишины
- writer и порог выбираются в одной ветке: консоль в деве, tee в проде
- числа lumberjack заданы явно: нулевые MaxBackups и MaxAge хранят архив вечно
- сублоггеры собираются один раз и передаются зависимостью, не глобалью
- выключение: ctx.Done от сигнала, затем done — Sync файла и lumber.Close
- тест: буфер в Stdout, карта по ключам, float64 у чисел, счётчик строк на порог
- go build собирает бинарник без единого CLI-флага: APP_ENV и LOG_LEVEL накрывают конфиг

### Проверка: сброс файлового буфера

**Вопрос:** Как в этом проекте гарантировать, что запись попала на диск при выключении?

- ✅ У zerolog.Logger в 1.34.0 нет Sync: os.File.Sync() и lumber.Close() руками в done
- Вызвать logger.Sync() перед выходом из main
- zerolog.Flush() сбрасывает все writer-ы разом
- Ничего не нужно: zerolog сам fsync-ит после каждой записи

Методов Sync и Close у Logger в v1.34.0 нет; SyncWriter из writer.go — это мьютекс, а не fsync. Сброс и закрытие делает владелец writer-а: assertion на os.File и lumber.Close внутри функции done.

### Проверка: куда уедет событие

**Вопрос:** Прод-конфиг: zerolog.New(zerolog.MultiLevelWriter(os.Stdout, lumber)). Куда попадёт Info-запись?

- ✅ В оба writer-а: stdout и файл — MultiLevelWriter дублирует, как tee
- Только в файл: stdout MultiLevelWriter игнорирует
- Только в stdout: файл пишется отдельным логгером
- Зависит от уровня: Error уезжает в оба, Info — только в файл

MultiLevelWriter повторяет семантику tee: каждая запись дублируется всем writer-ам, а не-LevelWriter-ы заворачиваются в LevelWriterAdapter. Уровень ни на что не влияет — за пороги отвечает глобальный SetGlobalLevel.

### Что напечатает сборка конфига с опечаткой?

```go
cfg := logger.Config{
        Env:      "production",
        LogLevel: "ifno", // опечатка вместо info
}

lg, done, err := logger.NewLogger(cfg)
fmt.Println(err != nil, done == nil)
```

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

- ✅ true false: ParseLevel вернул ошибку, done ещё не создан
- false true: NoLevel без ошибки, как на пустой строке
- false false: конструктор подставил дефолт Info внутри
- Паника: NewLogger не допускает мусорных уровней

ifno не совпадает ни с одним именем уровня и не парсится числом — ParseLevel вернёт ошибку, конструктор вернёт её дальше с nil-done. Пустая строка повела бы себя иначе: NoLevel без ошибки, поэтому дефолт ставится до парсинга.

### Соберите прод-ветку NewLogger

Восстановите пять пропусков: парсинг, порог, числа ротации и tee.

```go
lvl, err := zerolog.___(cfg.LogLevel)
if err != nil {
        return zerolog.Nop(), nil, err
}
zerolog.___(lvl)

lumber := &lumberjack.Logger{
        Filename:   cfg.LogFile,
        ___:        10,
        MaxBackups: 5,
        ___:        30,
        Compress:   true,
}

lg := zerolog.New(zerolog.___(out, lumber)).
        With().Timestamp().Logger()
```

Подсказка: Строку в уровень парсит ParseLevel, применяет SetGlobalLevel; мегабайты — MaxSize, дни — MaxAge; tee — MultiLevelWriter.

Правильные ответы в пропусках: `ParseLevel`, `SetGlobalLevel`, `MaxSize`, `MaxAge`, `MultiLevelWriter`

### Порядок в main

Расставьте строки main: конфиг, конструктор, воркер, сигнал, выключение.

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

1. `cfg := logger.Config{LogLevel: envOr("LOG_LEVEL", "info")}`
2. `lg, done, err := logger.NewLogger(cfg)`
3. `if err != nil {`
4. `  fmt.Fprintln(os.Stderr, "log config:", err)`
5. `  os.Exit(1)`
6. `}`
7. `defer done()`
8. `worker := NewWorker(lg.With().Str("module", "worker").Logger())`
9. `ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT)`
10. `<-ctx.Done()`
11. `worker.Stop()`

### Карточки: проект целиком

- **NewLogger(cfg)** — Конструктор проекта: ParseLevel, порог, writer по окружению. Возвращает логгер и функцию done: владелец writer-ов закрывает их сам.
- **MultiLevelWriter** — tee от zerolog: дублирует запись всем writer-ам, не-LevelWriter-ы заворачивает в LevelWriterAdapter. В проде — stdout плюс lumberjack.
- **Числа lumberjack** — MaxSize в мегабайтах, MaxBackups в штуках, MaxAge в днях, Compress — gzip. Нули значат «хранить всё»: задавайте явно.
- **Sync у Logger** — В zerolog 1.34.0 его нет: SyncWriter — мьютекс, не fsync. Файл сбрасывает os.File.Sync, закрывает lumber.Close внутри done.
- **Config.Stdout** — io.Writer в конфиге: nil — os.Stdout, тесты подставляют буфер. Логгер приходит компонентам зависимостью, а не глобалью.
- **SIGINT** — signal.NotifyContext превращает сигнал в отмену контекста: разблокированный ctx.Done() запускает выключение и defer-цепочку с done.

> **Про песочницу.** Проект из трёх файлов с сигналами и внешними пакетами в песочнице сайта не собирается: os/signal и lumberjack за её границей. Песочница покажет программу на чистом Go, которая печатает пару записей в формате key=value, — разбирайте построчно. Полный прогон локален: go build, APP_ENV=production LOG_LEVEL=info, Ctrl-C — и лента выключения в терминале.

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

Каркас готов к переносу: скопируйте logger-пакет в свой проект, подставьте свои поля конфига и держите конструктор единственным местом, где решается судьба writer-ов. Направления доращивания лежат в соседних уроках: метрика рядом с событием — из урока про Prometheus, корреляция записей через trace_id — из урока про трейсы, доступ-лог — из [урока про HTTP-middleware](/courses/zerolog/lessons/11-log-http/), когда у сервиса появится HTTP-слой. Флаги командной строки сознательно не подключены: переменные окружения накрывают конфиг логгера целиком, а когда флагов станет больше пяти, возьмите cobra — но это история про парсинг аргументов, а не про логи. Последний совет финала: прежде чем добавлять новый кирпич, прогоните тест конструктора — зелёная карта с уровнем и полями стоит минуты, а ловит поломку контракта раньше дежурного.

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

- Итоговый zerolog проект — конструктор NewLogger(cfg): конфиг, порог и writer в одном месте.
- Дефолт Info ставится до ParseLevel; мусорная строка валит старт, пустая — не ошибка, а «не настроено».
- Дев — ConsoleWriter в stdout; прод — MultiLevelWriter: JSON в stdout и в файл с lumberjack-ротацией.
- Числа ротации задаются явно: MaxSize в мегабайтах, MaxBackups и MaxAge — против вечного архива.
- У Logger в 1.34.0 нет Sync и Close: os.File.Sync и lumber.Close делает владелец в done.
- Сублоггеры с полем module передаются зависимостью; порог один на процесс через SetGlobalLevel.
- Тест: буфер в Config.Stdout, карта по ключам, float64 у чисел, счётчик строк против лишних событий.
- SIGINT через signal.NotifyContext: ctx.Done() запускает выключение, defer done закрывает журнал.

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

Соберите проект: logger-пакет с NewLogger и main с сигналом, прогоните оба окружения — в деве посмотрите на TimeFormat консоли, в проде убедитесь, что запись легла и в stdout, и в logs/service.log. Пошлите SIGINT и проверьте, что done выполнился: добавьте в него подтверждающую запись и найдите её последней строкой ленты. Сломайте контракт трижды: уберите дефолт LogLevel и запустите с пустой переменной, подставьте LogLevel ifno, верните в тесте Stdout без буфера — каждый раз тест обязан падать с внятным сообщением, а не молча. Выставьте MaxSize: 1 и залейте журнал до ротации, затем посчитайте бэкапы против MaxBackups. Напишите второй тест на дев-ветку: ConsoleWriter пишет не JSON, поэтому проверяйте в буфере подстроку уровня и отсутствие ключа message в виде JSON-поля.

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

### Почему NewLogger возвращает ошибку, а не зовёт log.Fatal на плохом LOG_LEVEL?

Fatal внутри библиотеки отнимает у вызывающего решение о судьбе процесса: конструктор не знает, чем является мусорный уровень — ошибкой конфигурации или поводом для фолбэка. Проект возвращает логгер, done и ошибку, а main решает: напечатать в stderr и выйти. Плюс defer-семантика: os.Exit пропускает defer-ы, и хорошо, что до точки ошибки ни один writer ещё не открыт.

### Когда fmt.Println лучше логгера?

Когда stdout — интерфейс программы: CLI-утилита печатает результат человеку или другому процессу, и каждая JSON-запись в этом потоке ломает парсер. Для короткоживущей утилиты с парой сообщений логгер вообще не нужен. Как только появляется диагностический поток — ошибки, повторы, длительности, — структурный логгер в stderr или файл выигрывает: уровни, поля и фильтры дешевле, чем grep по произвольным строкам.

### Нужны ли флаги командной строки для настройки логгера?

В проекте они сознательно не подключены: APP_ENV и LOG_LEVEL накрывают две оси конфигурации — окружение и порог, — и читаются одинаково в systemd-юните, docker-compose и CI. Флаги оправданы, когда одной командой запускаются разные сценарии вроде разового прогона. Для парсинга флагов возьмите cobra, но пусть логгер продолжает читать окружение: две системы конфигурации для двух полей — лишний шов.
