OKD environment — 3 APISIX + 3 etcd
The host names, addresses and the registry on this page are examples. Our lab run used its own values in their place; replace them with yours, and take the registry address and its credentials from your G8 contact.
Three APISIX Deployment replicas and a three-member etcd StatefulSet on
OKD/OpenShift. The installation runs under the restricted-v2 SCC with no SCC
relaxation whatsoever: no anyuid, no extra grant to the ServiceAccount, no
privileged.
This document was executed on OKD 4.19. The same manifests apply on OpenShift Container Platform; the difference is which registry the images come from.
This installation was verified on a single-node OKD (SNO). Three replicas verify the shape of the manifests: StatefulSet identities, peer addresses, the quorum arithmetic, the behaviour on member loss. When the node dies, all six pods go with it.
On a multi-node cluster podAntiAffinity must be changed from preferred to
required — see section 16. That change cannot be tried on a
single-node cluster: with required, only one of the three replicas is
scheduled.
1. What we are installing
| component | version | source |
|---|---|---|
| APISIX | 3.19.0-debian | harbor.g8.dev/infra/apisix:3.19.0-debian |
| etcd | v3.5.17 | harbor.g8.dev/infra/etcd:v3.5.17 |
| object | kind | note |
|---|---|---|
etcd | StatefulSet (3) + headless Service | Each member has its own PVC |
apisix | Deployment (3) + 3 Services | All three read the same ConfigMap |
apisix | Route | Data plane only; the Admin API is not exposed |
etcd-clients-only | NetworkPolicy | Decides who may reach etcd |
etcd-backup | CronJob + PVC | Daily snapshot |
2. Prerequisites
| requirement | value | note |
|---|---|---|
| OKD / OpenShift | 4.19 | The version this was verified on |
| Storage class | A StorageClass supporting RWO | Here topolvm-provisioner |
| Harbor access | The cluster must be able to pull from Harbor | Verified below |
oc | Logged in as cluster admin | Namespaces and NetworkPolicies are created |
Harbor is signed by our own CA; if the cluster does not trust that CA, no pod can start. Verify the trust is in place:
oc get image.config.openshift.io/cluster -o jsonpath='{.spec.additionalTrustedCA}{"\n"}'
{"name":"g8-lab-registry-ca"}
If it comes back empty the CA is not configured and image pulls fail with
x509: certificate signed by unknown authority.
2.1 Ports and access
| port | component | how it is reached |
|---|---|---|
| 9080 | APISIX data plane | apisix Service + Route (from outside the cluster) |
| 9180 | APISIX Admin API | apisix-admin Service, in-cluster only. No Route |
| 9091 | APISIX Prometheus | apisix-metrics Service, in-cluster only |
| 9090 | APISIX control API | Pod-local 127.0.0.1 only. The probes target this |
| 2379 2380 | etcd client and peer | Headless Service, restricted by NetworkPolicy |
The Admin API deliberately has no Route. If it must be managed from outside
the cluster, use oc port-forward; opening a permanent Route moves the Admin API
outside the cluster.
3. Namespace
oc create namespace g8-gateway
OpenShift assigns a uid range to every namespace; pods run as a random uid from that range. The range can be read:
oc get ns g8-gateway -o jsonpath='{.metadata.annotations.openshift\.io/sa\.scc\.uid-range}{"\n"}'
1000770000/10000
4. Verifying that images can be pulled from Harbor
Do not skip this step: a pull failure only surfaces as ImagePullBackOff after
the StatefulSet and Deployment are applied, and diagnosing it gets confused with
configuration errors.
oc -n g8-gateway create deployment pulltest \
--image=harbor.g8.dev/infra/apisix:3.19.0-debian -- sleep 120
oc -n g8-gateway rollout status deploy/pulltest --timeout=180s
oc -n g8-gateway delete deploy pulltest
deployment "pulltest" successfully rolled out
5. The etcd cluster
ETCD_NAME, ETCD_ADVERTISE_CLIENT_URLS and ETCD_INITIAL_ADVERTISE_PEER_URLS
are derived from the pod name. The $(POD_NAME) substitution is done by
kubelet, not by a shell; the etcd image contains no shell
(15.4).
podManagementPolicy: Parallel is mandatory — with the default OrderedReady
the cluster never forms at all (15.1).
publishNotReadyAddresses: true is mandatory too: the members must be able to
resolve each other while none of them is Ready yet.
cat <<'EOF' | oc apply -f -
apiVersion: v1
kind: Service
metadata:
name: etcd
namespace: g8-gateway
spec:
clusterIP: None
publishNotReadyAddresses: true
selector:
app: etcd
ports:
- name: client
port: 2379
- name: peer
port: 2380
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: etcd
namespace: g8-gateway
spec:
serviceName: etcd
podManagementPolicy: Parallel
replicas: 3
selector:
matchLabels:
app: etcd
template:
metadata:
labels:
app: etcd
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: etcd
topologyKey: kubernetes.io/hostname
containers:
- name: etcd
image: harbor.g8.dev/infra/etcd:v3.5.17
command: ["/usr/local/bin/etcd"]
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
- name: ETCD_NAME
value: "$(POD_NAME)"
- name: ETCD_DATA_DIR
value: /etcd-data
- name: ETCD_LISTEN_PEER_URLS
value: http://0.0.0.0:2380
- name: ETCD_LISTEN_CLIENT_URLS
value: http://0.0.0.0:2379
- name: ETCD_ADVERTISE_CLIENT_URLS
value: http://$(POD_NAME).etcd:2379
- name: ETCD_INITIAL_ADVERTISE_PEER_URLS
value: http://$(POD_NAME).etcd:2380
- name: ETCD_INITIAL_CLUSTER
value: etcd-0=http://etcd-0.etcd:2380,etcd-1=http://etcd-1.etcd:2380,etcd-2=http://etcd-2.etcd:2380
- name: ETCD_INITIAL_CLUSTER_STATE
value: new
- name: ETCD_INITIAL_CLUSTER_TOKEN
value: g8-okd
- name: ETCD_AUTO_COMPACTION_MODE
value: periodic
- name: ETCD_AUTO_COMPACTION_RETENTION
value: "1"
- name: ETCD_QUOTA_BACKEND_BYTES
value: "2147483648"
- name: ETCD_SNAPSHOT_COUNT
value: "10000"
- name: ETCD_HEARTBEAT_INTERVAL
value: "250"
- name: ETCD_ELECTION_TIMEOUT
value: "2500"
ports:
- name: client
containerPort: 2379
- name: peer
containerPort: 2380
readinessProbe:
httpGet:
path: /readyz
port: 2379
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 10
livenessProbe:
httpGet:
path: /livez
port: 2379
initialDelaySeconds: 30
periodSeconds: 15
timeoutSeconds: 10
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
volumeMounts:
- name: data
mountPath: /etcd-data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: [ReadWriteOnce]
storageClassName: topolvm-provisioner
resources:
requests:
storage: 8Gi
EOF
oc -n g8-gateway rollout status sts/etcd --timeout=300s
6. Admin key
APISIX's documented default admin key (edd1c9f0…) is public knowledge. The key
is kept in a Secret and passed to the pod as an environment variable; it is
never written by hand into the ConfigMap.
oc -n g8-gateway create secret generic apisix-admin-key \
--from-literal=G8_APISIX_ADMIN_KEY="$(openssl rand -hex 24)"
7. APISIX
In a container environment logs go to stdout/stderr, not to files: kubelet collects and rotates them (section 16). Writing to files grows the container's ephemeral layer without bound and destroys the log when the pod is recreated.
The probes target the control port 9090, not 9080; a bare TCP connection to
9080 puts a "status":"400" line into the access log.
In the manifest below, the Route's host: line carries this lab's apps domain.
Change it to your own cluster's before applying; a Route created with the
wrong domain answers nothing from outside:
oc get ingress.config/cluster -o jsonpath='{.spec.domain}{"\n"}'
cat <<'EOF' | oc apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
name: apisix-config
namespace: g8-gateway
data:
config.yaml: |
apisix:
node_listen:
- 9080
deployment:
role: traditional
role_traditional:
config_provider: etcd
admin:
admin_listen:
ip: 0.0.0.0
port: 9180
admin_key:
- name: admin
key: "${{G8_APISIX_ADMIN_KEY}}"
role: admin
allow_admin:
- 10.128.0.0/14
- 169.254.0.0/17
- 127.0.0.0/8
etcd:
host:
- "http://etcd-0.etcd:2379"
- "http://etcd-1.etcd:2379"
- "http://etcd-2.etcd:2379"
prefix: /apisix
timeout: 30
nginx_config:
error_log: /dev/stderr
error_log_level: warn
http:
access_log: /dev/stdout
access_log_format: '{"time":"$time_iso8601","remote_addr":"$remote_addr","status":"$status","method":"$request_method","uri":"$uri","rt":"$request_time","ua":"$http_user_agent"}'
access_log_format_escape: json
plugin_attr:
prometheus:
export_addr:
ip: 0.0.0.0
port: 9091
metrics:
http_status:
extra_labels:
- route_name: $route_name
- service_name: $service_name
http_latency:
extra_labels:
- route_name: $route_name
- service_name: $service_name
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: apisix
namespace: g8-gateway
spec:
replicas: 3
selector:
matchLabels:
app: apisix
template:
metadata:
labels:
app: apisix
spec:
affinity:
podAntiAffinity:
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
labelSelector:
matchLabels:
app: apisix
topologyKey: kubernetes.io/hostname
containers:
- name: apisix
image: harbor.g8.dev/infra/apisix:3.19.0-debian
envFrom:
- secretRef:
name: apisix-admin-key
ports:
- name: http
containerPort: 9080
- name: admin
containerPort: 9180
- name: metrics
containerPort: 9091
readinessProbe:
exec:
command: ["bash", "-c", "echo > /dev/tcp/127.0.0.1/9090"]
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
livenessProbe:
exec:
command: ["bash", "-c", "echo > /dev/tcp/127.0.0.1/9090"]
initialDelaySeconds: 30
periodSeconds: 15
timeoutSeconds: 5
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "2"
memory: 1Gi
volumeMounts:
- name: config
mountPath: /usr/local/apisix/conf/config.yaml
subPath: config.yaml
readOnly: true
volumes:
- name: config
configMap:
name: apisix-config
---
apiVersion: v1
kind: Service
metadata:
name: apisix
namespace: g8-gateway
spec:
selector:
app: apisix
ports:
- name: http
port: 9080
targetPort: 9080
---
apiVersion: v1
kind: Service
metadata:
name: apisix-admin
namespace: g8-gateway
spec:
selector:
app: apisix
ports:
- name: admin
port: 9180
targetPort: 9180
---
apiVersion: v1
kind: Service
metadata:
name: apisix-metrics
namespace: g8-gateway
spec:
selector:
app: apisix
ports:
- name: metrics
port: 9091
targetPort: 9091
---
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: apisix
namespace: g8-gateway
spec:
host: gw.apps.okd.example.com
to:
kind: Service
name: apisix
port:
targetPort: http
EOF
oc -n g8-gateway rollout status deploy/apisix --timeout=300s
7.1 PodDisruptionBudget
When Kubernetes drains a node (maintenance, upgrade, autoscaling) it evicts
the pods. By default there is no limit: when measured, all three etcd pods could
be evicted back to back and each returned 201 Success — meaning a single
maintenance operation can take quorum with it.
A PodDisruptionBudget stops that at the API level:
cat <<'EOF' | oc apply -f -
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: etcd
namespace: g8-gateway
spec:
minAvailable: 2
selector:
matchLabels:
app: etcd
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: apisix
namespace: g8-gateway
spec:
minAvailable: 2
selector:
matchLabels:
app: apisix
EOF
Verification:
oc -n g8-gateway get pdb
NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS
apisix 2 N/A 1
etcd 2 N/A 1
The ALLOWED DISRUPTIONS column is the real answer: one pod may be evicted, a
second may not. Measured:
1. tahliye (etcd-0): 201 Success -> izinli kesinti 1 -> 0
2. tahliye (etcd-1): TooManyRequests: Cannot evict pod as it
would violate the pod's disruption budget
This blocks voluntary evictions; if a node crashes, the PDB does not come into play. What it protects against is maintenance-induced outage.
8. NetworkPolicy
etcd has no authentication. Without a NetworkPolicy, any pod in the cluster
can connect to etcd-0.etcd:2379 and read or rewrite the gateway's entire
configuration. This policy narrows access down to three labels.
cat <<'EOF' | oc apply -f -
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: etcd-clients-only
namespace: g8-gateway
spec:
podSelector:
matchLabels:
app: etcd
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels:
app: apisix
- podSelector:
matchLabels:
app: etcd
- podSelector:
matchLabels:
app: etcd-backup
ports:
- protocol: TCP
port: 2379
- protocol: TCP
port: 2380
EOF
allow_admin is not access control in KubernetesAPISIX's allow_admin list looks at the source IP, but the SDN may rewrite that
address: behind a Service the same client appears sometimes with its own pod IP
and sometimes with the OVN masquerade address (15.3). The real
control is the NetworkPolicy; allow_admin is only a coarse second door.
9. Backup
The snapshot is written to a separate PVC. The filename is made unique with
$(POD_NAME) — date cannot be called because the etcd image has no shell
(15.4). Pruning old files falls to an initContainer that does
have a shell.
cat <<'EOF' | oc apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: etcd-backup
namespace: g8-gateway
spec:
accessModes: [ReadWriteOnce]
storageClassName: topolvm-provisioner
resources:
requests:
storage: 5Gi
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: etcd-backup
namespace: g8-gateway
spec:
schedule: "0 2 * * *"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 1800
template:
metadata:
labels:
app: etcd-backup
spec:
restartPolicy: Never
initContainers:
- name: prune
image: harbor.g8.dev/infra/apisix:3.19.0-debian
command:
- bash
- -c
- "ls -1t /backups/etcd-*.db 2>/dev/null | tail -n +15 | xargs -r rm -f; echo pruned"
volumeMounts:
- name: backups
mountPath: /backups
containers:
- name: snapshot
image: harbor.g8.dev/infra/etcd:v3.5.17
command: ["/usr/local/bin/etcdctl"]
args:
- "--endpoints=http://etcd-0.etcd:2379"
- "snapshot"
- "save"
- "/backups/etcd-$(POD_NAME).db"
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
volumeMounts:
- name: backups
mountPath: /backups
volumes:
- name: backups
persistentVolumeClaim:
claimName: etcd-backup
EOF
snapshot save requires a single endpoint; passing several returns
snapshot must be requested to one selected node.
The etcd-backup PVC is on the same node, on the same disk, as the etcd data
itself. If the node or the disk goes, the backup goes with it — so this backup
does not protect against the event it promises to.
This installation does not move backups off the cluster; a customer environment
must. The direction matters: the remote system pulls, and the gateway should
hold no write credential on it — anyone who takes the gateway would use that
credential to delete the backups too. A working example for the virtual-machine
installs is in 02-test-vm §13.3.
9.1 Defragmentation
Compaction removes old revisions logically but does not shrink the database
file — the freed pages stay inside etcd and never return to the filesystem.
Since the quota watches dbSize, the file keeps growing even when compaction is
working perfectly, and the quota fills sooner or later. The extreme measured:
dbSizeInUse at 41 kB while dbSize sat at 98 MB.
Defragmentation therefore has to be scheduled. Weekly is enough.
cat <<'EOF' | oc apply -f -
apiVersion: batch/v1
kind: CronJob
metadata:
name: etcd-defrag
namespace: g8-gateway
spec:
schedule: "0 3 * * 0"
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 3600
template:
metadata:
labels:
app: etcd-backup
spec:
restartPolicy: Never
initContainers:
- name: defrag-0
image: harbor.g8.dev/infra/etcd:v3.5.17
command: ["/usr/local/bin/etcdctl"]
args: ["--endpoints=http://etcd-0.etcd:2379", "--command-timeout=900s", "defrag"]
- name: defrag-1
image: harbor.g8.dev/infra/etcd:v3.5.17
command: ["/usr/local/bin/etcdctl"]
args: ["--endpoints=http://etcd-1.etcd:2379", "--command-timeout=900s", "defrag"]
- name: defrag-2
image: harbor.g8.dev/infra/etcd:v3.5.17
command: ["/usr/local/bin/etcdctl"]
args: ["--endpoints=http://etcd-2.etcd:2379", "--command-timeout=900s", "defrag"]
containers:
- name: alarms
image: harbor.g8.dev/infra/etcd:v3.5.17
command: ["/usr/local/bin/etcdctl"]
args: ["--endpoints=http://etcd-0.etcd:2379,http://etcd-1.etcd:2379,http://etcd-2.etcd:2379", "--command-timeout=30s", "alarm", "list"]
EOF
Verification:
oc -n g8-gateway create job defrag-now --from=cronjob/etcd-defrag
oc -n g8-gateway wait --for=condition=complete job/defrag-now --timeout=600s
POD=$(oc -n g8-gateway get pod -l job-name=defrag-now -o jsonpath='{.items[0].metadata.name}')
for c in defrag-0 defrag-1 defrag-2; do oc -n g8-gateway logs $POD -c $c | tail -1; done
oc -n g8-gateway delete job defrag-now
Finished defragmenting etcd member[http://etcd-0.etcd:2379]
Finished defragmenting etcd member[http://etcd-1.etcd:2379]
Finished defragmenting etcd member[http://etcd-2.etcd:2379]
Defrag blocks that member briefly, which is why the members are processed one at a time. Defragmenting all three at once drops quorum.
Defrag temporarily needs free space equal to the database size; that is one reason the quota must be meaningfully smaller than the disk.
10. Verification
10.1 SCC and the assigned uid
That the installation runs without SCC relaxation is read from the annotation on
the pod. Any value other than restricted-v2 means the installation has not been
verified in a restricted environment.
oc -n g8-gateway get pod -o custom-columns=\
'POD:.metadata.name,SCC:.metadata.annotations.openshift\.io/scc,UID:.spec.containers[0].securityContext.runAsUser' \
--no-headers | sort
apisix-799d857b7f-6lt62 restricted-v2 1000770000
apisix-799d857b7f-9gp6h restricted-v2 1000770000
apisix-799d857b7f-v7tqv restricted-v2 1000770000
etcd-0 restricted-v2 1000770000
etcd-1 restricted-v2 1000770000
etcd-2 restricted-v2 1000770000
Pod names and the uid vary per installation; the restricted-v2 column must
not.
oc runA pod you create directly is admitted with your own privileges. As a cluster
admin you get anyuid and the image runs as its own uid — which proves nothing
about a restricted environment. Details: 15.2.
10.2 The etcd cluster
The member count, the healthy-endpoint count and the leader count are each counted separately.
E=http://etcd-0.etcd:2379,http://etcd-1.etcd:2379,http://etcd-2.etcd:2379
echo "members=$(oc -n g8-gateway exec etcd-0 -- etcdctl --endpoints=$E member list -w simple | wc -l)"
echo "healthy=$(oc -n g8-gateway exec etcd-0 -- etcdctl --endpoints=$E endpoint health -w simple 2>&1 | grep -c 'is healthy')"
echo "leaders=$(oc -n g8-gateway exec etcd-0 -- etcdctl --endpoints=$E endpoint status -w simple | awk -F, '$5 ~ /true/' | wc -l)"
members=3
healthy=3
leaders=1
10.3 Admin API and key substitution
The Admin API has no Route; access is opened with oc port-forward. Three
requests: the real key, the unsubstituted raw template, and no key.
oc -n g8-gateway port-forward svc/apisix-admin 19180:9180 >/dev/null 2>&1 &
PF=$!
sleep 6
KEY=$(oc -n g8-gateway get secret apisix-admin-key -o jsonpath='{.data.G8_APISIX_ADMIN_KEY}' | base64 -d)
umask 077
printf 'header = "X-API-KEY: %s"\n' "$KEY" > /tmp/g8adm.conf
printf 'header = "X-API-KEY: ${{G8_APISIX_ADMIN_KEY}}"\n' > /tmp/g8raw.conf
curl -s -o /dev/null -w "real=%{http_code}\n" -K /tmp/g8adm.conf http://127.0.0.1:19180/apisix/admin/routes
curl -s -o /dev/null -w "raw=%{http_code}\n" -K /tmp/g8raw.conf http://127.0.0.1:19180/apisix/admin/routes
curl -s -o /dev/null -w "none=%{http_code}\n" http://127.0.0.1:19180/apisix/admin/routes
real=200
raw=401
none=401
The next section uses the $PF variable set here, the /tmp/g8adm.conf file,
and the port-forward that is still running. Open a new terminal and 10.4
fails with a connection error. Stay in the same shell.
10.4 Does the configuration propagate to all three pods
The route is written once and read back from each of the three pods separately. Reading through the Service is not enough: if load balancing lands on the same pod, the other two are never tested.
curl -s -o /dev/null -w "PUT=%{http_code}\n" -K /tmp/g8adm.conf \
-X PUT http://127.0.0.1:19180/apisix/admin/routes/g8-smoke \
-H "Content-Type: application/json" \
-d '{"uri":"/smoke","plugins":{"mocking":{"content_type":"text/plain","response_status":200,"response_example":"G8-GW-OKD-OK"}}}'
kill $PF 2>/dev/null
sleep 3
for p in $(oc -n g8-gateway get pod -l app=apisix -o jsonpath='{.items[*].metadata.name}'); do
oc -n g8-gateway port-forward pod/$p 19081:9080 >/dev/null 2>&1 &
PF=$!
sleep 4
echo "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:19081/smoke) $(curl -s http://127.0.0.1:19081/smoke)"
kill $PF 2>/dev/null
sleep 1
done
PUT=201
200 G8-GW-OKD-OK
200 G8-GW-OKD-OK
200 G8-GW-OKD-OK
10.5 The Route and the source IP
curl -s http://gw.apps.okd.example.com/smoke -w "\nstatus=%{http_code}\n"
G8-GW-OKD-OK
status=200
Which address a request through the Route arrives with is read from the access
log. This is the only truth that matters when configuring IP-based plugins
(ip-restriction, real-ip):
oc -n g8-gateway logs -l app=apisix --tail=50 --prefix=false \
| grep '"uri":"/smoke"' | sort | tail -1
{"time":"2026-08-24T21:00:48+00:00","remote_addr":"10.128.0.2","status":"200","method":"GET","uri":"/smoke","rt":"0.000","ua":"curl/8.14.1"}
The sort is mandatory: oc logs -l concatenates the logs of several pods and
the order is not chronological; taking tail -1 without sorting gives an old
line from a different pod. The JSON line starts with the time field, so
lexical order is chronological order.
remote_addr is not the client's address; it is the OVN gateway's. If the
real client IP is needed, add
haproxy.router.openshift.io/set-forwarded-headers to the Route and configure
the real-ip plugin in APISIX — this document does not cover that.
10.6 NetworkPolicy
That the policy works is measured with both controls. Seeing "blocked" alone is not enough: the pod may simply never have started.
The Pods here and in 10.9 are created directly, not by a controller. The
namespace's restricted PodSecurity profile inspects them too, and since the
manifest carries no security context it prints a long warning:
Warning: would violate PodSecurity "restricted:latest": allowPrivilegeEscalation != false ...
pod/npdeny created
The Warning: line is followed by created and the pod reaches Succeeded.
PodSecurity is in warn mode here; the warning is not a refusal. Do not add a
securityContext to the manifest — these are throwaway test pods, not part of
the installation.
cat <<'EOF' | oc apply -f -
apiVersion: v1
kind: Pod
metadata:
name: npdeny
namespace: g8-gateway
labels:
app: outsider
spec:
restartPolicy: Never
containers:
- name: t
image: harbor.g8.dev/infra/apisix:3.19.0-debian
command: ["bash","-c","timeout 8 bash -c 'echo > /dev/tcp/etcd-0.etcd/2379' && echo REACHABLE || echo BLOCKED"]
EOF
oc -n g8-gateway wait --for=jsonpath='{.status.phase}'=Succeeded pod/npdeny --timeout=120s >/dev/null
echo "unlabelled=$(oc -n g8-gateway logs npdeny)"
oc -n g8-gateway delete pod npdeny
A=$(oc -n g8-gateway get pod -l app=apisix -o jsonpath='{.items[0].metadata.name}')
echo "apisix=$(oc -n g8-gateway exec $A -- bash -c "timeout 8 bash -c 'echo > /dev/tcp/etcd-0.etcd/2379' && echo REACHABLE || echo BLOCKED")"
unlabelled=BLOCKED
apisix=REACHABLE
10.7 Quorum: one member down
The leader pod is deleted. Expected: the cluster elects a new leader, writes continue, the data plane is unaffected, and the StatefulSet brings the pod back.
E=http://etcd-0.etcd:2379,http://etcd-1.etcd:2379,http://etcd-2.etcd:2379
LEADER=$(oc -n g8-gateway exec etcd-0 -- etcdctl --endpoints=$E endpoint status -w simple \
| awk -F, '$5 ~ /true/ {print $1}' | sed 's|http://||;s|\.etcd:2379||')
oc -n g8-gateway delete pod $LEADER --wait=false
sleep 15
SURVIVOR=$(oc -n g8-gateway get pod -l app=etcd --no-headers | grep '1/1' | head -1 | awk '{print $1}')
oc -n g8-gateway exec $SURVIVOR -- etcdctl --endpoints=http://127.0.0.1:2379 put /g8-quorum-probe ok
curl -s http://gw.apps.okd.example.com/smoke -w "\nstatus=%{http_code}\n"
oc -n g8-gateway rollout status sts/etcd --timeout=300s
oc -n g8-gateway exec etcd-2 -- etcdctl --endpoints=$E get /g8-quorum-probe --print-value-only
oc -n g8-gateway exec etcd-0 -- etcdctl --endpoints=$E del /g8-quorum-probe
OK
G8-GW-OKD-OK
status=200
partitioned roll out complete: 3 new pods have been updated...
ok
1
10.8 Quorum loss
Scaling the members down to one leaves no quorum. The expected behaviour has two parts and both must be measured: the write fails, and the data plane keeps serving the existing routes.
oc -n g8-gateway scale sts etcd --replicas=1
sleep 40
if oc -n g8-gateway exec etcd-0 -- etcdctl --endpoints=http://127.0.0.1:2379 --command-timeout=15s put /g8-noquorum x >/dev/null 2>&1
then echo "write=UNEXPECTED-SUCCESS"; else echo "write=FAILED"; fi
curl -s -o /dev/null --max-time 10 -w "route=%{http_code}\n" http://gw.apps.okd.example.com/smoke
echo "apisix_ready=$(oc -n g8-gateway get pod -l app=apisix --no-headers | grep -c '1/1')"
echo "etcd_restarts=$(oc -n g8-gateway get pod etcd-0 -o jsonpath='{.status.containerStatuses[0].restartCount}')"
write=FAILED
route=200
apisix_ready=3
etcd_restarts=0
No error string is printed here: the string etcd returns varies from run to run — three independent measurements produced three different ones. What is asserted instead is that the write failed, which is the actual question.
etcd_restarts=0 is a regression sentinel. Anything above zero means the liveness
probe is watching cluster health and killing the last member on quorum loss
(15.8).
The APISIX pods stay ready because their probes target the control port, not etcd.
The cluster is brought back:
oc -n g8-gateway scale sts etcd --replicas=3
oc -n g8-gateway rollout status sts/etcd --timeout=300s
E=http://etcd-0.etcd:2379,http://etcd-1.etcd:2379,http://etcd-2.etcd:2379
echo "healthy=$(oc -n g8-gateway exec etcd-0 -- etcdctl --endpoints=$E endpoint health -w simple 2>&1 | grep -c 'is healthy')"
oc -n g8-gateway exec etcd-0 -- etcdctl --endpoints=$E put /g8-recovered ok
oc -n g8-gateway exec etcd-0 -- etcdctl --endpoints=$E del /g8-recovered
partitioned roll out complete: 3 new pods have been updated...
healthy=3
OK
1
10.9 Is the backup valid
That the CronJob exists does not prove a backup was taken. It is run once by hand and the file it produces is verified.
oc -n g8-gateway create job manual-backup --from=cronjob/etcd-backup
oc -n g8-gateway wait --for=condition=complete job/manual-backup --timeout=300s
POD=$(oc -n g8-gateway get pod -l job-name=manual-backup -o jsonpath='{.items[0].metadata.name}')
oc -n g8-gateway logs $POD -c snapshot 2>&1 | grep -o '/backups/[^"]*\.db' | tail -1
oc -n g8-gateway delete job manual-backup
/backups/etcd-manual-backup-64wnx.db
The filename varies per job run. That the file really is a readable snapshot is tested separately — replace the name below with the one from your own output:
cat <<'EOF' | oc apply -f -
apiVersion: v1
kind: Pod
metadata:
name: snapcheck
namespace: g8-gateway
labels:
app: etcd-backup
spec:
restartPolicy: Never
containers:
- name: c
image: harbor.g8.dev/infra/etcd:v3.5.17
command: ["/usr/local/bin/etcdutl"]
args: ["snapshot","status","/backups/etcd-manual-backup-64wnx.db","-w","table"]
volumeMounts:
- name: backups
mountPath: /backups
volumes:
- name: backups
persistentVolumeClaim:
claimName: etcd-backup
EOF
oc -n g8-gateway wait --for=jsonpath='{.status.phase}'=Succeeded pod/snapcheck --timeout=180s >/dev/null
oc -n g8-gateway logs snapcheck
oc -n g8-gateway delete pod snapcheck
+----------+----------+------------+------------+
| HASH | REVISION | TOTAL KEYS | TOTAL SIZE |
+----------+----------+------------+------------+
| ab038d9e | 86 | 93 | 37 kB |
+----------+----------+------------+------------+
HASH, REVISION and the key count vary per installation; the result being
looked for is that the command prints a table without erroring.
10.10 Cleaning up verification leftovers
10.3 writes the admin key into /tmp/g8adm.conf in cleartext. If the file is
not deleted the key stays on disk, and the care section 6 takes to keep the key
in a Secret is wasted here.
rm -f /tmp/g8adm.conf /tmp/g8raw.conf
ls /tmp/g8*.conf 2>/dev/null | wc -l
0
The g8-smoke route is left in place deliberately: it is lasting proof that the
installation works, and the uninstall in section 13 removes it along with the
namespace. On a cluster that will carry production traffic, delete it through the
Admin API if you do not want it there.
11. Observability
This environment gets its own Elasticsearch and Prometheus, both running inside the cluster. Request records go to Elasticsearch, request metrics to Prometheus.
| component | version | source |
|---|---|---|
| Elasticsearch | 9.5.2 | harbor.g8.dev/infra/elasticsearch:9.5.2 |
| Prometheus | 3.15.0 | harbor.g8.dev/infra/prometheus:v3.15.0 |
Prometheus starts with --enable-feature=promql-extended-range-selectors:
Analytics counts each bucket exactly with the anchored range selector, which
Prometheus 3.15 takes only behind that flag. From Prometheus 3.16 the selector
is on by default and the line can go; without it, Analytics shows its counts as
estimates.
Prometheus finds the APISIX pods through Kubernetes service discovery, so the
configuration does not change when the pod count does. external_labels marks
the environment.
11.1 Elasticsearch
apiVersion: v1
kind: Service
metadata:
name: elasticsearch
namespace: g8-gateway
spec:
selector:
app: elasticsearch
ports:
- name: http
port: 9200
targetPort: 9200
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: elasticsearch
namespace: g8-gateway
spec:
serviceName: elasticsearch
replicas: 1
selector:
matchLabels:
app: elasticsearch
template:
metadata:
labels:
app: elasticsearch
spec:
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: elasticsearch
image: harbor.g8.dev/infra/elasticsearch:9.5.2
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
env:
- name: node.name
value: es-okd
- name: cluster.name
value: g8-okd
- name: discovery.type
value: single-node
- name: ES_JAVA_OPTS
value: "-Xms1g -Xmx1g"
- name: xpack.security.enabled
value: "true"
- name: xpack.security.enrollment.enabled
value: "false"
- name: xpack.security.http.ssl.enabled
value: "false"
- name: xpack.security.transport.ssl.enabled
value: "false"
- name: ingest.geoip.downloader.enabled
value: "false"
- name: bootstrap.memory_lock
value: "false"
- name: ELASTIC_PASSWORD
valueFrom:
secretKeyRef:
name: g8-es-credentials
key: password
ports:
- name: http
containerPort: 9200
resources:
requests:
cpu: 300m
memory: 1600Mi
limits:
cpu: "2"
memory: 2200Mi
readinessProbe:
exec:
command:
- sh
- -c
- curl -sf -u "elastic:$ELASTIC_PASSWORD" http://127.0.0.1:9200/_cluster/health?local=true >/dev/null
initialDelaySeconds: 60
periodSeconds: 15
failureThreshold: 20
livenessProbe:
tcpSocket:
port: 9200
initialDelaySeconds: 120
periodSeconds: 20
failureThreshold: 6
volumeMounts:
- name: data
mountPath: /usr/share/elasticsearch/data
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: topolvm-provisioner
resources:
requests:
storage: 20Gi
11.2 Prometheus and NetworkPolicy
apiVersion: v1
kind: ConfigMap
metadata:
name: prometheus-config
namespace: g8-gateway
data:
prometheus.yml: |
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
g8_environment: okd
scrape_configs:
- job_name: apisix
metrics_path: /apisix/prometheus/metrics
kubernetes_sd_configs:
- role: pod
namespaces:
names: [g8-gateway]
relabel_configs:
- source_labels: [__meta_kubernetes_pod_label_app]
action: keep
regex: apisix
- source_labels: [__address__]
action: replace
regex: '([^:]+)(:\d+)?'
replacement: '$1:9091'
target_label: __address__
- source_labels: [__meta_kubernetes_pod_name]
target_label: pod
- target_label: g8_environment
replacement: okd
- job_name: prometheus
static_configs:
- targets: ["127.0.0.1:9090"]
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: prometheus
namespace: g8-gateway
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: prometheus
namespace: g8-gateway
rules:
- apiGroups: [""]
resources: ["pods", "services", "endpoints"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: prometheus
namespace: g8-gateway
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: prometheus
subjects:
- kind: ServiceAccount
name: prometheus
namespace: g8-gateway
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: prometheus-data
namespace: g8-gateway
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: topolvm-provisioner
resources:
requests:
storage: 10Gi
---
apiVersion: v1
kind: Service
metadata:
name: prometheus
namespace: g8-gateway
spec:
selector:
app: prometheus
ports:
- name: http
port: 9090
targetPort: 9090
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: prometheus
namespace: g8-gateway
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: prometheus
template:
metadata:
labels:
app: prometheus
spec:
serviceAccountName: prometheus
securityContext:
seccompProfile:
type: RuntimeDefault
containers:
- name: prometheus
image: harbor.g8.dev/infra/prometheus:v3.15.0
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
args:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=40d
- --storage.tsdb.retention.size=5GB
- --enable-feature=promql-extended-range-selectors
ports:
- name: http
containerPort: 9090
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
readinessProbe:
httpGet:
path: /-/ready
port: 9090
initialDelaySeconds: 10
periodSeconds: 10
volumeMounts:
- name: config
mountPath: /etc/prometheus
- name: data
mountPath: /prometheus
volumes:
- name: config
configMap:
name: prometheus-config
- name: data
persistentVolumeClaim:
claimName: prometheus-data
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: elasticsearch-clients-only
namespace: g8-gateway
spec:
podSelector:
matchLabels:
app: elasticsearch
policyTypes: ["Ingress"]
ingress:
- from:
- podSelector:
matchLabels:
app: apisix
- podSelector:
matchLabels:
app: prometheus
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: openshift-ingress
ports:
- protocol: TCP
port: 9200
The NetworkPolicy lets only APISIX, Prometheus and the ingress controller reach Elasticsearch.
Leave openshift-ingress out of the list and the Route/Ingress looks healthy while
requests never reach Elasticsearch. The symptom is a timeout, not a clear
rejection.
11.3 Exposing
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: elasticsearch
namespace: g8-gateway
spec:
host: es.apps.okd.example.com
to:
kind: Service
name: elasticsearch
port:
targetPort: http
---
apiVersion: route.openshift.io/v1
kind: Route
metadata:
name: prometheus
namespace: g8-gateway
spec:
host: prom.apps.okd.example.com
to:
kind: Service
name: prometheus
port:
targetPort: http
11.4 Install
The password is generated in the cluster and reaches neither this document nor the repository. It is the store administrator's password and no rule of the gateway carries it: the gateway writes with an account of its own (11.6).
oc -n g8-gateway create secret generic g8-es-credentials \
--from-literal=password="$(openssl rand -base64 24 | tr -d /+= | head -c 24)"
oc apply -f observability-es.yaml
oc apply -f observability-prometheus.yaml
oc apply -f observability-expose.yaml
oc -n g8-gateway wait --for=condition=Ready pod/elasticsearch-0 --timeout=300s
oc -n g8-gateway rollout status deploy/prometheus --timeout=300s
waitAfter delete pod --wait=false a loop that polls readiness can read the stale
pod object, call it ready and exit early. One verification reported data loss
because of exactly this; the real cause was that the pod had not come up yet.
11.5 Wiring APISIX
The metrics are enabled through a global rule, which leaves prefer_name off:
route and service on the metrics carry the ids Analytics counts by, and
metrics.*.extra_labels in config.yaml put each route's and service's name
beside them as route_name and service_name. The request records are not wired
here: g8 writes their rule itself when traffic logging is turned on
(11.7).
KEY=$(oc -n g8-gateway get secret apisix-admin-key -o jsonpath='{.data.G8_APISIX_ADMIN_KEY}' | base64 -d)
oc -n g8-gateway exec -i elasticsearch-0 -- sh -c \
"curl -s -o /dev/null -w '%{http_code}\n' -H 'X-API-KEY: $KEY' \
-X PUT http://apisix-admin:9180/apisix/admin/global_rules/1 \
-H 'Content-Type: application/json' --data-binary @-" <<'EOF'
{"plugins":{"prometheus":{"prefer_name":false}}}
EOF
201
prometheus plugin must be enabled; plugin_attr is not enoughplugin_attr.prometheus in config.yaml only sets the address the metrics are
published on. Unless the plugin is enabled on a global rule, no request metrics
are collected at all. The symptom is treacherous: the Prometheus target reads
up, /apisix/prometheus/metrics returns 200, and yet a query for
apisix_http_status comes back with zero series.
11.6 Accounts, the ingest pipeline and the index template in the log store
The commands of this section were run on a development stack on 2026-10-02 (APISIX 3.19.0, Elasticsearch 8.15.3) with the outputs shown. On Elasticsearch 9.5.2 the privileges were measured with roles that name the days by a wildcard, and without the line that reads the template; the roles below name the days by a regular expression, which was measured on 8.15.3 alone. The commands have not been run on this machine yet.
The ingest pipeline, the line of the server's role that reads it and the move of 14.2 were run on 2026-10-04 against a development stack's Elasticsearch 8.15.3 with the outputs shown; that a batch sent twice is stored once was also measured in the lab on Elasticsearch 9.5.2 and OpenSearch 3.9.0.
Two accounts are opened in Elasticsearch, and neither of them is elastic:
| account | used by | may |
|---|---|---|
g8-okd-gateway | the gateway, to deliver the request records | write records into the daily indices of this environment; it reads no record, deletes none and sees nothing of the cluster |
g8-okd-server | the g8 server, to show the records and to clean them up | read the state of the cluster and g8's ingest pipeline, read the daily indices of this environment and delete a day by its name |
The daily indices carry the slug of the environment in g8
(g8-<slug>-access-<yyyy.mm.dd>), so ENV below must be the slug this gateway is
registered under in the g8 console. The roles name the days by a regular
expression and not by g8-okd-access-*: a wildcard would also reach the days of
an environment whose slug begins with okd-access.
The passwords of the two accounts are generated into a Secret; they reach neither this document nor the repository:
oc -n g8-gateway create secret generic g8-es-accounts \
--from-literal=gateway="$(openssl rand -base64 24 | tr -d /+= | head -c 24)" \
--from-literal=server="$(openssl rand -base64 24 | tr -d /+= | head -c 24)"
ENV=okd
GWP=$(oc -n g8-gateway get secret g8-es-accounts -o jsonpath='{.data.gateway}' | base64 -d)
SVP=$(oc -n g8-gateway get secret g8-es-accounts -o jsonpath='{.data.server}' | base64 -d)
put() {
oc -n g8-gateway exec -i elasticsearch-0 -- sh -c \
"curl -s -o /dev/null -w '$1 -> %{http_code}\n' -u elastic:\$ELASTIC_PASSWORD \
-X PUT http://127.0.0.1:9200$1 -H 'Content-Type: application/json' --data-binary @-"
}
DAYS="/g8-$ENV-access-[0-9]{4}[.][0-9]{2}[.][0-9]{2}/"
put /_security/role/g8-$ENV-gateway <<EOF
{"indices":[{"names":["$DAYS"],"privileges":["index","auto_configure"]}]}
EOF
put /_security/role/g8-$ENV-server <<EOF
{"cluster":["monitor","read_pipeline"],"indices":[{"names":["$DAYS"],
"privileges":["read","view_index_metadata","monitor","delete_index"]}]}
EOF
put /_security/user/g8-$ENV-gateway <<EOF
{"password":"$GWP","roles":["g8-$ENV-gateway"]}
EOF
put /_security/user/g8-$ENV-server <<EOF
{"password":"$SVP","roles":["g8-$ENV-server"]}
EOF
/_security/role/g8-okd-gateway -> 200
/_security/role/g8-okd-server -> 200
/_security/user/g8-okd-gateway -> 200
/_security/user/g8-okd-server -> 200
The password of g8-okd-gateway is part of the gateway's configuration: whoever
can read the global rules of the gateway can obtain it. That is why this account
can only add records. When traffic logging is turned on with a plain password, g8
tries the account and refuses one that can read the cluster or a record; a
password given as a secret reference is resolved on the gateway and is not tried.
create_doc is not enough for the account either: the plugin sends an index
action, and with create_doc every record is refused while the request itself
answers 200.
The ingest pipeline stores each record under its request id. The gateway's
logger sends the records in batches and sends a batch again when the store
answers it late, and by then the store has usually written the first one: under
the request id, the second copy replaces the first instead of adding a record.
The index template below sends every record through this pipeline, so the
pipeline is applied first. A store that holds the template and not the pipeline
refuses every record while the gateway's bulk request still answers 200; g8
checks that the store holds this version of the pipeline and does not turn
traffic logging on without it. The store needs at least one node with the
ingest role, which a node carries by default.
oc -n g8-gateway exec -i elasticsearch-0 -- sh -c \
'curl -s -u elastic:$ELASTIC_PASSWORD -X PUT http://127.0.0.1:9200/_ingest/pipeline/g8-access \
-H "Content-Type: application/json" --data-binary @-' <<'EOF'
{
"description": "g8 request records: a record is keyed by its request id, so a delivery the gateway sends twice is stored once",
"version": 1,
"processors": [
{
"set": {
"field": "_id",
"value": "{{{request_id}}}",
"if": "ctx.request_id instanceof String && ctx.request_id.length() == 32"
}
}
]
}
EOF
{"acknowledged":true}
The index template fixes the type of every field of a request record before the first index of a day is born. The store's administrator applies it once, with the command below. g8 checks that the store holds this version of it and does not turn traffic logging on without it.
oc -n g8-gateway exec -i elasticsearch-0 -- sh -c \
'curl -s -u elastic:$ELASTIC_PASSWORD -X PUT http://127.0.0.1:9200/_index_template/g8-access \
-H "Content-Type: application/json" --data-binary @-' <<'EOF'
{
"index_patterns": [
"g8-*-access-*"
],
"priority": 200,
"template": {
"settings": {
"index": {
"number_of_shards": 1,
"codec": "best_compression",
"auto_expand_replicas": "0-1",
"default_pipeline": "g8-access",
"analysis": {
"analyzer": {
"g8_nodes": {
"type": "pattern",
"pattern": "(\\s*,\\s*)|(\\s+:\\s+)",
"lowercase": false
}
}
}
}
},
"mappings": {
"dynamic": false,
"properties": {
"start_time": {
"type": "date",
"format": "epoch_millis"
},
"latency": {
"type": "float"
},
"upstream_latency": {
"type": "float"
},
"apisix_latency": {
"type": "float"
},
"client_ip": {
"type": "ip",
"ignore_malformed": true
},
"route_id": {
"type": "keyword"
},
"service_id": {
"type": "keyword"
},
"upstream": {
"type": "keyword",
"ignore_above": 1024,
"fields": {
"node": {
"type": "text",
"analyzer": "g8_nodes",
"norms": false,
"index_options": "docs"
}
}
},
"consumer": {
"properties": {
"username": {
"type": "keyword"
}
}
},
"request": {
"properties": {
"method": {
"type": "keyword"
},
"uri": {
"type": "wildcard"
},
"url": {
"type": "keyword",
"index": false,
"doc_values": false
},
"size": {
"type": "long"
},
"headers": {
"type": "object",
"enabled": false
},
"querystring": {
"type": "object",
"enabled": false
}
}
},
"request_body": {
"type": "text",
"index": false
},
"response": {
"properties": {
"status": {
"type": "short"
},
"size": {
"type": "long"
},
"headers": {
"type": "object",
"enabled": false
},
"body": {
"type": "text",
"index": false
}
}
},
"server": {
"properties": {
"hostname": {
"type": "keyword"
},
"version": {
"type": "keyword"
}
}
},
"request_id": {
"type": "keyword"
},
"route_name": {
"type": "keyword"
},
"service_name": {
"type": "keyword"
},
"upstream_status": {
"type": "keyword"
},
"x_request_id": {
"type": "keyword"
},
"logged_at": {
"type": "date",
"format": "epoch_second"
}
},
"_meta": {
"g8_records": 2
},
"_source": {
"excludes": [
"response.headers.set-cookie"
]
}
}
}
}
EOF
{"acknowledged":true}
What the two accounts may and may not do, asked of the store itself. The day the check writes is deleted by its last line:
GWP=$(oc -n g8-gateway get secret g8-es-accounts -o jsonpath='{.data.gateway}' | base64 -d)
SVP=$(oc -n g8-gateway get secret g8-es-accounts -o jsonpath='{.data.server}' | base64 -d)
oc -n g8-gateway exec -i elasticsearch-0 -- sh -s "$GWP" "$SVP" <<'EOF'
curl -s -o /dev/null -w 'g8-okd-gateway reads the cluster -> %{http_code}\n' -u "g8-okd-gateway:$1" http://127.0.0.1:9200/
curl -s -o /dev/null -w 'g8-okd-gateway reads records -> %{http_code}\n' -u "g8-okd-gateway:$1" 'http://127.0.0.1:9200/g8-okd-access-*/_search'
curl -s -o /dev/null -w 'g8-okd-gateway writes a day -> %{http_code}\n' -u "g8-okd-gateway:$1" -X POST http://127.0.0.1:9200/g8-okd-access-2000.01.01/_doc -H 'Content-Type: application/json' -d '{}'
curl -s -o /dev/null -w 'g8-okd-gateway writes beside its days -> %{http_code}\n' -u "g8-okd-gateway:$1" -X POST http://127.0.0.1:9200/g8-okd-access-x-access-2000.01.01/_doc -H 'Content-Type: application/json' -d '{}'
curl -s -o /dev/null -w 'g8-okd-server reads the template -> %{http_code}\n' -u "g8-okd-server:$2" http://127.0.0.1:9200/_index_template/g8-access
curl -s -o /dev/null -w 'g8-okd-server reads the pipeline -> %{http_code}\n' -u "g8-okd-server:$2" http://127.0.0.1:9200/_ingest/pipeline/g8-access
curl -s -o /dev/null -w 'g8-okd-server reads beside its days -> %{http_code}\n' -u "g8-okd-server:$2" 'http://127.0.0.1:9200/g8-okd-access-x-access-2000.01.01/_search'
curl -s -o /dev/null -w 'g8-okd-server writes a record -> %{http_code}\n' -u "g8-okd-server:$2" -X POST http://127.0.0.1:9200/g8-okd-access-2000.01.01/_doc -H 'Content-Type: application/json' -d '{}'
curl -s -o /dev/null -w 'g8-okd-server deletes the day -> %{http_code}\n' -u "g8-okd-server:$2" -X DELETE http://127.0.0.1:9200/g8-okd-access-2000.01.01
EOF
g8-okd-gateway reads the cluster -> 403
g8-okd-gateway reads records -> 403
g8-okd-gateway writes a day -> 201
g8-okd-gateway writes beside its days -> 403
g8-okd-server reads the template -> 200
g8-okd-server reads the pipeline -> 200
g8-okd-server reads beside its days -> 403
g8-okd-server writes a record -> 403
g8-okd-server deletes the day -> 200
elasticsearch-logger writes an index action in its bulk request; data streams
require create and reject index. Hence one dated index per day.
11.7 Turning on the request records
These steps were walked on a development stack on 2026-10-02, with the two accounts of 11.6. They have not been walked against this machine yet.
The request records are turned on from the g8 console, once this gateway is
registered there as the environment okd. No command on this machine writes
the logger's rule.
- Environments › okd › Edit › Observability. Elasticsearch URL: the address
the store is exposed at in 11.3; the NetworkPolicy
of this namespace admits the g8 server through that address and no other.
Username
g8-okd-server, password theserverkey of theg8-es-accountsSecret. Test connection, then save. - Observe › Traffic Explorer › Settings. The Status card must read Log store Connected, Index template Applied and Ingest pipeline Applied.
- Turn on traffic logging. Log store address, as the gateway reaches it:
http://elasticsearch:9200. Usernameg8-okd-gateway, password thegatewaykey of theg8-es-accountsSecret. Turn on.
g8 writes one global rule, g8-request-records, beside rule 1: the logger with
the daily index of this environment, the fields the records need and the default
masks. What is recorded and masked is changed afterwards on the same Settings
page. Where the gateway writes is shown on the Status card as Gateway writes
to; Change address gives the logger another address and account for the
same store, through the same checks of the account as turning on.
11.8 Retention
A log store does not shrink on its own. With no limit the disk fills eventually, and past 95% Elasticsearch turns its indices read-only.
g8 deletes the request records: once a day it deletes, by its name, every daily index older than the number of days the environment keeps — 30 unless it is changed under Traffic Explorer › Settings › Retention › Change — and writes each deletion to the audit log. The store carries no lifecycle policy for these indices; a second deleter would delete days g8 still shows.
A day is named by its UTC date, by the gateway that writes it and by g8 that reads and deletes it.
On a single-node Elasticsearch a replica can never be allocated. The template
sets auto_expand_replicas: 0-1: an index has no replica on one node and one as
soon as a second node joins. The verification below must show 0 and green.
Prometheus is given two limits at once: retention.time=40d and
retention.size=5GB. Whichever fills first does the cutting. Forty days keep the
whole 30-day window of Analytics.
11.9 Verification
The output below was recorded on this machine while the rule of the earlier guide
was in place; the index it shows is named by the machine's local day. With the
rule g8 writes, the commands were run on a development stack on 2026-10-02: the
index of the UTC day showed the requests sent, 0 and green. They have not
been run on this machine since.
oc -n g8-gateway exec elasticsearch-0 -- sh -c 'curl -s -u elastic:$ELASTIC_PASSWORD \
"http://127.0.0.1:9200/_cat/indices/g8-*?v&h=index,docs.count,rep,health"'
oc -n g8-gateway exec elasticsearch-0 -- sh -c \
'curl -s "http://prometheus:9090/api/v1/targets?state=active"'
index docs.count rep health
g8-okd-access-2026.08.29 30 0 green
Every APISIX pod must appear as up in the Prometheus targets.
Traffic Explorer in the g8 console must list the same requests.
Analytics reads two things from these metrics: each route under its id with its name beside it, and each bucket counted exactly with the anchored range selector:
oc -n g8-gateway exec elasticsearch-0 -- sh -c \
'curl -sG http://prometheus:9090/api/v1/query --data-urlencode "query=count by (route, route_name) (apisix_http_status)"'
oc -n g8-gateway exec elasticsearch-0 -- sh -c \
'curl -sG http://prometheus:9090/api/v1/query --data-urlencode "query=sum(increase(apisix_http_status[5m] anchored))"'
The first query lists every route that answered with its id in route and its
name in route_name; a route that carries a name means a copy of the
prometheus plugin has prefer_name on, and Analytics reads that route empty.
The second must answer "status":"success"; a Prometheus started without
--enable-feature=promql-extended-range-selectors answers anchored modifier is experimental and not enabled, and Analytics marks its counts as estimates. Both
queries were run on this machine on 2026-10-04 against Prometheus 3.15.0: every
route with a name showed it in route_name, and the anchored query answered
success.
The gateway sends a batch of records once more after the first failure
(max_retry_count 1 in the rule g8 writes) and then drops it.
The loss was measured with the rule of the earlier guide, which tried three
times: on a production installation during a password change,
20 requests that arrived while Elasticsearch was restarting appeared in the
metrics but never reached the index.
Read the loss from APISIX's error log, not from a counter difference:
grep -c 'elasticsearch-logger' <error.log>
Subtracting the document count from the metric counter is not reliable and produces false alarms. A measured example: counter 88, documents 43, difference 45 — with not one delivery error in the log. The difference has three causes and none of them is loss: the counter counts from the moment APISIX started (requests from before the plugin was enabled are in the counter but not the index), a restart resets the counter while the index persists, and the two sides have different retention. The difference is a hint only within a single window — no APISIX restart, nothing deleted by retention, both plugins enabled together.
An installation that cannot afford losing records must put a buffer in between
(kafka-logger, or a local file-logger plus a separate shipper).
Deleting pods was measured to lose nothing: after elasticsearch-0 and the
Prometheus pod were deleted and came back, the document count was unchanged.
Persistence comes from the PVCs; delete a PVC and the data is gone.
12. Diagnostics
logs output is cumulative: it holds everything since the container or pod
was born. APISIX prints connection refused while waiting for etcd at startup,
and the same lines land again for members stopped during the quorum drill. Those
lines describe the past, not the present state.
Measured on a healthy installation in this environment:
request etcd endpoint 'http://etcd-1:2379/version' error, connection refused
error.log lines: 0
An empty error.log is normal too — APISIX's own error log is a separate file,
and the startup messages go to stdout rather than there.
The present state is what the verification section's counts tell you; logs is
for diagnosis, not proof of health. Decide whether a line is current from its
timestamp.
oc -n g8-gateway get pod -o wide
oc -n g8-gateway logs -l app=apisix --tail=50 --prefix
oc -n g8-gateway logs etcd-0 --tail=50
oc -n g8-gateway describe pod etcd-0
oc -n g8-gateway get events --sort-by=.lastTimestamp | tail -20
If a pod has restarted, read the previous container's log; the current log looks healthy while the reason for the crash is in there:
oc -n g8-gateway logs <pod> --previous
oc -n g8-gateway get pod <pod> \
-o jsonpath='{.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}'
Cluster tables — member IDs and timings differ on every installation:
E=http://etcd-0.etcd:2379,http://etcd-1.etcd:2379,http://etcd-2.etcd:2379
oc -n g8-gateway exec etcd-0 -- etcdctl --endpoints=$E member list -w table
oc -n g8-gateway exec etcd-0 -- etcdctl --endpoints=$E endpoint status -w table
Prometheus is tested separately on each pod:
for p in $(oc -n g8-gateway get pod -l app=apisix -o jsonpath='{.items[*].metadata.name}'); do
oc -n g8-gateway port-forward pod/$p 19091:9091 >/dev/null 2>&1 &
PF=$!
sleep 4
echo "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:19091/apisix/prometheus/metrics) $(curl -s http://127.0.0.1:19091/apisix/prometheus/metrics | grep -c '^apisix_')"
kill $PF 2>/dev/null
sleep 1
done
200 87
200 87
200 87
13. Uninstalling
Delete the environment in the g8 console first, or clear its log store there: g8 connects to the store once a day to delete the days past the retention, and records a failed cleanup every day for as long as the environment points at a store that is gone.
oc delete namespace g8-gateway
namespace "g8-gateway" deleted
Deleting the namespace deletes the PVCs too; because topolvm-provisioner uses
the Delete reclaim policy, the etcd data and the backups are gone
permanently. If a backup is to be kept, move it out first.
Verify that the deletion completed:
oc get ns g8-gateway --ignore-not-found | wc -l
oc get pv --no-headers | grep -c g8-gateway || true
0
0
14. Upgrading
A version upgrade was not attempted in this environment. The sequence below is a design, not evidence.
- Take the backup from
10.9and verify it. - APISIX: update the Deployment's image tag. The default
RollingUpdatereplaces pods one at a time; run 10.4 at every step. - etcd: the StatefulSet's default
RollingUpdatestarts from the highest ordinal and waits for each pod to becomeReady. Do not skip versions; verifyendpoint healthreturns three at every step.
Rollback path: the old image tags are in Harbor; oc rollout undo returns the
Deployment to its previous ReplicaSet.
On 2026-09-30 the APISIX Deployment moved from 3.18.0 to 3.19.0 by step 2
(oc set image deploy/apisix, rolling) and the data plane answered
Server: APISIX/3.19.0; etcd was not touched. Whether step 1's backup and the
10.4 verification at every step ran for that change is
not recorded, so the sequence stays unverified.
14.1 Request records of an installation made with the earlier guide
This move was rehearsed on a development stack on 2026-10-02: an installation made as the earlier guide wrote it, then the steps below in this order, with the outputs shown. It has not been carried out on this machine yet.
The earlier version of this guide wrote the logger into global rule 1 by hand:
with the elastic account, an index named g8-okd-access- and the machine's
local day, and a lifecycle policy that deleted the indices. Such an installation
is moved in this order:
Settings offers Turn on traffic logging as soon as the pipeline and the template
are applied.
Turning on over the old rule completes it in place: the account is neither asked
for nor tried, and elastic stays in the rule.
- Open the two accounts (11.6).
- Connect the store in the g8 console with
g8-okd-server(step 1 of 11.7). Settings then reads Index template Outdated, Ingest pipeline Missing and the logger rule Customized. From its next daily cleanup on, g8 deletes the days past the retention. - Apply the ingest pipeline and then the index template (11.6). They apply to the days born after them.
- Take the lifecycle policy off the indices and delete it:
oc -n g8-gateway exec elasticsearch-0 -- sh -c 'curl -s -u elastic:$ELASTIC_PASSWORD \
-X PUT "http://127.0.0.1:9200/g8-*-access-*/_settings" \
-H "Content-Type: application/json" -d "{\"index.lifecycle.name\":null}"'
oc -n g8-gateway exec elasticsearch-0 -- sh -c 'curl -s -u elastic:$ELASTIC_PASSWORD \
-X DELETE http://127.0.0.1:9200/_ilm/policy/g8-access-retention'
{"acknowledged":true}{"acknowledged":true}
- The policy kept the indices 14 days. g8 keeps them 30 days unless that is changed: set the number of days this store is sized for under Traffic Explorer › Settings › Retention › Change.
- Run the command of 11.5 again. It prints
200here; rule1then carries the metrics alone and Settings reads the logger rule Off. Only then turn traffic logging on, withg8-okd-gateway(steps 2 and 3 of 11.7). - Change the password of
elastic: it was part of the gateway's configuration for as long as the old rule existed.
No record is written between the two halves of step 6. Every day written before the move, and the day of the move itself, was born without g8's template and without the fields its records carry: Traffic Explorer cannot list those days, and Settings counts them as written under another version. They stay in the store until the retention deletes them. The records are listed from the first day whose index is born after the move.
g8 lists and deletes the days of the environment's own slug alone. If this
gateway is registered in g8 under another slug than okd, the days the old rule
wrote have no deleter once the policy is gone: delete them by their names when
they are no longer needed.
14.2 The ingest pipeline on an installation made before it
This move was rehearsed on 2026-10-04 against a development stack's Elasticsearch 8.15.3: an installation with the earlier template and a day born under it, then the steps below in this order, with the outputs shown. It has not been carried out on this machine yet.
An installation made before this version of the guide holds the index template without the ingest pipeline, and its server account may not read pipelines. Settings then reads Index template Outdated and Ingest pipeline Cannot be read. The gateway keeps writing records meanwhile, copies included, and the settings can still be saved; g8 refuses only to turn logging on anew until both read Applied. The move, in this order:
- Run the block that writes the two roles and the two users again
(11.6). It keeps the passwords and gives the server's
role the line that reads the pipeline; every line answers
200. - Apply the ingest pipeline (11.6).
- Apply the index template (11.6). The days born after it send their records through the pipeline.
- Send the day of today, born under the earlier template, through the pipeline at once. On a store that serves several environments, run it once for each environment's slug:
DAY=$(date -u +%Y.%m.%d)
oc -n g8-gateway exec elasticsearch-0 -- sh -c \
"curl -s -u elastic:\$ELASTIC_PASSWORD -X PUT http://127.0.0.1:9200/g8-okd-access-$DAY/_settings \
-H 'Content-Type: application/json' -d '{\"index.default_pipeline\":\"g8-access\"}'"
{"acknowledged":true}
A store that holds no record of today yet answers 404 index_not_found_exception
here: today's day will be born under the new template, and nothing more is
needed. Settings then reads both Applied. A day written before the move may hold copies
of a batch the gateway delivered twice; it stays as it is until the retention
deletes it.
15. Traps hit during installation
15.1 With OrderedReady, the etcd bootstrap deadlocks
Symptom: three replicas are requested but only one pod is created, and it
stays at 0/1.
etcd-0 0/1 Running
The default podManagementPolicy is OrderedReady: the StatefulSet does not
create etcd-1 until etcd-0 is Ready. etcd-0's readiness probe is
/readyz, and in a three-member cluster it can never be ready
alone because it cannot form a quorum. The log loops on raft elections:
msg":"4130acc46e67d483 became pre-candidate at term 1
The fix is podManagementPolicy: Parallel. That field is immutable; it
cannot be applied to an existing StatefulSet:
The StatefulSet "etcd" is invalid: spec: Forbidden: updates to statefulset spec for fields other than 'replicas', 'ordinals', 'template', 'updateStrategy', 'persistentVolumeClaimRetentionPolicy' and 'minReadySeconds' are forbidden
The StatefulSet is deleted and recreated. The PVCs left over from the failed bootstrap must be deleted too; a half-written member record breaks the new cluster.
15.2 Testing the SCC with oc run gives a false measurement
Symptom: no error — a wrong "success". The pod runs as the image's own uid and is believed to work in a restricted environment.
scc: anyuid
uid=636(apisix) gid=636(apisix)
The SCC is chosen based on who created the pod. If you create a pod directly
with oc run, that is you; a cluster admin has anyuid through
system:cluster-admins. A pod created by a controller (Deployment, StatefulSet)
is evaluated against the namespace's ServiceAccount and gets restricted-v2.
The correct test is to create it the way the real workload is created: apply a
Deployment and read the pod's openshift.io/scc annotation.
Impersonating with --as=system:serviceaccount:... does not work either: the
ServiceAccount has no RBAC to create pods, so the request is denied at RBAC
before it ever reaches SCC.
15.3 allow_admin produces intermittent 403s behind a Service
Symptom: the same client, with the same command, gets 200 sometimes and 403 other times.
{"remote_addr":"10.128.0.215","status":"401"}
{"remote_addr":"169.254.0.5","status":"403"}
When a request through the Service is routed back to its own pod (hairpin), OVN
translates the source address to the masquerade address. When the same request
lands on a different pod, the source is preserved. The result: if allow_admin
contains only the pod network, requests are rejected at random.
The masquerade network is read from the cluster:
oc -n openshift-ovn-kubernetes exec ds/ovnkube-node -c ovnkube-controller -- ip -4 route | grep 169.254
169.254.0.0/17 dev br-ex proto kernel scope link src 169.254.0.2
That network is added to allow_admin. But the real lesson is this: in
Kubernetes the source IP is not access control; the control is the NetworkPolicy
in section 8.
15.4 The etcd image contains no shell
Symptom:
executable file `sh` not found in $PATH: No such file or directory
The etcd image carries only the etcd binaries. oc exec … -- sh -c does not
work; commands are given directly (oc exec etcd-0 -- etcdctl …).
The same constraint shows up in the manifest: shell substitutions such as
$(date) cannot be used in command/args. Kubernetes' own $(VAR)
substitution is used instead — that one is done by kubelet, not a shell. Steps
that do need a shell (pruning files) are run as an initContainer from an image
that has one.
15.5 APISIX does not wait for etcd; it exits
Symptom: during an etcd outage, new APISIX pods enter CrashLoopBackOff. The
operator thinks APISIX is broken.
exitCode=1 reason=Error
request etcd endpoint 'http://etcd-0.etcd:2379/version' error, connection refused
If APISIX cannot reach etcd during its init_etcd startup step, it exits with 1;
it does not retry. In Kubernetes this heals itself: kubelet restarts the pod and
it comes up once etcd returns. Measured in this environment: Running after two
restarts.
Pods that are already running are unaffected — they keep serving from cache throughout the etcd outage (10.8).
15.6 A restart wait fires too early
Symptom: the wait loop reports "ready" on its first iteration, when nothing has changed yet.
Immediately after rollout restart, the old pods are still 1/1 Running. A
loop counting grep -c 1/1 counts those and its condition is satisfied; the real
state, while the new pods are coming up, is something else entirely.
Instead of a loop counting pods, wait on the restart itself:
oc -n g8-gateway rollout status deploy/apisix --timeout=300s
oc -n g8-gateway rollout status sts/etcd --timeout=300s
15.7 On quorum loss the first write hangs, later ones fail instantly
Symptom: the first Admin API write after etcd loses quorum hangs for tens of seconds. Running the same command immediately afterwards returns in milliseconds. The operator thinks "the server locked up" on the first attempt and "something recovered" on the second; both readings are wrong.
healthy: PUT=201 time=0.095456s
req1: PUT=503 time=42.382359s
req2: PUT=503 time=0.024547s
req3: PUT=503 time=0.029827s
The first request tries the three endpoints in turn and times out on each. After that APISIX marks the endpoints unhealthy and answers from its own record without going to etcd at all:
{"error_msg":"has no healthy etcd endpoint available"}
Two consequences: (1) an automation client's timeout must be chosen for the first
request, and (2) a fast 503 does not mean etcd is back — the state must be
asked for with endpoint health.
The measurement differs in the prod (Docker) installation: there the requests do
not get faster and each takes tens of seconds (03-prod-docker, 17.2). The two
measurements are not interchangeable.
15.8 A liveness probe on cluster health kills the last member
Symptom: during quorum loss the one surviving etcd member is restarted over
and over. oc describe shows:
Killing … (x6 over 10m) Container etcd failed liveness probe, will be restarted
etcdctl endpoint health is a cluster health check: it performs a read that
requires quorum. Put the same command on both readiness and liveness and, on
quorum loss, readiness failing is correct (the pod should leave the Service)
while liveness failing is a disaster — kubelet kills the last pod holding the
data roughly every 90 seconds, and the loop does not stop until quorum returns.
Since 3.5.12 etcd exposes two endpoints precisely to separate the two. Measured:
no quorum:
/livez -> HTTP/1.1 200 OK
/readyz -> HTTP/1.1 503 Service Unavailable
That is why the manifest in section 5 points liveness at /livez and readiness
at /readyz. Before and after the fix, across three minutes of quorum loss:
etcdctl endpoint health -> restartCount 1 → 5 → 6
/livez + /readyz -> restartCount 0 → 0 → 0
The rule: liveness must ask "is the process running", not "is the cluster healthy". The second question belongs to readiness.
15.9 The NOSPACE alarm is a one-way door
Symptom: writes are refused with etcdserver: mvcc: database space exceeded.
Nobody notices, because the data plane keeps returning 200 — and once it is
noticed, freeing space is not enough.
The recovery chain was measured, and all three steps are required:
159 writes -> dbSize=16789504 alarm=NOSPACE
delete + compact -> dbSize=16789504 write refused
defrag -> dbSize=122880 write refused
alarm disarm -> write OK
There are three separate misconceptions here, each verified individually:
- Deleting keys does not free space. etcd keeps the revision history; a delete writes a new revision.
- Compaction does not return disk space.
dbSizeInUsedrops,dbSizedoes not move at all — the freed pages stay free inside etcd and never return to the filesystem. Since the quota watchesdbSize, the quota is still full. The extreme measured in this environment:dbSizeInUseat 41 kB whiledbSizesat at 98 MB. - Defrag returns the space but does not lower the alarm. The alarm is sticky: it survives a process restart, a container recreation and a host reboot.
Recovery:
for n in 0 1 2; do
oc -n g8-gateway exec etcd-$n -- etcdctl --command-timeout=900s defrag
done
oc -n g8-gateway exec etcd-0 -- etcdctl --endpoints=http://etcd-0.etcd:2379,http://etcd-1.etcd:2379,http://etcd-2.etcd:2379 alarm disarm
Leave alarm disarm for last: disarming without actually freeing space puts you
straight back where you were on the next write.
The lasting fix is scheduled defragmentation — see
long-term operation. With no defrag scheduled, dbSize keeps
growing even when compaction is working correctly, and the quota fills sooner or
later.
15.10 A member that loses its disk cannot rejoin on its own
Symptom: a member loses its data directory (disk failure, accidental
deletion, a lost PVC). When it comes back it loops forever, logging
cluster ID mismatch or member has already been bootstrapped.
What makes it insidious: member list still shows that member as started
and the data plane keeps returning 200. The cluster silently drops to 2/3, and
losing one more member takes quorum with it.
The cause is ETCD_INITIAL_CLUSTER_STATE: new hardcoded in the manifest. Seeing
an empty data directory, etcd tries to bootstrap a new cluster rather than
join the existing one.
The recovery chain was measured:
STATE=new + bos veri dizini -> cluster ID mismatch (uye asla katilmaz)
member remove + olcek dusur -> veri dizini gercekten bos
member add (saglikli kume) -> ETCD_INITIAL_CLUSTER=...
STATE=existing, tek acilis -> healthy=3/3
STATE=new'e donus -> healthy=3/3, veri yerinde
The critical detail: STATE is only read while the data directory is empty.
So existing is needed for exactly one start; once the member has joined it
runs indefinitely under its normal definition.
Two steps are the easiest to miss and both are mandatory:
- Scale down. Deleting the pod is not enough; the StatefulSet recreates it immediately and etcd rebuilds the data directory. The member has to be gone before you wipe the directory.
- Verify the remaining cluster is healthy.
member addrefuses withetcdserver: unhealthy clusterwhile the cluster is unhealthy.
When wiping the data directory, delete hidden entries too; the pattern
rm -rf dir/* skips entries beginning with ..
Recovery:
E=http://etcd-0.etcd:2379,http://etcd-1.etcd:2379,http://etcd-2.etcd:2379
E2=http://etcd-0.etcd:2379,http://etcd-1.etcd:2379
ID=$(oc -n g8-gateway exec etcd-0 -- etcdctl --endpoints=$E member list -w simple | grep 'etcd-2' | cut -d, -f1)
oc -n g8-gateway exec etcd-0 -- etcdctl --endpoints=$E2 member remove "$ID"
oc -n g8-gateway scale sts etcd --replicas=2
oc -n g8-gateway delete pvc data-etcd-2
oc -n g8-gateway exec etcd-0 -- etcdctl --endpoints=$E2 endpoint health
ADD=$(oc -n g8-gateway exec etcd-0 -- etcdctl --endpoints=$E2 \
member add etcd-2 --peer-urls=http://etcd-2.etcd:2380)
IC=$(echo "$ADD" | grep -oE 'ETCD_INITIAL_CLUSTER="[^"]*"' | sed 's/ETCD_INITIAL_CLUSTER="//; s/"$//')
oc -n g8-gateway set env sts/etcd ETCD_INITIAL_CLUSTER_STATE=existing "ETCD_INITIAL_CLUSTER=$IC"
oc -n g8-gateway scale sts etcd --replicas=3
Once the member has joined, return to the normal definition:
oc -n g8-gateway set env sts/etcd ETCD_INITIAL_CLUSTER_STATE=new \
"ETCD_INITIAL_CLUSTER=etcd-0=http://etcd-0.etcd:2380,etcd-1=http://etcd-1.etcd:2380,etcd-2=http://etcd-2.etcd:2380"
15.11 ConfigMap and Secret changes never reach the pods
Symptom: you changed config.yaml, the ConfigMap was updated, and nothing
changed. There is no error; the pods keep reading the old content.
The ConfigMap is mounted with subPath — a single file rather than the whole
directory. Kubernetes does not update files mounted via subPath; this is
documented behaviour, not a defect. The same applies to the Secret holding the
admin key.
Measured:
ConfigMap d04c459e -> pod d04c459e
ConfigMap 6c4bb814 -> 90 s sonra pod hala d04c459e
rollout restart -> pod 6c4bb814
The way to put a change into effect is to recreate the pods:
oc -n g8-gateway rollout restart deploy/apisix
oc -n g8-gateway rollout status deploy/apisix --timeout=300s
This is also how the admin key is rotated: update the Secret, then
rollout restart. The restart is not disorderly — the Deployment replaces pods
one at a time, and the PodDisruptionBudget in 7.1 guarantees at least
two pods stay up.
16. Long-term operation
| topic | setting | why |
|---|---|---|
| etcd growth | ETCD_AUTO_COMPACTION_MODE=periodic, RETENTION=1 | Without compaction etcd keeps the old revision of every write |
| etcd quota | ETCD_QUOTA_BACKEND_BYTES=2147483648 | The PVC is 2Gi; the quota must be smaller than the disk so etcd alarms before the disk fills |
| Logs | access_log: /dev/stdout, error_log: /dev/stderr | Writing to the container's filesystem is not durable and is not rotated |
| Log rotation | kubelet: containerLogMaxSize=50Mi, containerLogMaxFiles=5 | At most 250 MB per container. No logrotate is installed on top |
| File descriptors | no setting needed | The CRI-O default measured 1048576 on this cluster; the 1024 problem from Docker does not exist here |
| Placement | podAntiAffinity: preferred | On a single-node cluster required leaves two of the three replicas unschedulable. Make it required on a multi-node cluster |
| Probe target | control port 9090 | A bare TCP connection to 9080 puts a 400 line into the access log |
| Backup | Daily CronJob, 14 files | Section 9 |
| etcd access | NetworkPolicy | etcd has no authentication; this is the only real control |
That backups are actually being produced must be checked regularly:
oc -n g8-gateway get cronjob etcd-backup
oc -n g8-gateway get job --sort-by=.metadata.creationTimestamp | tail -3
17. Scope limits and next steps
What this installation does not do, stated plainly:
- It was verified on a single-node cluster. Three replicas verify the shape
of the manifests, not fault tolerance. On a multi-node cluster
podAntiAffinitymust berequired, and that change was not tried here. - There is no TLS. Traffic between APISIX and etcd, and the Admin API, are plain HTTP. The Route does not terminate TLS either. TLS is separate work.
- The real client IP does not reach APISIX — 10.5.
- etcd has no authentication. Access is restricted only by NetworkPolicy.
- Restore was not attempted. A backup is taken and its validity verified, but bringing a three-member cluster up from a snapshot was not drilled.
- The upgrade was not attempted — section 14.
- Behaviour under load was not measured. Every verification is a single request.
- There is no HorizontalPodAutoscaler or resource quota. A PodDisruptionBudget is created in 7.1.