From b988d3e0e7dbc4eed23230ad0aaed75cf90f2f61 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 29 Jul 2026 22:15:21 +0800 Subject: [PATCH 1/5] feat(aisix): Helm chart for the AI gateway data plane, with HPA and KEDA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds charts/aisix, the public chart for the AISIX data plane. Until now the only Kubernetes install path was a hand-rolled Deployment manifest, which carries no autoscaling, no disruption budget, and no scrape target. The chart runs the gateway in control-plane-managed mode: it takes the data-plane manager endpoint plus the gateway certificate bundle issued from the console (inline PEMs, or an existing Secret so the private key stays out of the values file), and everything else arrives from the control plane. Autoscaling: - `autoscaling.*` renders a v2 HorizontalPodAutoscaler with CPU and memory targets, free-form `behavior`, and `extraMetrics` for custom / external metrics. - `keda.*` renders a KEDA ScaledObject for request-driven signals such as a Prometheus query over the gateway's own metrics. - The two are mutually exclusive; enabling both fails the render instead of letting two controllers fight over spec.replicas. The Deployment omits spec.replicas whenever either is on, so `helm upgrade` cannot reset the replica count the autoscaler chose. Scaling events are safe in both directions without any extra configuration. A new replica stays out of the Service until `/readyz` passes, which happens only after it has applied configuration from the control plane. A terminating one leaves the Service first, then drains: `terminationGracePeriodSeconds` defaults to 120 because a streaming response outlives the Kubernetes default of 30, and a preStop pause covers the window where endpoint removal has not yet reached every kube-proxy. Also included: a metrics Service and optional ServiceMonitor (scraping never rides the possibly-public proxy Service), a PodDisruptionBudget, and a first-class `rateLimit.backend=redis` switch — counters are per-replica otherwise, so every replica an autoscaler adds silently multiplies each configured limit. NOTES.txt warns when that combination is deployed. Two pod-spec settings are load-bearing and verified against a real cluster rather than assumed: - `enableServiceLinks: false`. The gateway reads every AISIX_-prefixed environment variable as configuration and rejects unknown keys, so service links from any Service named `aisix*` in the namespace crash it at boot ("unknown field `cp_ui_service_port_http`"). - `capabilities: drop [ALL], add [NET_BIND_SERVICE]`. The binary carries `cap_net_bind_service=+ep`; with that capability's effective bit set, dropping ALL without adding it back fails exec outright (exit 126, "Operation not permitted") rather than merely losing the privilege. Refs api7/AISIX-Cloud#1086 --- .github/workflows/ci.yaml | 1 + AGENTS.md | 3 + README.md | 2 + charts/aisix/.helmignore | 16 ++ charts/aisix/Chart.yaml | 21 ++ charts/aisix/README.md | 288 ++++++++++++++++++++ charts/aisix/README.md.gotmpl | 184 +++++++++++++ charts/aisix/ci/default-values.yaml | 8 + charts/aisix/templates/NOTES.txt | 57 ++++ charts/aisix/templates/_helpers.tpl | 127 +++++++++ charts/aisix/templates/deployment.yaml | 184 +++++++++++++ charts/aisix/templates/hpa.yaml | 40 +++ charts/aisix/templates/pdb.yaml | 18 ++ charts/aisix/templates/scaledobject.yaml | 33 +++ charts/aisix/templates/secret.yaml | 31 +++ charts/aisix/templates/service-metrics.yaml | 22 ++ charts/aisix/templates/service.yaml | 26 ++ charts/aisix/templates/serviceaccount.yaml | 13 + charts/aisix/templates/servicemonitor.yaml | 35 +++ charts/aisix/values.yaml | 283 +++++++++++++++++++ 20 files changed, 1392 insertions(+) create mode 100644 charts/aisix/.helmignore create mode 100644 charts/aisix/Chart.yaml create mode 100644 charts/aisix/README.md create mode 100644 charts/aisix/README.md.gotmpl create mode 100644 charts/aisix/ci/default-values.yaml create mode 100644 charts/aisix/templates/NOTES.txt create mode 100644 charts/aisix/templates/_helpers.tpl create mode 100644 charts/aisix/templates/deployment.yaml create mode 100644 charts/aisix/templates/hpa.yaml create mode 100644 charts/aisix/templates/pdb.yaml create mode 100644 charts/aisix/templates/scaledobject.yaml create mode 100644 charts/aisix/templates/secret.yaml create mode 100644 charts/aisix/templates/service-metrics.yaml create mode 100644 charts/aisix/templates/service.yaml create mode 100644 charts/aisix/templates/serviceaccount.yaml create mode 100644 charts/aisix/templates/servicemonitor.yaml create mode 100644 charts/aisix/values.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index d001cd31..b28ef922 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -38,6 +38,7 @@ jobs: --charts charts/gateway \ --charts charts/ingress-controller \ --charts charts/aisix-cp \ + --charts charts/aisix \ --charts charts/ngxdig' - name: Verify Chart.lock files diff --git a/AGENTS.md b/AGENTS.md index 72242956..00d2e5bf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ This repo (`api7/api7-helm-chart`) holds the Helm charts for API7 EE components | `api7-ingress-controller` | `charts/ingress-controller` | no — independent product version | | `developer-portal-fe` | `charts/developer-portal-fe` | no — independent product version | | `aisix-cp` (AISIX private-deployment control plane) | `charts/aisix-cp` | no — independent product version (source of truth: `api7/AISIX-Cloud` `helm/aisix-cp`) | +| `aisix` (AISIX AI gateway data plane) | `charts/aisix` | no — independent product version; **this repo is the source of truth** (unlike `aisix-cp`, no dev-repo counterpart to sync from) | | `ngxdig` (eBPF flame-graph / diagnosis agent, DaemonSet) | `charts/ngxdig` | no — independent product version (source of truth: `api7/ngx-flame`) | ## Multi-line maintenance model @@ -39,6 +40,8 @@ Cost: within a single line the chart can no longer use a major bump for breaking For **independently-versioned sub-charts** (`ingress-controller`, `developer-portal-fe`): their version axis is not the EE minor, so they are **shared across EE lines and not forked per line** by default. Only fork them onto their **own product minor** when an older EE line must ship a different version of them and keep patching it. +The **AISIX charts (`aisix-cp`, `aisix`) release as a pair**, on the AISIX OP version axis rather than the EE one: at each OP release both get `version` **and** `appVersion` set to that release. `appVersion` pins the image tag on both sides, so the two charts must never carry different `appVersion`s — a control plane and a data plane from different releases is not a combination we ship. + ## Release decision When releasing the chart for EE `X.Y.Z`: diff --git a/README.md b/README.md index 3fa8834f..442dd318 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,5 @@ Detailed instructions are enrolled in the README of each chart. * [api7-control-plane](./charts/api7/README.md) * [api7-gateway](./charts/gateway/README.md) * [api7-ingress-controller](./charts/ingress-controller/README.md) +* [aisix-cp](./charts/aisix-cp/README.md) — AISIX AI gateway control plane +* [aisix](./charts/aisix/README.md) — AISIX AI gateway data plane diff --git a/charts/aisix/.helmignore b/charts/aisix/.helmignore new file mode 100644 index 00000000..6937952e --- /dev/null +++ b/charts/aisix/.helmignore @@ -0,0 +1,16 @@ +.DS_Store +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +*.tmproj +*.orig +*.rej +*.swp +.idea/ +.vscode/ +README.md.gotmpl +ci/ diff --git a/charts/aisix/Chart.yaml b/charts/aisix/Chart.yaml new file mode 100644 index 00000000..fd209691 --- /dev/null +++ b/charts/aisix/Chart.yaml @@ -0,0 +1,21 @@ +apiVersion: v2 +name: aisix +description: Helm chart for the AISIX AI gateway data plane +type: application +version: 0.6.0 +appVersion: "0.6.0" + +keywords: + - ai-gateway + - llm + - gateway + - proxy + +home: https://api7.ai +sources: + - https://github.com/api7/api7-helm-chart + +maintainers: + - name: API7 + email: support@api7.ai + url: https://api7.ai diff --git a/charts/aisix/README.md b/charts/aisix/README.md new file mode 100644 index 00000000..2591879d --- /dev/null +++ b/charts/aisix/README.md @@ -0,0 +1,288 @@ +# aisix + +![Version: 0.6.0](https://img.shields.io/badge/Version-0.6.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.6.0](https://img.shields.io/badge/AppVersion-0.6.0-informational?style=flat-square) + +Helm chart for the AISIX AI gateway data plane + +AISIX is an AI gateway: it fronts LLM providers with routing, rate limiting, budgets, +caching, guardrails, and observability behind an OpenAI-compatible API. This chart +installs the **data plane** — the component that serves live AI traffic. + +The data plane is configured by the AISIX control plane, not by this chart. It +connects out to the control plane's data-plane manager over mutual TLS, using a +gateway certificate bundle issued from the console, and receives its models, API +keys, and policies from there. Install the control plane first — with the +[`aisix-cp`](../aisix-cp/README.md) chart, or any of the other options in the +[on-premises installation guide](https://docs.api7.ai/ai-gateway/on-premises/deployment). + +**Homepage:** + +## Maintainers + +| Name | Email | Url | +| ---- | ------ | --- | +| API7 | | | + +## Source Code + +* + +## Prerequisites + +* Kubernetes v1.23+ +* Helm v3+ +* An AISIX control plane, reachable from the cluster +* A gateway certificate bundle for the environment this gateway should serve + +## Install + +In the console, open the target environment's **Data planes** view and issue a +gateway certificate. Keep the three PEM values — the private key is shown only once. + +Put them in a Secret so the private key never lands in a values file: + +```sh +kubectl create namespace aisix +kubectl -n aisix create secret generic aisix-gateway-certificate \ + --from-file=cert.pem=./cert.pem \ + --from-file=key.pem=./key.pem \ + --from-file=ca.pem=./ca.pem +``` + +Then install the chart, pointing it at the data-plane manager endpoint from the +same view: + +```sh +helm repo add api7 https://charts.api7.ai +helm repo update + +helm install aisix api7/aisix --namespace aisix \ + --set controlPlane.baseURL=https://dp-manager.example.com:7944 \ + --set controlPlane.certificate.existingSecret=aisix-gateway-certificate +``` + +The gateway appears in the environment's **Data planes** view once its first +heartbeat lands. Each replica registers as its own instance; they share one +certificate. + +## Uninstall + +```sh +helm delete aisix --namespace aisix +``` + +## Configuration examples + +The full list of values is in the [Parameters](#parameters) table below. Put these +in a `values.yaml` and pass it with `-f values.yaml`. + +### Autoscale on CPU + +```yaml +autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 20 + targetCPUUtilizationPercentage: 70 +``` + +Requires `metrics-server` in the cluster. A replica the autoscaler adds is kept out +of the Service until `/readyz` passes, which happens only after it has loaded its +configuration from the control plane — so a scaling event never routes traffic to a +gateway that cannot serve it. + +Scale-down is equally guarded: the gateway stops reporting ready, then drains +in-flight requests. Because a streaming response can run for minutes, +`terminationGracePeriodSeconds` defaults to 120 rather than the Kubernetes default +of 30. Raise it if your workloads stream for longer. + +### Autoscale on request load with KEDA + +CPU is a proxy for load; the gateway's own metrics are the real signal. With +[KEDA](https://keda.sh) installed, scale on a Prometheus query instead: + +```yaml +metrics: + serviceMonitor: + enabled: true + +keda: + enabled: true + minReplicas: 2 + maxReplicas: 20 + triggers: + - type: prometheus + metadata: + serverAddress: http://prometheus.monitoring.svc:9090 + query: sum(rate(aisix_llm_requests_total[2m])) + threshold: "100" +``` + +`autoscaling` and `keda` are mutually exclusive — enabling both fails the render +rather than letting two controllers fight over the replica count. + +### Share rate-limit counters across replicas + +Rate-limit counters are per-replica by default, so N replicas enforce N× every +configured limit. Before running more than one replica — including any replica an +autoscaler adds — point the gateway at a shared Redis: + +```yaml +rateLimit: + backend: redis + redis: + url: redis://redis.default.svc:6379 +``` + +Use `rateLimit.redis.existingSecret` instead when the URL carries a password. + +### Publish the gateway through a load balancer + +```yaml +service: + type: LoadBalancer + port: 80 + # Preserve the client source IP, which the gateway uses for IP allowlists. + externalTrafficPolicy: Local +``` + +### Bind a privileged port + +The image carries the `CAP_NET_BIND_SERVICE` file capability, so the gateway binds +low ports without running as root: + +```yaml +containerPorts: + proxy: 80 +``` + +### Survive node drains + +```yaml +podDisruptionBudget: + enabled: true + minAvailable: 50% + +topologySpreadConstraints: + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: aisix +``` + +### Set any other gateway configuration + +Every field of the gateway's configuration file is reachable as an environment +variable named `AISIX_
__`: + +```yaml +extraEnvVars: + - name: AISIX_OBSERVABILITY__LOG_LEVEL + value: debug + - name: AISIX_CACHE__BACKEND + value: redis +``` + +## Parameters + +## Values + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| affinity | object | `{}` | Affinity rules for the gateway pods | +| autoscaling.behavior | object | `{}` | `spec.behavior` for the HPA. Empty uses the Kubernetes defaults (immediate scale-up, 5-minute scale-down stabilization). A gateway that carries long streaming responses usually wants a gentler scale-down, e.g. `scaleDown: {policies: [{type: Pods, value: 1, periodSeconds: 60}]}` | +| autoscaling.enabled | bool | `false` | Create a HorizontalPodAutoscaler for the gateway Deployment | +| autoscaling.extraMetrics | list | `[]` | Extra `spec.metrics` entries appended verbatim — Pods / Object / External metrics such as a Prometheus adapter series | +| autoscaling.maxReplicas | int | `10` | Upper replica bound | +| autoscaling.minReplicas | int | `2` | Lower replica bound | +| autoscaling.targetCPUUtilizationPercentage | int | `70` | Target average CPU utilization, in percent of the CPU request. Set to null to drop the CPU metric | +| autoscaling.targetMemoryUtilizationPercentage | string | `nil` | Target average memory utilization, in percent of the memory request. Null by default: gateway memory tracks in-flight streams more than load | +| containerPorts.metrics | int | `9090` | Port the Prometheus metrics listener binds inside the container | +| containerPorts.proxy | int | `3000` | Port the proxy listener binds inside the container. The image carries the `CAP_NET_BIND_SERVICE` file capability, so a privileged port works without running as root — see `securityContext` below | +| controlPlane.baseURL | string | `""` | Data-plane manager mTLS endpoint the gateway connects out to, e.g. `https://dpm.example.com:7944`. Required. | +| controlPlane.certificate.ca | string | `""` | CA bundle PEM. Used only when `existingSecret` is empty | +| controlPlane.certificate.caKey | string | `"ca.pem"` | Secret key holding the CA bundle PEM | +| controlPlane.certificate.cert | string | `""` | Client certificate PEM. Used only when `existingSecret` is empty | +| controlPlane.certificate.certKey | string | `"cert.pem"` | Secret key holding the client certificate PEM | +| controlPlane.certificate.existingSecret | string | `""` | Read the bundle from an existing Secret instead of the PEM values below. Recommended: it keeps the private key out of your values file | +| controlPlane.certificate.key | string | `""` | Private key PEM. Used only when `existingSecret` is empty | +| controlPlane.certificate.keyKey | string | `"key.pem"` | Secret key holding the private key PEM | +| controlPlane.etcdEndpoint | string | `""` | Control-plane etcd endpoint as bare `host:port`. Leave empty unless the control plane publishes an etcd endpoint distinct from `baseURL` | +| controlPlane.heartbeatIntervalSeconds | int | `15` | Heartbeat interval in seconds. The control plane marks a gateway connected on its first heartbeat. Clamped to [5, 300] by the gateway | +| extraEnvVars | list | `[]` | Extra environment variables for the gateway container. Every gateway configuration field is reachable as `AISIX_
__` | +| extraVolumeMounts | list | `[]` | Extra volume mounts for the gateway container | +| extraVolumes | list | `[]` | Extra volumes for the gateway pod | +| fullnameOverride | string | `""` | Override the fully qualified resource name prefix | +| global.imagePullSecrets | list | `[]` | Image pull secrets applied to every pod created by this chart | +| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy | +| image.repository | string | `"docker.io/api7/aisix"` | Gateway image repository | +| image.tag | string | `""` | Image tag. Empty resolves to the chart `appVersion` | +| keda.annotations | object | `{}` | Extra annotations for the ScaledObject | +| keda.behavior | object | `{}` | `behavior` for the HPA KEDA creates. Empty uses the Kubernetes defaults | +| keda.cooldownPeriod | int | `300` | Seconds to wait after the last trigger fires before scaling down | +| keda.enabled | bool | `false` | Create a KEDA ScaledObject for the gateway Deployment | +| keda.fallback | object | `{}` | Replica count to fall back to when a trigger source is unreachable, e.g. `{failureThreshold: 3, replicas: 4}` | +| keda.maxReplicas | int | `10` | Upper replica bound | +| keda.minReplicas | int | `2` | Lower replica bound | +| keda.pollingInterval | int | `15` | How often KEDA evaluates the triggers, in seconds | +| keda.restoreToOriginalReplicaCount | bool | `false` | Restore the original replica count when the ScaledObject is deleted | +| keda.triggers | list | `[]` | KEDA triggers. Required when `keda.enabled` is true. For example: `[{type: prometheus, metadata: {serverAddress: "http://prometheus:9090", query: "sum(rate(aisix_llm_requests_total[2m]))", threshold: "100"}}]` | +| livenessProbe.enabled | bool | `true` | | +| livenessProbe.failureThreshold | int | `3` | | +| livenessProbe.initialDelaySeconds | int | `10` | | +| livenessProbe.periodSeconds | int | `10` | | +| metrics.enabled | bool | `true` | Publish the gateway's Prometheus metrics on a separate ClusterIP Service, so scraping never rides the (possibly public) proxy Service | +| metrics.service.annotations | object | `{}` | Extra annotations for the metrics Service | +| metrics.service.port | int | `9090` | Metrics Service port | +| metrics.serviceMonitor.enabled | bool | `false` | Create a Prometheus Operator ServiceMonitor for the metrics Service | +| metrics.serviceMonitor.interval | string | `"30s"` | Scrape interval | +| metrics.serviceMonitor.labels | object | `{}` | Extra labels, e.g. the `release` label your Prometheus selects on | +| metrics.serviceMonitor.metricRelabelings | list | `[]` | Metric relabeling rules | +| metrics.serviceMonitor.namespace | string | `""` | Namespace to create the ServiceMonitor in. Empty uses the release namespace | +| metrics.serviceMonitor.relabelings | list | `[]` | Scrape-time relabeling rules | +| metrics.serviceMonitor.scrapeTimeout | string | `""` | Scrape timeout. Empty leaves the Prometheus default | +| nameOverride | string | `""` | Override the chart name used in resource names | +| nodeSelector | object | `{}` | Node selector for the gateway pods | +| podAnnotations | object | `{}` | Annotations for the gateway pods | +| podDisruptionBudget.enabled | bool | `false` | Create a PodDisruptionBudget so voluntary disruptions (node drains, cluster upgrades) cannot take the whole gateway down at once | +| podDisruptionBudget.maxUnavailable | int | `1` | Maximum unavailable pods | +| podDisruptionBudget.minAvailable | string | `""` | Minimum available pods. Takes precedence over `maxUnavailable` | +| podLabels | object | `{}` | Labels for the gateway pods | +| podSecurityContext.fsGroup | int | `10001` | | +| podSecurityContext.runAsNonRoot | bool | `true` | | +| podSecurityContext.runAsUser | int | `10001` | | +| podSecurityContext.seccompProfile.type | string | `"RuntimeDefault"` | | +| preStopSleepSeconds | int | `5` | Seconds to sleep in a `preStop` hook before the gateway receives SIGTERM. Endpoint removal and SIGTERM are concurrent, so without this pause a terminating pod can still be handed new connections by a kube-proxy that has not caught up. Set to 0 to drop the hook. | +| priorityClassName | string | `""` | Pod priority class | +| rateLimit.backend | string | `"memory"` | Rate-limit counter backend: `memory` (per-replica) or `redis` (shared) | +| rateLimit.redis.existingSecret | string | `""` | Read the connection URL from an existing Secret instead, so a URL carrying a password stays out of your values file | +| rateLimit.redis.existingSecretKey | string | `"redis-url"` | Secret key holding the connection URL | +| rateLimit.redis.url | string | `""` | Redis connection URL, e.g. `redis://redis.default.svc:6379`. Required when `rateLimit.backend` is redis and `existingSecret` is empty | +| readinessProbe.enabled | bool | `true` | | +| readinessProbe.failureThreshold | int | `3` | | +| readinessProbe.periodSeconds | int | `3` | | +| replicaCount | int | `2` | Number of gateway replicas. Ignored once `autoscaling.enabled` or `keda.enabled` is true — the autoscaler owns the replica count from then on and the Deployment omits `spec.replicas` so `helm upgrade` cannot reset it. | +| resources.limits.memory | string | `"1Gi"` | | +| resources.requests.cpu | string | `"500m"` | | +| resources.requests.memory | string | `"256Mi"` | | +| securityContext.allowPrivilegeEscalation | bool | `false` | | +| securityContext.capabilities.add[0] | string | `"NET_BIND_SERVICE"` | | +| securityContext.capabilities.drop[0] | string | `"ALL"` | | +| securityContext.readOnlyRootFilesystem | bool | `true` | | +| service.annotations | object | `{}` | Extra annotations for the proxy Service, e.g. cloud load-balancer settings | +| service.externalTrafficPolicy | string | `""` | `externalTrafficPolicy` for the proxy Service. `Local` preserves the client source IP on NodePort / LoadBalancer types | +| service.nodePort | string | `""` | Proxy Service nodePort, when `service.type` is NodePort or LoadBalancer | +| service.port | int | `80` | Proxy Service port | +| service.type | string | `"ClusterIP"` | Proxy Service type | +| serviceAccount.annotations | object | `{}` | ServiceAccount annotations | +| serviceAccount.create | bool | `true` | Create a ServiceAccount for the gateway | +| serviceAccount.name | string | `""` | ServiceAccount name. Defaults to the release fullname | +| startupProbe.enabled | bool | `true` | Gate liveness and readiness until the proxy listener is bound | +| startupProbe.failureThreshold | int | `30` | | +| startupProbe.periodSeconds | int | `2` | | +| terminationGracePeriodSeconds | int | `120` | Seconds the gateway may take to drain in-flight requests after SIGTERM. It drains without a deadline of its own, so this value is the real cap — and a streaming LLM response can run for minutes. The Kubernetes default of 30s would cut those connections during a scale-down or a rolling update. | +| tolerations | list | `[]` | Tolerations for the gateway pods | +| topologySpreadConstraints | list | `[]` | Topology spread constraints, e.g. to spread replicas across zones | +| updateStrategy | object | `{}` | Deployment update strategy | diff --git a/charts/aisix/README.md.gotmpl b/charts/aisix/README.md.gotmpl new file mode 100644 index 00000000..25719a8c --- /dev/null +++ b/charts/aisix/README.md.gotmpl @@ -0,0 +1,184 @@ +{{ template "chart.header" . }} + +{{ template "chart.badgesSection" . }} + +{{ template "chart.description" . }} + +AISIX is an AI gateway: it fronts LLM providers with routing, rate limiting, budgets, +caching, guardrails, and observability behind an OpenAI-compatible API. This chart +installs the **data plane** — the component that serves live AI traffic. + +The data plane is configured by the AISIX control plane, not by this chart. It +connects out to the control plane's data-plane manager over mutual TLS, using a +gateway certificate bundle issued from the console, and receives its models, API +keys, and policies from there. Install the control plane first — with the +[`aisix-cp`](../aisix-cp/README.md) chart, or any of the other options in the +[on-premises installation guide](https://docs.api7.ai/ai-gateway/on-premises/deployment). + +{{ template "chart.homepageLine" . }} + +{{ template "chart.maintainersSection" . }} + +{{ template "chart.sourcesSection" . }} + +## Prerequisites + +* Kubernetes v1.23+ +* Helm v3+ +* An AISIX control plane, reachable from the cluster +* A gateway certificate bundle for the environment this gateway should serve + +## Install + +In the console, open the target environment's **Data planes** view and issue a +gateway certificate. Keep the three PEM values — the private key is shown only once. + +Put them in a Secret so the private key never lands in a values file: + +```sh +kubectl create namespace aisix +kubectl -n aisix create secret generic aisix-gateway-certificate \ + --from-file=cert.pem=./cert.pem \ + --from-file=key.pem=./key.pem \ + --from-file=ca.pem=./ca.pem +``` + +Then install the chart, pointing it at the data-plane manager endpoint from the +same view: + +```sh +helm repo add api7 https://charts.api7.ai +helm repo update + +helm install aisix api7/aisix --namespace aisix \ + --set controlPlane.baseURL=https://dp-manager.example.com:7944 \ + --set controlPlane.certificate.existingSecret=aisix-gateway-certificate +``` + +The gateway appears in the environment's **Data planes** view once its first +heartbeat lands. Each replica registers as its own instance; they share one +certificate. + +## Uninstall + +```sh +helm delete aisix --namespace aisix +``` + +## Configuration examples + +The full list of values is in the [Parameters](#parameters) table below. Put these +in a `values.yaml` and pass it with `-f values.yaml`. + +### Autoscale on CPU + +```yaml +autoscaling: + enabled: true + minReplicas: 2 + maxReplicas: 20 + targetCPUUtilizationPercentage: 70 +``` + +Requires `metrics-server` in the cluster. A replica the autoscaler adds is kept out +of the Service until `/readyz` passes, which happens only after it has loaded its +configuration from the control plane — so a scaling event never routes traffic to a +gateway that cannot serve it. + +Scale-down is equally guarded: the gateway stops reporting ready, then drains +in-flight requests. Because a streaming response can run for minutes, +`terminationGracePeriodSeconds` defaults to 120 rather than the Kubernetes default +of 30. Raise it if your workloads stream for longer. + +### Autoscale on request load with KEDA + +CPU is a proxy for load; the gateway's own metrics are the real signal. With +[KEDA](https://keda.sh) installed, scale on a Prometheus query instead: + +```yaml +metrics: + serviceMonitor: + enabled: true + +keda: + enabled: true + minReplicas: 2 + maxReplicas: 20 + triggers: + - type: prometheus + metadata: + serverAddress: http://prometheus.monitoring.svc:9090 + query: sum(rate(aisix_llm_requests_total[2m])) + threshold: "100" +``` + +`autoscaling` and `keda` are mutually exclusive — enabling both fails the render +rather than letting two controllers fight over the replica count. + +### Share rate-limit counters across replicas + +Rate-limit counters are per-replica by default, so N replicas enforce N× every +configured limit. Before running more than one replica — including any replica an +autoscaler adds — point the gateway at a shared Redis: + +```yaml +rateLimit: + backend: redis + redis: + url: redis://redis.default.svc:6379 +``` + +Use `rateLimit.redis.existingSecret` instead when the URL carries a password. + +### Publish the gateway through a load balancer + +```yaml +service: + type: LoadBalancer + port: 80 + # Preserve the client source IP, which the gateway uses for IP allowlists. + externalTrafficPolicy: Local +``` + +### Bind a privileged port + +The image carries the `CAP_NET_BIND_SERVICE` file capability, so the gateway binds +low ports without running as root: + +```yaml +containerPorts: + proxy: 80 +``` + +### Survive node drains + +```yaml +podDisruptionBudget: + enabled: true + minAvailable: 50% + +topologySpreadConstraints: + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app.kubernetes.io/name: aisix +``` + +### Set any other gateway configuration + +Every field of the gateway's configuration file is reachable as an environment +variable named `AISIX_
__`: + +```yaml +extraEnvVars: + - name: AISIX_OBSERVABILITY__LOG_LEVEL + value: debug + - name: AISIX_CACHE__BACKEND + value: redis +``` + +## Parameters + +{{ template "chart.valuesSection" . }} diff --git a/charts/aisix/ci/default-values.yaml b/charts/aisix/ci/default-values.yaml new file mode 100644 index 00000000..88eecbdd --- /dev/null +++ b/charts/aisix/ci/default-values.yaml @@ -0,0 +1,8 @@ +# Values used by chart-testing. The chart deliberately fails to render +# without a control-plane endpoint and a certificate bundle, so CI supplies +# placeholders for both. The referenced Secret is never created — this file +# drives `ct lint` only, not `ct install`. +controlPlane: + baseURL: "https://dp-manager.example.com:7944" + certificate: + existingSecret: "aisix-gateway-certificate" diff --git a/charts/aisix/templates/NOTES.txt b/charts/aisix/templates/NOTES.txt new file mode 100644 index 00000000..cf0ea82d --- /dev/null +++ b/charts/aisix/templates/NOTES.txt @@ -0,0 +1,57 @@ +AISIX gateway {{ .Chart.AppVersion }} has been deployed as {{ include "aisix.fullname" . }}. + +It connects out to the control plane at {{ .Values.controlPlane.baseURL }} and +appears in that environment's Data planes view once its first heartbeat lands. + +Watch it come up: + + kubectl rollout status deploy/{{ include "aisix.fullname" . }} -n {{ .Release.Namespace }} + +Send a request through it: +{{- if eq .Values.service.type "ClusterIP" }} + + kubectl port-forward svc/{{ include "aisix.fullname" . }} 8080:{{ .Values.service.port }} -n {{ .Release.Namespace }} + curl http://localhost:8080/v1/models -H "Authorization: Bearer " +{{- else if eq .Values.service.type "NodePort" }} + + export NODE_IP=$(kubectl get nodes -o jsonpath='{.items[0].status.addresses[0].address}') + export NODE_PORT=$(kubectl get svc {{ include "aisix.fullname" . }} -n {{ .Release.Namespace }} -o jsonpath='{.spec.ports[0].nodePort}') + curl http://$NODE_IP:$NODE_PORT/v1/models -H "Authorization: Bearer " +{{- else }} + + export LB_IP=$(kubectl get svc {{ include "aisix.fullname" . }} -n {{ .Release.Namespace }} -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + curl http://$LB_IP:{{ .Values.service.port }}/v1/models -H "Authorization: Bearer " +{{- end }} + +{{ if .Values.autoscaling.enabled -}} +Autoscaling is on (HorizontalPodAutoscaler, {{ .Values.autoscaling.minReplicas }}-{{ .Values.autoscaling.maxReplicas }} replicas). Watch scaling +decisions and the metrics behind them: + + kubectl get hpa {{ include "aisix.fullname" . }} -n {{ .Release.Namespace }} --watch + kubectl describe hpa {{ include "aisix.fullname" . }} -n {{ .Release.Namespace }} + +CPU-target autoscaling needs metrics-server running in the cluster. +{{ else if .Values.keda.enabled -}} +Autoscaling is on (KEDA ScaledObject, {{ .Values.keda.minReplicas }}-{{ .Values.keda.maxReplicas }} replicas). Watch scaling +decisions and the triggers behind them: + + kubectl get scaledobject {{ include "aisix.fullname" . }} -n {{ .Release.Namespace }} + kubectl describe hpa keda-hpa-{{ include "aisix.fullname" . }} -n {{ .Release.Namespace }} +{{ else -}} +Running {{ .Values.replicaCount }} fixed replica(s). To scale on load, set +`autoscaling.enabled=true` (CPU / memory) or `keda.enabled=true` with triggers +of your own. +{{ end }} +{{- if eq .Values.rateLimit.backend "memory" }} +{{- if or .Values.autoscaling.enabled .Values.keda.enabled }} + +WARNING: rateLimit.backend is "memory", so each replica counts requests on its +own and every replica the autoscaler adds multiplies the effective rate limit. +Point rateLimit.redis.url at a shared Redis and set rateLimit.backend=redis. +{{- else if gt (int .Values.replicaCount) 1 }} + +WARNING: rateLimit.backend is "memory", so each replica counts requests on its +own and {{ .Values.replicaCount }} replicas enforce {{ .Values.replicaCount }}x every configured rate limit. Point +rateLimit.redis.url at a shared Redis and set rateLimit.backend=redis. +{{- end }} +{{- end }} diff --git a/charts/aisix/templates/_helpers.tpl b/charts/aisix/templates/_helpers.tpl new file mode 100644 index 00000000..27593b97 --- /dev/null +++ b/charts/aisix/templates/_helpers.tpl @@ -0,0 +1,127 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "aisix.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +*/}} +{{- define "aisix.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "aisix.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "aisix.labels" -}} +helm.sh/chart: {{ include "aisix.chart" . }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +app.kubernetes.io/part-of: aisix +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "aisix.selectorLabels" -}} +app.kubernetes.io/name: {{ include "aisix.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/component: gateway +{{- end }} + +{{/* +ServiceAccount name. +*/}} +{{- define "aisix.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "aisix.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{/* +Image pull secrets. +*/}} +{{- define "aisix.imagePullSecrets" -}} +{{- with .Values.global.imagePullSecrets }} +imagePullSecrets: + {{- toYaml . | nindent 2 }} +{{- end }} +{{- end }} + +{{/* +Name of the Secret holding the gateway certificate bundle. +*/}} +{{- define "aisix.certSecretName" -}} +{{- if .Values.controlPlane.certificate.existingSecret }} +{{- .Values.controlPlane.certificate.existingSecret }} +{{- else }} +{{- printf "%s-mtls" (include "aisix.fullname" .) }} +{{- end }} +{{- end }} + +{{/* +Name of the Secret holding the rate-limit Redis URL. +*/}} +{{- define "aisix.redisSecretName" -}} +{{- if .Values.rateLimit.redis.existingSecret }} +{{- .Values.rateLimit.redis.existingSecret }} +{{- else }} +{{- printf "%s-ratelimit" (include "aisix.fullname" .) }} +{{- end }} +{{- end }} + +{{/* +Secret key holding the rate-limit Redis URL. +*/}} +{{- define "aisix.redisSecretKey" -}} +{{- if .Values.rateLimit.redis.existingSecret }} +{{- .Values.rateLimit.redis.existingSecretKey | default "redis-url" }} +{{- else }} +{{- "redis-url" }} +{{- end }} +{{- end }} + +{{/* +Reject value combinations that render successfully but cannot run. +*/}} +{{- define "aisix.validateValues" -}} +{{- if not .Values.controlPlane.baseURL }} +{{- fail "controlPlane.baseURL is required: set it to the data-plane manager endpoint shown in the control plane's Data planes view" }} +{{- end }} +{{- if not .Values.controlPlane.certificate.existingSecret }} +{{- if not (and .Values.controlPlane.certificate.cert .Values.controlPlane.certificate.key .Values.controlPlane.certificate.ca) }} +{{- fail "a gateway certificate bundle is required: set controlPlane.certificate.existingSecret, or all three of controlPlane.certificate.{cert,key,ca}" }} +{{- end }} +{{- end }} +{{- if and .Values.autoscaling.enabled .Values.keda.enabled }} +{{- fail "autoscaling.enabled and keda.enabled are mutually exclusive: two controllers writing spec.replicas fight over the replica count" }} +{{- end }} +{{- if and .Values.keda.enabled (not .Values.keda.triggers) }} +{{- fail "keda.enabled requires at least one entry in keda.triggers" }} +{{- end }} +{{- if eq .Values.rateLimit.backend "redis" }} +{{- if not (or .Values.rateLimit.redis.url .Values.rateLimit.redis.existingSecret) }} +{{- fail "rateLimit.backend=redis requires rateLimit.redis.url or rateLimit.redis.existingSecret" }} +{{- end }} +{{- end }} +{{- end }} diff --git a/charts/aisix/templates/deployment.yaml b/charts/aisix/templates/deployment.yaml new file mode 100644 index 00000000..2bfd4467 --- /dev/null +++ b/charts/aisix/templates/deployment.yaml @@ -0,0 +1,184 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "aisix.fullname" . }} + labels: + {{- include "aisix.labels" . | nindent 4 }} + {{- include "aisix.selectorLabels" . | nindent 4 }} +spec: + {{- if not (or .Values.autoscaling.enabled .Values.keda.enabled) }} + replicas: {{ .Values.replicaCount }} + {{- end }} + {{- with .Values.updateStrategy }} + strategy: + {{- toYaml . | nindent 4 }} + {{- end }} + selector: + matchLabels: + {{- include "aisix.selectorLabels" . | nindent 6 }} + template: + metadata: + annotations: + # Roll the pods when the certificate bundle changes, so a rotated + # bundle takes effect without a manual restart. + {{- if not .Values.controlPlane.certificate.existingSecret }} + checksum/mtls: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} + {{- end }} + {{- with .Values.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "aisix.labels" . | nindent 8 }} + {{- include "aisix.selectorLabels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + # The gateway reads every AISIX_-prefixed environment variable as a + # configuration override and rejects unknown keys. Service links inject + # one variable per Service in the namespace, so any Service whose name + # starts with "aisix" — this chart's own, or a control plane installed + # alongside it — stops the gateway from booting ("unknown field + # `cp_ui_service_port_http`"). Not a value: there is no working setting + # other than false. + enableServiceLinks: false + {{- include "aisix.imagePullSecrets" . | nindent 6 }} + serviceAccountName: {{ include "aisix.serviceAccountName" . }} + {{- with .Values.priorityClassName }} + priorityClassName: {{ . }} + {{- end }} + {{- with .Values.podSecurityContext }} + securityContext: + {{- toYaml . | nindent 8 }} + {{- end }} + terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }} + containers: + - name: aisix + image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}" + imagePullPolicy: {{ .Values.image.pullPolicy }} + {{- with .Values.securityContext }} + securityContext: + {{- toYaml . | nindent 12 }} + {{- end }} + ports: + - name: proxy + containerPort: {{ .Values.containerPorts.proxy }} + protocol: TCP + - name: metrics + containerPort: {{ .Values.containerPorts.metrics }} + protocol: TCP + env: + - name: AISIX_CONFIG_PATH + value: /etc/aisix/config.managed.yaml + - name: AISIX_PROXY__ADDR + value: "0.0.0.0:{{ .Values.containerPorts.proxy }}" + - name: AISIX_OBSERVABILITY__METRICS__PROMETHEUS__ADDR + value: "0.0.0.0:{{ .Values.containerPorts.metrics }}" + - name: AISIX_MANAGED__CP_BASE_URL + value: {{ .Values.controlPlane.baseURL | quote }} + {{- with .Values.controlPlane.etcdEndpoint }} + - name: AISIX_MANAGED__CP_ETCD_ENDPOINT + value: {{ . | quote }} + {{- end }} + - name: AISIX_MANAGED__HEARTBEAT_INTERVAL_SECS + value: {{ .Values.controlPlane.heartbeatIntervalSeconds | quote }} + - name: AISIX_MANAGED__CP_CERT_PEM + valueFrom: + secretKeyRef: + name: {{ include "aisix.certSecretName" . }} + key: {{ .Values.controlPlane.certificate.certKey }} + - name: AISIX_MANAGED__CP_KEY_PEM + valueFrom: + secretKeyRef: + name: {{ include "aisix.certSecretName" . }} + key: {{ .Values.controlPlane.certificate.keyKey }} + - name: AISIX_MANAGED__CP_CA_PEM + valueFrom: + secretKeyRef: + name: {{ include "aisix.certSecretName" . }} + key: {{ .Values.controlPlane.certificate.caKey }} + {{- if eq .Values.rateLimit.backend "redis" }} + - name: AISIX_RATELIMIT__BACKEND + value: "redis" + - name: AISIX_RATELIMIT__REDIS__URL + valueFrom: + secretKeyRef: + name: {{ include "aisix.redisSecretName" . }} + key: {{ include "aisix.redisSecretKey" . }} + {{- end }} + {{- with .Values.extraEnvVars }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if .Values.startupProbe.enabled }} + # /livez answers as soon as the proxy listener is bound. Gating on + # it keeps liveness and readiness quiet during boot. + startupProbe: + httpGet: + path: /livez + port: proxy + periodSeconds: {{ .Values.startupProbe.periodSeconds }} + failureThreshold: {{ .Values.startupProbe.failureThreshold }} + {{- end }} + {{- if .Values.readinessProbe.enabled }} + # /readyz is 503 until configuration has been applied from the + # control plane, and again once draining begins — so a new replica + # joins the Service only when it can serve, and a terminating one + # leaves it before it stops accepting connections. + readinessProbe: + httpGet: + path: /readyz + port: proxy + periodSeconds: {{ .Values.readinessProbe.periodSeconds }} + failureThreshold: {{ .Values.readinessProbe.failureThreshold }} + {{- end }} + {{- if .Values.livenessProbe.enabled }} + livenessProbe: + httpGet: + path: /livez + port: proxy + initialDelaySeconds: {{ .Values.livenessProbe.initialDelaySeconds }} + periodSeconds: {{ .Values.livenessProbe.periodSeconds }} + failureThreshold: {{ .Values.livenessProbe.failureThreshold }} + {{- end }} + {{- if gt (int .Values.preStopSleepSeconds) 0 }} + lifecycle: + preStop: + exec: + command: ["/bin/sleep", "{{ .Values.preStopSleepSeconds }}"] + {{- end }} + {{- with .Values.resources }} + resources: + {{- toYaml . | nindent 12 }} + {{- end }} + volumeMounts: + # The gateway writes its mTLS material, gateway id, and + # configuration snapshot here. Per-pod and ephemeral by design: + # replicas share one certificate but register as separate + # instances, and a restarted pod re-registers. + - name: state + mountPath: /var/lib/aisix + {{- with .Values.extraVolumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + volumes: + - name: state + emptyDir: {} + {{- with .Values.extraVolumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.topologySpreadConstraints }} + topologySpreadConstraints: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/charts/aisix/templates/hpa.yaml b/charts/aisix/templates/hpa.yaml new file mode 100644 index 00000000..ec1c6642 --- /dev/null +++ b/charts/aisix/templates/hpa.yaml @@ -0,0 +1,40 @@ +{{- if .Values.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ include "aisix.fullname" . }} + labels: + {{- include "aisix.labels" . | nindent 4 }} + {{- include "aisix.selectorLabels" . | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ include "aisix.fullname" . }} + minReplicas: {{ .Values.autoscaling.minReplicas }} + maxReplicas: {{ .Values.autoscaling.maxReplicas }} + {{- with .Values.autoscaling.behavior }} + behavior: + {{- toYaml . | nindent 4 }} + {{- end }} + metrics: + {{- with .Values.autoscaling.targetCPUUtilizationPercentage }} + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ . }} + {{- end }} + {{- with .Values.autoscaling.targetMemoryUtilizationPercentage }} + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: {{ . }} + {{- end }} + {{- with .Values.autoscaling.extraMetrics }} + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/aisix/templates/pdb.yaml b/charts/aisix/templates/pdb.yaml new file mode 100644 index 00000000..1659f23e --- /dev/null +++ b/charts/aisix/templates/pdb.yaml @@ -0,0 +1,18 @@ +{{- if .Values.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "aisix.fullname" . }} + labels: + {{- include "aisix.labels" . | nindent 4 }} + {{- include "aisix.selectorLabels" . | nindent 4 }} +spec: + {{- if .Values.podDisruptionBudget.minAvailable }} + minAvailable: {{ .Values.podDisruptionBudget.minAvailable }} + {{- else }} + maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }} + {{- end }} + selector: + matchLabels: + {{- include "aisix.selectorLabels" . | nindent 6 }} +{{- end }} diff --git a/charts/aisix/templates/scaledobject.yaml b/charts/aisix/templates/scaledobject.yaml new file mode 100644 index 00000000..6935186b --- /dev/null +++ b/charts/aisix/templates/scaledobject.yaml @@ -0,0 +1,33 @@ +{{- if .Values.keda.enabled }} +apiVersion: keda.sh/v1alpha1 +kind: ScaledObject +metadata: + name: {{ include "aisix.fullname" . }} + labels: + {{- include "aisix.labels" . | nindent 4 }} + {{- include "aisix.selectorLabels" . | nindent 4 }} + {{- with .Values.keda.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + scaleTargetRef: + name: {{ include "aisix.fullname" . }} + pollingInterval: {{ .Values.keda.pollingInterval }} + cooldownPeriod: {{ .Values.keda.cooldownPeriod }} + minReplicaCount: {{ .Values.keda.minReplicas }} + maxReplicaCount: {{ .Values.keda.maxReplicas }} + {{- with .Values.keda.fallback }} + fallback: + {{- toYaml . | nindent 4 }} + {{- end }} + triggers: + {{- toYaml .Values.keda.triggers | nindent 4 }} + advanced: + restoreToOriginalReplicaCount: {{ .Values.keda.restoreToOriginalReplicaCount }} + {{- with .Values.keda.behavior }} + horizontalPodAutoscalerConfig: + behavior: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/charts/aisix/templates/secret.yaml b/charts/aisix/templates/secret.yaml new file mode 100644 index 00000000..d0a00809 --- /dev/null +++ b/charts/aisix/templates/secret.yaml @@ -0,0 +1,31 @@ +{{- include "aisix.validateValues" . }} +{{- if not .Values.controlPlane.certificate.existingSecret }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "aisix.certSecretName" . }} + labels: + {{- include "aisix.labels" . | nindent 4 }} + {{- include "aisix.selectorLabels" . | nindent 4 }} +type: Opaque +stringData: + {{ .Values.controlPlane.certificate.certKey }}: |- +{{ .Values.controlPlane.certificate.cert | trim | indent 4 }} + {{ .Values.controlPlane.certificate.keyKey }}: |- +{{ .Values.controlPlane.certificate.key | trim | indent 4 }} + {{ .Values.controlPlane.certificate.caKey }}: |- +{{ .Values.controlPlane.certificate.ca | trim | indent 4 }} +{{- end }} +{{- if and (eq .Values.rateLimit.backend "redis") (not .Values.rateLimit.redis.existingSecret) }} +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "aisix.redisSecretName" . }} + labels: + {{- include "aisix.labels" . | nindent 4 }} + {{- include "aisix.selectorLabels" . | nindent 4 }} +type: Opaque +stringData: + redis-url: {{ .Values.rateLimit.redis.url | quote }} +{{- end }} diff --git a/charts/aisix/templates/service-metrics.yaml b/charts/aisix/templates/service-metrics.yaml new file mode 100644 index 00000000..9018fc5f --- /dev/null +++ b/charts/aisix/templates/service-metrics.yaml @@ -0,0 +1,22 @@ +{{- if .Values.metrics.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "aisix.fullname" . }}-metrics + labels: + {{- include "aisix.labels" . | nindent 4 }} + {{- include "aisix.selectorLabels" . | nindent 4 }} + {{- with .Values.metrics.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: ClusterIP + ports: + - name: metrics + port: {{ .Values.metrics.service.port }} + targetPort: metrics + protocol: TCP + selector: + {{- include "aisix.selectorLabels" . | nindent 4 }} +{{- end }} diff --git a/charts/aisix/templates/service.yaml b/charts/aisix/templates/service.yaml new file mode 100644 index 00000000..947a3b01 --- /dev/null +++ b/charts/aisix/templates/service.yaml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "aisix.fullname" . }} + labels: + {{- include "aisix.labels" . | nindent 4 }} + {{- include "aisix.selectorLabels" . | nindent 4 }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + {{- with .Values.service.externalTrafficPolicy }} + externalTrafficPolicy: {{ . }} + {{- end }} + ports: + - name: proxy + port: {{ .Values.service.port }} + targetPort: proxy + protocol: TCP + {{- if and .Values.service.nodePort (ne (toString .Values.service.type) "ClusterIP") }} + nodePort: {{ .Values.service.nodePort }} + {{- end }} + selector: + {{- include "aisix.selectorLabels" . | nindent 4 }} diff --git a/charts/aisix/templates/serviceaccount.yaml b/charts/aisix/templates/serviceaccount.yaml new file mode 100644 index 00000000..306e227f --- /dev/null +++ b/charts/aisix/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "aisix.serviceAccountName" . }} + labels: + {{- include "aisix.labels" . | nindent 4 }} + {{- include "aisix.selectorLabels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +{{- end }} diff --git a/charts/aisix/templates/servicemonitor.yaml b/charts/aisix/templates/servicemonitor.yaml new file mode 100644 index 00000000..b37e6745 --- /dev/null +++ b/charts/aisix/templates/servicemonitor.yaml @@ -0,0 +1,35 @@ +{{- if and .Values.metrics.enabled .Values.metrics.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: {{ include "aisix.fullname" . }} + namespace: {{ .Values.metrics.serviceMonitor.namespace | default .Release.Namespace }} + labels: + {{- include "aisix.labels" . | nindent 4 }} + {{- include "aisix.selectorLabels" . | nindent 4 }} + {{- with .Values.metrics.serviceMonitor.labels }} + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + namespaceSelector: + matchNames: + - {{ .Release.Namespace }} + selector: + matchLabels: + {{- include "aisix.selectorLabels" . | nindent 6 }} + endpoints: + - port: metrics + path: /metrics + interval: {{ .Values.metrics.serviceMonitor.interval }} + {{- with .Values.metrics.serviceMonitor.scrapeTimeout }} + scrapeTimeout: {{ . }} + {{- end }} + {{- with .Values.metrics.serviceMonitor.relabelings }} + relabelings: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.metrics.serviceMonitor.metricRelabelings }} + metricRelabelings: + {{- toYaml . | nindent 8 }} + {{- end }} +{{- end }} diff --git a/charts/aisix/values.yaml b/charts/aisix/values.yaml new file mode 100644 index 00000000..b68e9439 --- /dev/null +++ b/charts/aisix/values.yaml @@ -0,0 +1,283 @@ +global: + # -- Image pull secrets applied to every pod created by this chart + imagePullSecrets: [] + +# -- Number of gateway replicas. Ignored once `autoscaling.enabled` or +# `keda.enabled` is true — the autoscaler owns the replica count from then on +# and the Deployment omits `spec.replicas` so `helm upgrade` cannot reset it. +replicaCount: 2 + +# -- Override the chart name used in resource names +nameOverride: "" +# -- Override the fully qualified resource name prefix +fullnameOverride: "" + +image: + # -- Gateway image repository + repository: docker.io/api7/aisix + # -- Image pull policy + pullPolicy: IfNotPresent + # -- Image tag. Empty resolves to the chart `appVersion` + tag: "" + +## The gateway runs as an AISIX Cloud data plane: it authenticates to the +## control plane's data-plane manager with the mTLS bundle issued from +## **Data planes** in the console, then reads its configuration from the +## control plane. Everything in this section comes from that view. +controlPlane: + # -- Data-plane manager mTLS endpoint the gateway connects out to, e.g. + # `https://dpm.example.com:7944`. Required. + baseURL: "" + # -- Control-plane etcd endpoint as bare `host:port`. Leave empty unless the + # control plane publishes an etcd endpoint distinct from `baseURL` + etcdEndpoint: "" + # -- Heartbeat interval in seconds. The control plane marks a gateway + # connected on its first heartbeat. Clamped to [5, 300] by the gateway + heartbeatIntervalSeconds: 15 + certificate: + # -- Read the bundle from an existing Secret instead of the PEM values + # below. Recommended: it keeps the private key out of your values file + existingSecret: "" + # -- Secret key holding the client certificate PEM + certKey: "cert.pem" + # -- Secret key holding the private key PEM + keyKey: "key.pem" + # -- Secret key holding the CA bundle PEM + caKey: "ca.pem" + # -- Client certificate PEM. Used only when `existingSecret` is empty + cert: "" + # -- Private key PEM. Used only when `existingSecret` is empty + key: "" + # -- CA bundle PEM. Used only when `existingSecret` is empty + ca: "" + +containerPorts: + # -- Port the proxy listener binds inside the container. The image carries + # the `CAP_NET_BIND_SERVICE` file capability, so a privileged port works + # without running as root — see `securityContext` below + proxy: 3000 + # -- Port the Prometheus metrics listener binds inside the container + metrics: 9090 + +service: + # -- Proxy Service type + type: ClusterIP + # -- Proxy Service port + port: 80 + # -- Proxy Service nodePort, when `service.type` is NodePort or LoadBalancer + nodePort: "" + # -- Extra annotations for the proxy Service, e.g. cloud load-balancer settings + annotations: {} + # -- `externalTrafficPolicy` for the proxy Service. `Local` preserves the + # client source IP on NodePort / LoadBalancer types + externalTrafficPolicy: "" + +metrics: + # -- Publish the gateway's Prometheus metrics on a separate ClusterIP + # Service, so scraping never rides the (possibly public) proxy Service + enabled: true + service: + # -- Metrics Service port + port: 9090 + # -- Extra annotations for the metrics Service + annotations: {} + serviceMonitor: + # -- Create a Prometheus Operator ServiceMonitor for the metrics Service + enabled: false + # -- Namespace to create the ServiceMonitor in. Empty uses the release namespace + namespace: "" + # -- Scrape interval + interval: 30s + # -- Scrape timeout. Empty leaves the Prometheus default + scrapeTimeout: "" + # -- Extra labels, e.g. the `release` label your Prometheus selects on + labels: {} + # -- Scrape-time relabeling rules + relabelings: [] + # -- Metric relabeling rules + metricRelabelings: [] + +## Rate-limit counters are per-replica by default, so N replicas enforce N× +## every configured limit. Point the gateway at a shared Redis before running +## more than one replica — including any replica an autoscaler adds. +rateLimit: + # -- Rate-limit counter backend: `memory` (per-replica) or `redis` (shared) + backend: memory + redis: + # -- Redis connection URL, e.g. `redis://redis.default.svc:6379`. Required + # when `rateLimit.backend` is redis and `existingSecret` is empty + url: "" + # -- Read the connection URL from an existing Secret instead, so a URL + # carrying a password stays out of your values file + existingSecret: "" + # -- Secret key holding the connection URL + existingSecretKey: "redis-url" + +## HorizontalPodAutoscaler. Scaling is safe by construction: a new replica is +## kept out of the Service until `/readyz` passes, which happens only after it +## has loaded its configuration from the control plane. +## +## Requires a metrics source for the chosen metrics (metrics-server for the +## CPU / memory targets below). Mutually exclusive with `keda.enabled`. +autoscaling: + # -- Create a HorizontalPodAutoscaler for the gateway Deployment + enabled: false + # -- Lower replica bound + minReplicas: 2 + # -- Upper replica bound + maxReplicas: 10 + # -- Target average CPU utilization, in percent of the CPU request. Set to + # null to drop the CPU metric + targetCPUUtilizationPercentage: 70 + # -- Target average memory utilization, in percent of the memory request. + # Null by default: gateway memory tracks in-flight streams more than load + targetMemoryUtilizationPercentage: null + # -- Extra `spec.metrics` entries appended verbatim — Pods / Object / + # External metrics such as a Prometheus adapter series + extraMetrics: [] + # -- `spec.behavior` for the HPA. Empty uses the Kubernetes defaults + # (immediate scale-up, 5-minute scale-down stabilization). A gateway that + # carries long streaming responses usually wants a gentler scale-down, e.g. + # `scaleDown: {policies: [{type: Pods, value: 1, periodSeconds: 60}]}` + behavior: {} + +## KEDA ScaledObject, for scaling on request-driven signals rather than +## CPU / memory — e.g. a Prometheus query over the gateway's own +## `aisix_llm_requests_total`. Requires KEDA in the cluster. +## Mutually exclusive with `autoscaling.enabled`. +keda: + # -- Create a KEDA ScaledObject for the gateway Deployment + enabled: false + # -- Lower replica bound + minReplicas: 2 + # -- Upper replica bound + maxReplicas: 10 + # -- How often KEDA evaluates the triggers, in seconds + pollingInterval: 15 + # -- Seconds to wait after the last trigger fires before scaling down + cooldownPeriod: 300 + # -- Restore the original replica count when the ScaledObject is deleted + restoreToOriginalReplicaCount: false + # -- Replica count to fall back to when a trigger source is unreachable, + # e.g. `{failureThreshold: 3, replicas: 4}` + fallback: {} + # -- Extra annotations for the ScaledObject + annotations: {} + # -- KEDA triggers. Required when `keda.enabled` is true. For example: + # `[{type: prometheus, metadata: {serverAddress: "http://prometheus:9090", + # query: "sum(rate(aisix_llm_requests_total[2m]))", threshold: "100"}}]` + triggers: [] + # -- `behavior` for the HPA KEDA creates. Empty uses the Kubernetes defaults + behavior: {} + +podDisruptionBudget: + # -- Create a PodDisruptionBudget so voluntary disruptions (node drains, + # cluster upgrades) cannot take the whole gateway down at once + enabled: false + # -- Minimum available pods. Takes precedence over `maxUnavailable` + minAvailable: "" + # -- Maximum unavailable pods + maxUnavailable: 1 + +serviceAccount: + # -- Create a ServiceAccount for the gateway + create: true + # -- ServiceAccount name. Defaults to the release fullname + name: "" + # -- ServiceAccount annotations + annotations: {} + +## No CPU limit by default: CFS throttling adds tail latency on a proxy and +## suppresses the very CPU signal the HorizontalPodAutoscaler scales on. The +## CPU *request* is mandatory for CPU-target autoscaling and is set below. +resources: + requests: + cpu: 500m + memory: 256Mi + limits: + memory: 1Gi + +## The image already runs as uid 10001. `runAsUser` is repeated here because +## the image declares its user by name, and the kubelet cannot verify a named +## user against `runAsNonRoot`. +podSecurityContext: + runAsNonRoot: true + runAsUser: 10001 + # fsGroup owns the state directory emptyDir so the gateway can write its + # mTLS material, gateway id, and configuration snapshot under a read-only + # root filesystem. + fsGroup: 10001 + seccompProfile: + type: RuntimeDefault + +## NET_BIND_SERVICE is added back after dropping ALL because the gateway +## binary carries `cap_net_bind_service=+ep` as a file capability (so it can +## bind privileged ports without root). With that capability's effective bit +## set, exec fails outright if the capability is missing from the container's +## bounding set — dropping ALL without adding it back yields a pod that never +## starts. +securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + add: + - NET_BIND_SERVICE + +## Probes hit the proxy listener. `/readyz` reports 503 until the gateway has +## applied configuration from the control plane and again while it drains, so +## a scaled-up replica joins the Service only once it can actually serve, and +## a scaled-down one leaves it before it stops. +startupProbe: + # -- Gate liveness and readiness until the proxy listener is bound + enabled: true + periodSeconds: 2 + failureThreshold: 30 +readinessProbe: + enabled: true + periodSeconds: 3 + failureThreshold: 3 +livenessProbe: + enabled: true + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 3 + +# -- Seconds the gateway may take to drain in-flight requests after SIGTERM. +# It drains without a deadline of its own, so this value is the real cap — and +# a streaming LLM response can run for minutes. The Kubernetes default of 30s +# would cut those connections during a scale-down or a rolling update. +terminationGracePeriodSeconds: 120 + +# -- Seconds to sleep in a `preStop` hook before the gateway receives SIGTERM. +# Endpoint removal and SIGTERM are concurrent, so without this pause a +# terminating pod can still be handed new connections by a kube-proxy that has +# not caught up. Set to 0 to drop the hook. +preStopSleepSeconds: 5 + +# -- Extra environment variables for the gateway container. Every gateway +# configuration field is reachable as `AISIX_
__` +extraEnvVars: [] + +# -- Extra volumes for the gateway pod +extraVolumes: [] +# -- Extra volume mounts for the gateway container +extraVolumeMounts: [] + +# -- Annotations for the gateway pods +podAnnotations: {} +# -- Labels for the gateway pods +podLabels: {} +# -- Node selector for the gateway pods +nodeSelector: {} +# -- Tolerations for the gateway pods +tolerations: [] +# -- Affinity rules for the gateway pods +affinity: {} +# -- Topology spread constraints, e.g. to spread replicas across zones +topologySpreadConstraints: [] +# -- Deployment update strategy +updateStrategy: {} +# -- Pod priority class +priorityClassName: "" From 2843f3e775b22a2ba60933c4128ba32838e83b4f Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 29 Jul 2026 22:27:56 +0800 Subject: [PATCH 2/5] fix(aisix): probe /status/ready for readiness, not the proxy's /readyz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /readyz withdraws an instance whose configuration watch has gone five minutes without an *event*, and an idle environment produces no events — so every replica reported itself unready five minutes after install and Kubernetes emptied the Service. Observed on a real cluster: /livez 200, /status/config reporting the etcd watch connected, /readyz 503, pods 0/1 and the autoscaler adding replicas that could never become Ready either. /status/ready on the metrics listener is the configuration-source gate the product documents for exactly this use: 503 until the first valid configuration is applied, 200 from then on. Same protection against serving before config arrives, without the idle-timeout cliff. --- charts/aisix/README.md | 17 +++++++++-------- charts/aisix/README.md.gotmpl | 17 +++++++++-------- charts/aisix/templates/deployment.yaml | 18 ++++++++++++------ charts/aisix/values.yaml | 12 ++++++------ 4 files changed, 36 insertions(+), 28 deletions(-) diff --git a/charts/aisix/README.md b/charts/aisix/README.md index 2591879d..8a6c533d 100644 --- a/charts/aisix/README.md +++ b/charts/aisix/README.md @@ -87,14 +87,15 @@ autoscaling: ``` Requires `metrics-server` in the cluster. A replica the autoscaler adds is kept out -of the Service until `/readyz` passes, which happens only after it has loaded its -configuration from the control plane — so a scaling event never routes traffic to a -gateway that cannot serve it. - -Scale-down is equally guarded: the gateway stops reporting ready, then drains -in-flight requests. Because a streaming response can run for minutes, -`terminationGracePeriodSeconds` defaults to 120 rather than the Kubernetes default -of 30. Raise it if your workloads stream for longer. +of the Service until its readiness probe passes, which happens only after it has +loaded its configuration from the control plane — so a scaling event never routes +traffic to a gateway that cannot serve it. + +Scale-down is guarded from the other side: a terminating pod leaves the Service +first, pauses while that removal propagates, then drains in-flight requests. +Because a streaming response can run for minutes, `terminationGracePeriodSeconds` +defaults to 120 rather than the Kubernetes default of 30. Raise it if your +workloads stream for longer. ### Autoscale on request load with KEDA diff --git a/charts/aisix/README.md.gotmpl b/charts/aisix/README.md.gotmpl index 25719a8c..e9991dc1 100644 --- a/charts/aisix/README.md.gotmpl +++ b/charts/aisix/README.md.gotmpl @@ -81,14 +81,15 @@ autoscaling: ``` Requires `metrics-server` in the cluster. A replica the autoscaler adds is kept out -of the Service until `/readyz` passes, which happens only after it has loaded its -configuration from the control plane — so a scaling event never routes traffic to a -gateway that cannot serve it. - -Scale-down is equally guarded: the gateway stops reporting ready, then drains -in-flight requests. Because a streaming response can run for minutes, -`terminationGracePeriodSeconds` defaults to 120 rather than the Kubernetes default -of 30. Raise it if your workloads stream for longer. +of the Service until its readiness probe passes, which happens only after it has +loaded its configuration from the control plane — so a scaling event never routes +traffic to a gateway that cannot serve it. + +Scale-down is guarded from the other side: a terminating pod leaves the Service +first, pauses while that removal propagates, then drains in-flight requests. +Because a streaming response can run for minutes, `terminationGracePeriodSeconds` +defaults to 120 rather than the Kubernetes default of 30. Raise it if your +workloads stream for longer. ### Autoscale on request load with KEDA diff --git a/charts/aisix/templates/deployment.yaml b/charts/aisix/templates/deployment.yaml index 2bfd4467..6c74504c 100644 --- a/charts/aisix/templates/deployment.yaml +++ b/charts/aisix/templates/deployment.yaml @@ -120,14 +120,20 @@ spec: failureThreshold: {{ .Values.startupProbe.failureThreshold }} {{- end }} {{- if .Values.readinessProbe.enabled }} - # /readyz is 503 until configuration has been applied from the - # control plane, and again once draining begins — so a new replica - # joins the Service only when it can serve, and a terminating one - # leaves it before it stops accepting connections. + # /status/ready on the metrics listener is the configuration-source + # readiness gate: 503 until the control plane's configuration has + # been applied, 200 from then on. A replica the autoscaler adds + # therefore joins the Service only once it can actually serve. + # + # Not the proxy listener's /readyz: that one also withdraws an + # instance whose configuration watch has gone five minutes without + # an event, which an idle environment does routinely — every + # replica would leave the Service while perfectly healthy. See + # https://docs.api7.ai/ai-gateway/reference/config-status. readinessProbe: httpGet: - path: /readyz - port: proxy + path: /status/ready + port: metrics periodSeconds: {{ .Values.readinessProbe.periodSeconds }} failureThreshold: {{ .Values.readinessProbe.failureThreshold }} {{- end }} diff --git a/charts/aisix/values.yaml b/charts/aisix/values.yaml index b68e9439..3417e890 100644 --- a/charts/aisix/values.yaml +++ b/charts/aisix/values.yaml @@ -114,8 +114,8 @@ rateLimit: existingSecretKey: "redis-url" ## HorizontalPodAutoscaler. Scaling is safe by construction: a new replica is -## kept out of the Service until `/readyz` passes, which happens only after it -## has loaded its configuration from the control plane. +## kept out of the Service until its readiness probe passes, which happens +## only after it has loaded its configuration from the control plane. ## ## Requires a metrics source for the chosen metrics (metrics-server for the ## CPU / memory targets below). Mutually exclusive with `keda.enabled`. @@ -225,10 +225,10 @@ securityContext: add: - NET_BIND_SERVICE -## Probes hit the proxy listener. `/readyz` reports 503 until the gateway has -## applied configuration from the control plane and again while it drains, so -## a scaled-up replica joins the Service only once it can actually serve, and -## a scaled-down one leaves it before it stops. +## Liveness and startup probe the proxy listener's `/livez`; readiness probes +## `/status/ready` on the metrics listener, which reports 503 until the +## gateway has applied configuration from the control plane. A scaled-up +## replica therefore joins the Service only once it can actually serve. startupProbe: # -- Gate liveness and readiness until the proxy listener is bound enabled: true From c21670aae00a9da106a15daf3852bb7752597286 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 29 Jul 2026 22:39:11 +0800 Subject: [PATCH 3/5] docs(aisix): note why the startup budget must exceed the gateway's boot retries --- charts/aisix/README.md | 2 +- charts/aisix/values.yaml | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/charts/aisix/README.md b/charts/aisix/README.md index 8a6c533d..9850a350 100644 --- a/charts/aisix/README.md +++ b/charts/aisix/README.md @@ -280,7 +280,7 @@ extraEnvVars: | serviceAccount.annotations | object | `{}` | ServiceAccount annotations | | serviceAccount.create | bool | `true` | Create a ServiceAccount for the gateway | | serviceAccount.name | string | `""` | ServiceAccount name. Defaults to the release fullname | -| startupProbe.enabled | bool | `true` | Gate liveness and readiness until the proxy listener is bound | +| startupProbe.enabled | bool | `true` | Gate liveness and readiness until the proxy listener is bound. The budget here (period x threshold) must stay longer than the gateway's own boot retries — it connects to the control plane before it binds, retrying for about 25s — or a recoverable boot race becomes a crash loop. | | startupProbe.failureThreshold | int | `30` | | | startupProbe.periodSeconds | int | `2` | | | terminationGracePeriodSeconds | int | `120` | Seconds the gateway may take to drain in-flight requests after SIGTERM. It drains without a deadline of its own, so this value is the real cap — and a streaming LLM response can run for minutes. The Kubernetes default of 30s would cut those connections during a scale-down or a rolling update. | diff --git a/charts/aisix/values.yaml b/charts/aisix/values.yaml index 3417e890..2597cf3c 100644 --- a/charts/aisix/values.yaml +++ b/charts/aisix/values.yaml @@ -230,7 +230,10 @@ securityContext: ## gateway has applied configuration from the control plane. A scaled-up ## replica therefore joins the Service only once it can actually serve. startupProbe: - # -- Gate liveness and readiness until the proxy listener is bound + # -- Gate liveness and readiness until the proxy listener is bound. The + # budget here (period x threshold) must stay longer than the gateway's own + # boot retries — it connects to the control plane before it binds, retrying + # for about 25s — or a recoverable boot race becomes a crash loop. enabled: true periodSeconds: 2 failureThreshold: 30 From d982806f0a24e63e9800f2f103a79739b3957a3f Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 29 Jul 2026 22:42:43 +0800 Subject: [PATCH 4/5] fix(aisix): omit the pod annotations block when there is nothing to put in it With an existingSecret and no podAnnotations the template emitted a bare `annotations:` key followed by a comment about a checksum it had just skipped. --- charts/aisix/templates/deployment.yaml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/charts/aisix/templates/deployment.yaml b/charts/aisix/templates/deployment.yaml index 6c74504c..a19828a7 100644 --- a/charts/aisix/templates/deployment.yaml +++ b/charts/aisix/templates/deployment.yaml @@ -18,15 +18,18 @@ spec: {{- include "aisix.selectorLabels" . | nindent 6 }} template: metadata: + {{- if or (not .Values.controlPlane.certificate.existingSecret) .Values.podAnnotations }} annotations: - # Roll the pods when the certificate bundle changes, so a rotated - # bundle takes effect without a manual restart. {{- if not .Values.controlPlane.certificate.existingSecret }} + # Roll the pods when the chart-managed bundle changes, so a rotated + # certificate takes effect without a manual restart. An existingSecret + # has its own lifecycle — restart the Deployment after rotating it. checksum/mtls: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} {{- end }} {{- with .Values.podAnnotations }} {{- toYaml . | nindent 8 }} {{- end }} + {{- end }} labels: {{- include "aisix.labels" . | nindent 8 }} {{- include "aisix.selectorLabels" . | nindent 8 }} From 5ec2b353adc4806d851775297fea1a3db3a8f1c4 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 29 Jul 2026 23:06:58 +0800 Subject: [PATCH 5/5] docs(aisix): point the readiness-probe note at the /readyz fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The idle-withdrawal behaviour is a bug being fixed in api7/aisix#841, not something inherent to /readyz — say so, and say when this probe moves back. --- charts/aisix/templates/deployment.yaml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/charts/aisix/templates/deployment.yaml b/charts/aisix/templates/deployment.yaml index a19828a7..176dd1ab 100644 --- a/charts/aisix/templates/deployment.yaml +++ b/charts/aisix/templates/deployment.yaml @@ -128,11 +128,12 @@ spec: # been applied, 200 from then on. A replica the autoscaler adds # therefore joins the Service only once it can actually serve. # - # Not the proxy listener's /readyz: that one also withdraws an - # instance whose configuration watch has gone five minutes without - # an event, which an idle environment does routinely — every - # replica would leave the Service while perfectly healthy. See - # https://docs.api7.ai/ai-gateway/reference/config-status. + # Not the proxy listener's /readyz: up to and including the + # appVersion this chart pins, that one also withdraws an instance + # whose configuration watch has gone five minutes without an event, + # which an idle environment does routinely — every replica leaves + # the Service while perfectly healthy. Fixed in api7/aisix#841; + # switch this probe to /readyz once appVersion carries that fix. readinessProbe: httpGet: path: /status/ready