Helm - váš package manager pro Kubernetes

Kubernetes v rámci Azure Container Service je skvělé řešení pro vaše kontejnerizované aplikace. Jenže co když ta se skládá z několika komponent ať už technologických (web, cache, databáze, ...) nebo s byznys logikou (mikroslužby)? Jak koordinovaně nasadit, upgradovat a rollbackovat celé aplikace bez nutnosti řešit každý dílek zvlášť? V Linuxu máte package manager jako je apt nebo yum. Existuje něco podobného pro Kubernetes? Ano a jmenuje se Helm. Vyzkoušejme si dnes.

Deis, Helm a k čemu to je

Jak už jsem v úvodu psal moderní aplikace se typicky skládá z několika technologických i byznysových komponent. Kubernetes se dokáže parádně postarat o běh a deployment komponent (kontejnerů), ale jak koordinovaně řešit aplikaci jako celek? Open source firma Deis, která je dnes po akvizici součástí Microsoft, vyvynula řešení Helm - "package manager" pro Kubernetes. Celou aplikaci pak dokážete popsat (vytvořit Chart) a tu pak lze jednoduše nasadit i upgradovat.

Sestavme si Kubernetes s Helm a vyzkoušejme

Protože se mi nechce stavět si Kubernetes cluster sám, použiji Azure Container Service. Ta pro vás připraví cluster na základě best practice v Azure, je to plně open source řešení a celá služba je zdarma (platíte jen za použité VM zdroje). Cluster naběhne s řadou hotových integrací, například CNI pluginu pro Azure networking, takže z Kubernetes jednoduše ovládáte i Azure Load Balancer a nemusíte tunelovat provoz (napojíte se na Azure networking, respektive VNet).

Sestavme si Kubernetes cluster s využitím Azure CLI 2.0. Nejprve vytvoříme Resource Group.

az group create -n kube -l westeurope

Následně spustíme vytvoření clusteru Kubernetes. Já zvolím řešení z jedním masterem (nepotřebuji teď redundanci control plane, nicméně stačí zvolit číslo 3 a máte ji - postup je stejný) a trojicí agent nodů s Linux (Kubernetes v Azure podporuje i Windows nody, pokud chcete orchestrovat svět Windows kontejnerů). Použiji vlastní SSH klíč, ale můžete nechat ACS rovnou nějaké vygenerovat, pokud nemáte.

az acs create --orchestrator-type=kubernetes --resource-group kube --name=mujkubernetes --agent-count=3 --ssh-key-value "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDFhm1FUhzt/9roX7SmT/dI+vkpyQVZp3Oo5HC23YkUVtpmTdHje5oBV0LMLBB1Q5oSNMCWiJpdfD4VxURC31yet4mQxX2DFYz8oEUh0Vpv+9YWwkEhyDy4AVmVKVoISo5rAsl3JLbcOkSqSO8FaEfO5KIIeJXB6yGI3UQOoL1owMR9STEnI2TGPZzvk/BdRE73gJxqqY0joyPSWOMAQ75Xr9ddWHul+v//hKjibFuQF9AFzaEwNbW5HxDsQj8gvdG/5d6mt66SfaY+UWkKldM4vRiZ1w11WlyxRJn5yZNTeOxIYU4WLrDtvlBklCMgB7oF0QfiqahauOEo6m5Di2Ex" --dns-prefix mujkubernetes --agent-vm-size Standard_A1_v2 --admin-username tomas

Teď stačí jen čekat. Následně si nainstalujte kubectl, tedy příkazovou řádku pro Kubernetes. To můžete udělat přímo z Azure CLI.

sudo az acs kubernetes install-cli 

Tím máme nainstalováné kubectl. Přímo ze své stanice můžeme ovládat Kubernetes cluster, stačí si stáhnout údaje o konektivitě a klíče. I to pro vás udělá Azure CLI.

az acs kubernetes get-credentials --resource-group=kube --name=mujkube

Pokud všechno dopadlo dobře, jste ve svém Kubernetes clusteru.

