---
title: "Удалённые хранилища: etcd и Consul"
description: "Удалённый конфиг go: viper с etcd и Consul — AddRemoteProvider, ReadRemoteConfig, секреты в KV и WatchRemoteConfig; когда один файл уже не справляется."
course: "Viper: конфигурация приложений на Go"
url: "https://golangmaster.ru/courses/viper/lessons/21-remote-kvstore/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 16
---

# Удалённые хранилища: etcd и Consul

*Курс «Viper: конфигурация приложений на Go», урок 21 из 25. ~16 минут. Обновлено 27.09.2026. Источник: https://golangmaster.ru/courses/viper/lessons/21-remote-kvstore/*

Удалённый конфиг go нужен в тот день, когда настроек становится больше, чем инстансов: viper умеет читать etcd и Consul через remote-провайдер — AddRemoteProvider, ReadRemoteConfig и WatchRemoteConfig, и это выход, когда файл из репозитория обслуживает уже пятый сервис и каждая правка таймаута превращается в релиз.

Пятый инстанс сервиса залили в кластер, и выяснилось, что он должен брать те же лимиты и адреса, что и первые четыре, — а config.yaml лежит в репозитории и разъезжается по инстансам вместе с образом. Правка «поменять таймаут» превращается в релиз: коммит, пайплайн, выкат в пять мест, и где-то обязательно остаётся старый образ со старым значением. Удалённый конфиг снимает это структурно: viper умеет читать настройки из etcd и Consul — KV-хранилищ, которые придуманы ровно для одного набора значений на много потребителей. Правка делается один раз в хранилище, инстансы видят её на следующем чтении или вотче, репозиторий остаётся для кода. В этом уроке — remote-провайдеры viper: подключение через AddRemoteProvider и ReadRemoteConfig, секреты в KV как продолжение разговора из урока про секреты, вотч с честной оговоркой про его механику и спор о том, что выбрать: KV или env из оркестратора.

### Remote-провайдеры viper: etcd, Consul и Firestore

Remote-провайдер — слой источников, который viper проверяет после файлов и до дефолтов: в лестнице из урока про [приоритеты источников](/courses/viper/lessons/09-prioritety-istochnikov/) он значится как external KV stores. Список провайдеров зашит в переменную SupportedRemoteProviders и по исходникам свежих версий выглядит так: etcd (старый v2-клиент), etcd3, consul, firestore и nats — последний добавили недавно, поэтому в старой документации его нет. Подключение начинается не с конфига, а с импорта: remote-поддержка вынесена в отдельный пакет, и без blank-импорта пакета viper/remote любые вызовы вернут ошибку с подсказкой «enable the remote features» — viper намеренно не тянет клиенты etcd в проекты, которые им не пользуются. Дальше три вызова: AddRemoteProvider объявляет источник, SetConfigType говорит, в каком формате лежат байты, ReadRemoteConfig тянет их и разбирает в реестр. Провайдеров можно добавить несколько — viper опрашивает их в порядке добавления.

### Три вызова до значения из etcd

```go
import _ "github.com/spf13/viper/remote" // включает remote-поддержку

v := viper.New()
v.AddRemoteProvider("etcd", "http://127.0.0.1:4001", "/config/app.json")
v.SetConfigType("json") // у байтов из KV нет расширения — формат задаём сами
if err := v.ReadRemoteConfig(); err != nil {
    log.Fatalf("remote config: %v", err)
}

fmt.Println(v.GetString("database.host")) // db.internal из etcd
```

Порядок жёсткий: сначала blank-импорт пакета remote — он регистрирует драйверы хранилищ, затем AddRemoteProvider с типом хранилища, адресом и путём к ключу. SetConfigType обязателен: в отличие от файла, у удалённых байтов нет расширения, и viper не догадается, что перед ним json. ReadRemoteConfig возвращает ошибку — глотать её нельзя, ниже покажу почему. Воспроизведите пример локальным Go: etcd поднимается докером одной командой, и весь каркас оживает за пару минут.

### ReadRemoteConfig: что возвращает и когда молчит

