Вступление

Я люблю манифесты Kubernetes. Правда, мне приносит большое удовольствие создавать по отдельности каждый ресурс командой kubectl apply. Но это только в начале... Когда у вас таких ресурсов больше пяти, а микросервисов и того больше, то управлять всем этим зоопарком становится болью. Вам необходимо манипулировать отдельными манифестами, если у вас несколько схожих сервисов, отличающихся небольшими деталями, то вам придётся для каждого создавать свою пачку манифестов. Про разные окружения я молчу.

Можно скинуть весь деплой ресурсов на CI/CD Pipeline и забыть о манифестах навсегда. Но если вам понадобится развернуть или, наоборот, "свернуть" приложение, то вышеперечисленных проблем не избежать. Таким образом, в этой статье я покажу свой опыт создания Helm чарта и его запуска, но перед этим изучив методы деплоя приложения без Helm.

Предисловие

После деплоя нескольких сервисов при помощи YAML манифестов и командыkubectl apply, я решил создать свой первый Helm чарт и подумал, почему бы не написать об этом статью! У автора довольно небольшой опыт работы с Helm, поэтому в статье могут быть допущены ошибки. Поэтому если вы обнаружите опечатку/грубую (и не только) ошибку, то просьба выделить текст и нажать на Ctrl+Enter. Спасибо!

Оглавление

Цель

Я уже создал репозитории с исходным кодом. Там можно найти две папки - kubectl, содержащий yaml манифесты и helm - чарт с теми же самыми манифестами. В статье будет минимум теории - основная часть это практика. Сначала я покажу из каких манифестов состоит приложение, как они запускаются, а затем попробуем создать свой Helm чарт, сделав манифесты более универсальными, и развернуть релиз.

Немного о Helm

Helm - это пакетный менеджер Kubernetes. Он позволяет легко запускать, обновлять и откатывать приложения. Основная сущность Helm - это чарты.

Чарт (Chart) - это коллекция связанных манифестов Kubernetes. При помощи чарта вы можете запускать приложения других разработчиков или же создать свой чарт и развернуть его.

Релиз (Release) - это установленный чарт. В один кластер можно установить сколько угодно релизов одного чарта. У каждого релиза есть своё название, которое можно использовать для нейминга ресурсов Kubernetes.

В первые месяцы использования Kubernetes я использовал Helm исключительно для разворачивания сервисов от сторонних разработчиков. Так, я активно использую ingress-nginx и loki-stack (кстати, использовал в прошлой статье). Но недавно мне пришла идея использовать Helm также для собственных сервисов. Причин для этого несколько:

  1. Helm позволяет управлять несколькими манифестами как единым целым. В наших сервисах по 4-7 манифестов, некоторые требуют подстановки переменных (через envsubst), что также осложняет деплой вне CI/CD пайплайна.

  2. Все наши сервисы строятся почти по одному принципу, следовательно, много кода повторяется. При помощи хелмовского values.yaml (об этом чуть позже) получится вынести меняющиеся значения из манифестов и использовать один чарт для нескольких приложении (релизов) сразу.

  3. Helm становится (или уже стал) "маст хев" технологией. В последнее время в вакансиях я чаще стал видеть Helm для Dev-Ops инженеров, требующих знания K8s.

Манифесты и их назначения

Сначала следует указать, что деплоить будем в Yandex Managed Kubernetes. В сервисах используется Lockbox (сервис от Yandex Cloud для хранения секретов) и External Secret Operator для синхронизации Kubernetes со сторонними провайдерами (в нашем случае с сервисами Yandex.Cloud). Для развертывания приложения предусмотрены следующие манифесты:

  • cert-external-secret.yaml - ресурс типа ExternalSecret. Манифест необходим, чтобы получить TLS сертификат из Yandex Certificate Manager и его приватный ключ. В дальнейшем, ExternalSecret создаст секрет с данными значениями, которые будут использоваться в ingress.yaml.

  • cert-secret-store.yaml - ресурс типа SecretStore. Он указывает к какому стороннему API обращаться за получением данных. В текущем манифесте провайдером указан yandexcertificatemanager.

  • clusterip.yaml - ресурс типа Service(ClusterIP). Обеспечивает доступ к запущенному сервису внутри кластера.

  • deploy.yaml - ресурс типа Deployment . Управляет подами и следит за тем, чтобы все реплики были развернуты. В нём указано запустить одну реплику с образом Java (Spring) и некоторыми переменными, взятых из секрета со значениями из Yandex Lockbox. Использует образ из Yandex Container Registry.

  • ingress.yaml - ресурс типа Ingress. Служит для обеспечения доступа к сервису через HTTP(-S). Одного Ingress не хватит, нужно отдельно развернуть Ingress Controller (обычно ingress-nginx). Использует данные из секрета, созданного cert-external-secret.yaml манифестом, для обеспечения доступа по HTTPS.

  • lockbox-external-secret.yaml - также ресурс типа ExternalSecret, в нём описываются все переменные, которые нужно получить из Lockbox.

  • lockbox-secret-storage.yaml - также ресурс типа SecretStore, провайдером служит yandexlockbox.