tomas@jump:~$ kubectl get nodes
NAME                    STATUS                     AGE       VERSION
k8s-agent-6d417f1c-0    Ready                      7m        v1.6.6
k8s-agent-6d417f1c-1    Ready                      6m        v1.6.6
k8s-agent-6d417f1c-2    Ready                      6m        v1.6.6
k8s-master-6d417f1c-0   Ready,SchedulingDisabled   7m        v1.6.6

Teď si můžeme stáhnout helm příkazovou řádku.

wget https://kubernetes-helm.storage.googleapis.com/helm-v2.5.0-linux-amd64.tar.gz
tar -xvf helm-v2.5.0-linux-amd64.tar.gz
sudo mv linux-amd64/helm /usr/bin/

Proveďme potřebnou inicializaci (Helm nainstaluje svou serverovou část) a updatujme repozitář.

helm init
helm repo update

Vyzkoušejme si teď nějaký z veřejných Helm balíčků, například Wordpress. Ten se bude skládat z kontejneru s databází, který bude mít jen interní adresu. Dále s webovou částí, která si vezme externí adresu - tedy zažádá si o ni (Kubernetess Ingress) a Kubernetes díky Azure pluginu zavolá samotný Azure a vytvoří novou veřejnou IP adresu na balanceru. Součástí Helm mohou být i další vstupní parametry, v mém případě například heslo do blogu a jeho název.

tomas@jump:~$ helm install stable/wordpress --name mujwp --set wordpressUsername=tomas,wordpressPassword=Azure12345678,wordpressBlogName=Muj-super-blog
NAME:   mujwp
LAST DEPLOYED: Mon Jun 26 08:44:43 2017
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1/Secret
NAME             TYPE    DATA  AGE
mujwp-mariadb    Opaque  2     2s
mujwp-wordpress  Opaque  3     2s

==> v1/ConfigMap
NAME           DATA  AGE
mujwp-mariadb  1     2s

==> v1/PersistentVolumeClaim
NAME             STATUS   VOLUME                                    CAPACITY  ACCESSMODES  STORAGECLASS  AGE
mujwp-wordpress  Bound    pvc-b48a4780-5a4b-11e7-bfac-000d3a250d6e  10Gi      RWO          default       2s
mujwp-mariadb    Pending  default                                   2s

==> v1/Service
NAME             CLUSTER-IP    EXTERNAL-IP  PORT(S)                     AGE
mujwp-mariadb    10.0.65.25           3306/TCP                    2s
mujwp-wordpress  10.0.200.140      80:32674/TCP,443:32156/TCP  2s

==> v1beta1/Deployment
NAME             DESIRED  CURRENT  UP-TO-DATE  AVAILABLE  AGE
mujwp-mariadb    1        1        1           0          2s
mujwp-wordpress  1        1        1           0          2s


