Примечание: это не полноценная статья-руководство, а скорее напоминание/подсказка для тех, кто уже пользуется ConfigMap в Kubernetes или только готовит своё приложение для работы в нём.
Предыстория: от rsync к… Kubernetes
Что было раньше? В эпоху «классического администрирования» в простейшем варианте файл конфига размещали прямо рядом с приложениями (или в репозитории, если угодно). Всё просто: делаем элементарную доставку (CD) для нашего кода вместе с конфигом. Даже реализацию на условном rsync можно назвать зачатками CD.
Когда инфраструктура вырастала, для разных сред (dev/stage/production) требовались разные конфиги. Приложение обучали понимать, какой конфиг использовать, передавая их в качестве аргументов к запуску или переменными среды, заданными в окружении. Ещё больше CD усложняется с появлением столь полезных Chef/Puppet/Ansible. У серверов появляются роли, а окружения перестают быть описанными в разных местах — мы приходим к IaC (Infrastructure as code).
Что за этим последовало? Если удалось увидеть в Kubernetes критичные для себя плюсы и даже смириться с необходимостью модифицировать приложения для работы в этой среде, происходила миграция. По пути ожидало множество нюансов и отличий в построении архитектуры, но когда с основной частью удавалось справиться, получалось долгожданное приложение, запущенное в K8s.
Оказавшись здесь, мы по-прежнему можем использовать конфиги, подготовленные в репозитории рядом с приложением или передавая ENV в контейнер. Однако в дополнение к этим способам стали также доступны ConfigMaps. Этот примитив K8s позволяет использовать Go templates в конфигах, т.е. рендерить их подобно HTML-страницам и делать reload приложения при изменении конфига без рестарта. С ConfigMaps больше нет потребности держать 3+ конфигов для разных окружений и следить за актуальностью каждого.
Общую инструкцию-введение по ConfigMaps можно найти, например, здесь. А в этой статье я остановлюсь на некоторых особенностях работы с ними.
Простейшие ConfigMap’ы
Как же стали выглядеть конфиги в Kubernetes? Что они получили от Go-шаблонов? Например, вот заурядный ConfigMap для приложения, разворачиваемого из Helm-чарта:
apiVersion: v1
kind: ConfigMap
metadata:
name: app
data:
config.json: |
{
"welcome": {{ pluck .Values.global.env .Values.welcome | quote }},
"name": {{ pluck .Values.global.env .Values.name | quote }}
}
Здесь значения, подставляемые в .Values.welcome
и .Values.name
, будут взяты из файла values.yaml
. Почему именно из values.yaml
? Как вообще работает Go-шаблонизатор? Подробнее об этих деталях мы уже рассказывали здесь.
Вызов pluck
помогает выбрать из map’а нужную строку:
$ cat .helm/values.yaml
welcome:
production: "Hello"
test: "Hey"
name:
production: "Bob"
test: "Mike"
Причём можно брать как конкретные строки, так и целые фрагменты конфига.
Например, ConfigMap может быть таким:
data:
config.json: |
{{ pluck .Values.global.env .Values.data | first | toJson | indent 4 }}
… а в values.yaml
— следующее содержимое:
data:
production:
welcome: "Hello"
name: "Bob"
Задействованное здесь global.env
— это название окружения. Подставляя это значение при деплое, можно рендерить ConfigMap’ы с разным контентом. first
здесь нужен, т.к. pluck
возвращает список, первый элемент которого и содержит нужное значение.
Когда конфигов много
Один ConfigMap может содержать несколько конфиг-файлов:
data:
config.json: |
{
"welcome": {{ pluck .Values.global.env .Values.welcome | first | quote }},
"name": {{ pluck .Values.global.env .Values.name | first | quote }}
}
database.yml: |
host: 127.0.0.1
db: app
user: app
password: app
Можно даже монтировать каждый конфиг отдельно:
volumeMounts:
- name: app-conf
mountPath: /app/configfiles/config.json
subPath: config.json
- name: app-conf
mountPath: /app/configfiles/database.yml
subPath: database.yml
… или забрать все конфиги сразу каталогом:
volumeMounts:
- name: app-conf
mountPath: /app/configfiles
Если при деплое изменить описание ресурса Deployment, то Kubernetes создаст новый ReplicaSet, уменьшая старый до 0 и увеличивая новый до указанного количества реплик. (Это справедливо для случая использования стратегии деплоя RollingUpdate
.)
Такие действия приведут к пересозданию pod’а с новым описанием. Например: был образ image:my-registry.example.com:v1
, а стал — image:my-registry.example.com:v2
. И совершенно не важно, что именно мы изменили в описании нашего Deployment’а: главное — что это вызвало пересоздание ReplicaSet (и, как следствие, pod’а). В таком случае новая версия конфиг-файла в новой версии приложения автоматически примонтируется и проблемы не будет.
Реакция на изменение ConfigMap
В случае появления изменений в ConfigMap’ах могут последовать четыре сценария событий. Рассмотрим их:
- Действие: исправлен ConfigMap, который смонтирован по subPath.
Результат: файл конфига в контейнере не обновился сам. - Действие: исправлен ConfigMap, а после его деплоя в кластер мы вручную удалили pod.
Результат: новый pod монтирует новую версию ресурса сам. - Действие: исправлен ConfigMap и аннотацией в Deployment мы завязались на его хэш-сумму.
Результат: несмотря на то, что модификации сделаны только в ConfigMap’е, изменился и Deployment, поэтому старый pod был удалён, а новый — запущен с новой версией ресурса без ручного вмешательства. - Действие: исправлен ConfigMap, смонтированный как каталог.
Результат: файл конфига в pod’е обновился без рестарта/пересоздания pod’а.
Разберем подробнее.
Сценарий 1
Мы правили только ConfigMap? Приложение не перезапустится. В случае с монтированием по subPath
не будет никаких изменений до ручного рестарта pod’а.
Тут всё просто: Kubernetes монтирует в pod наш ConfigMap определённой версии ресурса. Поскольку он смонтирован с subPath
, никакого дополнительного «влияния» на конфиг больше не оказывается.
Сценарий 2
Не можем обновить файл без пересоздания pod’а? Окей, у нас в Deployment’е 6 реплик, поэтому мы можем по очереди, вручную сделать всем delete pod
. Тогда при создании новых pod’ов они будут «забирать» новую версию ConfigMap’а.
Сценарий 3
Надоело выполнять подобные операции вручную? Вариант решения этой проблемы описан в Helm tips and tricks:
kind: Deployment
spec:
template:
metadata:
annotations:
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
[...]
Таким образом, в шаблон pod’а (spec.template
) просто прописывается хэш отрендеренного аннотацией конфига.
Аннотации — это произвольные поля key-value, в которых можно хранить свои значения. Если прописать их в шаблоне spec.template
будущего pod’а, эти поля попадут в ReplicaSet и сам pod. Kubernetes заметит, что шаблон pod’а изменился (т.к. изменился sha256 конфига) и запустит RollingUpdate
, в котором не меняется ничего кроме этой аннотации.
В результате, мы сохраняем ту же версию приложения и описания Deployment’а и по сути просто триггерим пересоздание pod’а автоматом — аналогичному тому, как делали бы вручную через kubectl delete
, но уже «правильно»: автоматизированно и с RollingUpdate
.
Сценарий 4
Возможно, приложение уже умеет следить за изменениями в конфиге и автоматически осуществлять reload? Здесь кроется немаловажная особенность ConfigMap’ов…
В Kubernetes, если конфиг смонтирован с subPath
, он не обновится до рестарта pod’а (см. первые три сценария, рассмотренные выше). Но если смонтировать ConfigMap как каталог, без subPath
, то внутри контейнера будет каталог с обновляющимся конфигом без рестарта pod’а.
Есть и другие особенности, про которые полезно помнить:
- Такой обновляемый файл конфига внутри контейнера обновляется с некоторой задержкой. Это происходит по той причине, что монтируется не совсем файл, а объект Kubernetes.
- Файл внутри — это симлинк. Пример с
subPath
:$ kubectl -n production exec go-conf-example-6b4cb86569-22vqv -- ls -lha /app/configfiles total 20K drwxr-xr-x 1 root root 4.0K Mar 3 19:34 . drwxr-xr-x 1 app app 4.0K Mar 3 19:34 .. -rw-r--r-- 1 root root 42 Mar 3 19:34 config.json -rw-r--r-- 1 root root 47 Mar 3 19:34 database.yml
А что будет без
subPath
, когда примонтировано каталогом?$ kubectl -n production exec go-conf-example-67c768c6fc-ccpwl -- ls -lha /app/configfiles total 12K drwxrwxrwx 3 root root 4.0K Mar 3 19:40 . drwxr-xr-x 1 app app 4.0K Mar 3 19:34 .. drwxr-xr-x 2 root root 4.0K Mar 3 19:40 ..2020_03_03_16_40_36.675612011 lrwxrwxrwx 1 root root 31 Mar 3 19:40 ..data -> ..2020_03_03_16_40_36.675612011 lrwxrwxrwx 1 root root 18 Mar 3 19:40 config.json -> ..data/config.json lrwxrwxrwx 1 root root 19 Mar 3 19:40 database.yml -> ..data/database.yml
Обновим конфиг (через деплой или
kubectl edit
), подождем 2 минуты (время кэширования apiserver) — и вуаля:$ kubectl -n production exec go-conf-example-67c768c6fc-ccpwl -- ls -lha --color /app/configfiles total 12K drwxrwxrwx 3 root root 4.0K Mar 3 19:44 . drwxr-xr-x 1 app app 4.0K Mar 3 19:34 .. drwxr-xr-x 2 root root 4.0K Mar 3 19:44 ..2020_03_03_16_44_38.763148336 lrwxrwxrwx 1 root root 31 Mar 3 19:44 ..data -> ..2020_03_03_16_44_38.763148336 lrwxrwxrwx 1 root root 18 Mar 3 19:40 config.json -> ..data/config.json lrwxrwxrwx 1 root root 19 Mar 3 19:40 database.yml -> ..data/database.yml
Обратите внимание на изменившийся timestamp в каталоге, созданном Kubernetes.
Отслеживание изменений
И напоследок — простой пример, как можно следить за изменениями в конфиге.
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"time"
"github.com/fsnotify/fsnotify"
)
// Config fo our application
type Config struct {
Welcome string `json:"welcome"`
Name string `json:"name"`
}
var (
globalConfig *Config
)
// LoadConfig - load our config!
func LoadConfig(path string) (*Config, error) {
configFile, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("Unable to read configuration file %s", path)
}
config := new(Config)
decoder := json.NewDecoder(configFile)
err = decoder.Decode(&config)
if err != nil {
return nil, fmt.Errorf("Unable to parse configuration file %s", path)
}
return config, nil
}
// ConfigWatcher - watches config.json for changes
func ConfigWatcher() {
watcher, err := fsnotify.NewWatcher()
if err != nil {
log.Fatal(err)
}
defer watcher.Close()
done := make(chan bool)
go func() {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
log.Println("event:", event)
if event.Op&fsnotify.Write == fsnotify.Write {
log.Println("modified file:", event.Name)
}
globalConfig, _ = LoadConfig("./configfiles/config.json")
log.Println("config:", globalConfig)
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Println("error:", err)
}
}
}()
err = watcher.Add("./configfiles/config.json")
if err != nil {
log.Fatal(err)
}
<-done
}
func main() {
log.Println("Start")
globalConfig, _ = LoadConfig("./configfiles/config.json")
go ConfigWatcher()
for {
log.Println("config:", globalConfig)
time.Sleep(30 * time.Second)
}
}
… дополнив его таким конфигом:
$ cat configfiles/config.json
{
"welcome": "Hello",
"name": "Alice"
}
Если запустить, в логе будет:
2020/03/03 22:18:22 config: &{Hello Alice}
2020/03/03 22:18:52 config: &{Hello Alice}
А теперь задеплоим это приложение в Kubernetes, смонтировав в pod конфиг ConfigMap’ом вместо файла из образа. На GitHub подготовлен пример Helm-чарта:
helm install -n habr-configmap --namespace habr-configmap ./habr-configmap --set 'name.production=Alice' --set 'global.env=production'
И поменяем только ConfigMap:
- production: "Alice"
+ production: "Bob"
Обновим Helm-чарт в кластере, например, так:
helm upgrade habr-configmap ./habr-configmap --set 'name.production=Bob' --set 'global.env=production'
Что произойдет?
- Приложения v1 и v2 не перезапускаются, т.к. для них никаких изменений в Deployment’е не произошло — они все ещё приветствуют Alice.
- Приложение v3 перезапустилось, перечитало конфиг и поприветствовало Bob’а.
- Приложение v4 не перезапускалось. Поскольку ConfigMap смонтирован как каталог, изменения в конфиге были замечены и конфиг изменился на лету, без рестарта pod’а. Да, приложение заметило изменения в нашем простом примере — см. сообщение о событии от fsnotify:
2020/03/03 22:19:15 event: "configfiles/config.json": CHMOD 2020/03/03 22:19:15 config: &{Hello Bob} 2020/03/03 22:19:22 config: &{Hello Bob}
Посмотреть на то, как подобная ситуация — слежение за изменением ConfigMap’а — решается в более взрослом (да и просто «настоящем») проекте, можно здесь.
Важно! Полезно также напомнить, что всё вышеописанное в статье справедливо и для Secret’ов в Kubernetes (kind: Secret
): ведь не зря они так похожи на ConfigMap…
Бонус! Сторонние решения
Если вам интересна тема отслеживания изменений в конфигах, для этого есть уже готовые утилиты:
- jimmidyson/configmap-reload — отправляет HTTP-запрос, если файл изменился. Разработчик планирует также научить отправлять и SIGHUP, но отсутствие коммитов с октября 2019 года оставляют эти планы под вопросом;
- stakater/Reloader — следит за ConfigMap/Secrets и выполняет rolling upgrade (как называет его автор) над ресурсами, связанными с ними.
Подобные приложения будет удобно запускать sidecar-контейнером к существующим приложениям. Однако, если знать особенности работы Kubernetes/ConfigMap и конфиги редактировать не «на живую» (через edit
), а только в рамках деплоя… то возможности таких утилит могут показаться лишними, т.е. дублирующими базовые функции.
Заключение
С появлением ConfigMap в Kubernetes конфиги перешли на очередной виток развития: использование шаблонизатора принесло им гибкость, сопоставимую с рендерингом HTML-страниц. Благо, такие усложнения не заменили существующие решения, а стали их дополнением. Поэтому для администраторов (а скорее — даже разработчиков), которые считают новые возможности излишними, по-прежнему доступны старые добрые файлы.
Для тех же, кто уже пользуется ConfigMap’ами или только присматривается к ним, в статье дан краткий обзор их сути и нюансов использования. Если же у вас есть свои tips & tricks по теме — буду рад увидеть в комментариях.
P.S.
Читайте также в нашем блоге:
- «Локальные файлы при переносе приложения в Kubernetes»;
- «Создание пакетов для Kubernetes с Helm: структура чарта и шаблонизация»;
- «SSL-сертификаты от Let's Encrypt с cert-manager в Kubernetes».
Автор: Михаил Носов