ReadRemoteConfig идёт по списку добавленных провайдеров в порядке добавления и берёт первого, у кого путь ответил: у etcd и etcd3 адрес — это http://ip:port, у Consul — ip:port без схемы, у Firestore — идентификатор проекта и путь collection/document. Несколько хостов перечисляются через точку с запятой — http://127.0.0.1:4001;http://127.0.0.1:4002, это задел на отказоустойчивость. Тихих места здесь два. Первое: AddRemoteProvider с пустым provider или endpoint ошибку не вернёт — провайдер просто не добавится, а ReadRemoteConfig потом отчитается уже невнятным «No Remote Providers», и вы будете искать сетевые проблемы там, где опечатка в адресе. Второе: если ни один провайдер не ответил, вернётся ошибка «No Files Found» — по формулировке не очевидно, что речь про KV, а не про файл. Поэтому адреса и пути к ключам я выношу в константы рядом с объявлением провайдера: они опечатываются чаще, чем кажется.

### Фолбэк на файл, когда KV недоступен

```go
v := viper.New()

// дефолты объявляем до чтения: пусть сервис стартует даже без KV
v.SetDefault("database.host", "localhost")
v.SetDefault("database.port", 5432)

v.AddRemoteProvider("etcd", "http://etcd.internal:2379", "/config/app.json")
v.SetConfigType("json")

if err := v.ReadRemoteConfig(); err != nil {
    log.Printf("KV недоступен, работаю на дефолтах: %v", err)
    // тот же реестр умеет читать файл — это фолбэк, а не второй источник
    v.SetConfigName("config")
    v.AddConfigPath("./configs")
    if err := v.ReadInConfig(); err != nil {
        log.Fatalf("ни KV, ни файл: %v", err)
    }
}
```

Фолбэк держится на том, что реестр один: ReadRemoteConfig и ReadInConfig наполняют его одинаково, и лестница приоритетов из урока про источники работает как обычно. Дефолты объявлены до чтения — они последние на лестнице и не перекроют ни KV, ни файл. Лог с ошибкой обязателен: строка «работаю на дефолтах» — не шум, а то, по чему ночью находят причину странных значений в ответах сервиса.

> **Сервис молчит на дефолтах — и health-check зелёный.** Живое место: локально всё работает, а в проде сетевая политика не пускает контейнер до etcd — порт 2379 закрыт, о чём никто не вспомнил. ReadRemoteConfig возвращает ошибку, её записывают в лог и идут дальше, сервис поднимается на дефолтах: хост базы localhost, лимиты зашитые. Pod зелёный, трафик идёт, ответы неверные — и ищут причину в коде, а не в сети. Лечение: для обязательных настроек log.Fatal сразу при ошибке чтения KV, для опциональных — явный флаг degraded и строка уровня error, а не info.

### Секреты в KV: AddSecureRemoteProvider и GPG-кейринг

KV-хранилище — естественное место для секретов из урока про [секреты конфигурации](/courses/viper/lessons/17-sekrety/): значение задаёт оператор с ACL, до кода оно доезжает через память процесса, в репозитории не появляется. Для шифрованного содержимого у viper есть AddSecureRemoteProvider — тот же контракт, что у AddRemoteProvider, плюс четвёртый аргумент: путь к GPG-секретному кейрингу, например /etc/secrets/mykeyring.gpg. Под капотом работает пакет crypt: положить значение можно его утилитой командой crypt set -plaintext /config/app.json config.json, а viper при чтении расшифрует сам, если кейринг правильный. Теперь честная оговорка, которую я обещал: README не документирует ручную регистрацию jsoncrypt-алгоритмов — форматы jsoncrypt и yamlcrypt живут внутри crypt и срабатывают автоматически, а сам пакет в истории переезжал с spf13/crypt на sagikazarmark/crypt, так что API крипто-слоя между версиями гулял. Всё, что сложнее штатного сценария «зашифровал crypt-ом, прочитал с кейрингом», сверяйте с документацией своей версии viper — а не со статьями, включая эту.

### WatchRemoteConfig: поллинг вместо fsnotify