NOTES:
1. Get the WordPress URL:

  NOTE: It may take a few minutes for the LoadBalancer IP to be available.
        Watch the status with: 'kubectl get svc --namespace default -w mujwp-wordpress'

  export SERVICE_IP=$(kubectl get svc --namespace default mujwp-wordpress -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
  echo http://$SERVICE_IP/admin

2. Login with the following credentials to see your blog

  echo Username: tomas
  echo Password: $(kubectl get secret --namespace default mujwp-wordpress -o jsonpath="{.data.wordpress-password}" | base64 --decode)

Jak vidíme Helm nasadil dva kontejnery - jeden s webem a jeden s databází. Externí IP adresa je pending, takže musíme chvilku počkat, až se Kubernetes a Azure Load Balancer domluví. Po chvilce najdeme IP adresu takto:

tomas@jump:~$ kubectl get services
NAME              CLUSTER-IP     EXTERNAL-IP       PORT(S)                      AGE
kubernetes        10.0.0.1                   443/TCP                      1h
mujwp-mariadb     10.0.65.25                 3306/TCP                     1h
mujwp-wordpress   10.0.200.140   137.116.197.194   80:32674/TCP,443:32156/TCP   1h

Připojím se na tuto IP adresu a můj blog je nahoře.

Mohu se přihlásit údaji, které jsme specifikovali při spuštění a můžeme začít psát články.

Podívejme se Helmu pod kapotu

Chcete si vytvořit vlastní Helm balíček? Dobrý způsob jak se to naučit je podívat se pod kapotu nějakému hotovému, jako například už vyzkoušený wordpress. Ten se nachází v Helm cache, tak si ho rozbalme a prozkoumejme jeho strukturu.

tar -xvf .helm/cache/archive/wordpress-0.6.6.tgz

Jednotlivé součástky Helmu se nazývají Chart. Wordpress Chart má dependency na Chart s mariadb. Tuto závislost najdeme v souboru requirements.yaml:

tomas@jump:~/wordpress$ cat requirements.yaml
dependencies:
- name: mariadb
  version: 0.6.3
  repository: https://kubernetes-charts.storage.googleapis.com/

Tak například onen Chart pro databázi obsahuje v adresáři templates šablony pro Kubernetes, které Helm při deploymentu vyplní v závislosti na konfiguračních parametrech. Tak například takhle vypadá samotný deployment template:

tomas@jump:~/wordpress$ cat charts/mariadb/templates/deployment.yaml
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: {{ template "fullname" . }}
  labels:
    app: {{ template "fullname" . }}
    chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
    release: "{{ .Release.Name }}"
    heritage: "{{ .Release.Service }}"
spec:
  template:
    metadata:
      labels:
        app: {{ template "fullname" . }}
      annotations:
        pod.alpha.kubernetes.io/init-containers: '
          [
            {
              "name": "copy-custom-config",
              "image": "{{ .Values.image }}",
              "imagePullPolicy": {{ .Values.imagePullPolicy | quote }},
              "command": ["sh", "-c", "mkdir -p /bitnami/mariadb/conf && cp -n /bitnami/mariadb_config/my.cnf /bitnami/mariadb/conf/my_custom.cnf"],
              "volumeMounts": [
                {
                  "name": "config",
                  "mountPath": "/bitnami/mariadb_config"
                },
                {
                  "name": "data",
                  "mountPath": "/bitnami/mariadb"
                }
              ]
            }
          ]'
    spec:
      containers:
      - name: {{ template "fullname" . }}
        image: "{{ .Values.image }}"
        imagePullPolicy: {{ .Values.imagePullPolicy | quote }}
        env:
        - name: MARIADB_ROOT_PASSWORD
          valueFrom:
            secretKeyRef:
              name: {{ template "fullname" . }}
              key: mariadb-root-password
        - name: MARIADB_USER
          value: {{ default "" .Values.mariadbUser | quote }}
        - name: MARIADB_PASSWORD
          valueFrom:
            secretKeyRef:
              name: {{ template "fullname" . }}
              key: mariadb-password
        - name: MARIADB_DATABASE
          value: {{ default "" .Values.mariadbDatabase | quote }}
        - name: ALLOW_EMPTY_PASSWORD
          value: "yes"
        ports:
        - name: mysql
          containerPort: 3306
        livenessProbe:
          exec:
            command:
            - mysqladmin
            - ping
          initialDelaySeconds: 30
          timeoutSeconds: 5
        readinessProbe:
          exec:
            command:
            - mysqladmin
            - ping
          initialDelaySeconds: 5
          timeoutSeconds: 1
        resources:
{{ toYaml .Values.resources | indent 10 }}
        volumeMounts:
        - name: data
          mountPath: /bitnami/mariadb
      volumes:
      - name: config
        configMap:
          name: {{ template "fullname" . }}
      - name: data
      {{- if .Values.persistence.enabled }}
        persistentVolumeClaim:
          claimName: {{ .Values.persistence.existingClaim | default (include "fullname" .) }}
      {{- else }}
        emptyDir: {}
      {{- end -}}

Používá perzistentní volume, jehož template je zde:

tomas@jump:~/wordpress$ cat charts/mariadb/templates/pvc.yaml
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }}
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: {{ template "fullname" . }}
  labels:
    app: {{ template "fullname" . }}
    chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
    release: "{{ .Release.Name }}"
    heritage: "{{ .Release.Service }}"
  annotations:
  {{- if .Values.persistence.storageClass }}
    volume.beta.kubernetes.io/storage-class: {{ .Values.persistence.storageClass | quote }}
  {{- else }}
    volume.alpha.kubernetes.io/storage-class: default
  {{- end }}