Содержимое манифестов

Теперь давайте развёрнем всю эту махину! Вы, возможно, предложите воспользоваться командойkubectl apply -f ./kubectl, чтобы по отдельности не выполнять команды для каждого файла. Дело в том, что в некоторых конфигурационных файлах используются переменные, подставляемые через envsubst. Это очень удобная утилита для вставки значении в файл. Она очень полезна для подстановки переменных в CI/CD пайплайне. С другой стороны, при локальном поднятии ресурсов это добавляет сложность.

Ниже, в порядке выполнения, будут указаны содержимое манифестов и команды для их развертывания. Также обращайте внимание на комментарии.
P.S. содержимое манифестов указано лишь с целью показать как изменятся манифесты после создания Helm чарта. Вам не обязательно разворачивать те же самые ресурсы/использовать сервисы Yandex.Cloud

Чуть не забыл! Сперва создадим отдельное пространство имён и добавим секрет с авторизированным ключом для доступа к Yandex Cloud:

$ kubectl create ns kubectl-ns
namespace/kubectl-ns created
$ kubectl --namespace kubectl-ns create secret generic yc-auth \
	      --from-file=authorized-key=authorized-key.json 
secret/yc-auth created

SecretStore (TLS Certificate)

cert-secret-store.yaml
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: spring-app-certificate-secret-store
  namespace: kubectl-ns
spec:
  provider:
    yandexcertificatemanager:
      auth:
        authorizedKeySecretRef:
          name: yc-auth
          key: authorized-key

$ kubectl apply -f ./kubectl/cert-secret-store.yaml
secretstore.external-secrets.io/spring-app-certificate-secret-store created
$ kubectl -n kubectl-ns get ss/spring-app-certificate-secret-store
NAME                                  AGE   STATUS   CAPABILITIES   READY
spring-app-certificate-secret-store   20s   Valid    ReadOnly       True

ExternalSecret (TLS Certificate)

cert-external-secret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: spring-app-certificate-external-secret
  namespace: kubectl-ns
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: spring-app-certificate-secret-store
    kind: SecretStore
  target:
    name: spring-app-certificate-secret
    template:
      type: kubernetes.io/tls
  data:
  - secretKey: tls.crt
    remoteRef:
      key: $CERTIFICATE_ID
      property: chain
  - secretKey: tls.key
    remoteRef:
      key: $CERTIFICATE_ID
      property: privateKey

Обратите внимание на $CERTIFICATE_ID. Так как ID сертификата из Certificate Manager может периодический изменяться, то хранить его в коде плохая практика. Поэтому сначала необходимо узнать ID сертификата, записать его в переменную окружения CERTIFICATE_ID и передать её через envsubst:

$ export CERTIFICATE_ID=<your_certificate_id_here>
$ envsubst \$CERTIFICATE_ID < ./kubectl/cert-external-secret.yaml | kubectl apply -f -
$ kubectl -n kubectl-ns get externalsecret/spring-app-certificate-external-secret
NAME                                     STORE                                 REFRESH INTERVAL   STATUS         READY
spring-app-certificate-external-secret   spring-app-certificate-secret-store   1h                 SecretSynced   True

SecretStore (Lockbox secret)

lockbox-secret-store.yaml
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: spring-app-lockbox-secret-store
  namespace: kubectl-ns
spec:
  provider:
    yandexlockbox:
      auth:
        authorizedKeySecretRef:
          name: yc-auth
          key: authorized-key

$ kubectl apply -f ./kubectl/lockbox-secret-store.yaml
secretstore.external-secrets.io/spring-app-lockbox-secret-store created
$ kubectl -n kubectl-ns get ss/spring-app-lockbox-secret-store  
NAME                              AGE   STATUS   CAPABILITIES   READY
spring-app-lockbox-secret-store   51s   Valid    ReadOnly       True

ExternalSecret (Lockbox secret)

lockbox-external-secret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: spring-app-lockbox-external-secret
  namespace: kubectl-ns
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: spring-app-lockbox-secret-store
    kind: SecretStore
  target:
    name: spring-app-lockbox-secret
  data:
  - secretKey: JDBC_URL
    remoteRef:
      key: $SECRET_ID
      property: JDBC_URL
  - secretKey: DB_USERNAME
    remoteRef:
      key: $SECRET_ID
      property: DB_USERNAME
  - secretKey: DB_PASSWORD
    remoteRef:
      key: $SECRET_ID
      property: DB_PASSWORD

Теперь же следует передать в файл $SECRET_ID - ID Yandex Lockbox секрета.

$ export SECRET_ID=<your_lockbox_secret_id_here>
$ envsubst \$SECRET_ID < ./kubectl/lockbox-external-secret.yaml | kubectl apply -f -
externalsecret.external-secrets.io/spring-app-lockbox-external-secret created
$ kubectl -n kubectl-ns get externalsecret/spring-app-lockbox-external-secret 
NAME                                 STORE                             REFRESH INTERVAL   STATUS         READY
spring-app-lockbox-external-secret   spring-app-lockbox-secret-store   1h                 SecretSynced   True

