ConfigMaps в Kubernetes: нюансы, о которых стоит знать

Примечание: это не полноценная статья-руководство, а скорее напоминание/подсказка для тех, кто уже пользуется ConfigMap в Kubernetes или только готовит своё приложение для работы в нём.

_hlylnaruzb2_b5zhbjpinrdphy.png

Предыстория: от 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«ах могут последовать четыре сценария событий. Рассмотрим их:

  1. Действие: исправлен ConfigMap, который смонтирован по subPath.
    Результат: файл конфига в контейнере не обновился сам.
  2. Действие: исправлен ConfigMap, а после его деплоя в кластер мы вручную удалили pod.
    Результат: новый pod монтирует новую версию ресурса сам.
  3. Действие: исправлен ConfigMap и аннотацией в Deployment мы завязались на его хэш-сумму.
    Результат: несмотря на то, что модификации сделаны только в ConfigMap«е, изменился и Deployment, поэтому старый pod был удалён, а новый — запущен с новой версией ресурса без ручного вмешательства.
  4. Действие: исправлен 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.


Отслеживание изменений


И напоследок — простой пример, как можно следить за изменениями в конфиге.

Воспользуемся таким Go-приложением
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'


Что произойдет?
Посмотреть на то, как подобная ситуация — слежение за изменением 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.


Читайте также в нашем блоге:

© Habrahabr.ru