Troubleshooting¶
Find your symptom in the quick reference below, then follow its diagnosis and fix.
Namespace
All kubectl commands below assume the default installation namespace haptic. Replace -n haptic with your namespace if you installed elsewhere.
Quick symptom reference¶
| Symptom | Section |
|---|---|
| Pods stuck in ImagePullBackOff | Image Pull Errors |
| "no kind HAProxyTemplateConfig is registered" | CRD Not Found |
| No DNS or API connectivity on a kind cluster | NetworkPolicy Issues in kind |
| Pod in CrashLoopBackOff | Controller Not Starting |
Pod stuck Running but not Ready (for example 1/2 or 3/4) |
Pods stuck not Ready |
| Pods running, no reconciliation activity | Controller Running But Not Processing |
| "template rendering failed" in logs | Invalid Template Syntax |
| "validation failed" / HAProxy errors | Configuration Validation Failures |
kubectl apply denied by an admission webhook |
Admission webhook denied the apply |
| "connection refused" to an HAProxy pod | Can't reach the agent |
| Controller reports success but HAProxy unchanged | Configuration Not Updating |
| 503 errors / no servers in HAProxy stats | Requests Not Reaching Backend |
| 404 for a host or path that should route | 404: no route matched |
| SSL handshake failures | SSL/TLS Issues |
| High CPU or slow reconciliation | Slow Reconciliation |
| OOMKilled / gradual memory growth | High Memory Usage |
| "shm-stats-file-max-objects" / reload failures | Shared Memory Stats Limit |
Install Issues¶
Problems that surface while the Helm chart installs, before the controller does any work.
Image pull errors¶
If pods are stuck in ImagePullBackOff:
Verify the haproxyVersion value matches an available image tag:
The controller image tag is derived from both the chart version and haproxyVersion. If pulling from a private registry, configure controller.podSpec.imagePullSecrets (and haproxy.podSpec.imagePullSecrets if the chart's HAProxy pods need the same registry).
CRD not found¶
If the controller fails with "no kind HAProxyTemplateConfig is registered":
CRDs are installed by the chart. If missing, reinstall the chart at the version you run:
helm upgrade --install haptic oci://registry.gitlab.com/haproxy-haptic/haptic/charts/haptic \
--version 0.2.0-alpha.1 --namespace haptic
NetworkPolicy Issues in kind¶
For kind clusters, ensure:
- Calico or Cilium Container Network Interface (CNI) is installed
- DNS access is allowed
- The
controller.networkPolicy.egress.kubernetesApiCIDRs cover your API server (see Networking)
Debug NetworkPolicy:
# Check controller can resolve DNS
kubectl exec -n haptic <controller-pod> -- nslookup kubernetes.default
# Check controller can reach HAProxy pod
kubectl exec -n haptic <controller-pod> -- curl http://<haproxy-pod-ip>:5555/healthz
For NetworkPolicy configuration details, see Networking.
Controller Issues¶
Controller not starting¶
Symptoms: CrashLoopBackOff, repeated restarts, initialization errors
Diagnosis:
kubectl get pods -n haptic -l app.kubernetes.io/name=haptic,app.kubernetes.io/component=controller
kubectl logs -n haptic -l app.kubernetes.io/name=haptic,app.kubernetes.io/component=controller --tail=100
kubectl describe pod -n haptic -l app.kubernetes.io/name=haptic,app.kubernetes.io/component=controller
Common Causes:
| Cause | Check | Solution |
|---|---|---|
| Missing HAProxyTemplateConfig | kubectl get haproxytemplateconfig,haproxytemplatelibrary -n haptic — a Helm install creates one HAProxyTemplateLibrary per enabled template library plus a single HAProxyTemplateConfig (the name in the Deployment's CRD_NAME); the controller waits for that config and every library its spec.libraryRefs names, at the revision it names, before it starts |
Reinstall Helm chart |
| Invalid credentials Secret | kubectl get secret -n haptic haptic-credentials -o jsonpath='{.data}' (Helm names it <release>-credentials) |
Recreate secret with correct keys |
| RBAC permissions | kubectl auth can-i list ingresses --all-namespaces --as=system:serviceaccount:<ns>:<sa> |
Verify ClusterRole/ClusterRoleBinding |
Pods stuck not ready¶
Symptoms: A pod shows fewer ready containers than it has (a default install expects 2/2 for the controller and 4/4 for HAProxy) and never reaches full readiness, but it isn't in CrashLoopBackOff or ImagePullBackOff.
First branch on the pod's actual state — "not Ready" is a readiness-probe outcome, not a single cause:
kubectl get pods -n haptic -o wide
kubectl describe pod -n haptic <pod> # read the Events and per-container State
- A container is in
WaitingwithCrashLoopBackOfforImagePullBackOff: the pod never starts, so it can't be Ready. Follow Controller not starting for crashes, or Image pull errors for pull failures. - Every container is
Runningbut the pod stays not Ready: a readiness probe is failing. Branch by which pod:- Controller pod (
1/2): the readiness probe hits/healthzoncontroller.ports.healthz(8080by default), which returns ready only once the controller has loaded a validHAProxyTemplateConfigand rendered its first config. A render or config-load failure keeps it not Ready — checkkubectl logs -n haptic <pod>for template or validation errors and follow Invalid template syntax./healthzshares the/debug/*listener and is required by the probe (see Debugging). - HAProxy pod (
3/4on a default install, fewer with sidecars disabled): HAProxy's/readyprobe is failing, which means no rendered configuration is running — the pod is still on the bootstrap config that answers 503. Inspect the HAProxy logs and the activated config. The agent's own readiness never gates this probe: it reports that the agent can accept applies, and stays true after one it rejected. SPOA hub and Vector child health doesn't gate it either; their supervisors log child failures. If configuration can't converge, follow Can't reach the agent.
- Controller pod (
Controller running but not processing¶
Symptoms: Pods running, no reconciliation activity
Diagnosis:
kubectl logs -n haptic -l app.kubernetes.io/name=haptic,app.kubernetes.io/component=controller | grep -i "watch\|sync complete"
Common Causes:
| Cause | Check | Solution |
|---|---|---|
| Informers not syncing | Logs show "timeout waiting for cache sync" | Check API server connectivity, network policies |
| No matching resources | kubectl get ingresses -A |
Verify resources exist in watched namespaces |
| Ingress class mismatch | kubectl get ingress <name> -o jsonpath='{.spec.ingressClassName}' |
The Ingress must reference the class the chart created; also check any watchedResources.*.fieldSelector namespace filter |
| Leader election (HA) | kubectl get lease -n haptic (the Lease is named after the Helm release) |
Ensure one pod shows is_leader=1 |
Configuration Issues¶
Invalid template syntax¶
Symptoms: "template rendering failed" errors
Diagnosis:
kubectl logs -n haptic -l app.kubernetes.io/name=haptic,app.kubernetes.io/component=controller | grep -i "template\|render"
Solution:
- Read the render error in the controller logs — it names the failing template and line — then open that template in your HAProxyTemplateConfig
-
Inspect the last rendered output via the debug server — port-forward first (see Debugging Guide):
-
See Templating Guide
Live traffic keeps flowing
A render or validation failure never drops requests. The leader refuses to deploy the broken output and HAProxy keeps serving the last good config, so the failure surfaces only in the controller logs and the haptic_reconciliation_errors_total metric — nothing changes in the data plane until a render succeeds again.
Configuration validation failures¶
Symptoms: validation failed, HAProxy errors
Common Errors:
| Error | Cause | Solution |
|---|---|---|
backend expects <name> |
Invalid HAProxy syntax | Fix template, test with haproxy -c -f config.cfg |
unable to load file |
Missing map/cert file | Define in maps section, use pathResolver.GetPath() |
invalid address |
Bad server address | Verify EndpointSlices exist, check service names |
Validation test failures¶
Symptoms: haptic validate fails
Quick Debugging:
# Step 1: Run with verbose output
haptic validate -f config.yaml --verbose
# Step 2: See full rendered content
haptic validate -f config.yaml --dump-rendered
# Step 3: Check template execution
haptic validate -f config.yaml --trace-templates
See Validation Tests for detailed debugging.
Admission webhook denied the apply¶
Symptoms: kubectl apply fails with admission webhook "...validation..." denied the request, followed by rendered-config errors.
The chart installs a validating admission webhook that renders your templates against the resource being applied and runs haproxy -c before the object reaches the cluster. When the render or that check fails, the apply is rejected and the denial message carries the line-numbered haproxy -c output pointing at the offending config line:
Error from server: error when creating "ingress.yaml": admission webhook
"ingress.validation.haptic-webhook" denied the request:
rendered config invalid: [ALERT] config: parsing [/etc/haproxy/haproxy.cfg:214]:
'http-request' expects ...
Fix the reported line in the template or resource, then re-apply. To reproduce and iterate locally without a cluster, run the same render-and-validate over your HAProxyTemplateConfig — it prints the identical line-numbered errors and runs the config's validationTests:
Two different gates sit behind this, depending on what you applied:
- Watched resources (Ingress, Gateway, and every other
watchedResourcesentry withenableValidationWebhook: true) go through the admission webhook, which usesfailurePolicy: Fail— a render-breaking apply is rejected, and if the webhook itself is unreachable the apply is blocked. - The
HAProxyTemplateConfigitself has no admission webhook, so a bad config is accepted by the apiserver and caught afterwards: the controller refuses to load it, keeps serving the last-good one, and incrementshaptic_config_rejected_total(see Monitoring). The reason lands on the object —kubectl describe htplcfg <name>shows theValidatedcondition with reasonConfigInvalid. On a fresh or upgraded pod the same failure is fatal: the pod crash-loops with reasonLoadGateFailedrather than serving untested config, which leaves the old pods running. Catch it before the apply withhaptic preflight.
HAProxy Pod Issues¶
Can't reach the agent¶
Symptoms: connection refused, timeout, deployment failures
Diagnosis:
HAPROXY_POD=$(kubectl get pods -n haptic -l app.kubernetes.io/component=loadbalancer -o jsonpath='{.items[0].metadata.name}')
kubectl port-forward -n haptic $HAPROXY_POD 5555:5555
# Substitute your actual agent password; see spec.credentialsSecretRef
curl -u admin:<password> http://localhost:5555/v1/state
/v1/state answers with the plan the pod applied, the plan its worker is
running, the digest of every file it holds, and what it last did with an apply.
/healthz needs no credentials, which is what the controller's connectivity
check above uses.
Common Causes:
| Cause | Check | Solution |
|---|---|---|
| Agent not running | kubectl logs $HAPROXY_POD -c agent |
Verify the container started, check port conflicts |
| Wrong credentials | kubectl get secret <release>-haptic-credentials -o yaml |
Update the credentials Secret; the controller's credentialsloader picks it up live, and the agent reads the same Secret through its environment — restart the HAProxy pods to pick up a rotation |
| Network policy | kubectl get networkpolicy |
Update egress rules for controller → HAProxy |
Configuration not updating¶
Symptoms: Controller shows success but HAProxy has old config
Diagnosis:
HAPROXY_POD=$(kubectl get pods -n haptic -l app.kubernetes.io/component=loadbalancer -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n haptic $HAPROXY_POD -c haproxy -- ls -lh /etc/haproxy/haproxy.cfg
kubectl logs -n haptic -l app.kubernetes.io/name=haptic,app.kubernetes.io/component=controller | grep -i "deployment.*succeeded"
Common Causes:
| Cause | Check | Solution |
|---|---|---|
| Volume mount issue | kubectl get pod $HAPROXY_POD -o yaml \| grep -A5 volumeMounts |
Ensure both containers share config volume |
| HAProxy not reloading | kubectl logs $HAPROXY_POD -c agent |
The agent logs the reload it asked for and HAProxy's answer; check master socket access |
Shared memory stats limit¶
Opt-in feature
This only applies when haproxy.shmStats.enabled: true is set in Helm values (the default is false) and HAProxy is 3.3+ — the shm-stats file is gated by semver_gte in the chart templates. If you're on the default config you won't see these errors; this section is for operators who turned shm-stats on for performance.
Symptoms: 100% deployment error rate, HAProxy reload failures with shm-stats-file-max-objects errors
Diagnosis:
kubectl logs -n haptic -l app.kubernetes.io/name=haptic,app.kubernetes.io/component=controller | grep "shm-stats"
Look for:
[ALERT] memory error while setting up shared counters for .../SRV_N server:
Cannot add additional object to '/dev/shm/haproxy-stats' file,
maximum number already reached (50000).
Common Causes:
| Cause | Check | Solution |
|---|---|---|
| Too many HAProxy objects for the configured limit | Count ingresses/services: kubectl get ingresses -A --no-headers \| wc -l |
Increase haproxy.shmStats.maxObjects in Helm values |
| Cluster grew beyond initial sizing | Compare object count to maxObjects value |
Recalculate using the formula below |
Solution:
Each HAProxy frontend, backend, and server directive counts as one shm-stats object. The file is fixed-size and can't be resized on reload. Increase haproxy.shmStats.maxObjects in your Helm values:
Sizing formula: (number of backends + number of servers) × 1.2 safety margin. Each object uses ~4KiB of shared memory. For example, 100,000 objects require ~390Mi in /dev/shm, which counts against the pod's memory limit.
Warning
After changing maxObjects, verify that haproxy.resources.limits.memory is large enough to accommodate the increased /dev/shm usage. The shm volume is memory-backed and counts against the pod's memory limit.
Routing Issues¶
Requests not reaching backend¶
Symptoms: 503 errors, timeouts, no servers in HAProxy stats
Diagnosis:
HAPROXY_POD=$(kubectl get pods -n haptic -l app.kubernetes.io/component=loadbalancer -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n haptic $HAPROXY_POD -c haproxy -- cat /etc/haproxy/haproxy.cfg | grep -A10 "backend"
kubectl get endpointslices -l kubernetes.io/service-name=<service>
Common Causes:
| Cause | Check | Solution |
|---|---|---|
| No endpoints | kubectl get endpointslices |
Verify backend pods running and ready |
| Backend not created | Controller logs for backend errors | Review template logic, check Ingress references |
| Routing not matching | Test with curl -H "Host: ..." |
Verify Host header, check ACLs and map files |
404: No route matched¶
Symptoms: HAProxy answers 404 Not Found (not 503) for a host or path you expect to route.
A 404 is distinct from a 503: a 503 means a route matched but its backend has no ready servers (Requests not reaching backend), whereas a 404 means no route matched at all. The request falls through to HAProxy's default_backend, which returns 404 (a gRPC request gets grpc-status 12 Unimplemented instead). Unless you configured a catch-all default backend, every unmatched request lands here.
Check the three things that stop a route from matching:
-
The Ingress was never adopted. HAPTIC only serves Ingresses whose
ingressClassName(or the legacykubernetes.io/ingress.classannotation) references the class the chart created. An Ingress with a different class produces no HAProxy route at all. -
The Host header doesn't match a rule host. Routing keys on the request's
Host. Send the exact host the Ingress declares: -
The path or
pathTypedoesn't match. AnExactpath matches only the exact request path;Prefixmatches path segments. Confirm the request path falls under a declared path, and inspect the generated routing maps:
SSL/TLS Issues¶
Symptoms: SSL handshake failures, certificate errors
Diagnosis:
HAPROXY_POD=$(kubectl get pods -n haptic -l app.kubernetes.io/component=loadbalancer -o jsonpath='{.items[0].metadata.name}')
kubectl exec -n haptic $HAPROXY_POD -c haproxy -- ls -lh /etc/haproxy/ssl/
# Port-forward HAProxy's HTTPS port, then probe the handshake.
# Stop the forward with `kill %1` (or Ctrl+C) when done.
kubectl port-forward -n haptic $HAPROXY_POD 443:443 &
openssl s_client -connect localhost:443 -servername your-host.example.com < /dev/null
Common Causes:
| Cause | Check | Solution |
|---|---|---|
| Certificate not deployed | Check sslCertificates section |
Define template, watch secret, use b64decode |
| Wrong cert path | grep "bind.*ssl.*crt" haproxy.cfg |
Use pathResolver.GetPath("cert.pem", "cert") |
"Secret not found" errors:
Check that the Secret exists in the correct namespace:
HAProxy fails to start with SSL errors:
Verify the certificate and key are valid:
# Extract and verify certificate
kubectl get secret default-ssl-cert -n haptic -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -text -noout
# Verify key
kubectl get secret default-ssl-cert -n haptic -o jsonpath='{.data.tls\.key}' | base64 -d | openssl rsa -check -noout
Certificate not being updated:
The controller watches the Secret and deploys certificate changes automatically within seconds. If HAProxy keeps serving the old certificate, check the controller logs for render or deployment errors.
By default the chart watches Secrets with an on-demand store (controller.config.watchedResources.secrets.store: on-demand), so cert bodies aren't kept resident in memory. Override it to full if you'd rather hold Secrets in the in-memory store.
For certificate provisioning and rotation (cert-manager, manual Secrets, the chart-generated default), see SSL Certificates.
Performance Issues¶
Slow reconciliation¶
Symptoms: Changes take minutes, high CPU
Diagnosis:
kubectl port-forward -n haptic deployment/haptic-controller 9090:9090
curl http://localhost:9090/metrics | grep reconciliation_duration_seconds
Solutions:
- Use namespace restrictions in
watchedResources - Add label selectors to filter resources
- Use cached store for large resources
- Optimize templates: cache values with
{% var %}, reduce nested loops
High memory usage¶
Symptoms: OOMKilled events, gradual memory growth
Solutions:
# Filter large fields
watchedResourcesIgnoreFields:
- metadata.managedFields
- metadata.annotations['kubectl.kubernetes.io/last-applied-configuration']
# Use cached store for secrets (fetches on-demand; TTL is auto-derived
# from driftPreventionInterval, not user-configurable)
watchedResources:
secrets:
store: on-demand
# Limit watch scope
watchedResources:
ingresses:
namespace: production
labelSelector: "app=myapp"
Getting help¶
Collect diagnostic information¶
# Controller version
kubectl get deployment -n haptic haptic-controller -o jsonpath='{.spec.template.spec.containers[0].image}'
# Controller logs
kubectl logs -n haptic -l app.kubernetes.io/name=haptic,app.kubernetes.io/component=controller --tail=500 > controller-logs.txt
# Configuration — every object, plus the merged result the controller assembles
kubectl get haproxytemplateconfig -n haptic -o yaml > config-objects.yaml
haptic config view --input --namespace haptic > config-merged.yaml
# HAProxy config (sanitize sensitive data!)
kubectl exec -n haptic $HAPROXY_POD -c haproxy -- cat /etc/haproxy/haproxy.cfg > haproxy.cfg
Enable debug logging¶
The controller supports multiple log levels via the LOG_LEVEL environment variable (case-insensitive):
| Level | Description |
|---|---|
ERROR |
Errors only |
WARN (or WARNING) |
Warnings and errors |
INFO |
Important state changes (default) |
| DEBUG | Detailed debugging information |
| TRACE | Very verbose, per-item iteration logs |
# Enable debug logging
kubectl set env -n haptic deployment/haptic-controller LOG_LEVEL=DEBUG
# Enable trace logging (very verbose)
kubectl set env -n haptic deployment/haptic-controller LOG_LEVEL=TRACE
The log level can also be configured via the HAProxyTemplateConfig CRD's spec.logging.level field. When set, the CRD value takes precedence over the LOG_LEVEL environment variable, and changes take effect without a pod restart:
# In values.yaml
controller:
logLevel: INFO # Initial LOG_LEVEL env var (used until the CRD is loaded)
config:
logging:
level: DEBUG # Written to spec.logging.level — overrides env var at runtime
Note
TRACE level produces extremely verbose output, including per-resource iteration logs, HTTP fetch retries, and test runner details. Enable it only for short, targeted sessions and set the level back to INFO afterwards — TRACE volume drowns everything else.
Access the debug server¶
The Helm chart enables the debug server on port 8080 by default (same port as /healthz). Port-forward to reach it:
The listener is configured by controller.ports.healthz and also serves
/healthz, so it's required by the liveness/readiness probes. Restrict access
via NetworkPolicy instead of disabling it. See the Debugging Guide
for the endpoint catalogue and usage.