spec:
  accessModes:
    - {{ .Values.persistence.accessMode | quote }}
  resources:
    requests:
      storage: {{ .Values.persistence.size | quote }}
{{- end }}

K databázi se přistupuje přes Kubernetes Service a i její template můžeme prozkoumat:

tomas@jump:~/wordpress$ cat charts/mariadb/templates/svc.yaml
apiVersion: v1
kind: Service
metadata:
  name: {{ template "fullname" . }}
  labels:
    app: {{ template "fullname" . }}
    chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
    release: "{{ .Release.Name }}"
    heritage: "{{ .Release.Service }}"
spec:
  type: {{ .Values.serviceType }}
  ports:
  - name: mysql
    port: 3306
    targetPort: mysql
  selector:
    app: {{ template "fullname" . }}

Velmi podobně se řeší template pro samotný Wordpress.

tomas@jump:~/wordpress$ cat templates/deployment.yaml
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: {{ template "fullname" . }}
  labels:
    app: {{ template "fullname" . }}
    chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
    release: "{{ .Release.Name }}"
    heritage: "{{ .Release.Service }}"
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: {{ template "fullname" . }}
    spec:
      containers:
      - name: {{ template "fullname" . }}
        image: "{{ .Values.image }}"
        imagePullPolicy: {{ default "" .Values.imagePullPolicy | quote }}
        env:
        - name: ALLOW_EMPTY_PASSWORD
        {{- if .Values.allowEmptyPassword }}
          value: "yes"
        {{- else }}
          value: "no"
        {{- end }}
        - name: MARIADB_ROOT_PASSWORD
          valueFrom:
            secretKeyRef:
              name: {{ template "mariadb.fullname" . }}
              key: mariadb-root-password
        - name: MARIADB_HOST
          value: {{ template "mariadb.fullname" . }}
        - name: MARIADB_PORT_NUMBER
          value: "3306"
        - name: WORDPRESS_DATABASE_NAME
          value: {{ default "" .Values.mariadb.mariadbDatabase | quote }}
        - name: WORDPRESS_DATABASE_USER
          value: {{ default "" .Values.mariadb.mariadbUser | quote }}
        - name: WORDPRESS_DATABASE_PASSWORD
          valueFrom:
            secretKeyRef:
              name: {{ template "mariadb.fullname" . }}
              key: mariadb-password
        - name: WORDPRESS_USERNAME
          value: {{ default "" .Values.wordpressUsername | quote }}
        - name: WORDPRESS_PASSWORD
          valueFrom:
            secretKeyRef:
              name: {{ template "fullname" . }}
              key: wordpress-password
        - name: WORDPRESS_EMAIL
          value: {{ default "" .Values.wordpressEmail | quote }}
        - name: WORDPRESS_FIRST_NAME
          value: {{ default "" .Values.wordpressFirstName | quote }}
        - name: WORDPRESS_LAST_NAME
          value: {{ default "" .Values.wordpressLastName | quote }}
        - name: WORDPRESS_BLOG_NAME
          value: {{ default "" .Values.wordpressBlogName | quote }}
        - name: SMTP_HOST
          value: {{ default "" .Values.smtpHost | quote }}
        - name: SMTP_PORT
          value: {{ default "" .Values.smtpPort | quote }}
        - name: SMTP_USER
          value: {{ default "" .Values.smtpUser | quote }}
        - name: SMTP_PASSWORD
          valueFrom:
            secretKeyRef:
              name: {{ template "fullname" . }}
              key: smtp-password
        - name: SMTP_USERNAME
          value: {{ default "" .Values.smtpUsername | quote }}
        - name: SMTP_PROTOCOL
          value: {{ default "" .Values.smtpProtocol | quote }}
        ports:
        - name: http
          containerPort: 80
        - name: https
          containerPort: 443
        livenessProbe:
          httpGet:
            path: /wp-login.php
            port: http
          initialDelaySeconds: 120
          timeoutSeconds: 5
          failureThreshold: 6
        readinessProbe:
          httpGet:
            path: /wp-login.php
            port: http
          initialDelaySeconds: 30
          timeoutSeconds: 3
          periodSeconds: 5
        volumeMounts:
        - mountPath: /bitnami/apache
          name: wordpress-data
          subPath: apache
        - mountPath: /bitnami/wordpress
          name: wordpress-data
          subPath: wordpress
        - mountPath: /bitnami/php
          name: wordpress-data
          subPath: php
        resources:
{{ toYaml .Values.resources | indent 10 }}
      volumes:
      - name: wordpress-data
      {{- if .Values.persistence.enabled }}
        persistentVolumeClaim:
          claimName: {{ template "fullname" . }}
      {{- else }}
        emptyDir: {}
      {{ end }}