Service (type: ClusterIP)

clusterip.yaml
apiVersion: v1
kind: Service
metadata:
  name: spring-app
  namespace: kubectl-ns
  labels:
    app-label: spring-app-clusterip-label
spec:
  ports:
  - name: http
    protocol: TCP
    port: 80
    targetPort: http
  selector:
    app-label: spring-app-label

$ kubectl apply -f ./kubectl/clusterip.yaml
service/spring-app created

Deployment

deploy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: spring-app
  namespace: kubectl-ns
  labels:
    app-label: spring-app-label
spec:
  replicas: 1
  selector:
    matchLabels:
      app-label: spring-app-label
  template:
    metadata:
      labels:
        app-label: spring-app-label
    spec:
      containers:
      - name: spring-app-app
        image: cr.yandex/$REGISTRY_ID/spring-app:$VERSION
        ports:
        - name: http
          containerPort: 8080
        env:
        # --- variables from Yandex Lockbox
        - name: JDBC_URL
          valueFrom:
            secretKeyRef:
              name: spring-app-lockbox-secret
              key: JDBC_URL
        - name: DB_USERNAME
          valueFrom:
            secretKeyRef:
              name: spring-app-lockbox-secret
              key: DB_USERNAME
        - name: DB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: spring-app-lockbox-secret
              key: DB_PASSWORD

Для данного манифеста необходимо передать два значения: $REGISTRY_ID - ID реестра и $VERSION - версия образа.

$ export REGISTRY_ID=<your_container_registry_id_here>
$ export VERSION=<your_image_version_here>
$ envsubst \$REGISTRY_ID,\$VERSION < ./kubectl/deploy.yaml | kubectl apply -f -
deployment.apps/spring-app created
$ kubectl -n kubectl-ns get deploy/spring-app
NAME         READY   UP-TO-DATE   AVAILABLE   AGE
spring-app   1/1     1            1           17m

Ingress

ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: spring-app-ingress
  namespace: kubectl-ns
spec:
  tls:
    - hosts:
      - spring-app.dev.example.com
      secretName: spring-app-certificate-secret
  ingressClassName: spring-app-class-resource
  rules:
    - host: spring-app.dev.example.com
      http:
        paths:
        - path: /
          pathType: Prefix
          backend:
            service:
              name: spring-app
              port:
                name: http

Создание ingress.yaml пропустим, так как для его настройки дополнительно нужно разворачивать Ingress Controller.

Вывод: рутина

Мы развернули все нужные ресурсы. А теперь посчитайте количество выполненных команд с учетом назначении переменных окружения. А теперь представьте, что вам нужно их выполнять чаще, чем раз в жизни? Конечно, можно использовать bash скрипты, но тогда придется для каждого сервиса создавать свой bash скрипт. Рутина, не так ли? И тут к нам на помощь приходит Helm!

Перед следующим разделом удалим все созданные ранее ресурсы:

$ kubectl delete -f ./kubectl/
externalsecret.external-secrets.io "spring-app-certificate-external-secret" deleted
secretstore.external-secrets.io "spring-app-certificate-secret-store" deleted
service "spring-app" deleted
deployment.apps "spring-app" deleted
ingress.networking.k8s.io "spring-app-ingress" deleted
externalsecret.external-secrets.io "spring-app-lockbox-external-secret" deleted
secretstore.external-secrets.io "spring-app-lockbox-secret-store" deleted

Создание чарта

Для начала создадим чарт:

$ helm create helm-chart
Creating helm-chart

Взглянем на структуру только что созданного чарта:

