Próba realizacji BuildShipRun dla tandemu argocd+helm

Sławomir Wolak (Gimbus)
7. listopada 2023
Reading time: 18 min
Próba realizacji BuildShipRun dla tandemu argocd+helm

W niniejszym artykule omówimy realizację koncepcji BuildShipRun (z 12-factor-app) w oparciu o tandem argocd+helm. Środowiskiem uruchomieniowym jest tu klaster k8s oparty o Vmware Tanzu.

BuildShipRun (BSR) to w tym wypadku i w tym kontekście, to :

  • rozdział artefaktu generycznego od konfiguracji
  • artefakt generyczny ma być pozbawiony konfiguracji
  • konfiguracja per środowisko (prod, test, dev) ma być dostarczana wraz z release artefaktu.

Przedstawiam 5 podejść do próby realizacji za pośrednictwem argocd, którre zostało zintegrowane z helm-charts:

  • 1. rozwiązanie – 1 repo z helm-chart a w nim 3 x values
  • 2. rozwiązanie – 3 x repo z helm-chart
  • 3. rozwiązanie – umbrella chart
  • 4. rozwiązanie – zmienne nadpisywane przez argo-aplikacje
  • 5. rozwiązanie – 2 osobne repozytoria – jedno na helm-chart drugie na konfig.
https://evoila.com/pl/kubernetes-ingress-autoingress/

Zaczynamy!

Oto proste repozytorium helma typu all-in-one – zawiera helm-chart + values:

sam helm-chart to:

  • k8s-deployment , k8s-svc i k8s-cm zrobione w formie templates 
  • jest też tester
  • z racji tego że to all-in-one jest również values.yaml 
├── Chart.yaml
├── templates
│   ├── configmap.yaml
│   ├── deployment.yaml
│   ├── NOTES.txt
│   ├── service.yaml
│   └── tests
│       └── test-connection.yaml
└── values.yaml

Zadanie polega na podziale frameworku opartego o tandem argocd+helm na środowiska DEV, TEST i PROD i zrobienie emulacji BuildShipRun na tymże tandemie (czyli jeden helm-chart, ale wdrażany wielokrotnie, za każdym razem inaczej i bazujący za każdym razem na innych wartościach w helmowym values.yaml) 

Trzeba zatem ten sam helm-chart wdrożyć oddzielnie dla DEV, dla TEST i dla PROD 

Na potrzeby tego LAB-u będziemy używać jednego GKE i zrobimy podział na Namespaces dev, test i prod – z grubsza odpowiada to normalnemu podziałowi na 3 osobne GKE. 

Tzw “różnice między środowiskami DEV, TEST i PROD” będą zaemulowane innymi portami k8s-svc i inną ilością replik (odpowiednio dla DEV : k8s-svc-port=2222 i 2repliki , dla TEST 3333+3repliki i dla PROD 4444+4r) 

W rzeczywistych środowiskach różnice są inne i jest ich znacznie więcej – tzn. diff między helmowym values-dev.yaml a values-prod.yaml może liczyć kilkadziesiąt parametrów – tu dla nas nie ma to znaczenia, bo nas interesuje mechanizm a nie zawartość.

Jednym słowem, dążymy do modelu gdzie helm-chart jest jeden ale ma 3 różne metody wdrożenia – czyli upraszczając 3 różne pliki values (odpowiednio dla DEV, dla TEST i dla PROD).

Pierwsze rozwiązanie – 1 repo z helm-chart a w nim 3 x values

Pierwsze intuicyjne (ale bardzo słabe) rozwiązanie jakie przychodzi do głowy, to oczywiście dodać do repo 3 x plik Values i zdefiniować 3 aplikacje argo.

Rozbijamy zatem w naszym repo values.yaml na 3 różne pliki. 

