DEV Community

Cover image for Helm vs Kustomize vs Kapitan on one Kubernetes app
Moep90 for kadet-hub

Posted on Originally published at kadet-hub.org

Helm vs Kustomize vs Kapitan on one Kubernetes app

Comparisons between Helm, Kustomize and Kapitan usually compare features.
This one compares output. The
example renders
the same small app with all three tools, and ./check fails unless the
manifests match for each environment. Every number below comes from that
directory.

The app

Two services in namespace shop: web and api. Each has a Deployment, a
ClusterIP Service and a ConfigMap mounted into the pod. Pods run with the
restricted Pod Security profile. The environments differ in these values:

dev prod
web image tag 1.5.0-rc.1 1.4.2
web replicas, requests 1, 50m/64Mi 3, 250m/256Mi
api image tag 2.0.0-rc.3 1.9.1
api replicas, requests 1, 50m/64Mi 2, 500m/512Mi
log level debug info
PodDisruptionBudget none web and api

The api reads the web URL from its ConfigMap, web_url:
http://web.shop.svc:80
. That value matters later.

The Helm side is one generic chart rendered once per app with a base and an
environment values file. The Kustomize side is a base per app and an overlay
per environment. The Kapitan side is an inventory that feeds the
kubernetes generator from kadet-hub, pinned by
digest:

dependencies:
  - type: oci
    source: ghcr.io/kadet-hub/kubernetes:1.2.0@sha256:52017626e77dd5bf7293ce465ff0c3dc7a61dae1ae8353a56a53ffebe1bfb67c
    output_path: lib/kubernetes
Enter fullscreen mode Exit fullscreen mode

check normalizes all three outputs (one document per object, sorted, keys
sorted) and diffs them. It drops one label, app.kubernetes.io/managed-by,
because it names the tool. Everything else must match.

$ ./check
dev: helm = kapitan (6 objects)
dev: kustomize = kapitan (6 objects)
prod: helm = kapitan (8 objects)
prod: kustomize = kapitan (8 objects)
Enter fullscreen mode Exit fullscreen mode

One app

I built web alone first and counted the files and lines each tool needs,
excluding rendered output and the fetched generator:

files lines
Helm 9 131
Kustomize 9 152
Kapitan 5 65

The numbers need context. The generator sets fields explicitly that
Kubernetes would default anyway: the rolling update strategy, every probe
threshold, defaultMode: 420, sessionAffinity: None. The Helm templates
and the Kustomize base spell them out so the output matches. A chart written
from scratch would be shorter.

The bigger point is who owns the template. Of Helm's 131 lines, 108 are the
chart. You write and maintain it. Kapitan's 65 lines are inventory only. The
templating lives in the generator, 4166 lines of Python that someone else
maintains and that you pin, verify and run. It is the same trade as pulling a
generic chart from a chart repository, with the difference that the generator
reads the whole inventory instead of one values tree.

The one-app prod target in Kapitan, inventory/targets/prod.yml:

classes: [common, apps.web]
parameters:
  web_tag: 1.4.2
  log_level: info
  components:
    web:
      replicas: 3
      resources:
        requests: {cpu: 250m, memory: 256Mi}
        limits: {memory: 512Mi}
      pdb_min_available: 2
Enter fullscreen mode Exit fullscreen mode

Adding a second app

Then I added api with the same pattern and diffed against the one-app
version:

files changed lines added lines removed
Helm 3 25 0
Kustomize 9 149 2
Kapitan 5 47 2

Helm wins this round. The chart is generic, so a new app is three values
files. Kapitan's 47 lines include 9 for the JSON output described below; the
api itself is 38 lines, mostly the component block in
classes/apps/api.yml. Kustomize has no parameters, so the usual move is
copying the base and renaming. You can share one base through namePrefix
and replacements, but labels, selectors, container names and mount paths
then become patches of their own.

A value derived from another

The api needs the web Service URL. In Kapitan it is an interpolation, so it
follows the web definition:

web_url:
  value: http://web.${namespace}.svc:${components:web:ports:http:service_port}
Enter fullscreen mode Exit fullscreen mode

Helm releases cannot read each other's values, so the Helm side repeats the
literal. The Kustomize side does too:

