Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ For **independently-versioned sub-charts** (`ingress-controller`, `developer-por

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.

A fix that is **chart-only** — no new image — publishes by bumping that chart's `version` alone and leaving `appVersion` on the release it deploys, so the two charts may carry different `version`s while their `appVersion`s stay equal (which is the invariant that actually matters). `version` can therefore run **ahead** of `appVersion`, exactly as it may on the EE charts. Consequence for the next release: take a `version` **above the highest already published for that chart**, not simply equal to the app version — `CR_SKIP_EXISTING` skips an already-released number and the workflow still reports success, so a collision publishes nothing and says nothing. `charts/aisix` went to `0.10.1` on `appVersion 0.10.0` this way (#354).

## Release decision

When releasing the chart for EE `X.Y.Z`:
Expand Down
2 changes: 1 addition & 1 deletion charts/aisix/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ apiVersion: v2
name: aisix
description: Helm chart for the AISIX AI gateway data plane
type: application
version: 0.10.0
version: 0.10.1
appVersion: "0.10.0"

keywords:
Expand Down
59 changes: 51 additions & 8 deletions charts/aisix/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# aisix

![Version: 0.10.0](https://img.shields.io/badge/Version-0.10.0-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.10.0](https://img.shields.io/badge/AppVersion-0.10.0-informational?style=flat-square)
![Version: 0.10.1](https://img.shields.io/badge/Version-0.10.1-informational?style=flat-square) ![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat-square) ![AppVersion: 0.10.0](https://img.shields.io/badge/AppVersion-0.10.0-informational?style=flat-square)

Helm chart for the AISIX AI gateway data plane

Expand Down Expand Up @@ -71,6 +71,53 @@ certificate.
helm delete aisix --namespace aisix
```

## Termination and draining

`terminationGracePeriodSeconds` defaults to 1230 seconds, far above the
Kubernetes default of 30. It is sized to protect request success rate across a
rolling update; the trade-off is that a rolling update can take longer.

On SIGTERM the gateway answers `/readyz` with 503 and keeps serving. It stops
accepting only once nothing is left in flight, and it drains with no deadline of
its own — so this value is the real cap on the whole sequence, and the `preStop`
sleep counts against it. When the cap expires Kubernetes sends SIGKILL, and every
request still in flight fails in the caller's hands.

The default is derived from how HTTP clients behave, not from any particular
workload. The gateway retires a keep-alive connection by marking its response
`Connection: close`, so the client learns of the retirement in a response it is
already receiving and the close that follows cannot race with a request being
dispatched onto that connection. What the gateway will not do is close a
connection the client has not been told about, which is the race a blind
server-side close creates.

Streaming responses are the case that does not fit. A stream that was already
sending when SIGTERM arrived has its headers on the wire, and HTTP/1.1 offers no
way to mark a connection retired after that. It runs to completion and goes back
to the client's pool unmarked; the client may reuse it once, and that response is
generated during the drain, carries `Connection: close`, and ends the chain
there.

So the drain has to cover two chained requests rather than one, and the default
budgets a ten-minute request for each — the timeout Claude Code, the Anthropic
SDKs, and the OpenAI Python and Node SDKs all default to — plus the 30s `preStop`
sleep. Client retries do not extend it: a retry is a new request, either on a
fresh connection the balancer routes to a pod that is not terminating, or on the
one reuse already counted here.

Treat it as a budget rather than a guarantee. A client's timeout reliably bounds
a non-streaming call, but it need not bound a stream that keeps producing output
— httpx, which the OpenAI Python SDK uses, measures inactivity between chunks
rather than total duration. A response still running when the grace period
expires is cut, so raise the value if your workloads stream for longer than it
allows.

Raising the value costs nothing while nothing runs that long: a pod exits as soon
as its last request finishes, so this is a ceiling and not a duration. Lower it
if your callers use a shorter timeout — the same arithmetic with a five-minute
client timeout gives 630 — or if you would rather bound how long a rolling update
may take, and accept that the longest streaming responses are cut.

## Configuration examples

The full list of values is in the [Parameters](#parameters) table below. Put these
Expand Down Expand Up @@ -108,12 +155,8 @@ a TCP check cannot see readiness at all, so the only signal it ever gets is the
listener closing, which is the very event the drain exists to avoid.

Only then does the gateway stop accepting, and only once nothing is left in
flight. In-flight requests drain with no deadline of the gateway's own, so
`terminationGracePeriodSeconds` is the real cap on the whole sequence — the
`preStop` sleep counts against it too. It defaults to 180: 30s of `preStop`, 30s
of drain window, and 120s left for in-flight requests, which matters because a
streaming response can run for minutes. Raise it if your workloads stream for
longer.
flight. What caps the whole sequence is `terminationGracePeriodSeconds`, covered
in [Termination and draining](#termination-and-draining) above.

### Autoscale on request load with KEDA

Expand Down Expand Up @@ -301,7 +344,7 @@ extraEnvVars:
| 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 | `180` | Seconds the whole termination sequence may take, from the pod being marked for deletion to SIGKILL. It covers the `preStop` sleep, the gateway's own drain window, and the in-flight drain that follows — the gateway 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 | int | `1230` | Seconds the whole termination sequence may take, from the pod being marked for deletion to SIGKILL. It covers the `preStop` sleep, the gateway's own drain window, and the in-flight drain that follows — the gateway drains without a deadline of its own, so this value is the real cap. The default is sized to protect request success rate across a rolling update, and the trade-off is that a rolling update can take longer: it budgets two chained requests at the ten-minute timeout mainstream agent clients default to, plus the `preStop` sleep. It is a budget, not a guarantee — a response still running when it expires is cut. See "Termination and draining" in the README. |
| tolerations | list | `[]` | Tolerations for the gateway pods |
| topologySpreadConstraints | list | `[]` | Topology spread constraints, e.g. to spread replicas across zones |
| updateStrategy | object | `{}` | Deployment update strategy |
55 changes: 49 additions & 6 deletions charts/aisix/README.md.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,53 @@ certificate.
helm delete aisix --namespace aisix
```

## Termination and draining

`terminationGracePeriodSeconds` defaults to 1230 seconds, far above the
Kubernetes default of 30. It is sized to protect request success rate across a
rolling update; the trade-off is that a rolling update can take longer.

On SIGTERM the gateway answers `/readyz` with 503 and keeps serving. It stops
accepting only once nothing is left in flight, and it drains with no deadline of
its own — so this value is the real cap on the whole sequence, and the `preStop`
sleep counts against it. When the cap expires Kubernetes sends SIGKILL, and every
request still in flight fails in the caller's hands.

The default is derived from how HTTP clients behave, not from any particular
workload. The gateway retires a keep-alive connection by marking its response
`Connection: close`, so the client learns of the retirement in a response it is
already receiving and the close that follows cannot race with a request being
dispatched onto that connection. What the gateway will not do is close a
connection the client has not been told about, which is the race a blind
server-side close creates.

Streaming responses are the case that does not fit. A stream that was already
sending when SIGTERM arrived has its headers on the wire, and HTTP/1.1 offers no
way to mark a connection retired after that. It runs to completion and goes back
to the client's pool unmarked; the client may reuse it once, and that response is
generated during the drain, carries `Connection: close`, and ends the chain
there.

So the drain has to cover two chained requests rather than one, and the default
budgets a ten-minute request for each — the timeout Claude Code, the Anthropic
SDKs, and the OpenAI Python and Node SDKs all default to — plus the 30s `preStop`
sleep. Client retries do not extend it: a retry is a new request, either on a
fresh connection the balancer routes to a pod that is not terminating, or on the
one reuse already counted here.

Treat it as a budget rather than a guarantee. A client's timeout reliably bounds
a non-streaming call, but it need not bound a stream that keeps producing output
— httpx, which the OpenAI Python SDK uses, measures inactivity between chunks
rather than total duration. A response still running when the grace period
expires is cut, so raise the value if your workloads stream for longer than it
allows.

Raising the value costs nothing while nothing runs that long: a pod exits as soon
as its last request finishes, so this is a ceiling and not a duration. Lower it
if your callers use a shorter timeout — the same arithmetic with a five-minute
client timeout gives 630 — or if you would rather bound how long a rolling update
may take, and accept that the longest streaming responses are cut.

## Configuration examples

The full list of values is in the [Parameters](#parameters) table below. Put these
Expand Down Expand Up @@ -102,12 +149,8 @@ a TCP check cannot see readiness at all, so the only signal it ever gets is the
listener closing, which is the very event the drain exists to avoid.

Only then does the gateway stop accepting, and only once nothing is left in
flight. In-flight requests drain with no deadline of the gateway's own, so
`terminationGracePeriodSeconds` is the real cap on the whole sequence — the
`preStop` sleep counts against it too. It defaults to 180: 30s of `preStop`, 30s
of drain window, and 120s left for in-flight requests, which matters because a
streaming response can run for minutes. Raise it if your workloads stream for
longer.
flight. What caps the whole sequence is `terminationGracePeriodSeconds`, covered
in [Termination and draining](#termination-and-draining) above.

### Autoscale on request load with KEDA

Expand Down
11 changes: 7 additions & 4 deletions charts/aisix/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -250,10 +250,13 @@ livenessProbe:
# -- Seconds the whole termination sequence may take, from the pod being
# marked for deletion to SIGKILL. It covers the `preStop` sleep, the gateway's
# own drain window, and the in-flight drain that follows — the gateway 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: 180
# without a deadline of its own, so this value is the real cap. The default is
# sized to protect request success rate across a rolling update, and the
# trade-off is that a rolling update can take longer: it budgets two chained
# requests at the ten-minute timeout mainstream agent clients default to, plus
# the `preStop` sleep. It is a budget, not a guarantee — a response still running
# when it expires is cut. See "Termination and draining" in the README.
terminationGracePeriodSeconds: 1230

# -- Seconds to sleep in a `preStop` hook before the gateway receives SIGTERM.
# Endpoint removal and SIGTERM are concurrent, so without this pause a
Expand Down
Loading