├── Chart.yaml
├── templates
│   ├── configmap.yaml
│   ├── deployment.yaml
│   ├── NOTES.txt
│   ├── service.yaml
│   └── tests
│       └── test-connection.yaml
├── values-dev.yaml
├── values-prod.yaml
└── values-test.yaml

te values-X.yaml różnią się ilością replik i portami dla k8s-svc:

$ cat values-dev.yaml | grep -E "replicaCount|servicePort|namespace"
replicaCount: 2
namespace: dev
servicePort : 2222
$ cat values-test.yaml | grep -E "replicaCount|servicePort|namespace"
replicaCount: 3
namespace: test
servicePort : 3333
$ cat values-prod.yaml | grep -E "replicaCount|servicePort|namespace"
replicaCount: 4
namespace: prod
servicePort : 4444

startujemy z czystym argo i dodajemy repo + 3 x argo-app:

argocd@argocd-server-5fff657769-fhml5:~$ argocd repo list 
TYPE  NAME  REPO  INSECURE  OCI  LFS  CREDS  STATUS  MESSAGE  PROJECT

argocd@argocd-server-5fff657769-fhml5:~$ argocd app list 
NAME  CLUSTER  NAMESPACE  PROJECT  STATUS  HEALTH  SYNCPOLICY  CONDITIONS  REPO  PATH  TARGET

argocd@argocd-server-5fff657769-fhml5:~$ argocd repo add https://github.com/slawekgh/argo-helm
Repository 'https://github.com/slawekgh/argo-helm' added

argocd@argocd-server-5fff657769-fhml5:~$ argocd app create argo-helm-dev --repo https://github.com/slawekgh/argo-helm --path test-chart --dest-namespace dev --dest-server https://kubernetes.default.svc --auto-prune --sync-policy automated --release-name test-release --values values-dev.yaml
application 'argo-helm-dev' created

argocd@argocd-server-5fff657769-fhml5:~$ argocd app create argo-helm-test --repo https://github.com/slawekgh/argo-helm --path test-chart --dest-namespace test --dest-server https://kubernetes.default.svc --auto-prune --sync-policy automated --release-name test-release --values values-test.yaml
application 'argo-helm-test' created

argocd@argocd-server-5fff657769-fhml5:~$ argocd app create argo-helm-prod --repo https://github.com/slawekgh/argo-helm --path test-chart --dest-namespace prod --dest-server https://kubernetes.default.svc --auto-prune --sync-policy automated --release-name test-release --values values-prod.yaml
application 'argo-helm-prod' created

po pewnym czasie wszystko wygląda na zgodne z założeniami, argo-apps się zsynchronizowały, zaś w każdym NS jest tyle podów ile miało być i k8s-svc są na tych portach na których miały być:

argocd@argocd-server-5fff657769-fhml5:~$ argocd app list 
NAME                   CLUSTER                         NAMESPACE  PROJECT  STATUS  HEALTH   SYNCPOLICY  CONDITIONS  REPO                                   PATH        TARGET
argocd/argo-helm-dev   https://kubernetes.default.svc  dev        default  Synced  Healthy  Auto-Prune  <none>      https://github.com/slawekgh/argo-helm  test-chart  
argocd/argo-helm-prod  https://kubernetes.default.svc  prod       default  Synced  Healthy  Auto-Prune  <none>      https://github.com/slawekgh/argo-helm  test-chart  
argocd/argo-helm-test  https://kubernetes.default.svc  test       default  Synced  Healthy  Auto-Prune  <none>      https://github.com/slawekgh/argo-helm  test-chart  

$ kk get po,svc -n dev 
NAME                                      READY   STATUS    RESTARTS   AGE
pod/test-release-deploy-7c9c7669c-dvjfc   1/1     Running   0          107s
pod/test-release-deploy-7c9c7669c-q9g9h   1/1     Running   0          107s

