Привет, Хабр!


Это третья часть в серии статей "Учимся разворачивать микросервисы", и сегодня речь пойдет о Helm 3. В прошлой части мы создали Kubernetes конфигурацию для учебного проекта из 2 микросервисов (бекенда и шлюза) и задеплоили все это в Google Kubernetes Engine. В этой статье мы напишем Helm-чарт для нашей системы, создадим для него репозиторий на основе GitHub Pages и задеплоим проект в GKE с помощью Helm.


План серии статей:


  1. Создание сервисов на Spring Boot, работа с Docker


    Ключевые слова: Java 11, Spring Boot, Docker, image optimization


  2. Разработка Kubernetes конфигурации и деплой системы в Google Kubernetes Engine


    Ключевые слова: Kubernetes, GKE, resource management, autoscaling, secrets


  3. Создание чарта с помощью Helm 3 для более эффективного управления кластером


    Ключевые слова: Helm 3, chart deployment


  4. Настройка Jenkins и пайплайна для автоматической доставки кода в кластер


    Ключевые слова: Jenkins configuration, plugins, separate configs repository



Helm — это пакетный менеджер, облегчающий задачи настройки, установки и обновления приложений в Kubernetes. В Helm система описывается в виде чарта — совокупности yaml-файлов с шаблонами конфигураций, а также нескольких служебных файлов. После написания чарт может быть упакован в архив и распространен с помощью мезанизма Helm-репозиториев. Установленные с помощью чартов приложения в дальнейшем я буду называть инсталляциями.


Helm позволяет абстрагироваться от объектов Kubernetes и настраивать систему, изменяя заранее определенный набор свойств. Эти свойства вынесены в отдельный файл values.yaml, и впоследствии используются шаблонизатором для генерации нужной Kubernetes-конфигурации. Также чарт может включать в себя вложенные чарты, но эта функциональность не будет рассмотрена в этой статье.


Код проекта доступен на GitHub по ссылке.


Структура чарта


Базовый чарт можно создать с помощью команды:


helm create <chart-name>

Это может послужить хорошей отправной точкой для экспериментов с Helm. Наш же чарт будет иметь имя msvc-chart и иметь следующую структуру:


.
L-- msvc-chart
    +-- charts
    +-- Chart.yaml
    +-- templates
    ¦   +-- backend.yaml
    ¦   +-- gateway.yaml
    ¦   +-- _helpers.tpl
    ¦   +-- NOTES.txt
    ¦   +-- secrets.yaml
    ¦   +-- service-account.yaml
    ¦   +-- tests
    ¦   ¦   L-- interaction-test.yaml
    ¦   L-- urls-config.yaml
    +-- values.schema.json
    L-- values.yaml

  • charts/ — в этой директории располагают вложенные чарты. Эта директория будет пуста, так как наша система не имеет зависимостей от других чартов.


  • Chart.yaml — файл со служебной информацией о чарте и приложении, который он разворачивает.


  • *templates/*.yaml* — файлы с шаблонами объектов Kuberenetes.


  • templates/NOTES.txt — содержит шаблон со справочной информацией, которая будет выведена пользователю при установке чарта.


  • *templates/tests/*.yaml* — файлы с шаблонами для тестов Helm.


  • templates/_helpers.tpl — файл со вспомогательными шабонами, которые могут быть переиспользованы в чарте. Подобные файлы по соглашению должен начинаться с нижнего подчеркивания и иметь расширение tpl.


  • values.yaml — содержит свойства чарта с их дефолтными значениями.


  • values.schema.json — JSON-схема для валидации свойств из values.yaml.



values.yaml


Один из главных файлов чарта — файл свойств values.yaml. Он предоставляет перечень настроек с их дефолтными значениями. При использовании чарта пользователь может переопределить нужные ему настройки.


backend:
  deployment:
    name:
    replicas: 2
  container:
    name:
    resources: {}
#      limits:
#        memory: 1024Mi
#        cpu: 500m
#      requests:
#        memory: 512Mi
#        cpu: 100m
  service:
    name:
    port: 8080
  image:
    name: anshelen/microservices-backend
    tag: latest
    pullPolicy: IfNotPresent
  hpa:
    enabled: false
    name:
    minReplicas: 1
    maxReplicas: 3
    targetCPUUtilizationPercentage: 50

gateway:
  deployment:
    name:
    replicas: 2
  container:
    name:
    resources: {}
#      limits:
#        memory: 1024Mi
#        cpu: 500m
#      requests:
#        memory: 512Mi
#        cpu: 100m
  service:
    name:
    port: 80
#   Can be one of ClusterIP, NodePort or LoadBalancer
    type: LoadBalancer
  image:
    name: anshelen/microservices-gateway
    tag: latest
    pullPolicy: IfNotPresent
  hpa:
    enabled: false
    name:
    minReplicas: 1
    maxReplicas: 3
    targetCPUUtilizationPercentage: 50

secrets:
  secret: default-secret

serviceAccount:
  # Specifies whether a service account should be created
  create: true
  # The name of the service account to use.
  # If not set and create is true, a name is generated using the release and
  # chart names
  name:

Свойства могут быть разбиты произвольным образом на логические группы, что упрощает восприятие системы. Некоторые свойства могут не иметь дефолтного значения, другие же подразумевать значения только из ограниченного множества (№ gateway.service.type). Также возможно использование гибкого подхода, при котором блок со всеми свойствами будет скопирован целиком в Kubernetes-конфигурацию (№ gateway.container.resources).


Для уменьшения вероятности ошибок возможно определить файл values.schema.json с JSON-схемой, которая будет использована для валидации при установке или обновлении чарта. Подобные схемы обладают широким функционалом, однако, могут быть просто чудовищно огромными.


values.schema.json
{
  "$schema": "https://json-schema.org/draft-07/schema#",
  "properties": {
    "backend": {
      "properties": {
        "deployment": {
          "properties": {
            "name": {
              "type": ["string", "null"]
            },
            "replicas": {
              "minimum": 1,
              "type": "integer"
            }
          },
          "type": "object"
        },
        "container": {
          "properties": {
            "name": {
              "type": ["string", "null"]
            },
            "resources": {
              "properties": {
                "limits": {
                  "properties": {
                    "memory": {
                      "type": ["string", "number"]
                    },
                    "cpu": {
                      "type": ["string", "number"]
                    }
                  },
                  "type": "object"
                },
                "requests": {
                  "properties": {
                    "memory": {
                      "type": ["string", "null"]
                    },
                    "cpu": {
                      "type": ["string", "null"]
                    }
                  },
                  "type": "object"
                }
              },
              "type": "object"
            }
          },
          "type": "object"
        },
        "service": {
          "properties": {
            "name": {
              "type": ["string", "null"]
            },
            "port": {
              "minimum": 1,
              "type": "integer"
            }
          },
          "type": "object"
        },
        "image": {
          "properties": {
            "name": {
              "type": "string"
            },
            "tag": {
              "type": "string"
            },
            "pullPolicy": {
              "enum": ["IfNotPresent", "Always", "Never"]
            }
          },
          "type": "object"
        },
        "hpa": {
          "properties": {
            "enabled": {
              "type": "boolean"
            },
            "name": {
              "type": ["string", "null"]
            },
            "minReplicas": {
              "minimum": 1,
              "type": "integer"
            },
            "maxReplicas": {
              "minimum": 1,
              "type": "integer"
            },
            "targetCPUUtilizationPercentage": {
              "minimum": 1,
              "maximum": 99,
              "type": "integer"
            }
          },
          "type": "object"
        }
      },
      "type": "object"
    },
    "gateway": {
      "properties": {
        "deployment": {
          "properties": {
            "name": {
              "type": ["string", "null"]
            },
            "replicas": {
              "minimum": 1,
              "type": "integer"
            }
          },
          "type": "object"
        },
        "container": {
          "properties": {
            "name": {
              "type": ["string", "null"]
            },
            "resources": {
              "properties": {
                "limits": {
                  "properties": {
                    "memory": {
                      "type": ["string", "number"]
                    },
                    "cpu": {
                      "type": ["string", "number"]
                    }
                  },
                  "type": "object"
                },
                "requests": {
                  "properties": {
                    "memory": {
                      "type": ["string", "null"]
                    },
                    "cpu": {
                      "type": ["string", "null"]
                    }
                  },
                  "type": "object"
                }
              },
              "type": "object"
            }
          },
          "type": "object"
        },
        "service": {
          "properties": {
            "name": {
              "type": ["string", "null"]
            },
            "port": {
              "minimum": 1,
              "type": "integer"
            },
            "type": {
              "enum": ["ClusterIP", "NodePort", "LoadBalancer"]
            }
          },
          "type": "object"
        },
        "image": {
          "properties": {
            "name": {
              "type": "string"
            },
            "tag": {
              "type": "string"
            },
            "pullPolicy": {
              "enum": ["IfNotPresent", "Always", "Never"]
            }
          },
          "type": "object"
        },
        "hpa": {
          "properties": {
            "enabled": {
              "type": "boolean"
            },
            "name": {
              "type": ["string", "null"]
            },
            "minReplicas": {
              "minimum": 1,
              "type": "integer"
            },
            "maxReplicas": {
              "minimum": 1,
              "type": "integer"
            },
            "targetCPUUtilizationPercentage": {
              "minimum": 1,
              "maximum": 99,
              "type": "integer"
            }
          },
          "type": "object"
        }
      },
      "type": "object"
    },
    "secrets": {
      "properties": {
        "secret": {
          "type": ["number", "string"]
        }
      },
      "type": "object"
    },
    "createAccount": {
      "properties": {
        "create": {
          "type": "boolean"
        },
        "name": {
          "type": ["string", "null"]
        }
      },
      "type": "object"
    }
  },
  "title": "Values",
  "type": "object"
}

Механизм шаблонов


Шаблонизатор Helm при каждой установке или обновлении приложения заполняет шаблоны на основании предоставленных свойств, а затем пытается применить получившиеся файлы к кластеру Kubernetes.


Наиболее частая потребность при написании шаблона — просто подставить значение свойства в шаблон. В Helm существует ряд дефолтно доступных объектов, предоставляющих данные о кластере (Release), свойствах (Values) или же самого чарта (Chart). Таким образом, чтобы подставить значение из свойства secrets.secret используется запись {{ .Values.secrets.secret }}.


Также существует ряд функций, позволяющих организовывать из них "конвеер" ("pipeline") — когда выходные данные одной функции становятся входными для другой. Например, мы можем заключить в кавычки версию нашего приложения: {{ .Chart.AppVersion | quote }}. Более сложные примеры использования функций мы увидим далее.


Как уже было сказано ранее, некоторые фрагменты конфигурации удобно вынести в отдельные файлы для переиспользования. Чтобы определить фрагмент используется функция define:


{{- define "msvc-chart.someFragment" -}}
...
{{- end -}}

Далее мы можем многократно включать этот фрагмент в шаблон функцией include:


{{ include "msvc-chart.someFragment" . }}

Конструкция {{- отличается от {{ тем, что в первом случае предшествующие месту вставки пробелы будут удалены. Часто для проставления корректных отступов лучше их прописывать вручную. Например, фрагмент {{- include "msvc-chart.someFragment" . | nindent 4 }} будет иметь отступ ровно 4 пробела.


Более подробно про создание шаблонов можно почитать в хорошей статье.


_helpers.tpl


Большое внимание в шаблонах Helm должно быть уделено меткам и наименованию компонентов. Каждый созданный объект Kubernetes должен иметь уникальное имя, а связи между объектами использовать уникальные метки. Это нужно для того, чтобы при установке нескольких чартов в одном неймспейсе не возникало конфиликтов имен. Также, как следствие, это позволяет развернуть один и тот же чарт несколько раз в одном неймспейсе. Хороший подход — дать пользователю возможность настроить имена компонентов вручную, а если он не пожелает, то сгенерировать их автоматически. Для этого можно использовать уникальное в рамках неймспейса имя инсталляции Release.Name.


Метки


Уникальные метки для установления связей между объектами:


{{- define "msvc-chart.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}

.Chart.Name вернет имя чарта ('msvc-chart' в нашем случае). Наименования меток могут быть произвольными, но все же следует их называть согласно рекомендациям Helm.


Имя чарта с версией:


{{- define "msvc-chart.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}}
{{- end -}}

Здесь мы складываем имя чарта с его версией, заменяем недопустимые символы, ограничиваем длину 63 символами (максимальная длина меток в Kubernetes) и обрезаем возможный суффикс. Это довольно стандартная последовательность действий с литералами в Helm. Мы будем поступать аналогично и при генерации имен Kubernetes-объектов (в этом случае ограничения на длину связаны с требованиями DNS системы).


Метки, общие для всех объектов-Kubernetes:


{{- define "msvc-chart.labels" -}}
helm.sh/chart: {{ include "msvc-chart.chart" . }}
{{ include "msvc-chart.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end -}}

Итого, при имени инсталляции 'msvc-project', итоговый набор меток наших объектов:


helm.sh/chart: msvc-chart-1.0.0
app.kubernetes.io/name: msvc-chart
app.kubernetes.io/instance: msvc-project
app.kubernetes.io/version: 1.0.0
app.kubernetes.io/managed-by: Helm

Имена объектов Kubernetes


В прошлой статье мы не имели дела с объектом Kubernetes под названием ServiceAccount. Эта сущность позволяет создать специального пользователя кластера с определенными полномочиями и доступами. Причем этот пользователь не имеет ни логина, ни пароля, а только токен. Таким образом ServiceAccount в основном используется внешними системами. В нашем случае ServiceAccount будет нужен для того, чтобы в следующей части дать Jenkins возможность обновлять кластер.


Имя сервисного аккаунта будет определяться по следующим правилам:


  • Если свойство serviceAccount.create=true, то создадим сервисный аккаунт под именем serviceAccount.name. Иначе сгенерируем уникальное имя.
  • Если serviceAccount.create=false, то имя должно быть равно свойству serviceAccount.name или же 'default', если оно не указано.

{{- define "msvc-chart.serviceAccountName" -}}
{{- if .Values.serviceAccount.create -}}
    {{ default (printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-") .Values.serviceAccount.name }}
{{- else -}}
    {{ default "default" .Values.serviceAccount.name }}
{{- end -}}
{{- end -}}

Для реализации этой логики мы используем if и полезную функцию default, которая возвращает первый аргумент, если второй равен null.


Имена других объектов Kubernetes будут либо заданы явно, либо задаваться с помощью префикса и имени инсталляции.


Пример для имени деплоймента шлюза:


{{- define "msvc-chart.gateway.defaultName" -}}
{{- printf "gateway-%s" .Release.Name -}}
{{- end -}}

{{- define "msvc-chart.gateway.deployment.name" -}}
{{- default (include "msvc-chart.gateway.defaultName" .) .Values.gateway.deployment.name | trunc 63 | trimSuffix "-" -}}
{{- end -}}

Обновление подов при изменении конфигурационных свойств


Напомню, что секреты и другие конфигурационные параметры мы передаем в контейнеры через переменные среды. Если мы обновим инсталляцию, изменив значение секрета, то Helm пересоздаст только Kubernetes-объект этого секрета. А так как контейнерам переменные среды устанавливаются на старте, то это изменение будет ими проигнорировано. Для устранения этой проблемы все зависимые поды должны быть перезапущены. В Helm это достигается с помощью использования специальной аннотации checksum/config, содержащей контрольную сумму всех файлов, от которых зависит под. И если Helm видит, что эта сумма после обновления поменялась, то он запускает механизм обновления деплоймента.


Фрагмент определения контрольной суммы конфигурационных файлов:


{{- define "msvc-chart.propertiesHash" -}}
{{- $secrets := include (print $.Template.BasePath "/secrets.yaml") . | sha256sum -}}
{{- $urlConfig := include (print $.Template.BasePath "/urls-config.yaml") . | sha256sum -}}
{{ print $secrets $urlConfig | sha256sum }}
{{- end -}}

Функция print просто складывает произвольное количество аргументов, а $.Template.BasePath возвращает путь к текущему файлу (_helpers.tpl). Операцией include мы получаем исходный текст файла с секретом, затем вычисляем его хеш-сумму и записываем ее в переменную $secrets. Аналогично поступаем с ConfigMap, а затем возвращаем контрольную сумму от сложения этих двух переменных.


Внимательный читатель заметит, что в операции include вторым аргументом мы передаем '. ', а при получении базового пути используем '\$' перед объектом Template. В Helm под '. ' подразумевается текущий контекст (scope), а '\$' — объект, ссылающийся на глобальный контекст. В нашем случае это не принципиально, так как мы не манипулируем контекстами. Дальнейшее обсуждение этой функциональности оставим за рамками данной статьи. Для интересующихся ссылка.


Шаблоны


Secret (secrets.yaml)


apiVersion: v1
kind: Secret
metadata:
  name: {{ include "msvc-chart.secrets.defaultName" . }}
  labels:
    {{- include "msvc-chart.labels" . | nindent 4 }}
type: Opaque
stringData:
  secret: {{ .Values.secrets.secret }}

ConfigMap (urls-config.yaml)


apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "msvc-chart.urlConfig.defaultName" . }}
  labels:
    {{- include "msvc-chart.labels" . | nindent 4 }}
data:
  BACKEND_URL: "http://{{ include "msvc-chart.backend.service.name" . }}:{{ .Values.backend.service.port }}/"

ServiceAccount (service-account.yaml)


{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
  name: {{ include "msvc-chart.serviceAccountName" . }}
  labels:
    {{- include "msvc-chart.labels" . | nindent 4 }}

---

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: "{{ include "msvc-chart.serviceAccountName" . }}-binding"
  labels:  
    {{- include "msvc-chart.labels" . | nindent 4 }}
subjects:
- kind: ServiceAccount
  name: {{ include "msvc-chart.serviceAccountName" . }}
  namespace: {{ .Release.Namespace }}
roleRef:
  kind: ClusterRole
  name: admin
  apiGroup: rbac.authorization.k8s.io
{{- end -}}

Если свойство serviceAccount.create=true, то мы создаем сервисный аккаунт и даем ему права на управление нашим кластером.


Deployments (backend.yaml и gateway.yaml)


В файле gateway.yaml собраны все объекты, относящиеся к слою шлюза, а в backend.yaml — слою бекенда. Каждый слой будет состоять из деплоймента, сервиса и HorizontalPodAutoscaler'а, причем последний будет выключен по умолчанию. Это связано с тем, что для его корректного функционирования необходимо вручную установить свойство gateway.container.resources.requests.cpu — выделяемую контейнеру в первый момент времени мощность. Более подробно про ресурсы в Kubernetes мы говорили в прошлой статье.


gateway.yaml:


apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "msvc-chart.gateway.deployment.name" . }}
  labels:
    {{- include "msvc-chart.labels" . | nindent 4 }}
    tier: gateway
spec:
  replicas: {{ .Values.gateway.deployment.replicas }}
  selector:
    matchLabels:
      {{- include "msvc-chart.selectorLabels" . | nindent 6 }}
      tier: gateway
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      annotations:
        checksum/config: {{ include "msvc-chart.propertiesHash" . }}
      labels:
        {{- include "msvc-chart.selectorLabels" . | nindent 8 }}
        tier: gateway
    spec:
      serviceAccountName: {{ include "msvc-chart.serviceAccountName" . }}
      containers:
        - name: {{ include "msvc-chart.gateway.container.name" . }}
          image: "{{ .Values.gateway.image.name }}:{{ .Values.gateway.image.tag }}"
          imagePullPolicy: {{ .Values.gateway.image.pullPolicy }}
          envFrom:
            - configMapRef:
                name: {{ include "msvc-chart.urlConfig.defaultName" . }}
          env:
            - name: SECRET
              valueFrom:
                secretKeyRef:
                  name: {{ include "msvc-chart.secrets.defaultName" . }}
                  key: secret
          readinessProbe:
            httpGet:
              path: /actuator/health
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 3
          ports:
            - containerPort: 8080
              protocol: TCP
          resources:
            {{- toYaml .Values.gateway.container.resources | nindent 12 }}

---

apiVersion: v1
kind: Service
metadata:
  name: {{ include "msvc-chart.gateway.service.name" . }}
  labels:
    {{- include "msvc-chart.labels" . | nindent 4 }}
    tier: gateway
spec:
  type: {{ .Values.gateway.service.type }}
  ports:
    - port: {{ .Values.gateway.service.port }}
      protocol: TCP
      targetPort: 8080
      name: http
  selector:
    {{- include "msvc-chart.selectorLabels" . | nindent 4 }}
    tier: gateway

---

{{- if .Values.gateway.hpa.enabled -}}
apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
  name: {{ include "msvc-chart.gateway.hpa.name" . }}
  labels:
    {{- include "msvc-chart.labels" . | nindent 4 }}
    tier: gateway
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ include "msvc-chart.gateway.deployment.name" . }}
  minReplicas: {{ .Values.gateway.hpa.minReplicas }}
  maxReplicas: {{ .Values.gateway.hpa.maxReplicas }}
  targetCPUUtilizationPercentage: {{ .Values.gateway.hpa.targetCPUUtilizationPercentage }}
{{- end -}}

Обращу внимание на использование аннотации "checksum/config", которую мы обсуждали выше, а также на привязку нашего деплоймента к ServiceAccount (spec.template.spec.serviceAccountName). В блоке ресурсов контейнера функция toYaml копирует все поддерево свойства backend.container.resources из файла values.yaml в шаблон.


Шаблон деплоймента бекенда аналогичен.


backend.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "msvc-chart.backend.deployment.name" . }}
  labels:
    {{- include "msvc-chart.labels" . | nindent 4 }}
    tier: backend
spec:
  replicas: {{ .Values.backend.deployment.replicas }}
  selector:
    matchLabels:
      {{- include "msvc-chart.selectorLabels" . | nindent 6 }}
      tier: backend
  strategy:
    rollingUpdate:
      maxSurge: 25%
      maxUnavailable: 25%
    type: RollingUpdate
  template:
    metadata:
      labels:
        {{- include "msvc-chart.selectorLabels" . | nindent 8 }}
        tier: backend
    spec:
      serviceAccountName: {{ include "msvc-chart.serviceAccountName" . }}
      containers:
        - name: {{ include "msvc-chart.backend.container.name" . }}
          image: "{{ .Values.backend.image.name }}:{{ .Values.backend.image.tag }}"
          imagePullPolicy: {{ .Values.backend.image.pullPolicy }}
          ports:
            - containerPort: 8080
              protocol: TCP
          readinessProbe:
            httpGet:
              path: /actuator/health
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 3
          resources:
            {{- toYaml .Values.backend.container.resources | nindent 12 }}

---

apiVersion: v1
kind: Service
metadata:
  name: {{ include "msvc-chart.backend.service.name" . }}
  labels:
    {{- include "msvc-chart.labels" . | nindent 4 }}
    tier: backend
spec:
  ports:
    - port: {{ .Values.backend.service.port }}
      protocol: TCP
      targetPort: 8080
      name: http
  selector:
    {{- include "msvc-chart.selectorLabels" . | nindent 4 }}
    tier: backend

---

{{- if .Values.backend.hpa.enabled -}}
apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
  name: {{ include "msvc-chart.backend.hpa.name" . }}
  labels:
    {{- include "msvc-chart.labels" . | nindent 4 }}
    tier: backend
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ include "msvc-chart.backend.deployment.name" . }}
  minReplicas: {{ .Values.backend.hpa.minReplicas }}
  maxReplicas: {{ .Values.backend.hpa.maxReplicas }}
  targetCPUUtilizationPercentage: {{ .Values.backend.hpa.targetCPUUtilizationPercentage }}
{{- end -}}

NOTES.txt


Файл NOTES.txt содержит вспомогательную информацию, которая будет выведена пользователю при установке чарта, либо же при проверке статуса инсталляции (helm status <name>).


В нашем случае этот файл будет содержать список команд для получения URL-адреса нашего приложения. Набор команд, очевидно, будет различаться в зависимости от типа сервиса шлюза. Дополнительно отобразим пользователю команды, которые понадобятся для получения токена нашего сервисного аккаунта.


1. Get the application URL by running these commands:
{{- if contains "NodePort" .Values.gateway.service.type }}
  export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "msvc-chart.gateway.service.name" . }})
  export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
  echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.gateway.service.type }}
     NOTE: It may take a few minutes for the LoadBalancer IP to be available.
           You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "msvc-chart.gateway.service.name" . }}'
  export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "msvc-chart.gateway.service.name" . }} -o jsonpath="{.status.loadBalancer.ingress[0].ip}")
  echo http://$SERVICE_IP:{{ .Values.gateway.service.port }}
{{- else if contains "ClusterIP" .Values.gateway.service.type }}
  export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name= {{ .Chart.Name }},app.kubernetes.io/instance={{ .Release.Name }},tier=gateway" -o jsonpath="{.items[0].metadata.name}")
  echo "Visit http://127.0.0.1:8080 to use your application"
  kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:8080
{{- end }}
2. Get service account token:
  export TOKEN=$(kubectl get serviceaccount {{ include "msvc-chart.serviceAccountName" . }} -n {{ .Release.Namespace }} -o go-template --template='{{`{{range .secrets}}{{.name}}{{"\n"}}{{end}}`}}')
  export TOKEN_DECODED=$(kubectl get secrets "$TOKEN" -n {{ .Release.Namespace }} -o go-template --template '{{`{{index .data "token"}}`}}' | base64 -d)
  echo $TOKEN_DECODED

Chart.yaml


Содержит служебную информацию о чарте. Основные поля — version и appVersion. version — версия чарта, следовательно это поле должно меняться при каждой его модификации. appVersion — версия приложения внутри чарта. Поле type может принимать значения 'application' и 'library' — для самостоятельных и вспомогательных чартов соответственно.


apiVersion: v2
name: msvc-chart
version: 1.0.0
description: Microservices project
type: application
sources:
  - https://github.com/Anshelen/microservices-backend
  - https://github.com/Anshelen/microservices-gateway
  - https://github.com/Anshelen/microservices-deploy
maintainers:
  - name: Anton Shelenkov
    email: anshelen@yandex.ru
    url: https://shelenkov.herokuapp.com
appVersion: 1.0.0

Тесты


Helm поддерживает создание высокоуровневых тестов. Они запускаются вручную командой helm test <name> и могут использоваться для проверки взаимодействия сервисов или же доступности внешних систем (№ баз данных). Тестовый файл представляет из себя шаблон с описанием Kubernetes-объекта, который должен выполнять некое разовое действие, например http-запрос. Если команда завершается штатно, то тест считается пройденным.


Наш тест будет проверять доступность нашей системы, делая запрос к сервису шлюза. Тест будет состоять из Kubernetes-объекта Job — удобной абстракции над подом, как раз предназначенной для выполнения разовых действий. В случае неудачи, Job будет перезапускать контейнер и повторять запрос максимум 3 раза (опция backoffLimit). Обращу внимание на аннотации helm.sh/hook и helm.sh/hook-delete-policy. Они сигнализируют Helm, что это описание тестового объекта, и что он должен быть уничтожен после выполнения теста.


interaction-test.yaml:


apiVersion: batch/v1
kind: Job
metadata:
  name: "{{ .Release.Name }}-interaction-test"
  labels:
    {{- include "msvc-chart.labels" . | nindent 4 }}
  annotations:
    "helm.sh/hook": test
    "helm.sh/hook-delete-policy": hook-succeeded,hook-failed
spec:
  template:
    spec:
      containers:
      - name: test
        image: busybox
        command: ['wget']
        args:  ['{{ include "msvc-chart.gateway.service.name" . }}:{{ .Values.gateway.service.port }}/']
      restartPolicy: Never
  backoffLimit: 3

Работа с Helm


При работе с Helm есть одно важное правило: все действия, модифицирующие кластер, должны быть совершены через Helm, а не напрямую с помощью kubectl. Helm при каждом обновлении кластера создает секрет, в котором описывает изменения. Если менять кластер через kubectl, то Helm не увидит изменений, и это чревато ошибками.


Установка чарта


В процессе разработки чарта бывает полезно проверить его на валидность, а также посмотреть на получившиеся Kubernetes-объекты:


helm install --dry-run --debug <name> <chart-folder>

После того, как чарт написан, можно развернуть наше приложение, создав инсталляцию под именем <name>:


helm install <name> <chart-folder>

Чтобы настроить инсталляцию и заменить значения из values.yaml, можно использовать опции --set <key>=<value>. Этот параметр может быть указан несколько раз. Если переопределяемых свойств много, то часто более удобно описать все свойства в yaml-файле и применить его опцией -f <overriden-values.yaml>. Параметр -n <namespace> позволяет задать неймспейс, в который будет установлен чарт.


Обновление инсталляции


Обновить инсталляцию:


helm upgrade <name> <chart-folder>

Для этой команды также доступны опции -f <file> и --set <key>=<value>. Дефолтно Helm при обновлении будет использовать только явно указанные свойства, то есть все установленные ранее опции будут проигнорированы. Чтобы сохранить уже примененные свойства, нужно указать опцию --reuse-values. Стоит отметить, что в текущей версии Helm присутствует баг, возникающий при совместном использовании --set и --reuse-values на инсталляциях, установленных без каких-либо параметров. То есть после следующих команд никаких изменений не произойдет:


helm install <name> <chart-folder>
helm upgrade <name> <chart-folder> --set key=value --reuse-values

Чтобы это обойти, можно устанавливать чарт, определив какое-нибудь фиктивное свойство, например, так:


helm install <name> <chart-folder> --set _=null

Получение информации об инсталляциях


helm list — выведет все установленные Helm'ом приложения.
helm status <name> — информация о состоянии конкретной инсталляции.
helm history <name> — история изменений инсталляции. При каждом изменении параметров или модификации самого чарта создается новая ревизия. В случае чего всегда существует возможность откатиться до определенной ревизии.
helm get values <name> — текущие переопределенные свойства инсталляции. Задав параметр --revision n, можно посмотреть эти свойства для n-ной ревизии.
helm show values <chart-folder> — выведет все доступные опции для чарта. По факту напечатает файл values.yaml.
helm get manifest <name> — отобразит созданные Helm'ом объекты для определенной инсталляции.


Прочие команды


helm rollback <name> <revision-num> — откатиться до определенной ревизии.
helm uninstall <name> — удалить инсталляцию.


Публикация репозитория


Helm-репозиторий служит для хранения и распространения чартов. В качестве такого репозитория может выступать любой HTTP-сервер или же произвольное S3-хранилище. Мы для создания своего репозитория используем GitHub Pages.


Репозиторий должен содержать файл index.yaml со служебной информацией о чартах, а также ссылками на их архивы. Наиболее простой способ организации репозитория — хранить эти архивы на том же сервере.


Создадим в нашем GitHub репозитории папку docs и применим следующие команды:


helm package msvc-chart/ -d docs/
helm repo index docs/ --url https://<github-username>.github.io/<github-repo-name>/

Первая команда упакует наш чарт в архив и поместит его в папку docs. Вторая команда сгенерирует файл index.yaml в той же папке. В опции url надо указать будущий URL-адрес папки с архивами. Обращаю внимание, что при любом изменении чарта его необходимо переиндексировать. Далее все это пушим, заходим в настройки репозитория и активируем GitHub Pages, указав в графе 'Source' опцию 'master branch /docs folder'.


Деплой системы


Как и в прошлой статье, задеплоим нашу систему в GKE, но в этот раз с помощью Helm.


Изначально создадим неймспейс и выберем его как текущий:


kubectl create namespace msvc-ns
kubectl config set-context --current --namespace=msvc-ns

Опционально можно применить скрипт для создания квот из прошлой части. Далее добавим наш репозиторий в Helm под именем msvc-repo и обновим список доступных чартов (URL у вас будет свой, если вы не поленились создать собственный Helm-репозиторий):


helm repo add msvc-repo https://anshelen.github.io/microservices-deploy/
helm repo update

Устанавливаем чарт:


helm install msvc-project msvc-repo/msvc-chart --set backend.container.resources.requests.cpu=50m --set backend.hpa.enabled=true --set gateway.container.resources.requests.cpu=50m --set gateway.hpa.enabled=true --set secrets.secret=secret

После установки в терминале отобразятся команды для получения URL-адреса нашего приложения. Делаем запрос и проверяем, что все работает, как и ожидалось.


Запустим тесты:


helm test msvc-project 

Далее попробуем изменить секрет:


helm upgrade msvc-project msvc-repo/msvc-chart --set secrets.secret=new-secret --reuse-values

Спустя какое-то время можно сделать еще один запрос к шлюзу и убедиться, что секрет был изменен.


Заключение


В этой статье мы написали Helm-чарт для системы из двух микросервисов, выложили его в собственный репозиторий и задеплоили систему в GKE. С Helm управлять нашим кластером стало проще, однако от момента коммита в Git до обновления кластера все равно необходимо предпринять ряд ручных действий — собрать новый образ, запушить его в реестр Docker, приконнектиться к кластеру и запустить обновление Helm.


Чтобы автоматизировать процесс доставки кода в кластер, в следующей статье мы настроим сервер непрерывной интеграции Jenkins и создадим пайплайн для нашего проекта.