Opět najdete definici i pro perzistentní Volume a také Service. Navíc je tu definice Ingress, což je Kubernetes řešení pro získání externího přístupu na službu (Kubernetes se domluví s Azure Load Balancer):

tomas@jump:~/wordpress$ cat templates/ingress.yaml
{{- if .Values.ingress.enabled -}}
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
 name: {{ template "fullname" . }}
 labels:
    app: {{ template "fullname" . }}
    chart: "{{ .Chart.Name }}-{{ .Chart.Version }}"
    release: "{{ .Release.Name }}"
    heritage: "{{ .Release.Service }}"
 annotations:
   {{- range $key, $value := .Values.ingress.annotations }}
     {{ $key }}: {{ $value | quote }}
   {{- end }}
spec:
  rules:
    - host: {{ .Values.ingress.hostname }}
      http:
        paths:
          - path: /
            backend:
              serviceName: {{ template "fullname" . }}
              servicePort: 80
{{- if .Values.ingress.tls }}
  tls:
{{ toYaml .Values.ingress.tls | indent 4 }}
{{- end -}}
{{- end -}}

Zkusme jednoduchý Helm Chart

Nechme Helm vytvořit jednoduchou kostru pro Chart, který v rámci příkladu bude nginx kontejner.

helm create mujtest

Prohlédněte si strukturu, zejména jednotlivé templaty. Následně pojďme tento Chart nainstalovat s tím, že si zapneme Ingress, tedy požádáme Kubernetes o přiřazení externí balancované public IP z Azure Load Balancer.

tomas@jump:~$ cd mujtest/
tomas@jump:~/mujtest$ helm install . --name mujtest --set ingress.enabled=true,service.type=LoadBalancer
NAME:   mujtest
LAST DEPLOYED: Mon Jun 26 11:06:03 2017
NAMESPACE: default
STATUS: DEPLOYED

RESOURCES:
==> v1beta1/Ingress
NAME             HOSTS                ADDRESS  PORTS  AGE
mujtest-mujtest  chart-example.local  80       1s

==> v1/Service
NAME             CLUSTER-IP  EXTERNAL-IP  PORT(S)       AGE
mujtest-mujtest  10.0.32.62      80:30506/TCP  1s

==> v1beta1/Deployment
NAME             DESIRED  CURRENT  UP-TO-DATE  AVAILABLE  AGE
mujtest-mujtest  1        1        1           0          1s