Локальный вотч из урока про [WatchConfig](/courses/viper/lessons/12-watch-config/) держится на fsnotify: файловая система сама сообщает об изменении. У удалённого хранилища такого механизма в viper нет — есть WatchRemoteConfig, который при каждом вызове просто заново тянет конфиг из KV. Поэтому документация показывает цикл: горутина, sleep на несколько секунд, WatchRemoteConfig, при ошибке лог и продолжение, затем Unmarshal свежих значений. В исходниках рядом живёт WatchRemoteConfigOnChannel, но в README он не показан, а колбэка вроде OnRemoteConfigChange, который встречается в обсуждениях, в API актуальных версий нет — я проверял по v1.20 и v1.21. Вывод практический: механизм удалённого вотча исторически нестабилен, и надёжнее писать собственный цикл поллинга с той частотой, которая вам нужна, — заодно вы контролируете нагрузку на хранилище. Коммент в документации «only tested with Etcd support» тоже держите в голове: поведение вотча на остальных провайдерах проверяйте сами.

### Цикл вотча в горутине

```go
go func() {
    for {
        time.Sleep(5 * time.Second)

        if err := v.WatchRemoteConfig(); err != nil {
            log.Printf("remote watch: %v", err)
            continue // хранилище моргнуло — попробуем на следующем тике
        }

        var cfg Config
        if err := v.Unmarshal(&cfg); err != nil {
            log.Printf("unmarshal: %v", err)
            continue
        }
        atomic.StoreInt32(&ready, 1) // потребители видят свежий срез целиком
    }
}()
```

Цикл повторяет официальный пример: пауза, WatchRemoteConfig, обработка ошибки, Unmarshal. Значение собирается в свежую структуру, а не поверх старой — чтобы половина полей не осталась от прошлого раза, если схема в хранилище изменилась. Ошибка вотча сервис не роняет: KV мог моргнуть, и следующий тик исправит ситуацию. Проверьте локальным Go: поднимите etcd, поменяйте ключ обычным put — через пять секунд значение в структуре обновится без рестарта.

### Файл против KV-хранилища

| Аспект | Файл в репозитории | KV: etcd или Consul |
| --- | --- | --- |
| Кого обновляет правка | деплой: образ пересобирается в каждом инстансе | одно значение в хранилище, видят все инстансы |
| Изменение без релиза | часы: коммит, пайплайн, выкатка | минуты: put в хранилище и вотч у сервиса |
| Откат | git revert и новый релиз | предыдущее значение тем же put |
| Секреты | нельзя: git-история и клоны | можно: ACL и шифрование через кейринг |
| История значений | в git, вместе с кодом | зависит от хранилища, чаще нет — логируйте сами |
| Когда хватает | один сервис, настройки меняются с релизом | много инстансов и сервисов, общие значения |

> **etcd и etcd3 — два разных провайдера.** В SupportedRemoteProviders сидят и etcd, и etcd3, и это не опечатка: под первым скрывается старый v2-клиент, под вторым — clientv3. Указанный тип должен совпадать с тем, что реально стоит в вашем etcd и с зависимостью, которую вы готовы тянуть. Ошибка в типе провайдера проявится только на чтении, странной ошибкой подключения. Адреса нескольких нод перечисляются через точку с запятой прямо в строке endpoint.

### Признаки, что файлу пора в KV

- Настроек становится больше, чем сервисов, которые их читают: одно значение на десяток инстансов.
- Правка конфига требует релиза, хотя код не менялся, — и релизы ради таймаутов всем надоели.
- Инстансы расходятся: в одном образе свежий таймаут, в другом — собранный на неделе раньше.
- Секретам нужны ACL и ротация, а env деплоя уже не справляется — продолжение темы из урока про секреты.
- Появился второй язык или стек, которому нужны те же значения, — общий KV дешевле синхронизации файлов.
- Изменения должны доезжать без рестарта: вотч раздаёт новые значения живым процессам.

### Проверка: SetConfigType

**Вопрос:** Почему для удалённого провайдера обязателен вызов SetConfigType?

- viper по умолчанию читает удалённые конфиги как JSON, тип нужен только для YAML
- ✅ у потока байт из KV нет расширения файла, и viper не может сам угадать формат
- SetConfigType задаёт путь к ключу внутри хранилища
- без него AddRemoteProvider возвращает ошибку при объявлении

Файл узнаётся по расширению вроде config.yaml, у удалённых байтов подсказки нет — тип задаётся явно: json, toml, yaml, yml, properties, props, prop, env, dotenv. Без явного типа ReadRemoteConfig разберёт поток неверно или не разберёт вовсе.

### Проверка: ошибка «enable the remote features»

**Вопрос:** Код remote-чтения написан верно, но ReadRemoteConfig стабильно возвращает ошибку «enable the remote features». Что забыли?

