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
41 changes: 41 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,47 @@ jobs:
kubectl delete namespace "$ns"
'

- name: Test aisix chart with several proxy listeners
run: |
kubectl cluster-info
ns=aisix-listeners
kubectl create namespace "$ns"
# Self-signed is enough: the only TLS clients here are the kubelet's
# probes and curl -k, and neither verifies the certificate.
openssl req -x509 -newkey rsa:2048 -nodes -days 1 \
-subj "/CN=aisix.$ns.svc" \
-keyout proxy-tls.key -out proxy-tls.crt
kubectl -n "$ns" create secret tls aisix-ci-proxy-tls \
--cert=proxy-tls.crt --key=proxy-tls.key
rm -f proxy-tls.key proxy-tls.crt
docker run --rm --interactive --network host \
--name ct-aisix-listeners \
--volume $HOME/.kube/config:/root/.kube/config \
--volume $PWD:/workdir \
--workdir /workdir \
quay.io/helmpack/chart-testing:v3.10.1 sh -c '
set -e
ns=aisix-listeners
if ! helm install aisix charts/aisix --namespace "$ns" \
--values charts/aisix/ci/standalone-values.yaml \
--values charts/aisix/ci/listeners-values.yaml \
--wait --timeout 5m; then
kubectl -n "$ns" get pods -o wide || true
kubectl -n "$ns" describe pods || true
kubectl -n "$ns" logs -l app.kubernetes.io/name=aisix --tail=200 || true
exit 1
fi
# Both listeners answer /livez through the one proxy Service.
kubectl -n "$ns" run curl-https --rm --attach --restart=Never \
--image=curlimages/curl:8.11.1 --command -- \
curl -fsS -k https://aisix:443/livez
kubectl -n "$ns" run curl-http --rm --attach --restart=Never \
--image=curlimages/curl:8.11.1 --command -- \
curl -fsS http://aisix:80/livez
helm uninstall aisix --namespace "$ns"
kubectl delete namespace "$ns"
'

