Skip to main content

Deploy AtherOps on Kubernetes

This guide covers deploying the full AtherOps platform on a Kubernetes cluster using central-helm, the GitOps source of truth for the prod cluster. ArgoCD watches the repo and reconciles the cluster to match it.

The stack includes:

  • Longhorn (v1.11.2) -- replicated block storage for the three datastore volumes
  • api (Go, :8081) -- identity, auth, fleet control
  • backend (Go, :8080) -- ingest and query
  • alerting (Go, :8082) -- alert rule management
  • ui (nginx SPA, :80) -- the web interface
  • Postgres -- identity and fleet state (20 GiB Longhorn PVC)
  • VictoriaMetrics -- metrics time-series (20 GiB Longhorn PVC)
  • ClickHouse -- logs and traces (50 GiB Longhorn PVC)

All traffic enters through Gateway eg (Envoy Gateway, MetalLB LB IP 192.168.1.245). There is no Ingress controller; routing uses the Gateway API with HTTPRoute.


How it works

ArgoCD uses an app-of-apps pattern: a root Application watches apps/*/application.yaml in the repo and creates a child Application per directory. Each Application is an umbrella Helm chart.

Sync-wave annotations control order. Longhorn (wave -5) must be healthy before atherops (wave 0) can bind its PVCs.

Secrets are encrypted in-place with SOPS + age and decrypted inside the ArgoCD repo-server at render time. No plaintext secret is ever committed to git.


Runtime configuration

All images are environment-portable: service URLs are injected by env at container start. No addresses are baked into the images, so the same image works in compose, minikube, and prod K8s.

UI runtime config (window.__ENV__)

The UI SPA reads service URLs from window.__ENV__ before making any API call. These values are written to /usr/share/nginx/html/config.js at container startup by the nginx entrypoint -- not compiled into the JavaScript bundle.

overrides.yaml keyDefaultWhat it controls
ui.apiURL/apiBase prefix for all /api/* calls
ui.backendURL`` (empty)Prefix for /query/* and /ingest/* calls; empty means same-origin
ui.alertingURL/alertingBase prefix for /alerting/* calls

With same-origin routing (the standard setup described in this guide), leave all three keys empty or omit them. The relative-path defaults apply and routing works via the Gateway HTTPRoute.

API inter-service URLs

When operators install the k8s otel agent via the UI, the api service returns a helm install command that includes the platform's public URLs. Set these to the external address of your Gateway:

# apps/atherops/overrides.yaml
api:
apiBaseURL: "https://atherops.your-domain.com"
ingestBaseURL: "https://atherops.your-domain.com"

Request routing

All requests arrive at a single hostname. The Gateway routes them to the right service by path prefix. The routing contract is identical in the K8s HTTPRoute and the compose nginx configuration.

Path prefixServicePortPrefix stripped?Note
/api/*api8081NoHMAC agent auth requires the full path
/auth/*api8081NoJWT login
/query/*backend8080NoMetrics, logs, traces query
/ingest/*backend8080NoMetrics, logs, traces ingest; agent heartbeat
/alerting/*alerting8082Yes -- /alerting/rules becomes /rulesK8s: URLRewrite ReplacePrefixMatch: /
/ (catch-all)ui80Nonginx SPA; client-side routing via try_files

Prerequisites

Cluster

  • Kubernetes 1.27 or newer
  • ArgoCD already bootstrapped via argocd/install.sh (see the central-helm README)
  • MetalLB providing LoadBalancer IPs (pool 192.168.1.245-192.168.1.250)
  • Envoy Gateway installed (apps/envoy-gateway + apps/gateway from the app-of-apps)

Every K8s node: open-iscsi

Longhorn attaches volumes over iSCSI. Every node in the cluster must have the open-iscsi package installed and the iscsid daemon running before the first Longhorn sync.

# Run on each node (control plane and workers)
sudo apt-get install -y open-iscsi nfs-common
sudo systemctl enable --now iscsid

# Verify
systemctl is-active iscsid
# Expected output: active

If iscsid is not running when Longhorn tries to attach a volume, the volume fails to mount and the StatefulSet pod stays in ContainerCreating indefinitely.

CLI tools

# Install on your workstation
# helm >= 3.14, kubectl, sops, age, argocd CLI (optional)
bash scripts/prereqs.sh # installs/updates all of them

One-time setup

Work through these steps in order. Each must complete before the next.

1. Confirm Longhorn node prereqs

SSH to each node and verify iscsid is active (see Prerequisites above). Missing iscsid is the most common cause of Longhorn volume attachment failures.

2. Re-encrypt SOPS secrets to your cluster's age key

The shipped apps/atherops/secrets.sops.yaml is encrypted to the developer's age key. ArgoCD on your cluster cannot decrypt it. You must replace the ciphertexts before merging.

# From the central-helm repo root
bash scripts/sops-encrypt.sh edit apps/atherops/secrets.sops.yaml

Generate each field with the commands below and paste the values into the editor:

FieldHow to generate
postgresPasswordopenssl rand -hex 32
postgresConnectionKeypostgresql://atherops:<password>@atherops-postgres:5432/atherops?sslmode=disable
jwtSecretopenssl rand -hex 32
apikeyEncryptionKeyopenssl rand -hex 32 (same value in api and backend)
clickhousePasswordopenssl rand -hex 32
harborDockerKeybase64-encoded docker config JSON (see step 3)

3. Create a Harbor pull robot and build the pull credential

The chart pulls images from harbor.atherops.com/observability/. Create a pull-only robot account in Harbor, then build the credential:

# Replace <robot-name> and <robot-token> with your Harbor robot credentials
ROBOT_AUTH=$(printf '%s' "robot\$<robot-name>:<robot-token>" | base64 -w0)
printf '{"auths":{"harbor.atherops.com":{"auth":"%s"}}}\n' "$ROBOT_AUTH" | base64 -w0

Paste the output as harborDockerKey when editing secrets.sops.yaml.

4. Push images to Harbor

The chart pins image tags in apps/atherops/overrides.yaml. Push them before merging:

# From the platform/ repo root
make docker/push/api TAG=0.1.0
make docker/push/backend TAG=0.1.0
make docker/push/alerting TAG=0.1.0
make docker/push/ui TAG=0.1.1

5. Set the HTTPRoute hostname and public-facing URLs

Edit apps/atherops/overrides.yaml and replace the placeholders with your actual DNS name and the public-facing URLs the agents will use:

httproute:
hostname: "atherops.your-domain.com"

# Public URLs returned to k8s agents in the helm install command.
# Both point at the Gateway hostname so agents reach the right service via path routing.
api:
apiBaseURL: "https://atherops.your-domain.com"
ingestBaseURL: "https://atherops.your-domain.com"

Point DNS for this name at the Gateway LB IP (192.168.1.245 by default).

6. Merge to main

ArgoCD watches targetRevision: main. Only content on main is synced. Merge the current branch (feat/sprint14-routing, PR #3) to main to trigger the sync.


Verify the deployment

After ArgoCD syncs, confirm the full stack is healthy:

# Both apps Synced and Healthy
kubectl get application longhorn atherops -n argocd
# NAME SYNC STATUS HEALTH STATUS
# longhorn Synced Healthy
# atherops Synced Healthy

# Longhorn StorageClass available
kubectl get storageclass longhorn
# NAME PROVISIONER RECLAIMPOLICY
# longhorn driver.longhorn.io Retain

# Three PVCs bound
kubectl get pvc -n atherops
# NAME STATUS STORAGECLASS CAPACITY
# data-atherops-postgres-0 Bound longhorn 20Gi
# data-atherops-victoria-metrics-0 Bound longhorn 20Gi
# data-atherops-clickhouse-0 Bound longhorn 50Gi

# Nine pods running (eight services + alerting)
kubectl get pods -n atherops
# NAME READY STATUS
# atherops-alerting-<hash> 1/1 Running
# atherops-api-<hash> 2/2 Running (x2)
# atherops-backend-<hash> 2/2 Running (x2)
# atherops-ui-<hash> 1/1 Running
# atherops-postgres-0 1/1 Running
# atherops-victoria-metrics-0 1/1 Running
# atherops-clickhouse-0 1/1 Running

# Health endpoints
kubectl port-forward -n atherops svc/atherops-api 8081:8081 &
sleep 2
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8081/healthz # 200
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8081/readyz # 200
kill %1

# Check the UI runtime config was written correctly
kubectl exec -n atherops deploy/atherops-ui -- cat /usr/share/nginx/html/config.js
# window.__ENV__ = { API_URL: "/api", BACKEND_URL: "", ALERTING_URL: "/alerting" };

Releasing a new version

To roll out a new build:

  1. Build and push the new images to Harbor.

  2. Bump the image tags in apps/atherops/overrides.yaml:

    api:
    image:
    tag: "0.2.0"
    backend:
    image:
    tag: "0.2.0"
    alerting:
    image:
    tag: "0.2.0"
    ui:
    image:
    tag: "0.2.0"
  3. Open a PR, get it reviewed, merge to main. ArgoCD reconciles within ~3 minutes.

To roll back, revert the tag change, open a PR, and merge.


Longhorn storage

Longhorn is the default StorageClass (longhorn) for all three datastores. It replicates each volume across 2 nodes by default, so a single-node failure does not lose data. When the StatefulSet pod reschedules on a surviving node, Longhorn re-attaches the volume (typically within 30-90 seconds).

The reclaim policy is Retain: deleting a PVC does not delete the underlying volume. This prevents accidental data loss during a chart update.

To access the Longhorn management UI:

kubectl port-forward -n longhorn-system svc/longhorn-frontend 8080:80
# Open: http://localhost:8080

Manual snapshot before a disruptive change

Take a snapshot before any chart update that touches a StatefulSet or PVC:

# Snapshot the Postgres volume
kubectl -n longhorn-system exec -it \
$(kubectl -n longhorn-system get pods -l app=longhorn-manager -o name | head -1) -- \
longhorn-manager snapshot create --volume-name data-atherops-postgres-0

Or use the Longhorn UI: Volumes → select the volume → Create Snapshot.


Troubleshooting

PVCs stuck in Pending

Cause: Longhorn is not yet Healthy, or storageClassName: longhorn in overrides.yaml does not match an existing StorageClass.

Check:

kubectl get application longhorn -n argocd # must be Synced + Healthy
kubectl get storageclass longhorn # must exist
kubectl describe pvc <name> -n atherops # look for the exact error event

If Longhorn is still progressing (DaemonSet rolling out), wait 2-3 minutes.

Longhorn volumes fail to attach after pod reschedule

Cause: iscsid is not running on the node where the pod was rescheduled.

Fix:

# SSH to the node and start iscsid
sudo systemctl enable --now iscsid
systemctl is-active iscsid # must print: active

Then delete the stuck pod to force a reschedule:

kubectl -n atherops delete pod <statefulset-pod-name>

ImagePullBackOff on api, backend, or ui pods

Cause: The Harbor pull credential is wrong, the robot token is expired, or the image tag does not exist in Harbor.

Check:

kubectl describe pod <pod-name> -n atherops
# Look for: Failed to pull image "harbor.atherops.com/observability/api:..."

Fix:

  1. Verify the image exists: docker pull harbor.atherops.com/observability/api:<tag>
  2. If the robot token is expired: regenerate it in Harbor, re-encrypt in secrets.sops.yaml, commit, merge. ArgoCD updates the pull Secret on next sync.

ArgoCD ComparisonError on first sync

Cause: secrets.sops.yaml is still encrypted to the developer's age key (not your cluster's key). ArgoCD cannot decrypt it.

Fix: Re-run step 2 (re-encrypt with sops-encrypt.sh edit) and push the updated file.


See also