- вызов SetConfigType("json") перед чтением
- ✅ blank-импорт пакета _ "github.com/spf13/viper/remote"
- вызов SetConfigName("config")
- включение AutomaticEnv

Remote-поддержка вынесена в отдельный пакет и включается blank-импортом: без него viper не тянет клиенты etcd и Consul в бинарник, а ReadRemoteConfig отвечает ошибкой с точной подсказкой. Остальные вызовы на это сообщение не влияют — они даже не успевают выполниться.

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

```go
// etcd на 127.0.0.1:4001 не запущен

v := viper.New()
v.AddRemoteProvider("etcd", "http://127.0.0.1:4001", "/config/app.json")
v.SetConfigType("json")

v.SetDefault("server.port", 8080)

if err := v.ReadRemoteConfig(); err != nil {
    log.Println("remote:", err)
}

fmt.Println(v.GetInt("server.port"))
```

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

- панику: ключ не найден ни в одном источнике
- 0: чтение упало, реестр остался пустым
- ✅ 8080: дефолт объявлен и отдаётся, когда выше по лестнице пусто
- ошибку компиляции: SetDefault нельзя смешивать с remote

Ошибка чтения KV не опустошает реестр: SetDefault объявлен до чтения и остаётся в лестнице, а Get спокойно спускается до него. Именно поэтому молчание на дефолтах так коварно — сервис работает, и только строка в логе с ошибкой чтения подсказывает, что значения не те, что лежат в хранилище.

### Соберите подключение Consul

Допишите подключение Consul: добавление провайдера, явный тип содержимого и само чтение из хранилища.

```go
v := viper.New()

v.___("consul", "localhost:8500", "MY_CONSUL_KEY")

v.___("json")

if err := v.___(); err != nil {
    log.Fatalf("remote: %v", err)
}

fmt.Println(v.Get("port")) // 8080
```

Подсказка: Провайдер добавляет AddRemoteProvider, формат потока байт задаёт SetConfigType, а чтение из хранилища выполняет ReadRemoteConfig.

Правильные ответы в пропусках: `AddRemoteProvider`, `SetConfigType`, `ReadRemoteConfig`

### Соберите секцию подключения

Расставьте строки по порядку: реестр, провайдер, тип содержимого, дефолт, чтение и печать значения.

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

1. `v := viper.New()`
2. `v.AddRemoteProvider("etcd", "http://127.0.0.1:4001", "/config/app.json")`
3. `v.SetConfigType("yaml")`
4. `v.SetDefault("server.port", 8080)`
5. `err := v.ReadRemoteConfig()`
6. `fmt.Println(v.GetString("server.port"))`

### Карточки: удалённые хранилища

- **AddRemoteProvider** — Объявление удалённого источника: тип хранилища (etcd, etcd3, consul, firestore, nats), адрес endpoint и путь к ключу. Пустые provider или endpoint молча не добавляются — ошибку вернёт уже ReadRemoteConfig.
- **ReadRemoteConfig** — Тянет конфиг из KV и разбирает в реестр. Провайдеры опрашиваются по порядку добавления, берётся первый ответивший; если никто не ответил — ошибка «No Files Found». Формат байтов задаёт SetConfigType.
- **WatchRemoteConfig** — Повторное чтение удалённого конфига при каждом вызове: поллинг, а не события. Официальный паттерн — горутина с паузой в несколько секунд, логом ошибки и Unmarshal свежих значений в новую структуру.
- **AddSecureRemoteProvider** — Вариант для шифрованного содержимого: четвёртым аргументом идёт путь к GPG-секретному кейрингу. Расшифровку выполняет пакет crypt; класть значения удобно его утилитой: crypt set -plaintext.
- **SupportedRemoteProviders** — Зашитый список провайдеров: etcd, etcd3, consul, firestore и nats в свежих версиях. Имя в AddRemoteProvider должно совпасть с элементом списка, иначе чтение вернёт UnsupportedRemoteProviderError.
- **KV против env из оркестратора** — Для одного-двух сервисов в k8s env с ConfigMap проще и не добавляет систему в эксплуатацию. KV оправдан на многих сервисах с общими значениями и правками без деплоя; bootstrap-доступ к самому KV всё равно живёт в env.

### Спор: KV-конфиг против env из оркестратора