NAME                   TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
service/test-release   ClusterIP   10.108.8.247   <none>        2222/TCP   2m30s
$ kk get po,svc -n test
NAME                                      READY   STATUS    RESTARTS   AGE
pod/test-release-deploy-7c9c7669c-hf5nj   1/1     Running   0          2m28s
pod/test-release-deploy-7c9c7669c-rbs49   1/1     Running   0          2m28s
pod/test-release-deploy-7c9c7669c-zk948   1/1     Running   0          2m28s

NAME                   TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
service/test-release   ClusterIP   10.108.8.138   <none>        3333/TCP   2m28s
$ kk get po,svc -n prod
NAME                                      READY   STATUS    RESTARTS   AGE
pod/test-release-deploy-7c9c7669c-2vdzr   1/1     Running   0          2m26s
pod/test-release-deploy-7c9c7669c-n2kkd   1/1     Running   0          2m26s
pod/test-release-deploy-7c9c7669c-qt7bp   1/1     Running   0          2m26s
pod/test-release-deploy-7c9c7669c-zfhlb   1/1     Running   0          2m26s

NAME                   TYPE        CLUSTER-IP    EXTERNAL-IP   PORT(S)    AGE
service/test-release   ClusterIP   10.108.2.95   <none>        4444/TCP   2m27s

rozwiązanie to mimo że proste i działa od ręki to jednak jest skrajnie niedoskonałe:

  • po pierwsze zakłada że repo z helm-chart należy do nas i możemy sobie do niego wrzucać swoje pliki (a tak może nie być, może go utrzymywać zupełnie inna osoba/grupa/firma/community)
  • po drugie burzy ideę BuildShipRun gdzie artefakt ma być generyczny i pozbawiony konfiguracji która to konfiguracja jest różna dla prod/test/dev oraz dogrywana podczas release na dev czy na prod
  • po trzecie uniemożliwia jakąkolwiek separację danych między PROD a resztą niższych środowisk , może np. dojść do sytuacji że developer robiący jakieś swoje poc na dev wrzuci omyłkowo coś do prod itd., itp.

Drugie rozwiązanie – 3 x repo z helm-chart

Drugie rozwiązanie (również pozornie proste ale również pozbawione większego sensu) to skopiowanie chartu do 3 różnych repozytoriów. Od tej pory trzeba będzie utrzymywać zawartość chartu w jakiejś synchronizacji, co spowoduje, że po chwili i tak wszystko się rozjedzie i zamieni się to w 3 osobne produkty.

Separacja między prod a nie-prod, co prawda będzie, ale BSR jest tu zupełnie nie zachowany i ryzyko różnic między PROD a środowiskami niższymi tak duże, że rozwiązanie to raczej nie nadaje się do wdrożeń – a zatem nawet go nie będziemy tu modelować.

https://evoila.com/pl/prawda-o-tanzu-bazodanowo-rzecz-biorac/

Trzecie rozwiązanie – umbrella chart

dodajemy kolejne repo (to repo będzie akurat shipem konfiguracyjnym dla środowiska DEV) które ma dependencies do centralnego repo z chartem. W repo tym jest tylko dependency (w Chart.yaml) i values.yaml dla DEV.

ship-repo-dev$ tree
├── Chart.yaml
├── README.md
└── values.yaml

W Chart.yaml umieszczamy dependency i w nim trzeba się odwołać do centralnego repo chartów helma. Głównym wyzwaniem tutaj będzie zrobienie na bazie naszego głównego repo nowego i prawidłowego repozytorium helmowego.

Zakładamy nowe repo, idąc za tym że:

chart repository is really just an HTTP server that hosts an index.yaml file together with a bunch of packaged charts in form of .tgz files.

trzeba zrobić TGZ oraz index.yaml 

TGZ robimy via

helm package test-chart

INDEX.YAML via 

helm repo index.

powstaje: 

chart-repository$ tree
.
├── index.yaml
└── test-chart-0.8.tgz

Trzeba jeszcze zmusić GitHuba żeby serwował pliki jak zwykły serwer HTTP – i tu z pomocą przychodzą ścieżki RAW jakie daje GitHuib – przykładowo:

https://raw.githubusercontent.com/slawekgh/argo-helm-chart-repository/main/index.yaml

a zatem komuś kto będzie chiał używać tego helm-repository taką ścieżkę trzeba będzie podawać. Zaś w helm-chartach, które będą się odwoływać do tego helm-repository, trzeba będzie na końcu ich Chart.yaml dodefiniować:

dependencies:
- name: test-chart
  version: 0.8
  repository: https://raw.githubusercontent.com/slawekgh/argo-helm-chart-repository/main/
 

pozostaje zatem w repo-ship-dev dodać takie dependency do Chart.yaml i sprawdzić via helm dependency update czy to się poprawnie przemieli.

ship-repo-dev$ tree
.
├── Chart.yaml
├── README.md
└── values.yaml

ship-repo-dev$ cat Chart.yaml 
apiVersion: v2
name: dev-chart
type: application
version: 1.12
appVersion: "3.6"
dependencies:
- name: test-chart
  version: 0.8
  repository: https://raw.githubusercontent.com/slawekgh/argo-helm-chart-repository/main/

Pamiętając też o values – tutaj są nieco inaczej zdefiniowane gdyż sięgają (nadpisują) values z child-chartu – stąd musi być to tym razem w sekcji test-chart (chodzi o to wcięcie dla jasność):

ship-repo-dev$ cat values.yaml 
# Default values for test-chart
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
test-chart:
  replicaCount: 2
  testerPodName: tester
  namespace: dev
  label: testlabel
  ala: alamakota
  servicePort : 2222
  PROTO : TCP
  targetPort : 8080
  obraz:
    image: gimboo/nginx_nonroot
    imagePolicy: Always

Sprawdźmy czy to działa:

ship-repo-dev$ helm dependency update
Getting updates for unmanaged Helm repositories...
...Successfully got an update from the "https://raw.githubusercontent.com/slawekgh/argo-helm-chart-repository/main/" chart repository
Saving 1 charts
Downloading test-chart from repo https://raw.githubusercontent.com/slawekgh/argo-helm-chart-repository/main/
Deleting outdated charts

ship-repo-dev$ tree
.
├── Chart.lock
├── charts
│   └── test-chart-0.8.tgz
├── Chart.yaml
├── README.md
└── values.yaml

jak widać lokalnie (bez argo) się zrobiło – coś ściągnął więc jak widać działa to poprawnie.  Teraz dodajemy to do argo:

argocd@argocd-server-5fff657769-fhml5:~$ argocd app list
NAME  CLUSTER  NAMESPACE  PROJECT  STATUS  HEALTH  SYNCPOLICY  CONDITIONS  REPO  PATH  TARGET
(czysto i pusto)
argocd@argocd-server-5fff657769-fhml5:~$ argocd app create argo-helm-dev --repo https://github.com/slawekgh/helm-argo-ship-dev --path . --dest-namespace dev --dest-server https://kubernetes.default.svc --auto-prune --sync-policy automated --release-name test-release --values values.yaml 
application 'argo-helm-dev' created

argocd@argocd-server-5fff657769-fhml5:~$ argocd app list 
NAME                  CLUSTER                         NAMESPACE  PROJECT  STATUS  HEALTH   SYNCPOLICY  CONDITIONS  REPO                                            PATH  TARGET
argocd/argo-helm-dev  https://kubernetes.default.svc  dev        default  Synced  Healthy  Auto-Prune  <none>      https://github.com/slawekgh/helm-argo-ship-dev  .  
na klastrze argo wykonało założenia  
$ kk get po -n dev 
NAME                                  READY   STATUS    RESTARTS   AGE
test-release-deploy-7c9c7669c-6cbqc   1/1     Running   0          69s
test-release-deploy-7c9c7669c-vnxh9   1/1     Running   0          69s 