- name: Setup Go
uses: actions/setup-go@v5
with:
Expand Down
65 changes: 62 additions & 3 deletions charts/aisix/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,64 @@ service:
externalTrafficPolicy: Local
```

### Serve HTTPS and plain HTTP together

By default the gateway serves one plain-HTTP proxy listener, on
`containerPorts.proxy`, published as `service.port`. Set `listeners` to serve
several at once — each on its own port, with its own TLS:

```yaml
listeners:
- name: https # port name, shared by the container port and the Service port
containerPort: 3443
servicePort: 443
nodePort: 0 # optional; only for non-ClusterIP Service types
tls:
secretName: aisix-proxy-tls # kubernetes.io/tls Secret (keys tls.crt / tls.key)
- name: http
containerPort: 3000
servicePort: 80
```

A non-empty `listeners` is the complete set of proxy listeners and replaces the
single default one: nothing binds `containerPorts.proxy`, and `service.port` /
`service.nodePort` are not read — each entry carries its own. There is still one
proxy Service; it publishes a port per entry. Every listener serves the same
routes, `/livez` and `/readyz` included, so the probes target the first entry
(over HTTPS when that entry terminates TLS; the kubelet does not verify the
certificate). This needs a gateway image that supports `proxy.listeners`.

TLS material is read from files, so each TLS listener needs a
`kubernetes.io/tls` Secret; the chart mounts it read-only at
`/etc/aisix/tls/<name>`. Create it from a certificate and key you already have:

```sh
kubectl -n aisix create secret tls aisix-proxy-tls \
--cert=./tls.crt --key=./tls.key
```

Or have [cert-manager](https://cert-manager.io) issue and renew it into the same
Secret:

```yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: aisix-proxy-tls
namespace: aisix
spec:
secretName: aisix-proxy-tls
dnsNames:
- gateway.example.com
issuerRef:
name: letsencrypt
kind: ClusterIssuer
```

A rotated certificate reaches the gateway as a changed file in that mount; roll
the pods to pick it up with
`kubectl rollout restart deploy/<release>-aisix -n <namespace>`.

### Bind a privileged port

The image carries the `CAP_NET_BIND_SERVICE` file capability, so the gateway binds
Expand Down Expand Up @@ -381,7 +439,7 @@ extraEnvVars:
| 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 |
| containerPorts.proxy | int | `3000` | Port the proxy listener binds inside the container. Nothing binds it when `listeners` is set — that list then carries every proxy port, and the gateway keeps requiring this address only to ignore it. 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 |
Expand Down Expand Up @@ -411,6 +469,7 @@ extraEnvVars:
| 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"}}]` |
| listeners | list | `[]` | Proxy listeners, one entry per port. Empty keeps the single plain-HTTP listener described by `containerPorts.proxy` and `service.port` — see "Serve HTTPS and plain HTTP together" above |
| livenessProbe.enabled | bool | `true` | |
| livenessProbe.failureThreshold | int | `3` | |
| livenessProbe.initialDelaySeconds | int | `10` | |
Expand Down Expand Up @@ -453,8 +512,8 @@ extraEnvVars:
| 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.nodePort | string | `""` | Proxy Service nodePort, when `service.type` is NodePort or LoadBalancer. Unused when `listeners` is set — each entry there carries its own `nodePort` |
| service.port | int | `80` | Proxy Service port. Unused when `listeners` is set — each entry there carries its own `servicePort` |
| service.type | string | `"ClusterIP"` | Proxy Service type |
| serviceAccount.annotations | object | `{}` | ServiceAccount annotations |
| serviceAccount.create | bool | `true` | Create a ServiceAccount for the gateway |
Expand Down
58 changes: 58 additions & 0 deletions charts/aisix/README.md.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,64 @@ service:
externalTrafficPolicy: Local
```

### Serve HTTPS and plain HTTP together

By default the gateway serves one plain-HTTP proxy listener, on
`containerPorts.proxy`, published as `service.port`. Set `listeners` to serve
several at once — each on its own port, with its own TLS:

```yaml
listeners:
- name: https # port name, shared by the container port and the Service port
containerPort: 3443
servicePort: 443
nodePort: 0 # optional; only for non-ClusterIP Service types
tls:
secretName: aisix-proxy-tls # kubernetes.io/tls Secret (keys tls.crt / tls.key)
- name: http
containerPort: 3000
servicePort: 80
```

A non-empty `listeners` is the complete set of proxy listeners and replaces the
single default one: nothing binds `containerPorts.proxy`, and `service.port` /
`service.nodePort` are not read — each entry carries its own. There is still one
proxy Service; it publishes a port per entry. Every listener serves the same
routes, `/livez` and `/readyz` included, so the probes target the first entry
(over HTTPS when that entry terminates TLS; the kubelet does not verify the
certificate). This needs a gateway image that supports `proxy.listeners`.

TLS material is read from files, so each TLS listener needs a
`kubernetes.io/tls` Secret; the chart mounts it read-only at
`/etc/aisix/tls/<name>`. Create it from a certificate and key you already have:

```sh
kubectl -n aisix create secret tls aisix-proxy-tls \
--cert=./tls.crt --key=./tls.key
```

Or have [cert-manager](https://cert-manager.io) issue and renew it into the same
Secret:

```yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: aisix-proxy-tls
namespace: aisix
spec:
secretName: aisix-proxy-tls
dnsNames:
- gateway.example.com
issuerRef:
name: letsencrypt
kind: ClusterIssuer
```

A rotated certificate reaches the gateway as a changed file in that mount; roll
the pods to pick it up with
`kubectl rollout restart deploy/<release>-aisix -n <namespace>`.

### Bind a privileged port

The image carries the `CAP_NET_BIND_SERVICE` file capability, so the gateway binds
Expand Down
25 changes: 25 additions & 0 deletions charts/aisix/ci/listeners-values.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Values used by chart-testing for the multiple-proxy-listeners mode: HTTPS on
# 3443 and plain HTTP on 3000, published on Service ports 443 and 80.
#
# `ct lint` renders every file in this directory on its own, so this one also
# carries control-plane placeholders to render — the same ones as
# ci/default-values.yaml, and equally never created in a cluster. The install
# test layers this file over ci/standalone-values.yaml, which turns the control
# plane off, so only the listeners below are read there.
controlPlane:
baseURL: "https://dp-manager.example.com:7944"
certificate:
existingSecret: "aisix-gateway-certificate"

# The referenced Secret is created by the CI step before it installs the chart:
# a self-signed certificate is enough, since the probes run against the
# listener from the kubelet, which does not verify it.
listeners:
- name: https
containerPort: 3443
servicePort: 443
tls:
secretName: aisix-ci-proxy-tls
- name: http
containerPort: 3000
servicePort: 80
28 changes: 22 additions & 6 deletions charts/aisix/templates/NOTES.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
{{- $proxyPort := .Values.service.port -}}
{{- $scheme := "http" -}}
{{- $curl := "curl" -}}
{{- if .Values.listeners -}}
{{- $first := first .Values.listeners -}}
{{- $proxyPort = $first.servicePort -}}
{{- if include "aisix.proxyListenerTLS" . -}}
{{- $scheme = "https" -}}
{{- $curl = "curl -k" -}}
{{- end -}}
{{- end -}}
AISIX gateway {{ .Chart.AppVersion }} has been deployed as {{ include "aisix.fullname" . }}.

{{ if .Values.controlPlane.enabled -}}
Expand All @@ -24,21 +35,26 @@ After editing the {{ if .Values.standalone.existingSecret }}Secret{{ else }}Conf
Watch it come up:

kubectl rollout status deploy/{{ include "aisix.fullname" . }} -n {{ .Release.Namespace }}

Send a request through it:
{{ if .Values.listeners }}
It serves these proxy listeners, all carrying the same routes:
{{ range .Values.listeners }}
{{ .name }}: Service port {{ .servicePort }} -> container port {{ .containerPort }}{{ if .tls }}{{ if .tls.secretName }}, TLS from Secret {{ .tls.secretName }}{{ end }}{{ end }}
{{- end }}
{{ end }}
Send a request through it{{ if .Values.listeners }} on the {{ (first .Values.listeners).name }} listener{{ end }}:
{{- 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 <api-key>"
kubectl port-forward svc/{{ include "aisix.fullname" . }} 8080:{{ $proxyPort }} -n {{ .Release.Namespace }}
{{ $curl }} {{ $scheme }}://localhost:8080/v1/models -H "Authorization: Bearer <api-key>"
{{- 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 <api-key>"
{{ $curl }} {{ $scheme }}://$NODE_IP:$NODE_PORT/v1/models -H "Authorization: Bearer <api-key>"
{{- 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 <api-key>"
{{ $curl }} {{ $scheme }}://$LB_IP:{{ $proxyPort }}/v1/models -H "Authorization: Bearer <api-key>"
{{- end }}

{{ if .Values.autoscaling.enabled -}}
Expand Down
65 changes: 65 additions & 0 deletions charts/aisix/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,50 @@ Secret key holding the rate-limit Redis URL.
{{- end }}
{{- end }}

{{/*
Multiple proxy listeners.

`listeners` empty is the single-listener default and every one of these is
inert, so a default render is unchanged.

"aisix.proxyPortName" is the port the probes target: the first listener, or
the built-in "proxy" port.

"aisix.proxyListenerTLS" is non-empty when that first listener terminates TLS,
so the probes know to speak HTTPS to it.

"aisix.proxyListenersJson" builds the AISIX_PROXY__LISTENERS value. The gateway
takes the whole list as one JSON document — indexed environment variables are
not a form it accepts — and reads TLS material from files, so each TLS listener
points at the directory its Secret is mounted in.
*/}}
{{- define "aisix.proxyPortName" -}}
{{- if .Values.listeners }}{{ (first .Values.listeners).name }}{{ else }}proxy{{ end }}
{{- end }}

{{- define "aisix.proxyListenerTLSDir" -}}/etc/aisix/tls/{{ .name }}{{- end }}

{{- define "aisix.proxyListenerTLS" -}}
{{- if .Values.listeners }}
{{- with (first .Values.listeners).tls }}{{ if .secretName }}true{{ end }}{{ end }}
{{- end }}
{{- end }}

{{- define "aisix.proxyListenersJson" -}}
{{- $listeners := list }}
{{- range $listener := .Values.listeners }}
{{- $entry := dict "addr" (printf "0.0.0.0:%d" (int $listener.containerPort)) }}
{{- if $listener.tls }}
{{- if $listener.tls.secretName }}
{{- $dir := include "aisix.proxyListenerTLSDir" $listener }}
{{- $_ := set $entry "tls" (dict "cert_file" (printf "%s/tls.crt" $dir) "key_file" (printf "%s/tls.key" $dir)) }}
{{- end }}
{{- end }}
{{- $listeners = append $listeners $entry }}
{{- end }}
{{- toJson $listeners }}
{{- end }}

{{/*
Reject value combinations that render successfully but cannot run.
*/}}
Expand Down Expand Up @@ -158,4 +202,25 @@ Reject value combinations that render successfully but cannot run.
{{- fail "rateLimit.backend=redis requires rateLimit.redis.url or rateLimit.redis.existingSecret" }}
{{- end }}
{{- end }}
{{- $names := list }}
{{- $ports := list }}
{{- range $i, $listener := .Values.listeners }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{{- if not $listener.name }}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{{- fail (printf "listeners[%d].name is required: it names both the container port and the Service port" $i) }}
{{- end }}
{{- if not $listener.containerPort }}
{{- fail (printf "listeners[%d] (%s) requires containerPort" $i $listener.name) }}
{{- end }}
{{- if not $listener.servicePort }}
{{- fail (printf "listeners[%d] (%s) requires servicePort" $i $listener.name) }}
{{- end }}
{{- if has $listener.name $names }}
{{- fail (printf "listeners[%d]: duplicate name %s — listener names must be unique" $i $listener.name) }}
{{- end }}
{{- if has (int $listener.containerPort) $ports }}
{{- fail (printf "listeners[%d] (%s): duplicate containerPort %d — the gateway rejects two listeners on one address" $i $listener.name (int $listener.containerPort)) }}
{{- end }}
{{- $names = append $names $listener.name }}
{{- $ports = append $ports (int $listener.containerPort) }}
{{- end }}
{{- end }}
Loading
Loading