APISIX as a Kubernetes ingress: global rules, TLS, and OpenTelemetry
Published: 2026-02-22
APISIX replaced Traefik across all six clusters. It handles ingress routing, TLS termination, access logging to Elasticsearch, distributed tracing via OpenTelemetry, and gzip compression — all through declarative CRDs. This covers the production setup: dual ingress classes, global plugins, TLS management, otel-collector integration, and per-environment patching.
Why APISIX over Traefik
APISIX offers features that Traefik requires enterprise for:
- Plugin ecosystem: 80+ built-in plugins, including OpenTelemetry, Kafka logger, IP restriction, OIDC
- ApisixGlobalRule: apply plugins cluster-wide without touching individual routes
- gRPC proxying out of the box
- Admin API for dynamic config without restarts
The trade-off: steeper learning curve, heavier resource footprint (requires etcd or uses APISIX's own etcd).
Two ingress classes
apisix-int runs on every environment. It's the internal ingress with ingressClass: apisix. Internal services (Grafana, Kibana, app dashboards) are exposed through it on private IPs.
apisix-ext exists only on the demo cluster. It has a dedicated LoadBalancer IP and serves external domains (*.test.antonnovikov.com, *.demo.test.antonnovikov.com). External traffic from the internet goes here.
Both are deployed via HelmRelease through FluxCD. The base HelmRelease lives in fluxcd/base/apisix-int.yaml; per-environment patches override only the LoadBalancer IP and the otel-collector address.
HelmRelease base
yamlapiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: apisix-int
namespace: ingress-apisix
spec:
chart:
spec:
chart: apisix
version: "2.8.*"
sourceRef:
kind: HelmRepository
name: apisix
namespace: flux-system
retries: -1
values:
apisix:
enabled: true
kind: DaemonSet
replicaCount: 1
gateway:
type: LoadBalancer
ingress-controller:
enabled: true
apisix:
serviceNamespace: ingress-apisix
etcd:
enabled: true
replicaCount: 1
kind: DaemonSet ensures one APISIX pod per node, distributing load without needing HPA.
ApisixGlobalRule: cluster-wide plugins
Instead of attaching plugins to every route individually, ApisixGlobalRule applies them to all traffic.
Elasticsearch access logging
yamlapiVersion: apisix.apache.org/v2
kind: ApisixGlobalRule
metadata:
name: elasticsearch-logger
namespace: ingress-apisix
spec:
plugins:
- name: elasticsearch-logger
enable: true
config:
endpoint_addr: "http://infra-master.elasticsearch.svc.cluster.local:9200"
field:
index: "apisix"
pipeline: "apisix"
batch_max_size: 1000
buffer_duration: 60
All requests from all routes land in the apisix Elasticsearch index. pipeline: "apisix" runs an ES ingest pipeline that parses the raw log JSON into structured fields. Without the ingest pipeline, logs land as unstructured JSON blobs that can't be filtered in Kibana.
OpenTelemetry tracing
yamlapiVersion: apisix.apache.org/v2
kind: ApisixGlobalRule
metadata:
name: opentelemetry
namespace: ingress-apisix
spec:
plugins:
- name: opentelemetry
enable: true
config:
sampler:
name: always_on
collector:
address: "otel-collector.elasticsearch.svc.cluster.local:4318"
additional_attributes:
- apisix_service_id
- apisix_balancer_ip
- apisix_consumer
- remote_addr
- upstream_addr
- upstream_status
- upstream_response_time
Sampling is always_on. APISIX sends spans via OTLP/HTTP to otel-collector at port 4318. The collector bridges to APM Server (which only accepts gRPC).
additional_attributes adds APISIX-specific context to each span: which upstream received the request (upstream_addr), the HTTP status code (upstream_status), and the response time from the upstream. These show up as span attributes in Kibana APM and are invaluable for debugging slow routes.
Use the full FQDN for the otel-collector address. Short DNS names (otel-collector.elasticsearch.svc) sometimes fail to resolve within APISIX's internal Lua runtime.
Gzip, real-ip, and request-id
Three remaining global rules:
yaml# Real-IP: read X-Forwarded-For from load balancer
- name: real-ip
enable: true
config:
source: http_x_forwarded_for
recursive: true
# Gzip: compress responses > 1kb
- name: gzip
enable: true
config:
min_length: 1024
http_version: 1.1
buffers:
number: 32
size: 4096
types:
- text/html
- application/json
- text/plain
- application/javascript
- text/css
# Request-ID: inject X-Request-ID header
- name: request-id
enable: true
config:
header_name: X-Request-ID
include_in_response: true
real-ip with recursive: true handles double-NAT situations where there are multiple load balancers in front of APISIX.
TLS with ApisixTls
TLS certificates are managed per-environment through ApisixTls CRDs:
yamlapiVersion: apisix.apache.org/v2
kind: ApisixTls
metadata:
name: dev-antonnovikov-com
namespace: ingress-apisix
spec:
hosts:
- "*.dev.test.antonnovikov.com"
secret:
name: dev-antonnovikov-com-tls
namespace: ingress-apisix
The TLS secret contains tls.crt and tls.key, sealed with SealedSecrets. ApisixTls tells APISIX which cert to serve for which domain pattern. For wildcard certs, one ApisixTls object covers all subdomains.
For cert rotation: update the SealedSecret with the new cert, commit, push. APISIX picks up the change immediately without restart when the underlying Secret changes.
ApisixRoute example
yamlapiVersion: apisix.apache.org/v2
kind: ApisixRoute
metadata:
name: grafana
namespace: ingress-apisix
spec:
http:
- name: grafana-rule
match:
hosts:
- grafana.dev.test.antonnovikov.com
paths:
- "/*"
backends:
- serviceName: kube-prom-stack-grafana
servicePort: 80
namespace: observability
Routes can reference services in other namespaces. This avoids duplicating the route in each namespace.
Cilium L2 for LoadBalancer IPs (on-prem)
On dev and test (bare-metal k3s), LoadBalancer services get IPs from a Cilium L2 pool. The APISIX service gets a specific IP:
yamlvalues:
gateway:
type: LoadBalancer
annotations:
io.cilium/lb-ipam-ips: "172.16.57.200"
On Yandex Cloud managed clusters, the cloud load balancer assigns the IP automatically.
Patching APISIX per environment
The hub patch overrides only what changes between environments:
yaml# projects/dev/kustomization/hub/patches/apisix-int.yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: apisix-int
namespace: dev
spec:
values:
apisix:
externalIPs:
- 172.16.57.200
plugins:
- name: opentelemetry
conf:
collector:
address: "otel-collector.elasticsearch.svc.cluster.local:4318"
Suspend/resume for manual intervention
bash# Freeze APISIX on dev (e.g., for manual config testing)
flux suspend helmrelease apisix-int -n dev --context=infra-k8s
# Restore
flux resume helmrelease apisix-int -n dev --context=infra-k8s
APISIX on Yandex Cloud clusters occasionally gets stuck in an upgrade→rollback loop due to DaemonSet update ordering. Suspend + fix + resume is the reliable recovery path.
Checking APISIX plugin status
bash# List loaded plugins
kubectl exec -n ingress-apisix deploy/apisix -- \
curl -s http://localhost:9080/apisix/admin/plugins/list
# Check global rules
kubectl exec -n ingress-apisix deploy/apisix -- \
curl -s http://localhost:9080/apisix/admin/global_rules
APISIX health check
bashkubectl port-forward -n ingress-apisix svc/apisix-admin 9080:9080
curl -s http://localhost:9080/apisix/admin/upstreams | jq '.total'