$ grep -rn web_url helm kustomize kapitan/inventory
helm/values/api-prod.yaml:6:  web_url: http://web.shop.svc:80
helm/values/api-dev.yaml:6:  web_url: http://web.shop.svc:80
kustomize/overlays/prod/api.yaml:7:  web_url: http://web.shop.svc:80
kustomize/overlays/dev/api.yaml:7:  web_url: http://web.shop.svc:80
kapitan/inventory/classes/apps/api.yml:20:            web_url:
Enter fullscreen mode Exit fullscreen mode

To show what that costs, I changed only the web Service port to 8081 in each
tool, in a copy of the example, and ran check:

--- /tmp/tmp.TpmPDYldGS/prod.kapitan
+++ /tmp/tmp.TpmPDYldGS/prod.helm
@@ -1,7 +1,7 @@
 apiVersion: v1
 data:
   log_level: info
-  web_url: http://web.shop.svc:8081
+  web_url: http://web.shop.svc:80
Enter fullscreen mode Exit fullscreen mode

The same diff appears for Kustomize and for dev, four mismatches in total,
and check exits 1. Kapitan followed the change. In Helm and Kustomize the
stale URL renders without an error.

Both tools have a workaround. An umbrella chart with global values can
share an input between subcharts, but you restructure the values around it.
Kustomize replacements can copy the Service port into part of the string
with delimiter: ":" and index: 2. I tested that in a copy of the prod
overlay: six lines, and the URL followed. It works per overlay and per field,
and it breaks when the string format changes. The example keeps the
literal.

Output that is not Kubernetes

The same inventory also renders endpoints.json per environment, through a
six-line jinja2 template and a three-line compile entry:

{
"api": {"url": "http://api.shop.svc:80", "image": "ghcr.io/example/api:1.9.1"},
"web": {"url": "http://web.shop.svc:80", "image": "ghcr.io/example/web:1.4.2"}
}
Enter fullscreen mode Exit fullscreen mode

That file can feed smoke tests, monitoring or docs. With the port change above
it says 8081 as well. Helm and Kustomize render Kubernetes objects only. The
equivalent there is a separate script that parses values files or rendered
YAML, and it is one more place to keep in sync. The same mechanism covers
Terraform: kadet-hub lists generators for AWS, AzureRM, Google Cloud and Open
Telekom Cloud that read the same inventory.

What Helm and Kustomize do better

Helm's ecosystem is the main reason to stay. Most vendors publish a chart,
Artifact Hub makes them searchable, and helm install, upgrade and rollback
keep release history in the cluster. Kapitan has no release concept. It
writes files, and you apply them with kubectl, Argo CD or Flux. For
third-party software, a vendor chart maintained by the vendor beats a
generator maintained by someone else. Kapitan can render charts through its
helm input type, so you do not have to give them up.

Kustomize ships inside kubectl. In this example kubectl kustomize and the
standalone binary produced identical output. Nothing to install, and patching
vendor YAML you do not control is where Kustomize is strongest. Kapitan has a
kustomize input type for that case too.

Both have a gentler start. A values file and a patch are plain YAML.
Kapitan asks you to learn classes, targets, interpolation and the generator's
schema before the first manifest, and it needs a Python toolchain. Argo CD and Flux render Helm and Kustomize natively. With Kapitan you
commit compiled output or add a compile step in CI.

Fetching generators honestly

Kapitan 0.36.3 checks neither attestations nor digests when it fetches
dependencies, so kapitan compile --fetch gives no integrity check. It also
runs generator code with your environment and credentials. check does not
use --fetch. On first run it verifies the generator's attestation with
gh attestation verify against the kadet-hub release workflow, pulls by
digest with oras and unpacks into kapitan/lib/kubernetes.

That is not the full consumer flow. It does not check the attested index
for yanked versions, and it compiles on your machine without a sandbox. In
CI, use the consumer
workflow
,
which does both and compiles in gVisor without network. The
supply-chain guide
explains why.

Running it

You need helm, kustomize and uv, plus oras and a logged-in gh for the first fetch:

$ git clone https://github.com/kadet-hub/examples
$ cd examples/same-app
$ ./check
Enter fullscreen mode Exit fullscreen mode

check runs Kapitan through uv run with
kapitan[omegaconf,reclass-rs]==0.36.3, the version the kadet-hub sandbox
pins. Versions and every command behind the numbers above are in NOTES.md.

If your stack is one or two apps built from vendor charts, Helm and
Kustomize are the better fit. Kapitan pays off once values start depending
on each other across apps, or the same facts need to reach Kubernetes,
Terraform and docs. To start, see Generator, Helm chart or
jinja2?
and the
catalog.

Originally published at kadet-hub.org.

Top comments (0)