Содержание урока · 10 разделов
- Секреты конфигурации Go: кто ещё читает config.yaml
- Где живут секреты: окружение деплоя
- Перечитывание секрета: viper не кэширует окружение
- Где живёт секрет и как доходит до кода
- Что делать, если пароль уже в истории git
- План действий после утечки секрета
- Спор: окружение деплоя или хранилище секретов
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Утренний коммит добавил в config.yaml строку database.password со значением — сервис подключился, тикет закрыт, все довольны. Через полгода репозиторий отдали подрядчику под интеграцию, и пароль уехал вместе с ним: не в текущей версии файла, а в истории, откуда его достаёт git show годичной давности. Секреты конфигурации Go по привычке кладут рядом с портом и таймаутами, в тот же файл, — и это самый дешёвый путь к утечке, потому что у config.yaml больше читателей, чем у любого другого артефакта проекта. В прошлом уроке про профили окружений мы договорились: в файлах репозитория живут только нечувствительные настройки, а пароли и ключи приходят переменными деплоя. Здесь разберём этот договор построчно: сколько на самом деле путей у секрета наружу, как viper доходит до него через окружение и что делать, когда ключ всё-таки в истории git.
Секреты конфигурации Go: кто ещё читает config.yaml#
Файл конфига — не приватная папка, и привычка держать пароли в конфиге обходится дороже, чем кажется. Первый и самый вечный читатель — git-история: коммит попадает в неё навсегда, и никакой следующий коммит с git rm её не стирает — этому ниже посвящён отдельный раздел. Второй — клоны и копии: CI-раннеры, зеркала, бэкапы хостинга, диски каждого, кто когда-то делал git clone; репозиторий разъезжается быстрее, чем меняются пароли. Третий — инфраструктура вокруг git: архивы GitLab, экспорты, вложенные в тикеты логи сборки, в которых WriteConfig из урока про запись конфига успела вывести реестр целиком. Четвёртый — сами логи: дамп конфига на старте, отладочная печать, отчёт об ошибке. Ни один из этих каналов не контролируется вами после утечки: вы не можете отозвать чужой клон или строку, уже записанную в собранные логи. Отсюда единственное рабочее правило — у секрета не должно быть копий в файлах, которые куда-то копируются.
Дамп конфига, который печатает пароль
GOv := viper.New()
v.SetConfigName("config")
v.AddConfigPath("./configs")
v.ReadInConfig() // в файле и database.password: "..."
// отладочная печать «всего конфига» на старте
fmt.Printf("startup config: %v
", v.AllSettings())
// map[database:map[host:db.internal password:s3cret-2024 retries:3] server:...]AllSettings собирает карту значений из всех источников, и пароль из файла входит в неё как обычный ключ. Printf с глаголом %v печатает карту целиком — вместе с database.password. Дальше строка уходит в stdout контейнера, сборщик логов размазывает её по индексам и репликам, и вычистить её оттуда куда неприятнее, чем изначально держать секрет в env деплоя.
Инженер в ночную смену не понял, почему сервис не видит базу, и добавил fmt.Println(v.AllSettings()) — обычная отладка. Сервис перезапустили, проблему починили, строку удалили из кода. А пароль остался: в собранных логах, в поисковом индексе, в скриншоте консоли внутри тикета. Логи хранятся дольше кода и читаются шире. Маскируйте значения при любой печати конфигурации или печатайте только имена ключей и их источники — но не значения.
Где живут секреты: окружение деплоя#
Куда класть вместо файла — туда, где уже живёт деплой. Три места без изобретения велосипедов: переменные окружения контейнера — docker run -e, env_file в compose, env в манифесте Kubernetes; Secrets Kubernetes, подставляемые в переменные контейнера через valueFrom.secretKeyRef; и EnvironmentFile в systemd — файл с парами KEY=VALUE вне репозитория, который юнит читает на старте. Общее у всех трёх: секрет назначается платформе деплоя, а не git, и до процесса доходит только через память процесса — в рабочей копии файл с паролем не появляется. Viper забирает секрет из окружения штатной лестницей из уроков про AutomaticEnv и префиксы: SetEnvPrefix задаёт неймспейс, SetEnvKeyReplacer превращает ключи в имена переменных, AutomaticEnv проверяет окружение на каждом чтении, а BindEnv привязывает явно — для имён вроде DB_PASSWORD, которые не выводятся из ключа. Контракт получается честный: ключ в коде есть, значения в репозитории нет.
Каркас чтения секрета из окружения
GOv := viper.New()
v.SetEnvPrefix("APP") // APP_ перед именами
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv() // APP_DATABASE_HOST и соседи
// имя, которое не выводится из ключа, привязываем явно
v.BindEnv("database.password", "APP_DB_PASSWORD")
// запуск: APP_DB_PASSWORD=... ./app
if !v.IsSet("database.password") {
log.Fatal("секрет database.password не задан: проверьте env деплоя")
}Секрета в файле нет, поэтому единственный источник — окружение. Ключи вроде database.host AutomaticEnv находит сама по имени APP_DATABASE_HOST, а короткое APP_DB_PASSWORD для database.password привязывается явно через BindEnv. IsSet на старте — минимальная проверка, что секрет вообще пришёл: молчаливая пустая строка вместо пароля всплывёт только на первом коннекте. Проверьте каркас локальным Go: без переменной процесс падает на старте, с ней — работает.
Перечитывание секрета: viper не кэширует окружение#
Механика, важная именно для секретов: viper не снимает копию переменной в момент BindEnv или AutomaticEnv — документация фиксирует прямо, что окружение не кэшируется и значение читается при каждом обращении. Для настроек это деталь, для секрета — инструмент. Окружение процесса фиксируется при старте контейнера, но обновить его изнутри можно: os.Setenv меняет память процесса, и следующий Get вернёт уже новое значение. Отсюда рабочий паттерн доставки свежего пароля без рестарта: платформа деплоя кладёт секрет в смонтированный файл, Kubernetes умеет обновлять такие файлы на лету, а маленькая горутина в сервисе перечитывает файл и обновляет переменную через os.Setenv — viper на следующем чтении отдаёт новый секрет, и соединение переподнимается. Это не замена ротации, а механизм доставки новой пары после неё.
Новый секрет без рестарта
GOv.BindEnv("database.password", "APP_DB_PASSWORD")
fmt.Println(len(v.GetString("database.password")), "байт") // 16 байт
// ротация прошла: горутина перечитала смонтированный секрет
os.Setenv("APP_DB_PASSWORD", "n3w-pass-w0rd-2024-secure")
fmt.Println(len(v.GetString("database.password")), "байт") // 25 байт — свежийВ отладочной печати — длина значения, а не сам секрет: привычка, которая спасает от утечки через лог. Между двумя Println переменная процесса обновилась через os.Setenv, и чтение по привязанному ключу вернуло новое значение — копии окружения в реестре нет. Воспроизведите локальным Go: два разных значения одной и той же строки кода чтения, без пересоздания реестра и без рестарта.
Где живёт секрет и как доходит до кода#
| Место хранения | Кто задаёт | Путь до кода |
|---|---|---|
| Переменные контейнера | docker run -e, compose, манифест k8s | env процесса: AutomaticEnv или BindEnv |
| Secret Kubernetes | манифест или менеджер секретов | valueFrom.secretKeyRef в env контейнера |
| systemd EnvironmentFile | файл вне репозитория на хосте | переменные процесса юнита на старте |
| KV-хранилище: etcd, Consul | операторы через ACL | remote-провайдер viper — тема урока 21 |
| config.yaml в репозитории | никто — и это проблема | никогда: место секрета не здесь |
Что делать, если пароль уже в истории git#
Пусть пароль всё-таки закоммитили — неделю назад, отдельным коммитом с внятным diff. Первое действие — ротация: смена скомпрометированного пароля в базе, у провайдера, в панели API. Ротация закрывает саму дыру — даже если секрет у кого-то есть, он уже бесполезен. Всё остальное — гигиена, а не защита. Чистка истории делается инструментами вроде git filter-repo или BFG: они переписывают коммиты, убирая файл или строку из всех ревизий, после чего история меняет хеши и нужен force push. И здесь нужна честность, которую неудобно проговаривать: переписанная история не отзывает старые копии. У каждого, кто клонировал репозиторий до чистки — коллеги, CI, форки, — старая история со старым паролем остаётся, и исправить это вы не можете ничем. Поэтому порядок именно такой: ротация, затем поиск всех мест, где секрет засветился, и только потом переписывание истории — ради новых клонов, а не ради иллюзии, что секрет «удалён».
План действий после утечки секрета#
- Сменить пароль или токен: ротация всегда первая — чистка истории никогда её не отменяет.
- Найти, где ещё живёт секрет: история git, собранные логи, бэкапы, вложения тикетов, кэши CI.
- Переписать историю git filter-repo или BFG и заранее согласовать force push с командой.
- Попросить всех, кто клонировал репозиторий, пере-клонироваться — и принять, что проверить это невозможно.
- Просмотреть журналы базы и провайдера за период утечки на следы использования старого ключа.
- Закрыть путь на будущее: секреты идут через env деплоя, в репозитории — только нечувствительные настройки.
git rm config.yaml и коммит «убрал секрет» — типичная ловушка: файл исчез из текущей версии, но любой из предыдущих коммитов по-прежнему содержит пароль, и git show HEAD~3 вернёт его одной командой. Публичные платформы сканируют загружаемые репозитории на известные паттерны ключей, и уехавший в открытый доступ секрет начинают использовать раньше, чем вы успеваете его удалить. Считайте закоммиченный секрет скомпрометированным с момента push — независимо от того, как быстро вы его «удалили».
Проверка: порядок действий
В config.yaml нашли закоммиченный месяц назад пароль базы. Что нужно сделать первым делом?
Проверка: перечитывание окружения
Ротация прошла, горутина сервиса обновила переменную через os.Setenv. Что вернёт следующий viper.GetString по BindEnv-ключу?
Что выведет программа?
Прочитайте код и выберите, что он выведет:
// configs/config.yaml: database.password: "from-file"
v := viper.New()
v.SetConfigName("config")
v.AddConfigPath("./configs")
v.ReadInConfig()
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
v.AutomaticEnv()
// запуск: DATABASE_PASSWORD=from-env ./app
fmt.Println(v.GetString("database.password"))Соберите чтение секрета
Допишите каркас: неймспейс окружения, явную привязку имени переменной и включение проверки env на каждом чтении.
Соберите секцию подготовки реестра
Расставьте строки: отдельный реестр, префикс окружения, замена разделителей, включение env, явная привязка секрета и проверка наличия.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: секреты конфигурации
Спор: окружение деплоя или хранилище секретов#
Моё правило, с которым коллеги спорят регулярно: большинству сервисов секретам достаточно окружения деплоя, и выделенные хранилища не нужны. Env уже управляется платформой, история значений живёт в истории манифестов, ещё одной системы в эксплуатации не появляется, и viper читает секреты без единой дополнительной зависимости. Плата проста: ротация руками и никакого журнала, кто и когда значение доставал. Хранилище оправдано, когда сервисов много, секреты меняются по расписанию, нужен аудит доступа и права раздаются по ACL — тогда viper подключается к etcd или Consul через remote-провайдер: AddRemoteProvider и AddSecureRemoteProvider с шифрованным содержимым описаны в документации, а подробный разбор — в ближайшем уроке про удалённые хранилища. И даже там останется bootstrap-секрет, доступ к самому хранилищу, который живёт в env: у дороги всегда есть первый метр, который ничем другим не закрыть. Не стройте хранилище секретов ради одного пароля от базы — окружение справится.
Секреты закрыты: пароли живут в окружении деплоя, viper достаёт их BindEnv и AutomaticEnv, дампы конфигурации замаскированы, а на случай утечки есть план, где первой строкой идёт ротация. Осталась незакрытой другая боль — сам viper расползся по коду: GetString с одними и теми же ключами в десятке файлов, и каждое переименование превращается в квест по репозиторию. Следующий урок собирает собственный пакет config: Load() возвращает готовую структуру, строковые ключи остаются внутри одного файла, а весь остальной код видит только поля. Общий маршрут по темам — на странице курса viper. И проверьте прямо сейчас: git log -p по вашему config.yaml — нет ли в истории того, что следовало держать в env деплоя.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Секреты конфигурации Go не живут в config.yaml: файл навсегда попадает в git-историю, разъезжается с клонами и бэкапами и светится в дампах.
- AllSettings и WriteConfig печатают и записывают секреты как обычные ключи — маскируйте значения при любой отладочной печати конфигурации.
- Место секрета — окружение деплоя: переменные контейнера, Secrets Kubernetes через valueFrom.secretKeyRef, EnvironmentFile в systemd.
- viper читает секреты штатной лестницей: SetEnvPrefix, SetEnvKeyReplacer, AutomaticEnv, а имена, которые не выводятся из ключа, — BindEnv.
- Окружение не кэшируется: значение читается при каждом Get, поэтому os.Setenv из горутины доставляет свежий секрет без рестарта.
- При утечке первым делом ротация: чистка истории git filter-repo не отзывает старые клоны и форки.
- KV-хранилища вроде etcd и Consul оправданы при многих сервисах и ротации по расписанию; для одного пароля достаточно env деплоя.
Домашнее задание
Заведите в каркасе из прошлого урока секрет DATABASE_PASSWORD через EnvironmentFile или переменную compose, уберите его из config.yaml, добавьте BindEnv и IsSet-проверку на старте. Затем найдите в истории репозитория git log -p все коммиты, где пароль появлялся, и составьте план ротации на случай, если он там всё-таки есть.
Личные заметкиПусто
Вопросы и ответы по уроку
Можно ли шифровать config.yaml и держать секрет в репозитории?
Технически — да: SOPS, git-crypt и ключи в переменных деплоя. Но появляется ещё один секрет — ключ шифрования, который нельзя потерять, и ещё один инструмент в цепочке. Для одного пароля окружение деплоя проще и надёжнее; шифрование оправдано, когда файл со значениями обязан лежать в git по регламенту.
Пустая переменная для секрета — это ошибка?
По умолчанию viper считает пустую переменную не заданной и проваливается к следующему источнику — файлу или дефолтам. Для секрета это опасно: IsSet может пройти по значению из файла. Задавайте AllowEmptyEnv осознанно или проверяйте переменную на непустоту в критичных местах.
Что делать с секретами в тестах и CI?
Тестовый контур — тоже деплой: тестовый пароль задаётся переменными окружения раннера, а не файлом в репозитории. Префикс контура из урока про профили помогает не перепутать: TEST_DATABASE_PASSWORD не совпадёт с боевой переменной, даже если манифест собран криво.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
- Пакет config: оборачиваем viper в свой API
- Валидация конфигурации до запуска
- Тестирование конфигурации: New и Reset
- Удалённые хранилища: etcd и Consul
- Профили dev, staging и prod
- RegisterAlias: синонимы ключей и миграция схемы
- Sub(): разбираем конфиг на поддеревья
- WriteConfig и SafeWriteConfig: сохраняем состояние
- Все курсы по Go
Следующая ступень учебного пути
Chi: роутер для Go HTTP-сервисов
Интерактивный курс Chi — роутера Go на net/http: маршруты, middleware, группы, контекст, JSON API, таймауты, graceful shutdown. 25 уроков с примерами и практикой.
- Строить маршруты chi: методы, параметры пути, wildcards
- Писать middleware и понимать порядок их выполнения
- Группировать маршруты: Route, Group и подроутеры
- Прокидывать данные в хендлеры через context.Context