Analogicznie obok repo-ship-dev. Należy założyć identyczne osobne repo dla test i osobne dla prod. Ich struktura będzie taka sama – mają tu być tylko dependency (w Chart.yaml) do helm-repository i values.yaml odpowiednie dla TEST i dla PROD. 

ship-repo-dev$ tree
├── Chart.yaml
├── README.md
└── values.yaml

Czy rozwiązanie z umbrella-chart ma sens? Ogólnie tak, ale:

  • trzeba mu niestety dostarczyć “prawdziwe helm-repository” – czyli zamiast trzymać główny helm-chart w kodzie trzeba go za każdym razem pakować do TGZ i generować index.yaml
  • dodatkowo potrzebny jest web-serwer do serwowania tych plików
  • Jednym słowem idea wydaje się być dobra (jest separacja, jest BuildShipRun), niestety realizacja koncepcji jest nieco utrudniona i nieelastyczna 

Czwarte rozwiązanie – zmienne nadpisywane przez argo-aplikacje

Do repo spod linku, dodajemy ponownie generyczny values (values-generic.yaml), który co prawda ma wpisane defaultowe wartości dla replicaCount, servicePort i namespace, ale wg założeń nigdy nie powinny one być użyte .  

helm-chart-repo$ cat test-chart/values-generic.yaml 
replicaCount: 1
testerPodName: tester
namespace: dev
label: testlabel
ala: alamakota
servicePort : 1111
PROTO : TCP
targetPort : 8080
obraz:
  image: gimboo/nginx_nonroot
  imagePolicy: Always

Nie będą użyte ponieważ w tej metodzie zmienne replicaCount, servicePort oraz namespace będziemy. nadpisywać dostępne opcje dla argo app create:

--helm-set stringArray                       Helm set values on the command line (can be repeated to set several values: --helm-set key1=val1 --helm-set key2=val2)
--helm-set-file stringArray                  Helm set values from respective files specified via the command line (can be repeated to set several values: --helm-set-file key1=path1 --helm-set-file key2=path2)
--helm-set-string stringArray                Helm set STRING values on the command line (can be repeated to set several values: --helm-set-string key1=val1 --helm-set-string key2=val2)

tutaj użyjemy –helm-set:

argocd@argocd-server-85f85d648b-tf2bx:~$ argocd repo list
TYPE  NAME  REPO  INSECURE  OCI  LFS  CREDS  STATUS  MESSAGE  PROJECT
argocd@argocd-server-85f85d648b-tf2bx:~$ argocd repo add https://github.com/slawekgh/argo-helm
Repository 'https://github.com/slawekgh/argo-helm' added

argocd@argocd-server-85f85d648b-tf2bx:~$ argocd app list
NAME  CLUSTER  NAMESPACE  PROJECT  STATUS  HEALTH  SYNCPOLICY  CONDITIONS  REPO  PATH  TARGET

argocd@argocd-server-85f85d648b-tf2bx:~$ argocd app create argo-helm-dev --repo https://github.com/slawekgh/argo-helm --path test-chart --dest-namespace dev --dest-server https://kubernetes.default.svc --auto-prune --sync-policy automated --release-name test-release --values values-generic.yaml --helm-set replicaCount=2 --helm-set servicePort=2222 --helm-set namespace=dev
application 'argo-helm-dev' created

argocd@argocd-server-85f85d648b-tf2bx:~$ argocd app create argo-helm-test --repo https://github.com/slawekgh/argo-helm --path test-chart --dest-namespace test --dest-server https://kubernetes.default.svc --auto-prune --sync-policy automated --release-name test-release --values values-generic.yaml --helm-set replicaCount=3 --helm-set servicePort=3333 --helm-set namespace=test
application 'argo-helm-test' created

