Mijn homelab begon met één ArgoCD Application. Daarna een handvol. Toen ik mezelf voor de vijftiende keer kubectl apply -f zag draaien om weer een Application te registreren, wist ik dat ik precies had gebouwd wat ik wilde vermijden: handmatige stappen die ik moest onthouden, in een volgorde die ik moest onthouden, zonder enige administratie van wat er zou moeten bestaan.
Het App-of-Apps pattern lost dat op met één idee. Je maakt met de hand een enkele root Application, en die maakt de rest aan. Na een gewist cluster bouw ik het geheel terug met één kubectl apply. Die eigenschap is de hele reden dat ik het draai, en het is dezelfde reden dat ik überhaupt self-host: ik wil dat de repository de waarheid is, niet mijn geheugen.
Dit is inmiddels hoe ik elke GitOps repo structureer, en de vorm houdt stand of het nu drie apps in mijn homelab zijn of honderd op het werk.
Het probleem: application sprawl
Wanneer je ArgoCD voor het eerst adopteert, maak je Applications met de hand aan:
kubectl apply -f apps/frontend.yaml
kubectl apply -f apps/backend.yaml
kubectl apply -f apps/database.yaml
# herhaal voor elke service
Voor een paar services is dit prima. Daarna gaat het pijn doen:
- Handmatige creatie: elke nieuwe service vraagt om een
kubectl applydie je moet draaien en onthouden - Geen hiërarchie: elke Application is een peer, zonder logische groepering
- Moeilijke onboarding: een nieuwe collega (of de toekomstige ik) heeft geen enkele plek die opsomt wat er bestaat
- Bootstrap probleem: bouw een cluster opnieuw en je moet elke Application met de hand recreëren, in de juiste volgorde
Dit stoorde me altijd. Je adopteerde GitOps zodat je cluster matcht met een Git repo. Vervolgens maakte je de Applications zelf aan met imperatieve commando’s die nergens leven. De deployments zijn declaratief; het ding dat de deployments definieert is een stapel tribal knowledge.
De oplossing: App-of-Apps
App-of-Apps is één regel: Applications kunnen Applications aanmaken.
flowchart TD
ROOT["Root Application<br/>(beheert alles)"]
ROOT --> INFRA["Infrastructure App"]
ROOT --> PLATFORM["Platform App"]
ROOT --> APPS["Apps App"]
INFRA --> NS["namespaces"]
INFRA --> RBAC["rbac"]
INFRA --> NP["netpols"]
PLATFORM --> MON["monitoring"]
PLATFORM --> LOG["logging"]
PLATFORM --> ING["ingress"]
APPS --> FE["frontend"]
APPS --> BE["backend"]
APPS --> WK["workers"]
De root Application synct een directory vol Application manifests. Die Applications syncen op hun beurt de daadwerkelijke workloads. Zodra je het ziet is het logisch: een Application is gewoon nog een Kubernetes resource, dus natuurlijk kan ArgoCD Applications beheren zoals het Deployments beheert.
De repository structuur
Dit is hoe ik een GitOps repo indeel:
gitops-repo/
├── clusters/
│ ├── production/
│ │ ├── root.yaml # Root application
│ │ ├── infrastructure/
│ │ │ ├── app.yaml # Infrastructure app-of-apps
│ │ │ ├── namespaces.yaml
│ │ │ ├── rbac.yaml
│ │ │ └── network-policies.yaml
│ │ ├── platform/
│ │ │ ├── app.yaml # Platform app-of-apps
│ │ │ ├── monitoring.yaml
│ │ │ ├── logging.yaml
│ │ │ └── ingress.yaml
│ │ └── apps/
│ │ ├── app.yaml # Workloads app-of-apps
│ │ ├── frontend.yaml
│ │ ├── backend.yaml
│ │ └── workers.yaml
│ └── staging/
│ └── (zelfde vorm als production)
├── base/
│ ├── monitoring/
│ │ ├── kustomization.yaml
│ │ └── (manifests)
│ └── (meer componenten)
└── apps/
├── frontend/
│ ├── base/
│ └── overlays/
│ ├── staging/
│ └── production/
└── backend/
└── (zelfde vorm)
De hiërarchie bouwen
Level 1: de root Application
De root is de enige Application die je ooit met de hand aanraakt. Alles daaronder volgt uit dit ene bestand:
# clusters/production/root.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://github.com/yourorg/gitops-repo.git
targetRevision: main
path: clusters/production
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: true
Dit synct de clusters/production directory, die de volgende laag apps bevat.
Level 2: categorie Applications
Groepeer gerelateerde applicaties op wat ze zijn. Ik splits de mijne in infrastructure, platform en workloads, omdat die lagen een andere blast radius en een andere deploy-timing hebben:
# clusters/production/infrastructure/app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: infrastructure
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/yourorg/gitops-repo.git
targetRevision: main
path: clusters/production/infrastructure
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: true
Dit synct elk YAML bestand in de infrastructure directory, inclusief de individuele Applications erin.
Level 3: leaf Applications
De daadwerkelijke workload applicaties:
# clusters/production/apps/frontend.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: frontend
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/yourorg/gitops-repo.git
targetRevision: main
path: apps/frontend/overlays/production
destination:
server: https://kubernetes.default.svc
namespace: frontend
syncPolicy:
syncOptions:
- CreateNamespace=true
automated:
prune: true
selfHeal: true
Sync waves: volgorde controleren
Sommige dingen moeten echt eerst. Een namespace voor de pods die erin leven. Een database voor de app die ermee verbindt. cert-manager voor alles dat een certificaat wil. Vuurt alles tegelijk af, dan krijg je een vlaag fouten die allemaal op de volgende retry oplossen, wat werkt maar er alarmerend uitziet en tijd kost.
ArgoCD sync waves geven je een deploy-volgorde zonder dat je het zelf hoeft te orkestreren:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: namespaces
annotations:
argocd.argoproj.io/sync-wave: "-10" # Deploy eerst
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: monitoring
annotations:
argocd.argoproj.io/sync-wave: "-5" # Na namespaces
---
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: frontend
annotations:
argocd.argoproj.io/sync-wave: "0" # Default, na platform
Lagere nummers syncen eerst. Ik gebruik:
-10: Namespaces, RBAC-5: Platform componenten (monitoring, logging, ingress)0: Applicaties10: Post-deployment jobs
Health checks en dependencies
Sync waves doen er alleen toe omdat ArgoCD wacht tot één wave healthy is voordat het de volgende start:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: database
spec:
# source en destination zoals hierboven
syncPolicy:
automated:
selfHeal: true
syncOptions:
- CreateNamespace=true
# Health bepaalt wanneer "ready"
De database Application rapporteert healthy zodra zijn pods draaien. Alles in een latere wave dat ervan afhangt blijft wachten tot dat klopt. Je krijgt ordering gratis, afgeleid uit echte health in plaats van een hardcoded sleep.
Multi-cluster met App-of-Apps
Zodra je meer dan één cluster hebt, krijgt de hiërarchie er bovenaan gewoon een laag bij:
gitops-repo/
├── clusters/
│ ├── production-eu/
│ │ └── root.yaml
│ ├── production-us/
│ │ └── root.yaml
│ └── staging/
│ └── root.yaml
Elk cluster krijgt zijn eigen root, en ze delen base config via Kustomize overlays zodat je niet drie keer dezelfde monitoring stack zit te kopiëren.
Een nieuwe Application toevoegen
Hier zit de winst die het opzetten de moeite waard maakt. Een nieuwe applicatie toevoegen is één bestand plus een git push:
# 1. Maak het Application manifest
cat > clusters/production/apps/new-service.yaml <<EOF
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: new-service
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/yourorg/gitops-repo.git
path: apps/new-service/overlays/production
targetRevision: main
destination:
server: https://kubernetes.default.svc
namespace: new-service
syncPolicy:
syncOptions:
- CreateNamespace=true
automated:
prune: true
selfHeal: true
EOF
# 2. Maak de applicatie manifests
mkdir -p apps/new-service/overlays/production
# voeg hier je Kustomization toe
# 3. Commit en push
git add .
git commit -m "Add new-service"
git push
ArgoCD ziet het nieuwe Application manifest bij de volgende sync en deployt het. Geen kubectl, geen geklik door de UI, en een reviewbare diff in Git die de volgende persoon precies vertelt wat er veranderde en waarom.
ApplicationSets: het volgende level
Betrap je jezelf erop dat je steeds bijna identieke Application manifests schrijft, dan genereren ApplicationSets ze uit een template:
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: apps
namespace: argocd
spec:
generators:
- git:
repoURL: https://github.com/yourorg/gitops-repo.git
revision: main
directories:
- path: apps/*
template:
metadata:
name: '{{path.basename}}'
spec:
project: default
source:
repoURL: https://github.com/yourorg/gitops-repo.git
targetRevision: main
path: '{{path}}/overlays/production'
destination:
server: https://kubernetes.default.svc
namespace: '{{path.basename}}'
syncPolicy:
automated:
prune: true
selfHeal: true
Zet een directory onder apps/ en je krijgt een Application, zonder manifest te schrijven. Ik pak dit als de apps uniform zijn. Hebben ze individuele aanpassingen nodig, dan blijven expliciete Application bestanden makkelijker te lezen, dus ik meng beide afhankelijk van de laag.
Veelvoorkomende fouten
Dit zijn de fouten die mij beten, ongeveer in de volgorde waarin ze me beten.
Fout 1: te diepe nesting
Drie levels is meestal genoeg. Ga je dieper, dan ben je meer tijd kwijt aan uitzoeken waar iets gedefinieerd staat dan aan het daadwerkelijk wijzigen:
✗ root → category → subcategory → team → service → component
✓ root → category → service
Fout 2: finalizers vergeten
Deze kostte me een middag. Zonder finalizers verwijdert het weghalen van een app-of-apps de parent, maar laat het zijn children draaiend achter als orphans, niet langer beheerd door iets:
metadata:
finalizers:
- resources-finalizer.argocd.argoproj.io
Met de finalizer op zijn plek cascadeert het verwijderen van de parent naar beneden en ruimt het de children mee op.
Fout 3: circulaire dependencies
Laat Application A niet afhangen van Application B die weer terug afhangt van Application A. ArgoCD ontwart dat niet voor je, en je kijkt toe hoe beide in een permanente progressing state blijven hangen.
Fout 4: alles in één sync wave
Prop alles in wave 0 en je bent terug bij race conditions, alleen met extra YAML. Het hele punt van de structuur is een geordende, op health gebaseerde rollout, dus gebruik de waves.
Mijn standaard structuur
Dit is waar ik op uitkwam na genoeg iteraties om er niet meer aan te twijfelen:
clusters/{env}/
├── root.yaml
├── infrastructure/ # Sync wave -10
│ ├── app.yaml
│ ├── namespaces.yaml
│ ├── rbac.yaml
│ └── network-policies.yaml
├── platform/ # Sync wave -5
│ ├── app.yaml
│ ├── argocd.yaml # ArgoCD beheert zichzelf
│ ├── monitoring.yaml
│ ├── logging.yaml
│ ├── ingress.yaml
│ └── cert-manager.yaml
├── data/ # Sync wave -2
│ ├── app.yaml
│ └── databases.yaml
└── apps/ # Sync wave 0
├── app.yaml
└── {service}.yaml
De volgorde garandeert de saaie maar belangrijke dingen:
- Namespaces bestaan voordat iets erin probeert te deployen
- Platform tools draaien voordat de apps die ervan afhangen
- Databases draaien voordat apps proberen te connecten
Het bootstrap probleem opgelost
Hier betaalt het zich uit. In GitOps Disaster Recovery betoogde ik dat recovery een procedure moet zijn die je half slapend kunt draaien. App-of-Apps brengt je een heel eind:
# 1. Nieuw cluster
# 2. Installeer ArgoCD
# 3. Apply ÉÉN bestand
kubectl apply -f clusters/production/root.yaml
# 4. Alles recreëert
Eén commando, en het cluster bouwt zichzelf terug uit de repo. Ik heb dit echt gedraaid na het bewust wissen van een homelab cluster, en honderd resources in de juiste volgorde zien terugkomen uit een enkele kubectl apply is zo bevredigend als infrastructuur maar wordt.
Dat is de eigenschap waar het me om gaat. Met App-of-Apps stopt de Git repo een map met bestanden te zijn die toevallig dingen deployen en wordt het de daadwerkelijke specificatie van wat zou moeten bestaan. Drift er iets, dan trekt ArgoCD het terug. Sterft het cluster, dan brengt de repo het terug. Ik hoef niet te vertrouwen op mijn herinnering aan hoe het bedraad was, en dat is precies het soort afhankelijkheid dat ik uit mijn systemen probeer te ontwerpen.