NOTES:
1. Get the application URL by running these commands:
     NOTE: It may take a few minutes for the LoadBalancer IP to be available.
           You can watch the status of by running 'kubectl get svc -w mujtest-mujtest'
  export SERVICE_IP=$(kubectl get svc --namespace default mujtest-mujtest -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
  echo http://$SERVICE_IP:80

Po chvilce si zjistíme veřejnou IP a zkusíme se připojit prohlížečem.

tomas@jump:~/mujtest$ kubectl get services
NAME              CLUSTER-IP     EXTERNAL-IP       PORT(S)                      AGE
kubernetes        10.0.0.1                   443/TCP                      2h
mujtest-mujtest   10.0.32.62     40.114.150.4      80:30506/TCP                 4m
mujwp-mariadb     10.0.65.25                 3306/TCP                     2h
mujwp-wordpress   10.0.200.140   137.116.197.194   80:32674/TCP,443:32156/TCP   2h

Vyzkoušejme si teď využít Kubernetes ConfigMap k tomu, abychom v rámci instalaci zajistili jednoduchý statický obsah pro webovky. Nejprve zrušte předchozí deployment Helmu.

helm delete mujtest --purge

Vytvořte tento soubor:

nano templates/configmap.yaml

Toto bude obsah našeho souboru. V zásadě říkáme, že obsah soubor index.html si chceme vzít z proměnné index v našem Values.yaml souboru (nebo z příkazové řádky při instalaci).

apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ template "fullname" . }}
  labels:
    heritage: {{ .Release.Service }}
    release: {{ .Release.Name }}
    chart: {{ .Chart.Name }}-{{ .Chart.Version }}
    app: {{ template "name" . }}
data:
  index.html: {{ .Values.index | quote }}

Tuto konfigurační mapu může namountovat do kontejneru na místo, kam si nginx dává obsah webových stránek. Za tím účelem potřebujeme změnit deployment.yaml šablonu - nastavíme mountpoint a také specifikujeme volume. Celý soubor vypadá takhle:

apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: {{ template "fullname" . }}
  labels:
    app: {{ template "name" . }}
    chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }}
    release: {{ .Release.Name }}
    heritage: {{ .Release.Service }}
spec:
  replicas: {{ .Values.replicaCount }}
  template:
    metadata:
      labels:
        app: {{ template "name" . }}
        release: {{ .Release.Name }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          ports:
            - containerPort: {{ .Values.service.internalPort }}
          volumeMounts:
            - mountPath: /usr/share/nginx/html
              name: wwwdata-volume
          livenessProbe:
            httpGet:
              path: /
              port: {{ .Values.service.internalPort }}
          readinessProbe:
            httpGet:
              path: /
              port: {{ .Values.service.internalPort }}
          resources:
{{ toYaml .Values.resources | indent 12 }}
    {{- if .Values.nodeSelector }}
      nodeSelector:
{{ toYaml .Values.nodeSelector | indent 8 }}
    {{- end }}
      volumes:
        - name: wwwdata-volume
          configMap:
            name: {{ template "fullname" . }}

Teď už zbývá jen nainstalovat tento Chart a předat mu naše parametry.

helm install . --name dalsitest --set ingress.enabled=true,service.type=LoadBalancer,index="Tohle je moje webovka"

Zjistíme si veřejnou adresu.

tomas@jump:~/mujtest$ kubectl get services
NAME                CLUSTER-IP     EXTERNAL-IP       PORT(S)                      AGE
dalsitest-mujtest   10.0.247.205   52.174.240.147    80:30206/TCP                 1m
kubernetes          10.0.0.1                   443/TCP                      1d
mujwp-mariadb       10.0.65.25                 3306/TCP                     1d
mujwp-wordpress     10.0.200.140   137.116.197.194   80:32674/TCP,443:32156/TCP   1d

Připojte se na ni. Měli bychom zjistit, že se nám podařilo vytvořit Helm, který nainstaluje nginx, z Azure si Kubernetes získá veřejnou adresu a v kontejneru je nastrčen náš statický obsah.

Povedlo se!

 



Kubernetes praticky: serverless s KEDA, Osiris a Azure Functions Kubernetes Kontejnery
Pohled na hybridní svět IT nově i s Azure Arc Kubernetes
Kubernetes praticky: doporučená bezpečnostní nastavení a scan s kubesec.io Kubernetes Kontejnery
Kubernetes praticky: používání externí konfigurace v Azure App Configuration Service Kubernetes Kontejnery
Kubernetes praticky: vystavování aplikací s Ingress a Azure App Gateway (WAF) Kubernetes Kontejnery