Моё мнение, с которым коллеги в k8s-проектах спорят охотно: оркестратор уже сделал env-конфиг удобнее, чем когда-либо, и это отъело у KV большую часть сценариев. ConfigMap монтируется или подставляется в переменные, значения версионируются вместе с манифестами, правки проходят через git-review, и ещё одной системы в эксплуатации не появляется. Если у вас один-два сервиса в k8s и настройки меняются вместе с релизом — env честно закрывает потребность, и KV будет решением в поисках проблемы. Там же, где KV всё ещё выигрывает: много сервисов разного стека, гибридная инфраструктура без единого оркестратора, значения, общие для десятков потребителей, и правки, которые обязаны доезжать без выкатки. Граница, которую я провожу: появился второй язык или первый инстанс, которому нужен тот же конфиг, — начинайте смотреть в KV; пока конфиг приватен одному сервису — env и никаких церемоний. И помните про bootstrap: адрес и секрет доступа к самому хранилищу всё равно живут в env, от этой пары KV не уводит.

Удалённый конфиг собран: провайдер объявлен, тип задан, ошибка чтения обработана, вотч крутится в горутине, секреты едут через кейринг. Осталось не забыть про дежурного, который будет это эксплуатировать: положите рядом описание того, что происходит при недоступности KV — фолбэк или Fatal, — и закрепите его тестом. Дальше по курсу разговор пойдёт о том, когда viper вообще не нужен: сравним env-конфиг и библиотеки вроде koanf с честными критериями выбора. Общий маршрут по темам — на [странице курса viper](/courses/viper/). И задание на сейчас: посчитайте, сколько инстансов читают ваш config.yaml, — если больше одного, пример из урока уже применим.

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

- Удалённый конфиг go закрывает случай «один набор значений на много инстансов»: правка делается в KV один раз вместо релиза.
- Remote-поддержка включается blank-импортом viper/remote; без него ReadRemoteConfig отвечает ошибкой с точной подсказкой.
- Контракт из трёх вызовов: AddRemoteProvider, обязательный SetConfigType и ReadRemoteConfig; ошибка чтения — строка в логе или Fatal, но не тишина.
- Секреты едут через AddSecureRemoteProvider с GPG-кейрингом, расшифровку делает пакет crypt; ручная регистрация jsoncrypt-кодеков документацией не подтверждена.
- WatchRemoteConfig — поллинг: горутина с паузой сама тянет изменения; колбэков в документации нет, WatchRemoteConfigOnChannel в README не показан.
- Файл дешевле для одного сервиса, KV — для многих потребителей, правок без деплоя и общих секретов с ACL.
- Env из оркестратора забрал большинство сценариев KV: KV оправдан, когда конфиг общий для многих сервисов и стеков.

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

Поднимите etcd докером и подключите к каркасу из урока про пакет config remote-провайдер: AddRemoteProvider, SetConfigType, ReadRemoteConfig с проверкой ошибки и фолбэком на файл. Затем вынесите database.password в AddSecureRemoteProvider с кейрингом и проверьте циклом WatchRemoteConfig, как правка значения доезжает до сервиса без рестарта.

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

### WatchRemoteConfig — это push или pull, насколько он надёжен?

Строго pull: вызов заново тянет конфиг из хранилища, событий как у fsnotify нет. Официальный пример — горутина с паузой и повтором; ошибки не роняют сервис. Колбэков вроде OnRemoteConfigChange в API актуальных версий нет, а WatchRemoteConfigOnChannel в README не показан, поэтому цикл поллинга — самый надёжный путь.

### Нужно ли шифровать значения в Consul или хватит ACL?

ACL решает, кто читает и пишет, но не защищает значение на диске хранилища и в его логах. AddSecureRemoteProvider с GPG-кейрингом шифрует само содержимое: без ключа расшифровки дамп KV бесполезен. Для секретов базы я совмещаю оба уровня: ACL на пути и шифрование содержимого.

### С чего начинать: etcd, Consul, Firestore или NATS?

Начинайте с etcd или Consul: их примеры описаны дольше всех, и они привычнее в self-hosted эксплуатации. Firestore — вариант, если вы уже в Google Cloud, NATS появился в списке провайдеров недавно. Для первого опыта разница невелика: контракт из трёх вызовов у всех одинаков.
