Deploy AISIX Gateways on Kubernetes
Deploy and scale AISIX gateways as AISIX Enterprise data planes on Kubernetes using the api7/aisix Helm chart, a HorizontalPodAutoscaler, or KEDA.
Use the api7/aisix Helm chart to deploy, expose, and scale AISIX gateways in your Kubernetes cluster. The gateways serve live AI traffic in your environment and connect to an existing AISIX Enterprise control plane for configuration.
To run the open-source gateway without a control plane, see Deploy the Open-Source AISIX Gateway on Kubernetes.
This is api7/aisix, the gateway (data-plane) chart. It is a different chart from api7/aisix-cp, the control-plane chart — see On-Premises Installation.
The chart requires the data-plane manager endpoint and a gateway certificate bundle issued for the target environment. Once connected, the gateways receive models, caller API keys, and policies from the control plane.
Prerequisites
- A Kubernetes cluster with
kubectlconfigured to access it and Helm 3 installed. The publishedaisixgateway images are multi-architecture manifests carrying bothlinux/amd64andlinux/arm64, so the chart's pods schedule on arm64 nodes (for example, AWS Graviton instances) as well as amd64 ones. - Access to an AISIX Enterprise environment where you can issue a gateway certificate.
- Network access from the cluster to the AISIX Enterprise data-plane manager endpoint. If you use On-Premises, complete On-Premises Installation first.
- An AISIX Enterprise control plane, self-hosted or API7-hosted. This procedure sets
controlPlane.enabled: true, which requirescontrolPlane.baseURLand a gateway mTLS certificate bundle. The same chart also supports an open-source installation withcontrolPlane.enabled: falseand astandaloneresource source.
Install the Chart
In the dashboard, open the target environment's Data planes view and issue a gateway certificate, then copy the generated Kubernetes (Helm) tab's install command — it already inlines the data-plane manager endpoint and certificate bundle for you. The private key is shown only once. The shape of that generated command is:
helm repo add api7 https://charts.api7.ai
helm repo update
kubectl create namespace aisix-dp
# Keep the bundle in a Secret so the private key never reaches a values
# file. Feeding it through stdin also keeps it out of the process
# arguments, where `kubectl create secret --from-literal` would put it
# and any user on the host could read it from `ps`. It does NOT keep it
# out of your shell history -- see "Manage the Gateway Certificate in
# Production" below. The chart reads the Secret via
# controlPlane.certificate.existingSecret.
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Secret
metadata:
name: aisix-dp-mtls
namespace: aisix-dp
type: Opaque
stringData:
cert.pem: |-
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
key.pem: |-
-----BEGIN EC PRIVATE KEY-----
...
-----END EC PRIVATE KEY-----
ca.pem: |-
-----BEGIN CERTIFICATE-----
...
-----END CERTIFICATE-----
EOF
helm install aisix-dp api7/aisix --namespace aisix-dp \
--set controlPlane.baseURL='https://<your-dp-manager>:7944' \
--set controlPlane.certificate.existingSecret=aisix-dp-mtls \
--set replicaCount=1The exact release name, namespace, secret name, and certificate PEMs are generated per data plane — use what the dashboard gives you, not the placeholders above. The chart selects the gateway image matching its own version, so neither image.repository nor image.tag needs setting on a release install. The Dev documentation overrides it with a ghcr.io/api7/aisix:dev image instead of the docker.io/api7/aisix release image.
Set controlPlane.baseURL on an explicit https:// scheme (for example https://dpmgr.example.com:7944). A scheme-less value such as dpmgr.example.com:7944 is normalized to https:// at load, so it still works; a value that is neither shape fails the gateway's startup, naming the variable, instead of connecting and later rejecting proxied requests with a confusing 429 budget_exceeded — the failure mode on gateways older than 1.3.0. If you operate a mixed fleet with pre-1.3.0 gateways, check this value first when one of them reports that error while looking otherwise healthy.
When the Gateway Runs in the Control Plane's Cluster
Installing the gateway into the same cluster as a self-hosted control plane — the usual shape of an evaluation — needs no external endpoint at all. Dial the dp-manager service directly:
--set controlPlane.baseURL='https://aisix-cp-dpm.aisix-cp.svc.cluster.local:7944'Substitute your own release name and namespace. dp-manager issues its server certificate for whatever host the gateway dials, so the in-cluster DNS name is covered on the first handshake and api.dpmgrBaseURL does not have to be set for this case.
The gateway appears in the environment's Data planes view once its first heartbeat lands. The chart defaults to two replicas, but this initial command starts one because rate-limit counters use per-replica memory until you configure shared Redis. The dashboard Helm snippet omits replicaCount, so installing that snippet unchanged starts two replicas. Add --set replicaCount=1 until Redis is configured, as shown above. Share Rate-Limit Counters across Replicas before increasing the replica count or enabling an autoscaler.
Each replica registers as its own instance, and they all share the one certificate.
To see every value the chart accepts:
helm show values api7/aisixThe chart source is published in the api7/api7-helm-chart repository.
Expose the Gateway
The chart creates a ClusterIP Service for the proxy by default. To publish it through a cloud load balancer:
service:
type: LoadBalancer
port: 80
# Preserve the client source IP, which per-model IP allowlists match on.
externalTrafficPolicy: LocalThe gateway binds port 3000 inside the container by default. A Service can expose port 80 or 443 without making the process bind a privileged container port.
Bind a port below 1024 inside the container only when the gateway must listen on that port directly. The published image runs as non-root UID 10001, and the gateway binary carries the effective CAP_NET_BIND_SERVICE file capability:
containerPorts:
proxy: 80If you customize the rendered Pod to drop all capabilities, add NET_BIND_SERVICE back to the AISIX container:
spec:
containers:
- name: aisix
securityContext:
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"]The Kubernetes Restricted Pod Security Standard allows this capability. Because the binary's file capability has the effective bit set, the container can fail with exec: Operation not permitted when the runtime prevents the capability from being granted.
Use hostNetwork or hostPort only when the gateway must bind directly on a node and the cluster policy allows it. These options introduce node-port conflicts and reduce network isolation; the Baseline and Restricted Pod Security Standards also disallow them.
See Network and Security for the full exposure and credential model.
Run on OpenShift
The chart installs on OpenShift under the default restricted-v2 security context constraint. No custom SCC and no service-account changes are needed.
The chart pins no UID. Its default pod security context is exactly runAsNonRoot: true plus seccompProfile.type: RuntimeDefault, so restricted-v2 supplies the runAsUser and fsGroup from the namespace's allocated range. The gateway image declares a numeric user and runs under any assigned UID with GID 0; the only path written at runtime is an emptyDir volume. The container security context already satisfies restricted-v2 — all capabilities dropped except NET_BIND_SERVICE, a read-only root filesystem, and no privilege escalation.
To pin a fixed UID instead, set the values back:
podSecurityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefaultVerify the Assigned UID
restricted-v2 injects runAsUser into the container's security context, not the pod's, so oc get pod -o jsonpath='{.spec.securityContext.runAsUser}' stays empty on a correctly admitted pod. Read the running process instead:
oc get pod -n aisix-dp -o jsonpath='{.items[*].metadata.annotations.openshift\.io/scc}'
oc exec -n aisix-dp deploy/aisix-dp -- idrestricted-v2
uid=1000740000(1000740000) gid=0(root) groups=0(root),1000740000An arbitrary UID from the namespace's range with GID 0 is the expected result, not the image's 10001.
Expose the Gateway with a Route
The chart creates a ClusterIP service and no Route. Publish the proxy by naming the service port, not its number:
oc -n aisix-dp create route edge aisix-dp --service=aisix-dp --port=proxyUse the port name
The proxy service listens on port 80 and forwards to container port 3000. The OpenShift router resolves a Route's targetPort against the service's endpoints, so a Route created with --port=80 matches no endpoint port and every request returns 503 with the pods healthy and the gateway logging nothing. --port=proxy resolves correctly. The same applies to the metrics service, whose port is named metrics.
A Route terminating TLS at the edge is fine for the proxy — unlike dp-manager, the gateway's inbound traffic is ordinary HTTP. Before the first route is configured the proxy answers 404, which confirms the listener is bound.
For the control plane on OpenShift, see Install on OpenShift.
Manage the Gateway Certificate in Production
The install above creates the Secret by hand, which is the right trade for a first gateway and the wrong one to keep. Be clear about what that step does and does not protect:
| Hand-written Secret | |
|---|---|
| Private key in a values file | No — the chart reads controlPlane.certificate.existingSecret |
Private key in a .pem file on disk | No — the heredoc goes to kubectl over stdin |
Private key in process arguments (ps) | No — unlike kubectl create secret --from-literal |
| Private key in your shell history | Yes |
| Private key recoverable from etcd | Yes, unless the cluster encrypts Secrets at rest |
The last two are why this does not scale past the first install. The key is a long-lived credential that authenticates a gateway to your control plane, and a ~/.zsh_history is not where it should live. If you run the command as shown, treat that history entry as something to remove afterwards; a shell configured with setopt HIST_IGNORE_SPACE (zsh) or HISTCONTROL=ignorespace (bash) skips a command typed with one leading space, which is a mitigation only if the option is already set.
For anything beyond a first gateway, have a secret manager produce the Secret instead of a person:
- External Secrets Operator — keep the bundle in Vault, AWS Secrets Manager, Azure Key Vault or GCP Secret Manager and let the operator project it into the namespace as a Secret.
- Sealed Secrets — commit an encrypted
SealedSecretto Git and let the in-cluster controller decrypt it. This is the one that fits a GitOps repository, where a plain Secret manifest cannot go. - Vault Agent Injector or the Secrets Store CSI Driver — mount the bundle from Vault at pod startup.
Whichever you use, the chart's contract is the same and needs no special support from these tools: it reads one Secret named by controlPlane.certificate.existingSecret, and the three keys inside it are configurable, so you point the chart at whatever key names your tool produces rather than renaming anything:
controlPlane:
certificate:
existingSecret: aisix-dp-mtls
certKey: cert.pem # default
keyKey: key.pem # default
caKey: ca.pem # defaultIndependently of how the Secret is created, encrypt Secrets at rest in the cluster — without it, anyone who can read etcd or take an etcd backup can read the key, whatever produced it.
Share Rate-Limit Counters across Replicas
Rate-limit counters live in each gateway's own memory by default, so N replicas enforce N times every configured request, token, and concurrency limit. Before running more than one replica — including any replica an autoscaler adds — point every replica at one Redis:
rateLimit:
backend: redis
redis:
url: redis://redis.default.svc:6379Use rateLimit.redis.existingSecret instead when the connection URL carries a password.
After every replica points to the same Redis deployment, increase replicaCount or enable one of the autoscaling options below. If the deployment does not use request, token, or concurrency limits, you can deliberately accept the per-replica memory backend instead.
Scale on CPU or Memory
autoscaling creates a HorizontalPodAutoscaler for the gateway Deployment:
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 20
targetCPUUtilizationPercentage: 70The targets are percentages of the pod's resource requests, so the chart sets a CPU request by default. It deliberately sets no CPU limit: throttling adds tail latency to a proxy and suppresses the signal the autoscaler reads. Scaling on CPU requires metrics-server in the cluster.
On Linux, proxy.workers defaults to the CPU parallelism available to the gateway process. A Kubernetes CPU request does not constrain that value, but a CPU limit does. Because the chart sets no CPU limit, set AISIX_PROXY__WORKERS explicitly when each replica needs a stable worker count, and keep it within the CPU capacity planned for the replica. See Thread-per-Core Workers for worker configuration and sizing considerations.
When autoscaling is enabled, the Deployment omits spec.replicas so that a later helm upgrade cannot reset the replica count the autoscaler chose. The replicaCount value is ignored from then on.
Pass any behavior policy through unchanged, for example to make scale-down gentler than the Kubernetes default:
autoscaling:
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Pods
value: 1
periodSeconds: 60Use autoscaling.extraMetrics for Pods, Object, or External metrics such as a series exposed through a Prometheus adapter.
Scale on Request Load with KEDA
CPU is a proxy for load. To scale on the gateway's own traffic instead, use KEDA and a Prometheus query over the gateway metrics.
Before enabling the example, prepare these cluster components:
- Install KEDA, including the
ScaledObjectCRD and controller. - Provide a Prometheus server that can query the gateway metrics.
- If the chart should create the scrape configuration shown below, install Prometheus Operator or another controller that consumes
ServiceMonitorobjects. Installing only the CRD lets Helm create the object, but nothing turns it into scrape configuration. Setmetrics.serviceMonitor.labelswhen the Prometheus instance selects ServiceMonitors by label. If Prometheus discovers the metrics Service another way, leavemetrics.serviceMonitor.enabledset tofalseand omit that block.
Confirm the CRDs required by the values you enable:
kubectl get crd scaledobjects.keda.sh
# Required only when metrics.serviceMonitor.enabled is true.
kubectl get crd servicemonitors.monitoring.coreos.comConfigure the chart after those prerequisites are available:
metrics:
serviceMonitor:
enabled: true
keda:
enabled: true
minReplicas: 2
maxReplicas: 20
pollingInterval: 15
cooldownPeriod: 300
triggers:
- type: prometheus
metadata:
serverAddress: http://prometheus.monitoring.svc:9090
query: sum(rate(aisix_llm_requests_total[2m]))
threshold: "100"aisix_llm_requests_total counts model-inference requests, such as /v1/chat/completions. It does not include MCP or A2A calls. Use aisix_proxy_requests_total if that is the load you want to scale on.
autoscaling and keda are mutually exclusive. Enabling both fails the Helm render rather than letting two controllers write spec.replicas.
What Happens during a Scaling Event
A replica the autoscaler adds does not receive traffic before it can serve it. Until the gateway has applied configuration from the control plane it does not bind the proxy listener at all, so every probe the chart points at that listener is refused rather than answered.
The chart's startupProbe is what covers that wait. Kubernetes holds the readiness and liveness probes back until a startup probe succeeds, so during the wait the readiness probe does not run and the pod never becomes ready — Kubernetes keeps it out of the Service endpoints, and liveness does not restart a pod that is still reaching the control plane. The startup probe's budget is periodSeconds x failureThreshold, and it has to cover reaching the control plane and applying what it holds. A pod whose control-plane connection stays unreachable for longer than that budget has its container killed and restarted, and repeated restarts show as CrashLoopBackOff — the intended outcome for an instance that has never had anything to serve. See Startup and the First Configuration for the readiness contract and how to size the budget.
When a replica scales down, Kubernetes starts removing it from Service endpoints and terminating the pod concurrently. Two chart values protect in-flight requests while that happens.
preStopSleepSeconds pauses before the container receives SIGTERM, so endpoint removal reaches every node before the gateway begins shutting down. That covers a balancer watching the Kubernetes API; one that polls a health check instead sees a still-ready pod throughout the pause and is covered by the gateway's own drain window, shutdown.min_drain_secs.
terminationGracePeriodSeconds caps the pause, the drain window, and the in-flight drain that follows. When snapshot persistence is enabled, it also covers up to five more seconds for an outstanding cache write. The gateway drains in-flight requests without a deadline of its own. The Kubernetes default of 30 seconds would cut a streaming response, so the chart ships a longer one.
Both values ship with defaults sized for endpoint withdrawal and long-running requests; helm show values api7/aisix reports what they currently are. Raise terminationGracePeriodSeconds for workloads that stream beyond the remaining budget after the pause and drain window. When snapshot persistence is enabled, also leave five seconds for the cache-write drain.
To watch scaling decisions and the metrics behind them:
kubectl -n aisix-dp get hpa aisix-dp --watch
kubectl -n aisix-dp describe hpa aisix-dpSurvive Node Disruption
A PodDisruptionBudget keeps voluntary disruptions — node drains, cluster upgrades — from taking every gateway down at once. Spread constraints keep replicas out of a single failure domain:
podDisruptionBudget:
enabled: true
minAvailable: 50%
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: aisixThe chart uses an emptyDir volume for /var/lib/aisix. It survives a container restart within the same Pod but is lost when the Pod is replaced. The chart supplies the certificate bundle from a Secret at every start. A replacement Pod therefore reconstructs its identity from that bundle and downloads its configuration from the control plane before receiving traffic. Keep multiple replicas across failure domains so an existing replica can continue serving during a replacement.
To recover cached configuration after a Pod replacement while the control plane is unavailable, customize the workload in two ways. Give each replica its own persistent state directory, and set AISIX_MANAGED__SNAPSHOT_CACHE_ENABLED to true. Persistent storage alone does not enable the cache. See Restart from Cached Configuration for the full recovery and security requirements.
See High Availability for the wider deployment pattern.
Set Any Other Gateway Configuration
The control plane owns dynamic resources, and the chart owns the pod. For a startup setting that the chart does not expose as a value, set the environment variable directly — every configuration field is reachable as AISIX_<SECTION>__<FIELD>:
extraEnvVars:
- name: AISIX_OBSERVABILITY__LOG_LEVEL
value: "debug"
- name: AISIX_UPSTREAM__POOL_MAX_IDLE_PER_HOST
value: "32"See Environment Variables for the naming rules.