argocd@argocd-server-85f85d648b-tf2bx:~$ argocd app create argo-helm-prod --repo https://github.com/slawekgh/argo-helm --path test-chart --dest-namespace prod --dest-server https://kubernetes.default.svc --auto-prune --sync-policy automated --release-name test-release --values values-generic.yaml --helm-set replicaCount=4 --helm-set servicePort=4444 --helm-set namespace=prod
application 'argo-helm-prod' created

argocd@argocd-server-85f85d648b-tf2bx:~$ argocd app listNAME                   CLUSTER                         NAMESPACE  PROJECT  STATUS  HEALTH   SYNCPOLICY  CONDITIONS  REPO                                   PATH        TARGET
argocd/argo-helm-dev   https://kubernetes.default.svc  dev        default  Synced  Healthy  Auto-Prune  <none>      https://github.com/slawekgh/argo-helm  test-chart  
argocd/argo-helm-prod  https://kubernetes.default.svc  prod       default  Synced  Healthy  Auto-Prune  <none>      https://github.com/slawekgh/argo-helm  test-chart  
argocd/argo-helm-test  https://kubernetes.default.svc  test       default  Synced  Healthy  Auto-Prune  <none>      https://github.com/slawekgh/argo-helm  test-chart  

Jak widać 3 argo-app się poprawnie zsynchronizowały, zaś na GKE jest również zgodnie z założeniami:

-----dev-------------
NAME                                  READY   STATUS    RESTARTS   AGE
test-release-deploy-7c9c7669c-8h498   1/1     Running   0          40s
test-release-deploy-7c9c7669c-b2nxt   1/1     Running   0          40s
NAME           TYPE        CLUSTER-IP    EXTERNAL-IP   PORT(S)    AGE
test-release   ClusterIP   10.108.5.59   <none>        2222/TCP   41s
-----test-------------
NAME                                  READY   STATUS    RESTARTS   AGE
test-release-deploy-7c9c7669c-jghr8   1/1     Running   0          18s
test-release-deploy-7c9c7669c-jlmxw   1/1     Running   0          18s
test-release-deploy-7c9c7669c-z46qx   1/1     Running   0          18s
NAME           TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
test-release   ClusterIP   10.108.11.61   <none>        3333/TCP   19s
-----prod-------------
NAME                                  READY   STATUS    RESTARTS   AGE
test-release-deploy-7c9c7669c-55cg2   1/1     Running   0          5s
test-release-deploy-7c9c7669c-t8dcm   1/1     Running   0          4s
test-release-deploy-7c9c7669c-t8x5q   1/1     Running   0          4s
test-release-deploy-7c9c7669c-z8mp5   1/1     Running   0          4s
NAME           TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
test-release   ClusterIP   10.108.10.248   <none>        4444/TCP   6s

SPEC argo-aplikacji wygląda następująco (tu na przykładzie argo-helm-dev) – zwracamy uwagę na sekcję parameters:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  creationTimestamp: "2023-08-23T08:43:38Z"
  generation: 14
  name: argo-helm-dev
  namespace: argocd
  resourceVersion: "79461"
  uid: aa96eae1-ff2d-4699-8aaa-6f382309413b
spec:
  destination:
    namespace: dev
    server: https://kubernetes.default.svc
  project: default
  source:
    helm:
      parameters:
      - name: replicaCount
        value: "2"
      - name: servicePort
        value: "2222"
      - name: namespace
        value: dev
      releaseName: test-release
      valueFiles:
      - values-generic.yaml
    path: test-chart
    repoURL: https://github.com/slawekgh/argo-helm
  syncPolicy:
    automated:
      prune: true

Oczywiście podobnie jak argo-app-dev również argo-app dla test i dla prod zawierają takie parametry.

Zalety:

  • jest separacja środowisk PROD, DEV i TEST
  • jest też podział na część generyczną i część konfiguracyjną (czyli BuildShipRun jest w miarę spełniony) 

Wady:

  • gdy
    • trzymanie konfiguracji środowiska w parametrach argo-application
    • gdy