Как видим, команда helm create создала несколько папок и вложенных в них файлов. Коротко о каждом:

  • charts/ - папка, содержащая сторонние чарты, от которых зависит текущий

  • Chart.yaml - файл, содержащий основные сведения о чарте.

  • templates/ - папка, содержащая шаблоны Kubernetes с возможностью форматирования и вставки значении Helm.

  • templates/*.tpl - файлы, содержащие именнованые шаблоны. Вы можете создавать файлы с расширением tpl и размещать в них собственные шаблоны, а затем использовать в манифестах.

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

Переносим манифесты в чарт и форматируем их

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

Встроенные объекты

В Helm, помимо использования собственных переменных, можно использовать встроенные. Таким образом, вы можете использовать в шаблонах (дальше) и манифестах такие переменные как Release.Name (название релиза),Values.example(значение example из values.yaml),Chart.Version(версия чарта) и т.д. С полным списком Built-in объектов можно ознакомиться тут.

values.yaml

Главным помощником для того, чтобы сделать чарт более гибким и универсальным, является упомянутый ранее файл values.yaml. Он содержит переменные, которые назначаются перед установкой релиза. Изначально оставим его пустым и по ходу переноса манифестов будем его наполнять.

_helpers.tpl

_helpers.tpl
{{/*
Expand the name of the chart.
*/}}
{{- define "helm-chart.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}

{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "helm-chart.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}

{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "helm-chart.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}

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

{{/*
Selector labels
*/}}
{{- define "helm-chart.selectorLabels" -}}
app.kubernetes.io/name: {{ include "helm-chart.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

{{/*
Create the name of the service account to use
*/}}
{{- define "helm-chart.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "helm-chart.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

В данном файле уже определены некоторые именнованые шаблоны. В данных шаблонах используются Built-In переменные:

  • "helm-chart.name" - название чарта

  • "helm-chart.fullname" - полное название чарта

  • "helm-chart.chart" - название чарта + его версия

  • "helm-chart.labels" - общие для всех ресурсов чарта метки. Я их буду использовать везде, так как по документации Helm они используются для идентификации ресурса.

  • "helm-chart.selectorLabels" - метки, используемые для селектов в ресурсах ReplicaSet и Deployment. Также эти метки входят в шаблон "helm-chart.labels"

Шаблонные функции

Помимо использования переменных, вы можете вызывать шаблонные функции. Подробнее в документации: тык.

Стартуем! Начнём заполнение шаблонов в том же порядке, как и в случае с манифестами из kubectl.

cert-secret-store.yaml & lockbox-secret-store.yaml

Скопируем манифесты из kubectl и перенесем в папку templates:

$ cp -r ./kubectl/*-secret-store.yaml ./helm-chart/templates 

Произведем некоторые изменения. Во-первых, удалим из названия ресурса название приложения и заменим его названием релиза:

metadata:
  name: {{ .Release.Name }}-certificate-secret-store

Во-вторых, вставим шаблон helm-chart.labels с помощью include и передадим вывод (символ | ) в шаблонную функцию nident, добавляющую переданное количество пробелов в начало строки. Я передаю в функцию 4, так как необходимо именно столько пробелов:

metadata:
  labels:
    {{- include "helm-chart.labels" . | nindent 4 }}

Теперь, чтобы узнать как будет выглядеть наш манифест после всех манипуляции хелма, воспольуемся командой helm install с параметром --dry-run, который "понарошку" установит чарт:

$ helm install --dry-run test-release ./helm-chart
NAME: test-release
STATUS: pending-install
MANIFEST:
---
# Source: helm-chart/templates/cert-secret-store.yaml
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: test-release-certificate-secret-store
  labels:
    helm.sh/chart: helm-chart-0.1.0
    app.kubernetes.io/name: helm-chart
    app.kubernetes.io/instance: test-release
    app.kubernetes.io/version: "1.16.0"
    app.kubernetes.io/managed-by: Helm
  ...

Название релиза и метки были успешно вставлены в манифест.
Так как не все наши развернутые приложения требуют доступ к TLS сертификатам и Lockbox секретам, сделаем SecretStore и ExternalSecret манифесты опциональными. Добавим следующие значения в values.yaml:

lockboxSecretStore:
  enabled: true

certificateSecretStore:
  enabled: true

По умолчанию эти ресурсы будут со значениями true.
Теперь давайте добавим логику активации манифеста, исходя из ранее добавленных значении. Для этого воспользуемся условным выражением:

{{- if .Values.certificateSecretStore.enabled -}}
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
...
{{- end }}

Снова выполним helm install --dry-run, но переопределим значение certificateSecretStore.enabled, сделав его false. Это можно сделать двумя способами: создать свой values.yaml и определить значения там или воспользоваться параметром --set. Убедимся, что список манифестов окажется пустым:

$ helm install --dry-run --set certificateSecretStore.enabled=false test-release ./helm-chart
NAME: test-release
STATUS: pending-install
MANIFEST:

Полный код манифестов:

cert-secret-store.yaml
{{- if .Values.certificateSecretStore.enabled -}}
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: {{ .Release.Name }}-certificate-secret-store
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "helm-chart.labels" . | nindent 4 }}
spec:
  provider:
    yandexcertificatemanager:
      auth:
        authorizedKeySecretRef:
          name: yc-auth
          key: authorized-key
{{- end }}

lockbox-secret-store.yaml
{{- if .Values.lockboxSecretStore.enabled -}}
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: {{ .Release.Name }}-lockbox-secret-store
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "helm-chart.labels" . | nindent 4 }}
spec:
  provider:
    yandexlockbox:
      auth:
        authorizedKeySecretRef:
          name: yc-auth
          key: authorized-key
{{- end }}

cert-external-secret.yaml

Так как SecretStore и ExternalSecret связанные сущности, создадим аналогичные условия деплоя для ExternalSecret манифестов. Но сначала добавим новое значение для ID сертификата в values.yaml:

certificateSecretStore:
  enabled: true
  externalSecret:
    certificateId: ""

И затем в манифест:

...
  - secretKey: tls.crt
    remoteRef:
      key: {{ .Values.certificateSecretStore.externalSecret.certificateId }}
      property: chain
  - secretKey: tls.key
    remoteRef:
      key: {{ .Values.certificateSecretStore.externalSecret.certificateId }}
      property: privateKey

Полный код манифестов:

cert-external-secret.yaml
{{- if .Values.certificateSecretStore.enabled -}}
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: {{ .Release.Name }}-certificate-external-secret
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "helm-chart.labels" . | nindent 4 }}
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: {{ .Release.Name }}-certificate-secret-store
    kind: SecretStore
  target:
    name: {{ .Release.Name }}-certificate-secret
    template:
      type: kubernetes.io/tls
  data:
  - secretKey: tls.crt
    remoteRef:
      key: {{ .Values.certificateSecretStore.externalSecret.certificateId }}
      property: chain
  - secretKey: tls.key
    remoteRef:
      key: {{ .Values.certificateSecretStore.externalSecret.certificateId }}
      property: privateKey
{{- end }}

lockbox-external-secret.yaml

Все секреты отличаются между собой значениями. Поэтому аналогично с другими меняющимися значениями вынесем поля из файла и поместим их в values.yaml. Не забудем также вынести и $SECRET_ID:

lockboxSecretStore:
  enabled: true
  externalSecret:
    secretId: ""
    data:
    - secretKey: JDBC_URL
      property: JDBC_URL
    - secretKey: DB_USERNAME
      property: DB_USERNAME
    - secretKey: DB_PASSWORD
      property: DB_PASSWORD

Теперь в манифесте нужно перебрать все заданные значения. Для этого воспользуемся циклом - в Helm для этого используется оператор range:

...
spec:
  ...
    data:
   {{- range .Values.lockboxSecretStore.externalSecret.data }}
    - secretKey: {{ .secretKey }}
      remoteRef:
        key: {{ $.Values.lockboxSecretStore.externalSecret.secretId }}
        property: {{ .property }}
  {{- end }}

Обратите внимание на знак доллара в remoteRef.key. Операторы range и with создают свою область видимости. В данном случае . указывает на текущую область видимости, которая задана оператором range. Поэтому, чтобы получить значение из values.yaml необходимо в начало добавить $., указывающий шаблонизатору обращаться к корневой области видимости.

Полный код манифестов:

lockbox-external-secret.yaml
{{- if .Values.lockboxSecretStore.enabled -}}
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: {{ .Release.Name }}-lockbox-external-secret
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "helm-chart.labels" . | nindent 4 }}
spec:
  refreshInterval: 1h
  secretStoreRef:
    name: {{ .Release.Name }}-lockbox-secret-store
    kind: SecretStore
  target:
    name: {{ .Release.Name }}-lockbox-secret
  data:
  {{- range .Values.lockboxSecretStore.externalSecret.data }}
    - secretKey: {{ .secretKey }}
      remoteRef:
        key: {{ $.Values.lockboxSecretStore.externalSecret.secretId }}
        property: {{ .property }}        
  {{- end }}
{{- end }}

clusterip.yaml

Редактирование манифеста для сервиса не отличается от остальных. Вынесем в values.yaml значения spec.ports[0].port и spec.ports[0].targetPort:

clusterip:
  port: 80
  targetPort: http

Также вместо собственных селекторов воспользуемся шаблоном helm-chart.selectorLabels, предлагаемым из коробки:

...
spec:
  ports:
  - name: http
    protocol: TCP
    port: {{ .Values.clusterip.port }}
    targetPort: {{ .Values.clusterip.targetPort }}
  selector:
    {{- include "helm-chart.selectorLabels" . | nindent 4 }}

Полный код манифестов:

clusterip.yaml
apiVersion: v1
kind: Service
metadata:
  name: {{ .Release.Name }}
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "helm-chart.labels" . | nindent 4 }}
spec:
  ports:
  - name: http
    protocol: TCP
    port: {{ .Values.clusterip.port }}
    targetPort: {{ .Values.clusterip.targetPort }}
  selector:
    {{- include "helm-chart.selectorLabels" . | nindent 4 }}

deploy.yaml

Добавим в values.yaml следующие значения:

deployment:
  replicaCount: 1
  image: ""
  containerPort: 8080
  resources:
    requests:
      cpu: "150m"
      memory: "400Mi"
    limits:
      cpu: "250m"
      memory: "600Mi"

Так как в контейнер передаются в качестве переменных окружения переменные из Lockbox секрета, добавим такой же цикл, как в lockbox-external-secret.yaml, но с немного другой структурой. Не забудем добавить перед циклом условие, что Lockbox используется в релизе. Чуть не забыл самое главное! Вставим ресурсы (лимиты и запросы) для контейнера при помощи функции toYaml:

spec:
  ...
  template:
    ...
    spec:
      containers:
      - name: {{ .Release.Name }}-app
		image: {{ .Values.deployment.image }}
		ports:
		- name: {{ .Values.clusterip.targetPort }}
		  containerPort: {{ .Values.deployment.containerPort }}
		resources:
          {{- toYaml .Values.deployment.resources | nindent 10 }}
	    {{- if .Values.lockboxSecretStore.enabled }}
        env:
        {{- range .Values.lockboxSecretStore.externalSecret.data }}
        - name: {{ .secretKey }}
          valueFrom:
            secretKeyRef:
              name: {{ $.Release.Name }}-lockbox-secret
              key: {{ .secretKey }}
        {{- end }}
        {{- end }}

Полный код манифестов:

deploy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ .Release.Name }}
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "helm-chart.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.deployment.replicaCount }}
  selector:
    matchLabels:
      {{- include "helm-chart.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      labels:
        {{- include "helm-chart.selectorLabels" . | nindent 8 }}
    spec:
      containers:
      - name: {{ .Release.Name }}-app
        image: {{ .Values.deployment.image }}
        ports:
        - name: {{ .Values.clusterip.targetPort }}
          containerPort: {{ .Values.deployment.containerPort }}
        resources:
          {{- toYaml .Values.deployment.resources | nindent 10 }}
        {{- if .Values.lockboxSecretStore.enabled }}
        env:
        {{- range .Values.lockboxSecretStore.externalSecret.data }}
        - name: {{ .secretKey }}
          valueFrom:
            secretKeyRef:
              name: {{ $.Release.Name }}-lockbox-secret
              key: {{ .secretKey }}
        {{- end }}
        {{- end }}

ingress.yaml

Добавим также ресурс типа Ingress. Также как и с ExternalSecret, сделаем создание ингресса на усмотрение пользователя. В values.yaml добавим значения:

ingress:
  enabled: true
  host: ""

Полный код манифестов:

ingress.yaml
{{- if .Values.ingress.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: {{ .Release.Name }}
  namespace: {{ .Release.Namespace }}
  labels:
    {{- include "helm-chart.labels" . | nindent 4 }}
spec:
  tls:
    - hosts:
      - {{ .Values.ingress.host }}
      secretName: {{ .Release.Name }}-certificate-secret
  ingressClassName: {{ .Release.Name }}-class-resource
  rules:
    - host: {{ .Values.ingress.host }}
      http:
        paths:
        - path: /
          pathType: Prefix
          backend:
            service:
              name: {{ .Release.Name }}
              port:
                name: {{ .Values.clusterip.port }}
{{- end }}

А также values.yaml со всеми добавленными значениями:

values.yaml
lockboxSecretStore:
  enabled: true
  externalSecret:
    secretId: ""
    data:
    - secretKey: JDBC_URL
      property: JDBC_URL
    - secretKey: DB_USERNAME
      property: DB_USERNAME
    - secretKey: DB_PASSWORD
      property: DB_PASSWORD

certificateSecretStore:
  enabled: true
  externalSecret:
    certificateId: ""

clusterip:
  port: 80
  targetPort: http

deployment:
  replicaCount: 1
  image: ""
  containerPort: 8080
  
  resources:
    requests:
      cpu: "150m"
      memory: "400Mi"
    limits:
      cpu: "250m"
      memory: "600Mi"

ingress:
  enabled: true
  host: ""

Запускаем чарт

И так, мы создали свой Helm чарт с довольно гибким values.yaml файлом. Вы можете использовать стандартный values.yaml, но вряд-ли он будет соотвествовать всем вашим потребностям. Поэтому вы можете назначить значения, воспользовавшись параметром --set при установке релиза или же, если их много, написать yaml файл со своими значениями и указать путь с помощью параметра -f. Так как для развертывания моих сервисов нужно перезаписать достаточно много значении, я создал файлик my-app-values.yaml. Установим чарт:

$ helm install -n kubectl-ns -f ./my-app-values.yaml my-app ./helm-chart 
NAME: my-app
LAST DEPLOYED: Fri Oct 20 17:21:18 2023
NAMESPACE: kubectl-ns
STATUS: deployed
REVISION: 1
TEST SUITE: None

Получим список всех ресурсов:

$ kubectl -n kubectl-ns get all                                     
NAME                                 READY   STATUS   RESTARTS      AGE
pod/my-app-84c8d4cfdd-mhqb4   0/1    Error   1 (36s ago)  106s

NAME                    TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
service/my-app          ClusterIP   10.96.167.201   <none>        80/TCP    107s

NAME                            READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/my-app          0/1     1            0           107s

NAME                                       DESIRED   CURRENT   READY   AGE
replicaset.apps/my-app-84c8d4cfdd          1         1         0       107s

Под не запустился! Выясним в чём причина, выполнив kubectl describe:

$ kubectl -n kubectl-ns describe pods/my-app-84c8d4cfdd-mhqb4
Events:
  Type     Reason     Age                    From               Message
  ----     ------     ----                   ----               -------
  Normal   Scheduled  5m40s                  default-scheduler  Successfully assigned kubectl-ns/my-app-84c8d4cfdd-mhqb4 to cl12s2vrpmu4of6it02q-itys
  Warning  Failed     5m40s (x2 over 5m40s)  kubelet            Error: secret "my-app-lockbox-secret" not found

secret not found. Но почему? Посмотрим список всех секретов:

$ kubectl -n kubectl-ns get secret                                   
NAME                                  TYPE                                  DATA   AGE
my-app-certificate-secret             kubernetes.io/tls                     2      7m40s
my-app-lockbox-secret                 Opaque                                4      7m39s

Так вот же они! Я был в небольшом ступоре, когда обнаружил эту проблему, но быстро смог понять в чем дело. Давайте выполним ту же самую команду для установки чарта, но используем параметр --dry-run. В этой статье я уже использовал эту команду: она не по настоящему устанавливает чарт и выводит список всех манифестов в порядке их установки. Чтобы не смотреть содержимое всех манифестов, "грепнем" вывод команды и получим только названия манифестов:

$ helm install --dry-run -n kubectl-ns -f ./my-app-values.yaml config-server ./helm-chart | grep "Source:"
# Source: helm-chart/templates/clusterip.yaml
# Source: helm-chart/templates/deploy.yaml
# Source: helm-chart/templates/ingress.yaml
# Source: helm-chart/templates/cert-external-secret.yaml
# Source: helm-chart/templates/lockbox-external-secret.yaml
# Source: helm-chart/templates/cert-secret-store.yaml
# Source: helm-chart/templates/lockbox-secret-store.yaml

Заметили? ExternalSecret и SecretStore создаются после Deployment.

Порядок запуска ресурсов

Helm сортирует все ресурсы чарта и выполняет их в такой очерёдности:

Очерередность запуска ресурсов

Исходя из этого, Secret(5) создается раньше Deployment(21). Но дело в том, что секрет со значениями из Lockbox создает ресурс типа ExternalSecret, которого в списке нет. Поэтому Helm выполняет неизвестные ему типы ресурсов последними (SecretStore и ExternalSecret). Но повлиять на очередь загрузки можно при помощи хуков чарта.

Хуки

Хуки позволяют выполнять манифесты в какой-то определённый момент, например перед установкой релиза или после его удаления. Чтобы указать в какой момент выполнять хук, на ресурс накидывается аннотация helm.sh/hook. Список всех возможных хуков привёден в документации:

Нам понадобится pre-install. Помимо аннотации с указанием хука, мы можем также указать его вес аннотацией helm.sh/hook-weight, чтобы назначить конкретный порядок выполнения ресурсов, выполняемых в рамках одного хука. По умолчанию всем ресурсам назначается вес, равный "0". Значение аннотации должно быть строковым и может быть как отрицательным, так и положительным. Helm в дальнейшем сортирует ресурсы в порядке возрастания веса. Кроме двух вышеуказанных аннотации, можно воспользоваться аннотацией helm.sh/hook-delete-policy, определяющую политику удаления хука:

По умолчанию указан before-hook-creation, что означает, что ресурс, созданный хуком, не удалится до тех пор, пока не будет запущен новый хук.

Определяем свой порядок создания ресурсов

Снова залезем в код. Зададим для всех ресурсов типа ExternalSecret и SecretStore аннотацию с хуком pre-install. Так как ресурсы типа SecretStore должен создаваться раньше, назначим им вес "-2". Тогда ExternalSecret будут иметь вес "-1". Так как секреты после выполнения хука нам будут нужны для запуска Deployment и настройки Ingress по HTTPS, оставим значение аннотации helm.sh/hook-deletion-policy по умолчанию.

cert-secret-store.yaml | lockbox-secret-store.yaml:

apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  ...
  annotations:
    "helm.sh/hook": pre-install
    "helm.sh/hook-weight": "-2"
...

cert-external-secret.yaml | lockbox-external-secret:

apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  ...
  annotations:
    "helm.sh/hook": pre-install
    "helm.sh/hook-weight": "-1"
...

Снова выполним команду для установки чарта и убедимся, что под находится в статусе Running:

$ kubectl -n kubectl-ns get pods
NAME                             READY   STATUS    RESTARTS      AGE
my-app-84c8d4cfdd-hksz5          1/1     Running   0             49s

Под был успешно запущен!

Запускаем второй релиз

Теперь попробуем запустить второй релиз. Возьмем стандартный образ nginx. Всё что нам понадобится это Deployment и Service(ClusterIP). На этот раз, для назначения кастомных значении релизу воспользуемся параметром --set:

$ kubectl create namespace nginx-helm
namespace/nginx-helm created
$ helm install \
    -n nginx-helm \
    --set lockboxSecretStore.enabled=false \
    --set certificateSecretStore.enabled=false \
    --set deployment.image=nginx:1.25.2 \
    --set deployment.containerPort=80 \
    --set ingress.enabled=false \
    nginx ./helm-chart
NAME: nginx
LAST DEPLOYED: Fri Oct 20 20:10:58 2023
NAMESPACE: nginx-helm
STATUS: deployed
REVISION: 1
TEST SUITE: None
$ kubectl -n nginx-helm get all
NAME                         READY   STATUS    RESTARTS   AGE
pod/nginx-658dbf5895-5rrmm   1/1     Running   0          14s

NAME            TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
service/nginx   ClusterIP   10.96.171.141   <none>        80/TCP    14s

NAME                    READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/nginx   1/1     1            1           14s

NAME                               DESIRED   CURRENT   READY   AGE
replicaset.apps/nginx-658dbf5895   1         1         1       14s

Также мы можем посмотреть список всех релизов в пространстве имен:

$ helm -n nginx-helm ls
NAME    NAMESPACE       REVISION        UPDATED                                 STATUS       CHART                   APP VERSION
nginx   nginx-helm      1               2023-10-20 20:10:58.163471204 +0300 MSK deployed     helm-chart-0.1.0        1.16.0 

Удалим релиз:

$ helm -n nginx-helm uninstall nginx     
release "nginx" uninstalled

Итоги и что дальше

Начальная цель была выполнена успешна - мы создали чарт, который можно использовать для деплоя своих приложений! Вы можете сами поэксперементировать с чартом и, возможно, даже дополнить его новыми возможностями. Я планирую доработать чарт для еще более гибкой настройки и начать переводить запуск своих приложений через Helm. Возможно, что будет создан новый репозитории с чартом, который я буду периодически развивать (если у вас будет желание поучаствовать в разработке - welcome). Спасибо за прочтение!

Комментарии (8)


  1. past
    22.10.2023 16:48
    +3

    Я в свое время написал 100+ чартов и сейчас считаю, что Хелм это зло. Он приносит кучу добавленной сложности в замен так называемых "релизов" которые, как правило, оказываются сломаны в тот момент, когда они особенно нужны, например, когда надо срочно чинить прод. Мы года 4 назад перешли на kustomize и с тех пор забыли про Хелм как про страшный сон, чего и вам желаю.


    1. AzamatKomaev Автор
      22.10.2023 16:48

      Добрый вечер! По поводу опции в голосовании вы правы - упустил этот момент, но уже поздно(

      Пока Helm кажется идеальным решением, чтобы облегчить возню с деплоем приложении. Это открывает большие возможности - сейчас у нас десяток микросервисов и для каждого своя папка с YAML манифестами, с хелмом получится удалить все эти манифесты и обойтись одной командой helm install, только лишь изменив некоторые значения параметром --set. С kustomize признаюсь почти не работал, единственное для чего пытался его использовать, так это для создания секрета со значениями из .env файлика, но впоследствии отказался от этого. В любом случае, спасибо за совет, но я пока остановлюсь на Helm :)


      1. ToomIm
        22.10.2023 16:48
        +1

        Кастомайз всё же лучше как мне кажется, попробуйте!

        - Делаете для ci шаблон делоя, сервиса, конфиг-мапы и тд.
        - Дальше в зафисимости от среды деплоя(можно папками разделить) главный(например dev). В нем описываете откуда секреты тянуть и тд.
        - Делаете папки с наименованиями стендов
        - Делаете там kustomization.yml и переопределяете то что нужно и отличается от dev

        В целом по основному всё :)

        Есть моменты, не спорю, когда helm может порешать. Но я их вижу только в рамках официальных чартов того же vault, например.


    1. ElectricPigeon
      22.10.2023 16:48

      Подскажите, пожалуйста, один момент на Вашем опыте. Я для себя открыл helm, когда столкнулся с необходимостью параметризовать манифесты в CI/CD. Конкретнее, в случае, когда я хотел деплоить приложения на dev и prod окружения. Я использовал values.yaml, чтобы указать там имя секрета, доменное имя для ingress и прочие вещи, которые могут различаться в разных окружениях.

      Я согласен с вашим мнением про добавленную сложность, но как тогда решить проблему, когда тебе действительно нужна параметризация?


      1. koala1101
        22.10.2023 16:48
        +1

        тут есть два варината:
        1) можно при вызове helm указывать флаги --set ingress.baseHost=example.domain
        2) держать 2 values.yaml по типу values-dev.yaml и values-prod.yaml, указывая также при вызове helm какой файлик вы хотите использовать helm -f values-dev.yaml


        1. ElectricPigeon
          22.10.2023 16:48

          Спасибо за ответ) Я как раз так и делал.

          Я хотел поинтересоваться у автора комментария, как он в случае с Kustomize решает такую же проблему, если вообще с ней сталкивается


          1. past
            22.10.2023 16:48

            В kustomize есть такая вещь, как слои.
            Обычно делают базовый слой, где лежит не параметризованный набор манифестов и оверлеи, которые ссылаются на базовый слой как родительский и в которых накладываются патчи с конкретными значениями, которые нужно поменять для окружений. Вот годная статья от Фланта https://habr.com/ru/companies/flant/articles/469179/


  1. past
    22.10.2023 16:48
    +2

    В голосовании не хватает опции "использую только для сторонних чартов. Многие вендоров распространяют свое ПО только в виде чартов.