Helm Charts with Python — Deep Dive

Chart structure for a Python service

A production Helm chart for a FastAPI or Django service typically includes more than the basics. Here’s a real-world layout:

my-python-app/
├── Chart.yaml
├── values.yaml
├── values-staging.yaml
├── values-production.yaml
├── templates/
│   ├── _helpers.tpl
│   ├── deployment.yaml
│   ├── service.yaml
│   ├── ingress.yaml
│   ├── hpa.yaml
│   ├── configmap.yaml
│   ├── secret.yaml
│   ├── serviceaccount.yaml
│   ├── pdb.yaml
│   └── tests/
│       └── test-connection.yaml
└── charts/
    └── postgresql/

Advanced templating techniques

Named templates for reuse

The _helpers.tpl file defines reusable template fragments:

{{- define "myapp.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}

{{- define "myapp.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}

Conditional resources

Python services often need different resources per environment:

# templates/hpa.yaml
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: {{ include "myapp.fullname" . }}
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: {{ include "myapp.fullname" . }}
  minReplicas: {{ .Values.autoscaling.minReplicas }}
  maxReplicas: {{ .Values.autoscaling.maxReplicas }}
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: {{ .Values.autoscaling.targetCPU }}
{{- end }}

Deployment template with Python-specific concerns

# templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "myapp.fullname" . }}
  labels:
    {{- include "myapp.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      {{- include "myapp.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      annotations:
        checksum/config: {{ include (print .Template.BasePath "/configmap.yaml") . | sha256sum }}
      labels:
        {{- include "myapp.selectorLabels" . | nindent 8 }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
          ports:
            - containerPort: {{ .Values.service.port }}
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: {{ include "myapp.fullname" . }}-secret
                  key: database-url
            - name: WORKERS
              value: {{ .Values.gunicorn.workers | quote }}
            - name: WORKER_CLASS
              value: {{ .Values.gunicorn.workerClass | quote }}
          livenessProbe:
            httpGet:
              path: {{ .Values.probes.liveness.path }}
              port: {{ .Values.service.port }}
            initialDelaySeconds: {{ .Values.probes.liveness.initialDelay }}
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: {{ .Values.probes.readiness.path }}
              port: {{ .Values.service.port }}
            initialDelaySeconds: {{ .Values.probes.readiness.initialDelay }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}

The checksum/config annotation forces a pod restart when ConfigMap contents change — critical for Python apps that read config at startup.

Values file for a Python web service

# values.yaml
replicaCount: 2

image:
  repository: ghcr.io/myorg/myapp
  tag: "latest"
  pullPolicy: IfNotPresent

service:
  type: ClusterIP
  port: 8000

gunicorn:
  workers: 4
  workerClass: "uvicorn.workers.UvicornWorker"

probes:
  liveness:
    path: /health
    initialDelay: 10
  readiness:
    path: /ready
    initialDelay: 5

resources:
  requests:
    cpu: 250m
    memory: 256Mi
  limits:
    cpu: "1"
    memory: 512Mi

autoscaling:
  enabled: false
  minReplicas: 2
  maxReplicas: 10
  targetCPU: 70

postgresql:
  enabled: true
  auth:
    database: myapp
    username: myapp

Generating charts with Python

When managing many microservices, manual chart maintenance doesn’t scale. Python can generate charts from service definitions:

import yaml
from pathlib import Path
from dataclasses import dataclass, field

@dataclass
class ServiceSpec:
    name: str
    port: int = 8000
    replicas: int = 2
    cpu_request: str = "250m"
    memory_request: str = "256Mi"
    health_path: str = "/health"
    dependencies: list[str] = field(default_factory=list)

def generate_chart(spec: ServiceSpec, output_dir: Path) -> None:
    chart_dir = output_dir / spec.name
    templates_dir = chart_dir / "templates"
    templates_dir.mkdir(parents=True, exist_ok=True)

    # Chart.yaml
    chart_meta = {
        "apiVersion": "v2",
        "name": spec.name,
        "version": "0.1.0",
        "appVersion": "1.0.0",
        "description": f"Helm chart for {spec.name}",
    }
    if spec.dependencies:
        chart_meta["dependencies"] = [
            {"name": dep, "version": ">=0.1.0", "repository": "file://../" + dep}
            for dep in spec.dependencies
        ]

    (chart_dir / "Chart.yaml").write_text(yaml.dump(chart_meta))

    # values.yaml
    values = {
        "replicaCount": spec.replicas,
        "image": {
            "repository": f"ghcr.io/myorg/{spec.name}",
            "tag": "latest",
        },
        "service": {"port": spec.port},
        "resources": {
            "requests": {
                "cpu": spec.cpu_request,
                "memory": spec.memory_request,
            }
        },
    }
    (chart_dir / "values.yaml").write_text(yaml.dump(values))

    print(f"Generated chart for {spec.name} at {chart_dir}")

# Usage
specs = [
    ServiceSpec("user-service", port=8001, dependencies=["postgresql"]),
    ServiceSpec("order-service", port=8002, replicas=3),
    ServiceSpec("notification-service", port=8003, cpu_request="100m"),
]
for spec in specs:
    generate_chart(spec, Path("./charts"))

Testing Helm charts

helm template (local rendering)

helm template myrelease ./my-python-app -f values-staging.yaml

This renders all templates without deploying — essential for catching syntax errors.

helm unittest (plugin)

# templates/tests/deployment_test.yaml
suite: deployment tests
templates:
  - deployment.yaml
tests:
  - it: should set correct replica count
    set:
      replicaCount: 5
    asserts:
      - equal:
          path: spec.replicas
          value: 5

  - it: should use uvicorn worker class
    asserts:
      - contains:
          path: spec.template.spec.containers[0].env
          content:
            name: WORKER_CLASS
            value: "uvicorn.workers.UvicornWorker"

Python-based chart validation

import subprocess
import yaml

def validate_chart(chart_path: str, values_file: str) -> list[str]:
    """Render chart and validate the output."""
    result = subprocess.run(
        ["helm", "template", "test", chart_path, "-f", values_file],
        capture_output=True, text=True
    )
    if result.returncode != 0:
        return [f"Template error: {result.stderr}"]

    errors = []
    for doc in yaml.safe_load_all(result.stdout):
        if doc is None:
            continue
        kind = doc.get("kind", "")
        if kind == "Deployment":
            containers = (
                doc.get("spec", {})
                .get("template", {})
                .get("spec", {})
                .get("containers", [])
            )
            for c in containers:
                if "resources" not in c:
                    errors.append(
                        f"Container {c['name']} missing resource limits"
                    )
                if "livenessProbe" not in c:
                    errors.append(
                        f"Container {c['name']} missing liveness probe"
                    )
    return errors

CI/CD automation with Helm

A typical GitHub Actions workflow for a Python service with Helm:

name: Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build and push image
        run: |
          docker build -t ghcr.io/myorg/myapp:${{ github.sha }} .
          docker push ghcr.io/myorg/myapp:${{ github.sha }}

      - name: Deploy with Helm
        run: |
          helm upgrade --install myapp ./chart \
            --set image.tag=${{ github.sha }} \
            -f chart/values-production.yaml \
            --wait --timeout 5m \
            --atomic

The --atomic flag ensures that if the deployment fails health checks, Helm automatically rolls back to the previous release. This is Python-app-friendly because slow startups (common with Django) won’t leave the cluster in a half-deployed state.

Tradeoffs and alternatives

ApproachStrengthsWeaknesses
HelmMature ecosystem, rollback, OCI registryGo templating is awkward, YAML indentation issues
KustomizeNo templating engine, pure overlaysLess flexible for complex variations
CDK8s (Python)Real programming language, type safetyExtra build step, smaller community
Pulumi K8sFull Python, state managementHeavier, requires Pulumi runtime

For Python teams, CDK8s is increasingly attractive because you write Kubernetes resources in Python with full IDE support, then synthesize to YAML or Helm-compatible output.

The one thing to remember: Production Helm charts for Python services succeed when they encode operational knowledge — health probes, resource limits, config checksums, and rollback safety — into the chart itself, making deployments self-documenting and self-protecting.

pythonkuberneteshelmdevops

See Also