Forgejo OOM crash loop under faceted issue-query flood, 2026-08-24 #909

Closed
opened 2026-08-24 18:33:38 +00:00 by coilyco-ops · 8 comments
Owner

Summary

forgejo/forgejo-6df4ddf4d5-f6zjh on kai-server is in an OOMKill crash loop. 46 container restarts on a pod whose current instance dates to 2026-08-12, with 5 of those restarts inside a 90-minute window on 2026-08-24. Each kill produced a user-visible outage of up to ~14 minutes because kubelet CrashLoopBackOff had grown to its 5-minute ceiling.

Reported by Kai as "forgejo was just down for like 10 minutes" at 2026-08-24 18:28 UTC.

Observed before-state

Container status at 18:30 UTC, from kubectl describe pod:

Last State:     Terminated
  Reason:       OOMKilled
  Exit Code:    137
  Started:      Mon, 24 Aug 2026 11:16:36 -0700
  Finished:     Mon, 24 Aug 2026 11:25:46 -0700
Restart Count:  46
Limits:
  memory:  1500Mi
Requests:
  memory:  512Mi
Liveness:  http-get http://:3000/api/healthz delay=60s timeout=5s period=30s #success=1 #failure=6

Process start banners (cmd/web.go:250:runWeb() [I] Starting Forgejo on PID: 7), instance number from k8s.container.restart_count:

  • 42 - 16:55:32 UTC
  • 43 - 17:28:24 UTC
  • 44 - 18:02:30 UTC
  • 45 - 18:16:37 UTC, OOMKilled 18:25:46 UTC after 9m10s
  • 46 - 18:26:01 UTC, running and Ready as of 18:32 UTC

The gap Kai noticed is the 18:02:30 to 18:16:37 window, ~14 minutes with no serving process.

Node was healthy throughout. MemoryPressure=False, DiskPressure=False, PIDPressure=False, Ready=True, 32 GiB capacity. This is a container-limit OOM, not node exhaustion.

Trigger

Immediately before instance 45 died, Forgejo was serving a flood of faceted issue and pull list queries across ward, umbra, and sirens-echo, permuting assignee, labels, milestone, poster, project, state, and type. Latency climbed from ~3s to 298s inside 9 minutes:

18:25:44 [W] router: slow GET /coilyco-flight-deck/umbra/issues?assignee=0&labels=197%2C210%2C225%2C323%2C384&... elapsed 3151.1ms @ repo/issue.go:515(repo.Issues)
18:25:45 [I] router: completed GET /coilyco-flight-deck/ward/issues?assignee=7&labels=198%2C323%2C345%2C347%2C351&milestone=9&... 200 OK in 298336.4ms @ repo/issue.go:515(repo.Issues)
18:25:45 [E] GetIssueStats: context canceled
18:25:45 [E] GetLabelsByOrgID: context canceled
18:25:45 [E] GetReleaseCountByRepoID: context canceled

The permutation pattern is consistent with a crawler walking every filter-combination URL. Forgejo 16 serves those combinations without a crawl guard.

Contributing conditions

The rate limiter cannot see individual clients. forgejo/forgejo-ratelimit (94d old) is average: 50, burst: 100, period: 1s. Traefik's default sourceCriterion keys on the direct remote address, and klipper-lb (svclb-traefik, externalTrafficPolicy: Cluster) SNATs every inbound connection to 10.42.0.167. Every client on the internet therefore shares one 50 rps bucket. The limiter does not bound the crawler per-source, and it does 429 legitimate traffic that shares the bucket.

Client attribution is lost. Every request in Forgejo's router log shows for 10.42.0.167:0, the klipper-lb pod IP, despite FORGEJO__security__REVERSE_PROXY_TRUSTED_PROXIES=10.42.0.0/16. The real source address is destroyed at the klipper-lb hop before Traefik sets X-Forwarded-For, so there is currently no way to identify or block the offending client.

Blast radius

Flux GitOps reconciliation is stalled on the same failures. bridge-deploy and infrastructure Kustomizations both report empty READY status.

GitOperationFailed: unable to list remote for 'https://forgejo.coilysiren.me/coilyco-flight-deck/infrastructure.git': status code: 503   (count 185, 18:02:37 UTC)
GitOperationFailed: unable to list remote for 'https://forgejo.coilysiren.me/coilyco-flight-deck/infrastructure.git': status code: 429   (count 24,  18:27:47 UTC)
GitOperationFailed: unable to list remote for 'https://forgejo.coilysiren.me/coilyco-bridge/deploy.git':            status code: 503   (count 169, 18:02:46 UTC)

503 during the crash windows, 429 from the shared rate-limit bucket once Forgejo is back up.

After-state at time of filing

Forgejo is up and fast: /api/healthz returns 200 with cache:ping and database:ping both pass, in 27-102ms. Instance 46 has held for ~7 minutes with no further restart. Container RSS 313 MB against the 1500Mi limit.

No change has been applied. The conditions that produced the crash loop are all still in place, so a recurrence is expected rather than possible.

Containment still outstanding

Nothing is fixed. Candidate work, unsequenced:

  1. Restore client attribution. externalTrafficPolicy: Local on the traefik service, or another path that preserves the source address through klipper-lb. Everything below is guesswork without this.
  2. Re-key the rate limiter once attribution works, so the bucket is per-source rather than global.
  3. Add a crawl guard on the faceted issue and pull query endpoints. robots.txt at minimum, and consider whether the combinations should be reachable unauthenticated at all.
  4. Reconsider the 1500Mi limit against what repo.Issues actually costs under concurrency. Raising it alone buys time and does not stop the flood.
  5. Bring the Forgejo Deployment under GitOps. It carries no kustomize.toolkit, helm.toolkit, or meta.helm labels and appears in neither infrastructure nor deploy. It is hand-applied, so none of the above is reviewable or reproducible today.

Evidence sources

kai-server k3s API (kubectl describe pod, get middleware, get kustomization, node conditions and events), SigNoz logs for k8s.namespace.name = 'forgejo', metrics-server container memory, and direct HTTPS probes of forgejo.coilysiren.me.

## Summary `forgejo/forgejo-6df4ddf4d5-f6zjh` on kai-server is in an OOMKill crash loop. 46 container restarts on a pod whose current instance dates to 2026-08-12, with 5 of those restarts inside a 90-minute window on 2026-08-24. Each kill produced a user-visible outage of up to ~14 minutes because kubelet CrashLoopBackOff had grown to its 5-minute ceiling. Reported by Kai as "forgejo was just down for like 10 minutes" at 2026-08-24 18:28 UTC. ## Observed before-state Container status at 18:30 UTC, from `kubectl describe pod`: ``` Last State: Terminated Reason: OOMKilled Exit Code: 137 Started: Mon, 24 Aug 2026 11:16:36 -0700 Finished: Mon, 24 Aug 2026 11:25:46 -0700 Restart Count: 46 Limits: memory: 1500Mi Requests: memory: 512Mi Liveness: http-get http://:3000/api/healthz delay=60s timeout=5s period=30s #success=1 #failure=6 ``` Process start banners (`cmd/web.go:250:runWeb() [I] Starting Forgejo on PID: 7`), instance number from `k8s.container.restart_count`: * 42 - 16:55:32 UTC * 43 - 17:28:24 UTC * 44 - 18:02:30 UTC * 45 - 18:16:37 UTC, OOMKilled 18:25:46 UTC after 9m10s * 46 - 18:26:01 UTC, running and Ready as of 18:32 UTC The gap Kai noticed is the 18:02:30 to 18:16:37 window, ~14 minutes with no serving process. Node was healthy throughout. `MemoryPressure=False`, `DiskPressure=False`, `PIDPressure=False`, `Ready=True`, 32 GiB capacity. This is a container-limit OOM, not node exhaustion. ## Trigger Immediately before instance 45 died, Forgejo was serving a flood of faceted issue and pull list queries across `ward`, `umbra`, and `sirens-echo`, permuting `assignee`, `labels`, `milestone`, `poster`, `project`, `state`, and `type`. Latency climbed from ~3s to 298s inside 9 minutes: ``` 18:25:44 [W] router: slow GET /coilyco-flight-deck/umbra/issues?assignee=0&labels=197%2C210%2C225%2C323%2C384&... elapsed 3151.1ms @ repo/issue.go:515(repo.Issues) 18:25:45 [I] router: completed GET /coilyco-flight-deck/ward/issues?assignee=7&labels=198%2C323%2C345%2C347%2C351&milestone=9&... 200 OK in 298336.4ms @ repo/issue.go:515(repo.Issues) 18:25:45 [E] GetIssueStats: context canceled 18:25:45 [E] GetLabelsByOrgID: context canceled 18:25:45 [E] GetReleaseCountByRepoID: context canceled ``` The permutation pattern is consistent with a crawler walking every filter-combination URL. Forgejo 16 serves those combinations without a crawl guard. ## Contributing conditions **The rate limiter cannot see individual clients.** `forgejo/forgejo-ratelimit` (94d old) is `average: 50, burst: 100, period: 1s`. Traefik's default `sourceCriterion` keys on the direct remote address, and klipper-lb (`svclb-traefik`, `externalTrafficPolicy: Cluster`) SNATs every inbound connection to `10.42.0.167`. Every client on the internet therefore shares one 50 rps bucket. The limiter does not bound the crawler per-source, and it does 429 legitimate traffic that shares the bucket. **Client attribution is lost.** Every request in Forgejo's router log shows `for 10.42.0.167:0`, the klipper-lb pod IP, despite `FORGEJO__security__REVERSE_PROXY_TRUSTED_PROXIES=10.42.0.0/16`. The real source address is destroyed at the klipper-lb hop before Traefik sets `X-Forwarded-For`, so there is currently no way to identify or block the offending client. ## Blast radius Flux GitOps reconciliation is stalled on the same failures. `bridge-deploy` and `infrastructure` Kustomizations both report empty READY status. ``` GitOperationFailed: unable to list remote for 'https://forgejo.coilysiren.me/coilyco-flight-deck/infrastructure.git': status code: 503 (count 185, 18:02:37 UTC) GitOperationFailed: unable to list remote for 'https://forgejo.coilysiren.me/coilyco-flight-deck/infrastructure.git': status code: 429 (count 24, 18:27:47 UTC) GitOperationFailed: unable to list remote for 'https://forgejo.coilysiren.me/coilyco-bridge/deploy.git': status code: 503 (count 169, 18:02:46 UTC) ``` 503 during the crash windows, 429 from the shared rate-limit bucket once Forgejo is back up. ## After-state at time of filing Forgejo is up and fast: `/api/healthz` returns 200 with `cache:ping` and `database:ping` both `pass`, in 27-102ms. Instance 46 has held for ~7 minutes with no further restart. Container RSS 313 MB against the 1500Mi limit. No change has been applied. The conditions that produced the crash loop are all still in place, so a recurrence is expected rather than possible. ## Containment still outstanding Nothing is fixed. Candidate work, unsequenced: 1. Restore client attribution. `externalTrafficPolicy: Local` on the traefik service, or another path that preserves the source address through klipper-lb. Everything below is guesswork without this. 2. Re-key the rate limiter once attribution works, so the bucket is per-source rather than global. 3. Add a crawl guard on the faceted issue and pull query endpoints. `robots.txt` at minimum, and consider whether the combinations should be reachable unauthenticated at all. 4. Reconsider the 1500Mi limit against what `repo.Issues` actually costs under concurrency. Raising it alone buys time and does not stop the flood. 5. Bring the Forgejo Deployment under GitOps. It carries no `kustomize.toolkit`, `helm.toolkit`, or `meta.helm` labels and appears in neither `infrastructure` nor `deploy`. It is hand-applied, so none of the above is reviewable or reproducible today. ## Evidence sources kai-server k3s API (`kubectl describe pod`, `get middleware`, `get kustomization`, node conditions and events), SigNoz logs for `k8s.namespace.name = 'forgejo'`, metrics-server container memory, and direct HTTPS probes of `forgejo.coilysiren.me`.
Author
Owner

Change 1 of 2 applied: Forgejo memory limit 1500Mi -> 3Gi

Applied 2026-08-24 18:55:36 UTC. Kai approved the memory bump plus source attribution, and declined the crawl guard for now.

kubectl diff before apply showed exactly one spec change, memory limit only. cpu, ephemeral-storage, and all requests untouched.

         limits:
           cpu: "1"
           ephemeral-storage: 1Gi
-          memory: 1500Mi
+          memory: 3Gi

Deployment strategy is Recreate, so this replaced the pod. forgejo-6df4ddf4d5-f6zjh (46 restarts) is gone, replaced by forgejo-6fb99c98fc-8hsfp.

Node headroom at time of change: 19.98 GiB available of 33.3 GiB, 40% used. The extra 1.5Gi ceiling is comfortably affordable.

After-state, verified end-to-end rather than by probe alone

healthz                        code=200  0.089s
GET /coilyco-flight-deck/infrastructure   code=200  0.139s
GET /api/v1/.../issues/909                #909 open
git-upload-pack info/refs                 code=200  0.025s

The git-upload-pack path is the one Flux was failing on with 503 and 429. It now returns 200.

As of 19:01 UTC the new pod has held 5m48s with 0 restarts.

Rollback: kubectl apply -f the captured rollback-deployment-forgejo.yaml, or set the limit back to 1500Mi.

Change 2 of 2 NOT applied: traefik externalTrafficPolicy

Prepared, diffed, and blocked by the operator permission classifier. Not applied. This is unresolved and the source-attribution half of the fix is still outstanding.

A trap found while preparing it, worth recording

The obvious route, editing HelmChartConfig/traefik, would have taken down public ingress for every service on the cluster.

Decoding the live Helm release (sh.helm.release.v1.traefik.v3, chart traefik-34.2.1+up34.2.0) shows the user-supplied values contain service.loadBalancerIP: 99.110.50.213, but the chart-rendered Service spec contains no loadBalancerIP at all:

# helm-rendered traefik Service spec
type: LoadBalancer
selector: {app.kubernetes.io/name: traefik, app.kubernetes.io/instance: traefik-kube-system}
ipFamilyPolicy: PreferDualStack
ports: [web 80, websecure 443]

Traefik chart 34.x moved that key to service.spec.loadBalancerIP. The old top-level key is silently ignored. The public IP is present on the live Service only because someone hand-applied over the Helm-managed object, which is what the kubernetes.io/change-cause: Update loadBalancerIP to public IP annotation records.

Editing the HelmChartConfig triggers a Helm upgrade, which would re-render the Service without loadBalancerIP and drop the public IP binding for all 20+ public hostnames.

Anyone touching the traefik HelmChartConfig must move loadBalancerIP under service.spec in the same edit. This is a live landmine independent of this incident and deserves its own issue.

The prepared change, direct Service apply

Chosen because the Service is already hand-managed, so this matches the existing pattern and does not trigger a Helm upgrade. It is not durable across a future Helm upgrade, but neither is the current loadBalancerIP, so it makes nothing worse.

kubectl diff confirmed the change is scoped to the policy flip, its auto-allocated healthCheckNodePort, and the change-cause annotation. loadBalancerIP, ports, nodePorts (31400/31428), and selector all unchanged.

-  externalTrafficPolicy: Cluster
+  externalTrafficPolicy: Local
+  healthCheckNodePort: 32769

Full manifest, recorded here so it survives the session:

apiVersion: v1
kind: Service
metadata:
  annotations:
    kubernetes.io/change-cause: "Set externalTrafficPolicy=Local to preserve client source IP through klipper-lb (infrastructure#909)"
    meta.helm.sh/release-name: traefik
    meta.helm.sh/release-namespace: kube-system
  labels:
    app.kubernetes.io/instance: traefik-kube-system
    app.kubernetes.io/managed-by: Helm
    app.kubernetes.io/name: traefik
    helm.sh/chart: traefik-34.2.1_up34.2.0
  name: traefik
  namespace: kube-system
spec:
  allocateLoadBalancerNodePorts: true
  clusterIP: 10.43.103.250
  clusterIPs:
  - 10.43.103.250
  externalTrafficPolicy: Local
  internalTrafficPolicy: Cluster
  ipFamilies:
  - IPv4
  ipFamilyPolicy: PreferDualStack
  loadBalancerIP: 99.110.50.213
  ports:
  - name: web
    nodePort: 31400
    port: 80
    protocol: TCP
    targetPort: web
  - name: websecure
    nodePort: 31428
    port: 443
    protocol: TCP
    targetPort: websecure
  selector:
    app.kubernetes.io/instance: traefik-kube-system
    app.kubernetes.io/name: traefik
  sessionAffinity: None
  type: LoadBalancer

Safety argument: kai-server is the only node in this cluster and it runs the traefik pod, so Local cannot blackhole traffic to a node without a backend. Expected impact is a brief blip while kube-proxy and klipper-lb reprogram.

Pre-change baseline for comparison after it lands, all via the public IP 99.110.50.213:

forgejo.coilysiren.me      200  0.025s
eco-gnome.coilysiren.me    200  0.042s
factory.coilysiren.me      200  0.130s
galaxy-gen.coilysiren.me   200  0.049s
website.coilysiren.me      200  0.049s
auth.coilysiren.me         200  0.043s

Residual risk

The memory bump raises the OOM ceiling. It does not stop the crawler. If the flood returns at the same intensity, Forgejo may still reach 3Gi.

Once externalTrafficPolicy: Local lands, the existing forgejo-ratelimit middleware becomes per-source automatically, because Traefik's default sourceCriterion keys on the remote address, which will finally be the real client. That stops Flux and other legitimate traffic taking collateral 429s. But 50 rps per source is still enough for a single crawler to hurt Forgejo, so the rate limit likely wants lowering, and item 3 in the original description, the crawl guard, remains the actual fix for the trigger.

## Change 1 of 2 applied: Forgejo memory limit 1500Mi -> 3Gi Applied 2026-08-24 18:55:36 UTC. Kai approved the memory bump plus source attribution, and declined the crawl guard for now. `kubectl diff` before apply showed exactly one spec change, memory limit only. cpu, ephemeral-storage, and all requests untouched. ``` limits: cpu: "1" ephemeral-storage: 1Gi - memory: 1500Mi + memory: 3Gi ``` Deployment strategy is `Recreate`, so this replaced the pod. `forgejo-6df4ddf4d5-f6zjh` (46 restarts) is gone, replaced by `forgejo-6fb99c98fc-8hsfp`. Node headroom at time of change: 19.98 GiB available of 33.3 GiB, 40% used. The extra 1.5Gi ceiling is comfortably affordable. ### After-state, verified end-to-end rather than by probe alone ``` healthz code=200 0.089s GET /coilyco-flight-deck/infrastructure code=200 0.139s GET /api/v1/.../issues/909 #909 open git-upload-pack info/refs code=200 0.025s ``` The `git-upload-pack` path is the one Flux was failing on with 503 and 429. It now returns 200. As of 19:01 UTC the new pod has held 5m48s with **0 restarts**. Rollback: `kubectl apply -f` the captured `rollback-deployment-forgejo.yaml`, or set the limit back to 1500Mi. ## Change 2 of 2 NOT applied: traefik externalTrafficPolicy Prepared, diffed, and **blocked by the operator permission classifier**. Not applied. This is unresolved and the source-attribution half of the fix is still outstanding. ### A trap found while preparing it, worth recording The obvious route, editing `HelmChartConfig/traefik`, **would have taken down public ingress for every service on the cluster.** Decoding the live Helm release (`sh.helm.release.v1.traefik.v3`, chart `traefik-34.2.1+up34.2.0`) shows the user-supplied values contain `service.loadBalancerIP: 99.110.50.213`, but the chart-rendered Service spec contains **no `loadBalancerIP` at all**: ```yaml # helm-rendered traefik Service spec type: LoadBalancer selector: {app.kubernetes.io/name: traefik, app.kubernetes.io/instance: traefik-kube-system} ipFamilyPolicy: PreferDualStack ports: [web 80, websecure 443] ``` Traefik chart 34.x moved that key to `service.spec.loadBalancerIP`. The old top-level key is silently ignored. The public IP is present on the live Service **only** because someone hand-applied over the Helm-managed object, which is what the `kubernetes.io/change-cause: Update loadBalancerIP to public IP` annotation records. Editing the HelmChartConfig triggers a Helm upgrade, which would re-render the Service without `loadBalancerIP` and drop the public IP binding for all 20+ public hostnames. **Anyone touching the traefik HelmChartConfig must move `loadBalancerIP` under `service.spec` in the same edit.** This is a live landmine independent of this incident and deserves its own issue. ### The prepared change, direct Service apply Chosen because the Service is already hand-managed, so this matches the existing pattern and does not trigger a Helm upgrade. It is not durable across a future Helm upgrade, but neither is the current `loadBalancerIP`, so it makes nothing worse. `kubectl diff` confirmed the change is scoped to the policy flip, its auto-allocated `healthCheckNodePort`, and the change-cause annotation. `loadBalancerIP`, ports, nodePorts (31400/31428), and selector all unchanged. ``` - externalTrafficPolicy: Cluster + externalTrafficPolicy: Local + healthCheckNodePort: 32769 ``` Full manifest, recorded here so it survives the session: ```yaml apiVersion: v1 kind: Service metadata: annotations: kubernetes.io/change-cause: "Set externalTrafficPolicy=Local to preserve client source IP through klipper-lb (infrastructure#909)" meta.helm.sh/release-name: traefik meta.helm.sh/release-namespace: kube-system labels: app.kubernetes.io/instance: traefik-kube-system app.kubernetes.io/managed-by: Helm app.kubernetes.io/name: traefik helm.sh/chart: traefik-34.2.1_up34.2.0 name: traefik namespace: kube-system spec: allocateLoadBalancerNodePorts: true clusterIP: 10.43.103.250 clusterIPs: - 10.43.103.250 externalTrafficPolicy: Local internalTrafficPolicy: Cluster ipFamilies: - IPv4 ipFamilyPolicy: PreferDualStack loadBalancerIP: 99.110.50.213 ports: - name: web nodePort: 31400 port: 80 protocol: TCP targetPort: web - name: websecure nodePort: 31428 port: 443 protocol: TCP targetPort: websecure selector: app.kubernetes.io/instance: traefik-kube-system app.kubernetes.io/name: traefik sessionAffinity: None type: LoadBalancer ``` Safety argument: kai-server is the only node in this cluster and it runs the traefik pod, so `Local` cannot blackhole traffic to a node without a backend. Expected impact is a brief blip while kube-proxy and klipper-lb reprogram. Pre-change baseline for comparison after it lands, all via the public IP 99.110.50.213: ``` forgejo.coilysiren.me 200 0.025s eco-gnome.coilysiren.me 200 0.042s factory.coilysiren.me 200 0.130s galaxy-gen.coilysiren.me 200 0.049s website.coilysiren.me 200 0.049s auth.coilysiren.me 200 0.043s ``` ## Residual risk The memory bump raises the OOM ceiling. It does not stop the crawler. If the flood returns at the same intensity, Forgejo may still reach 3Gi. Once `externalTrafficPolicy: Local` lands, the existing `forgejo-ratelimit` middleware becomes per-source automatically, because Traefik's default `sourceCriterion` keys on the remote address, which will finally be the real client. That stops Flux and other legitimate traffic taking collateral 429s. But 50 rps per source is still enough for a single crawler to hurt Forgejo, so the rate limit likely wants lowering, and item 3 in the original description, the crawl guard, remains the actual fix for the trigger.
Author
Owner

Correction: change 2 landed but did NOT achieve source attribution

Kai applied the traefik Service change at ~20:00 UTC. It applied cleanly and broke nothing. It also did not do what I said it would. Correcting the record.

What I claimed, and why it was wrong

In the previous comment I wrote:

Once externalTrafficPolicy: Local lands, the existing forgejo-ratelimit middleware becomes per-source automatically, because Traefik's default sourceCriterion keys on the remote address, which will finally be the real client.

That is false. Client source IP is still not preserved.

Evidence

The policy change caused k3s to recreate the ServiceLB pod. It came back with a new name and a new IP:

svclb-traefik-bcc27bfa-2m44w   2/2   Running   0   62s   10.42.0.93   kai-server

Forgejo's router log post-change, timestamps after the apply:

2026/08/24 20:01:46  for 10.42.0.93
2026/08/24 20:01:48  for 10.42.0.93
2026/08/24 20:01:52  for 10.42.0.93
2026/08/24 20:02:07  for 10.42.0.93
...

Every external request is still masked. The masking IP simply moved from the old ServiceLB pod 10.42.0.167 to the new one 10.42.0.93.

Why

k3s ServiceLB (klipper-lb) forwards traffic through a pod's network namespace, so the packet is SNATed to that pod's IP before it ever reaches Traefik. externalTrafficPolicy: Local governs which nodes are eligible to receive traffic. It does not remove the klipper-lb hop, so it cannot preserve the source address here. On a single-node cluster it changes effectively nothing.

The rate limiter therefore still keys the entire internet into one shared 50 rps bucket, exactly as described in the original report. Nothing about that has improved.

What would actually work

Source preservation requires removing the klipper-lb hop, not reconfiguring around it:

  1. Bypass ServiceLB for traefik. Run the traefik pod with hostPort on 80/443, or hostNetwork: true, and disable ServiceLB for that Service. Traefik then binds the node's ports directly and sees the router's DNAT source address. Simplest option for a single-node cluster.
  2. Replace ServiceLB with MetalLB in L2 mode, which does honor externalTrafficPolicy: Local for source preservation. Heavier, and more moving parts than this cluster needs.

Both need their own change window. Neither is done.

Current state of the applied change

Verified against the pre-change baseline, all via the public IP:

forgejo.coilysiren.me      200  0.024s   (baseline 0.025s)
eco-gnome.coilysiren.me    200  0.103s   (baseline 0.042s)
factory.coilysiren.me      200  0.078s   (baseline 0.130s)
galaxy-gen.coilysiren.me   200  0.046s   (baseline 0.049s)
website.coilysiren.me      200  0.049s   (baseline 0.049s)
auth.coilysiren.me         200  0.044s   (baseline 0.043s)

loadBalancerIP: 99.110.50.213, nodePorts 31400/31428, and the selector all survived as intended. healthCheckNodePort: 30975 was allocated.

The change is harmless and is a reasonable prerequisite for option 1 above, so leaving it in place is defensible. Reverting it is equally defensible since it currently buys nothing. Rollback source is rollback-svc-traefik.yaml, or set externalTrafficPolicy back to Cluster.

Forgejo itself is holding

forgejo-6fb99c98fc-8hsfp, 0 restarts at 66 minutes, against 46 restarts on the previous pod. The 3Gi limit is doing its job so far.

Confirmed the git path is genuinely working rather than returning an HTML page:

HTTP/2 200
content-type: application/x-git-upload-pack-advertisement

Flux, partial recovery

bridge-deploy GitRepository recovered: stored artifact for revision 'main@sha1:be74966d852b73d186415e3bdf43377154959cda'.

flux-system GitRepository (the infrastructure repo) is still failing, and the error is current as of 20:02:09 UTC, not stale:

failed to checkout and determine revision: unable to list remote for
'https://forgejo.coilysiren.me/coilyco-flight-deck/infrastructure.git':
context deadline exceeded

The failure mode changed from 503 and 429 to timeout. Its spec sets timeout: 60s and it clones with secretRef: flux-system, so this is an authenticated full clone exceeding 60s, a different code path from the unauthenticated advertisement that returns in 25ms for me. Whether that is repo size, residual Forgejo slowness, or something else is not yet established.

Worth noting for anyone reading this as a GitOps outage: infrastructure and bridge-deploy Kustomizations both have suspend: true and observedGeneration: -1. They are deliberately paused and were before this incident. The flux-system and apps Kustomizations are READY=True on main@sha1:b5fbbf45. So nothing is auto-applying regardless, and the stale GitRepository is not currently blocking a deploy.

## Correction: change 2 landed but did NOT achieve source attribution Kai applied the traefik Service change at ~20:00 UTC. It applied cleanly and broke nothing. **It also did not do what I said it would.** Correcting the record. ### What I claimed, and why it was wrong In the previous comment I wrote: > Once `externalTrafficPolicy: Local` lands, the existing `forgejo-ratelimit` middleware becomes per-source automatically, because Traefik's default `sourceCriterion` keys on the remote address, which will finally be the real client. That is false. Client source IP is still not preserved. ### Evidence The policy change caused k3s to recreate the ServiceLB pod. It came back with a new name and a new IP: ``` svclb-traefik-bcc27bfa-2m44w 2/2 Running 0 62s 10.42.0.93 kai-server ``` Forgejo's router log post-change, timestamps after the apply: ``` 2026/08/24 20:01:46 for 10.42.0.93 2026/08/24 20:01:48 for 10.42.0.93 2026/08/24 20:01:52 for 10.42.0.93 2026/08/24 20:02:07 for 10.42.0.93 ... ``` Every external request is still masked. The masking IP simply moved from the old ServiceLB pod `10.42.0.167` to the new one `10.42.0.93`. ### Why k3s ServiceLB (klipper-lb) forwards traffic **through a pod's network namespace**, so the packet is SNATed to that pod's IP before it ever reaches Traefik. `externalTrafficPolicy: Local` governs which nodes are eligible to receive traffic. It does not remove the klipper-lb hop, so it cannot preserve the source address here. On a single-node cluster it changes effectively nothing. The rate limiter therefore still keys the entire internet into one shared 50 rps bucket, exactly as described in the original report. Nothing about that has improved. ### What would actually work Source preservation requires removing the klipper-lb hop, not reconfiguring around it: 1. Bypass ServiceLB for traefik. Run the traefik pod with `hostPort` on 80/443, or `hostNetwork: true`, and disable ServiceLB for that Service. Traefik then binds the node's ports directly and sees the router's DNAT source address. Simplest option for a single-node cluster. 2. Replace ServiceLB with MetalLB in L2 mode, which does honor `externalTrafficPolicy: Local` for source preservation. Heavier, and more moving parts than this cluster needs. Both need their own change window. Neither is done. ### Current state of the applied change Verified against the pre-change baseline, all via the public IP: ``` forgejo.coilysiren.me 200 0.024s (baseline 0.025s) eco-gnome.coilysiren.me 200 0.103s (baseline 0.042s) factory.coilysiren.me 200 0.078s (baseline 0.130s) galaxy-gen.coilysiren.me 200 0.046s (baseline 0.049s) website.coilysiren.me 200 0.049s (baseline 0.049s) auth.coilysiren.me 200 0.044s (baseline 0.043s) ``` `loadBalancerIP: 99.110.50.213`, nodePorts 31400/31428, and the selector all survived as intended. `healthCheckNodePort: 30975` was allocated. The change is harmless and is a reasonable prerequisite for option 1 above, so leaving it in place is defensible. Reverting it is equally defensible since it currently buys nothing. Rollback source is `rollback-svc-traefik.yaml`, or set `externalTrafficPolicy` back to `Cluster`. ## Forgejo itself is holding `forgejo-6fb99c98fc-8hsfp`, **0 restarts at 66 minutes**, against 46 restarts on the previous pod. The 3Gi limit is doing its job so far. Confirmed the git path is genuinely working rather than returning an HTML page: ``` HTTP/2 200 content-type: application/x-git-upload-pack-advertisement ``` ## Flux, partial recovery `bridge-deploy` GitRepository **recovered**: `stored artifact for revision 'main@sha1:be74966d852b73d186415e3bdf43377154959cda'`. `flux-system` GitRepository (the infrastructure repo) is **still failing**, and the error is current as of 20:02:09 UTC, not stale: ``` failed to checkout and determine revision: unable to list remote for 'https://forgejo.coilysiren.me/coilyco-flight-deck/infrastructure.git': context deadline exceeded ``` The failure mode changed from 503 and 429 to timeout. Its spec sets `timeout: 60s` and it clones with `secretRef: flux-system`, so this is an authenticated full clone exceeding 60s, a different code path from the unauthenticated advertisement that returns in 25ms for me. Whether that is repo size, residual Forgejo slowness, or something else is not yet established. Worth noting for anyone reading this as a GitOps outage: `infrastructure` and `bridge-deploy` Kustomizations both have **`suspend: true`** and `observedGeneration: -1`. They are deliberately paused and were before this incident. The `flux-system` and `apps` Kustomizations are `READY=True` on `main@sha1:b5fbbf45`. So nothing is auto-applying regardless, and the stale GitRepository is not currently blocking a deploy.
Author
Owner

Resolved: the Flux timeouts were transient, no revert needed

Correcting my own call

Mid-investigation I concluded the traefik change had caused "textbook externalTrafficPolicy: Local hairpin breakage" and started preparing a revert. That was premature. I asserted a persistent break from a correlation without first checking whether the failure was still occurring. It was not.

What the timeouts actually were

Failure-mode timeline from source-controller, bucketed:

15:10 - 19:10   429 / 503 only      Forgejo crash loop + shared rate-limit bucket
19:20 - 19:50   quiet               recovering after the 3Gi memory fix
20:02 - 20:14   timeout x18         transient, correlated with the traefik apply
20:20 - 20:21   429 x2              pre-existing shared-bucket issue
20:22 - 20:30   clean               no errors

The traefik apply caused k3s to recreate the ServiceLB pod (10.42.0.167 -> 10.42.0.93). In-cluster traffic to the public hostname broke for roughly 12 minutes while NAT and conntrack state rebuilt against the new pod, then recovered on its own. A disruption window during the change, not a standing break.

The two 429s at 20:20 are the original shared-bucket problem, unrelated to this.

Current state, 20:30 UTC

Both GitRepositories recovered with fresh artifacts:

bridge-deploy   True   stored artifact for revision 'main@sha1:be74966d852b73d186415e3bdf43377154959cda'
flux-system     True   stored artifact for revision 'main@sha1:b5fbbf452cad60ab1002c43e6f97dfcadafd13c9'

Kustomizations flux-system and apps are READY=True. infrastructure and bridge-deploy remain blank because they carry suspend: true, which predates this incident and is deliberate.

Forgejo: forgejo-6fb99c98fc-8hsfp, 0 restarts at 95 minutes, against 46 restarts on the pod it replaced.

Public edge unchanged:

forgejo.coilysiren.me   200  0.027s
website.coilysiren.me   200  0.101s
auth.coilysiren.me      200  0.047s

Disposition

Kai chose to leave externalTrafficPolicy: Local in place. That decision stands and needs no revisiting. A revert manifest was prepared and diffed but not applied, and is no longer warranted.

One correction to my earlier assessment stands, though: I described the change as harmless. It is harmless in steady state, but applying it cost ~12 minutes of degraded in-cluster access to public hostnames, because it recreates the ServiceLB pod. Anyone changing externalTrafficPolicy on this Service in future should expect that window rather than treating it as a no-op edit.

Incident closed, containment not

Forgejo is stable and Flux is reconciling. Nothing in this incident is fixed at the cause level:

  • The crawler is unmitigated. The 3Gi ceiling buys headroom, nothing more.
  • The rate limiter still keys the entire internet into one 50 rps bucket.
  • Client source IP is still masked by klipper-lb. externalTrafficPolicy: Local does not fix that, as established above. It needs traefik on hostPort/hostNetwork with ServiceLB bypassed, or MetalLB.
  • The Forgejo Deployment remains hand-applied and outside GitOps.
  • #910 remains open, and is the sharpest live risk here: any HelmChartConfig/traefik edit or k3s upgrade drops the public IP for every hostname.
## Resolved: the Flux timeouts were transient, no revert needed ### Correcting my own call Mid-investigation I concluded the traefik change had caused "textbook `externalTrafficPolicy: Local` hairpin breakage" and started preparing a revert. **That was premature.** I asserted a persistent break from a correlation without first checking whether the failure was still occurring. It was not. ### What the timeouts actually were Failure-mode timeline from source-controller, bucketed: ``` 15:10 - 19:10 429 / 503 only Forgejo crash loop + shared rate-limit bucket 19:20 - 19:50 quiet recovering after the 3Gi memory fix 20:02 - 20:14 timeout x18 transient, correlated with the traefik apply 20:20 - 20:21 429 x2 pre-existing shared-bucket issue 20:22 - 20:30 clean no errors ``` The traefik apply caused k3s to recreate the ServiceLB pod (`10.42.0.167` -> `10.42.0.93`). In-cluster traffic to the public hostname broke for roughly 12 minutes while NAT and conntrack state rebuilt against the new pod, then recovered on its own. A disruption window during the change, not a standing break. The two 429s at 20:20 are the original shared-bucket problem, unrelated to this. ### Current state, 20:30 UTC Both GitRepositories recovered with fresh artifacts: ``` bridge-deploy True stored artifact for revision 'main@sha1:be74966d852b73d186415e3bdf43377154959cda' flux-system True stored artifact for revision 'main@sha1:b5fbbf452cad60ab1002c43e6f97dfcadafd13c9' ``` Kustomizations `flux-system` and `apps` are `READY=True`. `infrastructure` and `bridge-deploy` remain blank because they carry `suspend: true`, which predates this incident and is deliberate. Forgejo: `forgejo-6fb99c98fc-8hsfp`, **0 restarts at 95 minutes**, against 46 restarts on the pod it replaced. Public edge unchanged: ``` forgejo.coilysiren.me 200 0.027s website.coilysiren.me 200 0.101s auth.coilysiren.me 200 0.047s ``` ### Disposition Kai chose to leave `externalTrafficPolicy: Local` in place. That decision stands and needs no revisiting. A revert manifest was prepared and diffed but **not applied**, and is no longer warranted. One correction to my earlier assessment stands, though: I described the change as harmless. It is harmless in steady state, but applying it cost ~12 minutes of degraded in-cluster access to public hostnames, because it recreates the ServiceLB pod. Anyone changing `externalTrafficPolicy` on this Service in future should expect that window rather than treating it as a no-op edit. ### Incident closed, containment not Forgejo is stable and Flux is reconciling. Nothing in this incident is fixed at the cause level: * The crawler is unmitigated. The 3Gi ceiling buys headroom, nothing more. * The rate limiter still keys the entire internet into one 50 rps bucket. * Client source IP is still masked by klipper-lb. `externalTrafficPolicy: Local` does not fix that, as established above. It needs traefik on `hostPort`/`hostNetwork` with ServiceLB bypassed, or MetalLB. * The Forgejo Deployment remains hand-applied and outside GitOps. * #910 remains open, and is the sharpest live risk here: any `HelmChartConfig/traefik` edit or k3s upgrade drops the public IP for every hostname.
Author
Owner

The crawler ignores robots.txt, so item 3 needs rewriting

Kai asked whether the crawler is actually giving useful visibility. Checked it, and the answer changes the recommended fix.

Forgejo already ships a restrictive robots.txt

Served live at forgejo.coilysiren.me/robots.txt, the Forgejo default, including:

User-agent: *
Disallow: /api/
Disallow: /explore/*?*
Disallow: /*/*/src/
Disallow: /*/*/watchers
Disallow: /*/*/stargazers
Disallow: /*/*/forks

It is being ignored

Classifying 1010 recent request paths from the router log against those rules:

raw/blame/commit               604   59.8%
DISALLOWED /*/*/src/           225   22.3%
other                          113   11.2%
DISALLOWED /api/                55    5.4%
repo landing page               13    1.3%
---
robots-disallowed: 280 of 1010 (27.7%)

The client fetched /robots.txt at 20:43:10 UTC and is still generating 27.7% disallowed traffic. It reads the file and does not honor it.

It is not a search engine

The request-shape distribution rules that out. 60% raw/blame/commit plus 22% /src/ at pinned tags and commits, against 1.3% repo landing pages. Search indexing wants landing pages, descriptions, and READMEs. This wants file contents at every revision, which is the signature of source harvesting rather than indexing.

Sample of the live pattern:

20:42:12 GET /coilyco-flight-deck/agentic-os/src/tag/v0.122.0/.agents/skills/coding-discord-bot-architect/references/foundation-discordjs-commands.md?display=source

Deep file content at a pinned tag, under a path robots.txt disallows.

Consequence for the fix

Item 3 in the description said "robots.txt at minimum". That is now known-insufficient and should be treated as retracted. The policy it would express is already published and already ignored, so adding rules to it changes nothing for this client.

The guard has to be enforced rather than advisory:

  • A Traefik rule rejecting the faceted /*/*/issues?* and /*/*/pulls?* permutations, which are the queries that actually drove the OOM via repo.Issues.
  • Optionally extend enforcement to the already-disallowed /*/*/src/ and /api/ paths, since publishing that policy has demonstrably not worked.

Worth stating plainly for anyone weighing this against SEO: blocking the faceted query permutations costs zero search visibility. Those URLs are an infinite duplicate-content space that search engines penalize rather than reward, and repo landing pages, READMEs, and ordinary code browsing stay reachable for well-behaved crawlers. Discovery for these projects happens on the GitHub mirrors regardless.

Caveat

Client attribution is still masked by klipper-lb (now 10.42.0.93), so all of the above describes aggregate traffic and cannot be attributed to a single client with certainty. A well-behaved crawler and an abusive one are indistinguishable by source address today. That limitation is unchanged and is why the source-IP work still matters.

## The crawler ignores robots.txt, so item 3 needs rewriting Kai asked whether the crawler is actually giving useful visibility. Checked it, and the answer changes the recommended fix. ### Forgejo already ships a restrictive robots.txt Served live at `forgejo.coilysiren.me/robots.txt`, the Forgejo default, including: ``` User-agent: * Disallow: /api/ Disallow: /explore/*?* Disallow: /*/*/src/ Disallow: /*/*/watchers Disallow: /*/*/stargazers Disallow: /*/*/forks ``` ### It is being ignored Classifying 1010 recent request paths from the router log against those rules: ``` raw/blame/commit 604 59.8% DISALLOWED /*/*/src/ 225 22.3% other 113 11.2% DISALLOWED /api/ 55 5.4% repo landing page 13 1.3% --- robots-disallowed: 280 of 1010 (27.7%) ``` The client fetched `/robots.txt` at 20:43:10 UTC and is still generating 27.7% disallowed traffic. It reads the file and does not honor it. ### It is not a search engine The request-shape distribution rules that out. 60% raw/blame/commit plus 22% `/src/` at pinned tags and commits, against **1.3%** repo landing pages. Search indexing wants landing pages, descriptions, and READMEs. This wants file contents at every revision, which is the signature of source harvesting rather than indexing. Sample of the live pattern: ``` 20:42:12 GET /coilyco-flight-deck/agentic-os/src/tag/v0.122.0/.agents/skills/coding-discord-bot-architect/references/foundation-discordjs-commands.md?display=source ``` Deep file content at a pinned tag, under a path robots.txt disallows. ### Consequence for the fix **Item 3 in the description said "robots.txt at minimum". That is now known-insufficient and should be treated as retracted.** The policy it would express is already published and already ignored, so adding rules to it changes nothing for this client. The guard has to be **enforced rather than advisory**: * A Traefik rule rejecting the faceted `/*/*/issues?*` and `/*/*/pulls?*` permutations, which are the queries that actually drove the OOM via `repo.Issues`. * Optionally extend enforcement to the already-disallowed `/*/*/src/` and `/api/` paths, since publishing that policy has demonstrably not worked. Worth stating plainly for anyone weighing this against SEO: blocking the faceted query permutations costs zero search visibility. Those URLs are an infinite duplicate-content space that search engines penalize rather than reward, and repo landing pages, READMEs, and ordinary code browsing stay reachable for well-behaved crawlers. Discovery for these projects happens on the GitHub mirrors regardless. ### Caveat Client attribution is still masked by klipper-lb (now `10.42.0.93`), so all of the above describes aggregate traffic and cannot be attributed to a single client with certainty. A well-behaved crawler and an abusive one are indistinguishable by source address today. That limitation is unchanged and is why the source-IP work still matters.
Author
Owner

Posture decision: steer, do not block

Kai decided 2026-08-24: source harvesting is acceptable. The only thing to stop is harvesters reaching endpoints that are expensive to serve.

This narrows the guard and retracts the broader option from my previous comment, which offered to "extend enforcement to the already-disallowed /*/*/src/ and /api/ paths." That is declined. Code browsing, raw file access, and /src/ stay open.

What remains in scope for this issue

A narrow enforced guard on the faceted issue and pull query permutation space only:

/*/*/issues?<permuted assignee|labels|milestone|poster|project|state|type>
/*/*/pulls?<same>

These are what drove repo.Issues to 298336ms and OOM-killed the process. They are an unbounded-cost endpoint rather than content anyone would want indexed, and blocking the permutations costs nothing discoverable because the unfiltered issue and pull pages stay reachable.

Everything else in the original item 3 is dropped.

The complementary half

The "steer" side is now designed in coilyco-bridge/deploy#785: serve robots.txt and llms.txt at the infra level across public surfaces, giving automated readers a cheap curated path instead of brute-forcing expensive ones. That issue is design-only and explicitly does not mitigate this incident.

Worth restating so the two are not confused: llms.txt is an unenforced convention and the current crawler already ignores robots.txt. deploy#785 improves how the estate presents itself. It provides no protection. This issue remains the only thing standing between the crawler and another OOM.

Still unmitigated

Nothing has changed on the cause since the 3Gi bump. Forgejo has been stable, but the trigger is untouched and the rate limiter is still a single global bucket.

## Posture decision: steer, do not block Kai decided 2026-08-24: source harvesting is acceptable. The only thing to stop is harvesters reaching endpoints that are expensive to serve. This narrows the guard and **retracts the broader option** from my previous comment, which offered to "extend enforcement to the already-disallowed `/*/*/src/` and `/api/` paths." That is declined. Code browsing, raw file access, and `/src/` stay open. ### What remains in scope for this issue A narrow enforced guard on the faceted issue and pull query permutation space only: ``` /*/*/issues?<permuted assignee|labels|milestone|poster|project|state|type> /*/*/pulls?<same> ``` These are what drove `repo.Issues` to 298336ms and OOM-killed the process. They are an unbounded-cost endpoint rather than content anyone would want indexed, and blocking the permutations costs nothing discoverable because the unfiltered issue and pull pages stay reachable. Everything else in the original item 3 is dropped. ### The complementary half The "steer" side is now designed in `coilyco-bridge/deploy#785`: serve `robots.txt` and `llms.txt` at the infra level across public surfaces, giving automated readers a cheap curated path instead of brute-forcing expensive ones. That issue is design-only and explicitly does not mitigate this incident. Worth restating so the two are not confused: `llms.txt` is an unenforced convention and the current crawler already ignores `robots.txt`. deploy#785 improves how the estate presents itself. It provides no protection. This issue remains the only thing standing between the crawler and another OOM. ### Still unmitigated Nothing has changed on the cause since the 3Gi bump. Forgejo has been stable, but the trigger is untouched and the rate limiter is still a single global bucket.
Author
Owner

Recurrence 2026-08-26, and the guard is now authored in #939

Kai reported "getting some 503s from forgejo" at ~01:44 UTC. Same incident, higher intensity. The 3Gi ceiling did not hold.

Before-state

forgejo-6fb99c98fc-8hsfp, the pod created by the 3Gi change in this issue, is no longer at 0 restarts.

Last State:     Terminated
  Reason:       OOMKilled
  Exit Code:    137
  Started:      Tue, 25 Aug 2026 18:15:38 -0700
  Finished:     Tue, 25 Aug 2026 18:36:21 -0700
Restart Count:  3
Limits:
  memory:  3Gi

That instance lived 21 minutes. Container memory sampled live: 892 MB at 01:44:21 UTC, 975 MB at ~01:47:30 UTC, roughly 26 MB/min of climb after startup. Node stayed healthy throughout (MemoryPressure=False, DiskPressure=False), so this is a container-limit OOM again, not node exhaustion.

Kubelet log volume for the dead instance was 9.68 MB across 21 minutes, about 460 KB/min.

Trigger, same shape, worse

01:25:14 [I] router: completed GET /coilyco-flight-deck/ward/pulls?labels=201%2C211%2C325%2C345%2C347&milestone=6&poster=0&project=-1&state=open&type=all ... 200 OK in 347548.2ms @ repo/issue.go:515(repo.Issues)
01:25:14 [I] router: completed GET /coilyco-gaming/eco-app/issues?assignee=7&labels=216%2C361%2C364&milestone=0&poster=0&project=0&state=closed&type=all ... 200 OK in 345539.1ms @ repo/issue.go:515(repo.Issues)

347s, against 298s at the peak of the 2026-08-24 event.

repo.Issues hits per 10 minutes over 6h: baseline ~2,000-3,600, ramping from ~00:40 UTC to a peak of 28,883 (~48/s).

New evidence: attribution by user-agent

Traefik access logs do carry request_User-Agent, which the earlier comments did not use. Over a 2h window on forgejo.coilysiren.me:

  • forgejo-runner/v12.13.2 - 44,712
  • eight rotating desktop browser UA strings - 18,400 / 18,390 / 18,205 / 18,204 / 18,073 / 18,053 / 18,004 / 17,819
  • meta-externalagent/1.1 - 4,251 + 2,200
  • git/2.52.0 - 952

Eight distinct "browser" agents landing within 3 percent of each other is not organic traffic. That is one fleet rotating UA strings, roughly 145k requests over 2h, plus ~6.4k that openly declare Meta's crawler.

This does not overcome the attribution limit recorded above, since ClientHost is still 10.42.0.93 for every one of them. It does mean UA is a usable signal even though source IP is not, if a future guard wants one.

Confirmations of prior findings

  • externalTrafficPolicy: Local is still live on svc/traefik with the #909 change-cause annotation, svclb-traefik DaemonSet is still 1/1, and Forgejo still logs every client as 10.42.0.93. The correction in this comment holds.
  • The forgejo-ratelimit middleware is unchanged at average: 50, burst: 100, period: 1s, still a single global bucket, still above the ~20/s the crawler sustains.

Also found: memory limit drift

Live was 3Gi, tracked deploy/forgejo.yml said 1500Mi. The 2026-08-24 bump was applied live but never landed in the manifest, so the next Ansible converge would have reset a still-OOMing service to the limit it was already dying at. Worth noting for item 5 in the description, which is the same root cause: the Deployment is hand-applied.

What is authored

#939 implements the narrow guard decided in the posture comment, plus the 4Gi limit Kai approved, plus a docs/forgejo-deploy-plan.md section recording why externalTrafficPolicy: Local and robots.txt are both retracted, so they stop being re-proposed.

kubectl diff passes server-side dry-run and pre-commit is green.

Nothing has been applied live. kubectl apply was blocked by the operator permission classifier in my session. The rate numbers and the priority: 100 route are unverified at runtime, and Forgejo is still exposed to the crawler right now.

Still unmitigated

Everything in the previous closing list, unchanged, plus the observation that the crawl intensity has grown between the two events. #938 was filed before I found this issue and has been closed as a duplicate.

## Recurrence 2026-08-26, and the guard is now authored in #939 Kai reported "getting some 503s from forgejo" at ~01:44 UTC. Same incident, higher intensity. The 3Gi ceiling did not hold. ### Before-state `forgejo-6fb99c98fc-8hsfp`, the pod created by the 3Gi change in this issue, is no longer at 0 restarts. ``` Last State: Terminated Reason: OOMKilled Exit Code: 137 Started: Tue, 25 Aug 2026 18:15:38 -0700 Finished: Tue, 25 Aug 2026 18:36:21 -0700 Restart Count: 3 Limits: memory: 3Gi ``` That instance lived 21 minutes. Container memory sampled live: 892 MB at 01:44:21 UTC, 975 MB at ~01:47:30 UTC, roughly 26 MB/min of climb after startup. Node stayed healthy throughout (`MemoryPressure=False`, `DiskPressure=False`), so this is a container-limit OOM again, not node exhaustion. Kubelet log volume for the dead instance was 9.68 MB across 21 minutes, about 460 KB/min. ### Trigger, same shape, worse ``` 01:25:14 [I] router: completed GET /coilyco-flight-deck/ward/pulls?labels=201%2C211%2C325%2C345%2C347&milestone=6&poster=0&project=-1&state=open&type=all ... 200 OK in 347548.2ms @ repo/issue.go:515(repo.Issues) 01:25:14 [I] router: completed GET /coilyco-gaming/eco-app/issues?assignee=7&labels=216%2C361%2C364&milestone=0&poster=0&project=0&state=closed&type=all ... 200 OK in 345539.1ms @ repo/issue.go:515(repo.Issues) ``` 347s, against 298s at the peak of the 2026-08-24 event. `repo.Issues` hits per 10 minutes over 6h: baseline ~2,000-3,600, ramping from ~00:40 UTC to a peak of **28,883 (~48/s)**. ### New evidence: attribution by user-agent Traefik access logs do carry `request_User-Agent`, which the earlier comments did not use. Over a 2h window on `forgejo.coilysiren.me`: * `forgejo-runner/v12.13.2` - 44,712 * eight rotating desktop browser UA strings - 18,400 / 18,390 / 18,205 / 18,204 / 18,073 / 18,053 / 18,004 / 17,819 * `meta-externalagent/1.1` - 4,251 + 2,200 * `git/2.52.0` - 952 Eight distinct "browser" agents landing within 3 percent of each other is not organic traffic. That is one fleet rotating UA strings, roughly 145k requests over 2h, plus ~6.4k that openly declare Meta's crawler. This does not overcome the attribution limit recorded above, since `ClientHost` is still `10.42.0.93` for every one of them. It does mean UA is a usable signal even though source IP is not, if a future guard wants one. ### Confirmations of prior findings * `externalTrafficPolicy: Local` is still live on `svc/traefik` with the #909 change-cause annotation, `svclb-traefik` DaemonSet is still 1/1, and Forgejo still logs every client as `10.42.0.93`. The correction in [this comment](#issuecomment-74349) holds. * The `forgejo-ratelimit` middleware is unchanged at `average: 50, burst: 100, period: 1s`, still a single global bucket, still above the ~20/s the crawler sustains. ### Also found: memory limit drift Live was **3Gi**, tracked `deploy/forgejo.yml` said **1500Mi**. The 2026-08-24 bump was applied live but never landed in the manifest, so the next Ansible converge would have reset a still-OOMing service to the limit it was already dying at. Worth noting for item 5 in the description, which is the same root cause: the Deployment is hand-applied. ### What is authored #939 implements the narrow guard decided in [the posture comment](#issuecomment-74373), plus the 4Gi limit Kai approved, plus a `docs/forgejo-deploy-plan.md` section recording why `externalTrafficPolicy: Local` and `robots.txt` are both retracted, so they stop being re-proposed. `kubectl diff` passes server-side dry-run and `pre-commit` is green. **Nothing has been applied live.** `kubectl apply` was blocked by the operator permission classifier in my session. The rate numbers and the `priority: 100` route are unverified at runtime, and Forgejo is still exposed to the crawler right now. ### Still unmitigated Everything in the previous closing list, unchanged, plus the observation that the crawl intensity has grown between the two events. #938 was filed before I found this issue and has been closed as a duplicate.
Author
Owner

Applied: #939 merged and live. Guard works mechanically, but is NOT yet proven against the crawler.

Kai merged #939 and applied it at ~02:26 UTC.

Gate check before apply

main still carried the stale 1500Mi until the merge landed, so the first kubectl diff after git pull proposed 3Gi -> 1500Mi, a reduction on a service that was OOMing at 3Gi. Caught before apply. Post-merge the diff read correctly:

-            memory: 3Gi
+            memory: 4Gi
+ traefik.io.v1alpha1.Middleware.forgejo.forgejo-faceted-query-limit
+ traefik.io.v1alpha1.IngressRoute.forgejo.forgejo-faceted-query

Anyone repeating this should read the diff rather than trusting the command sequence. The check is now in docs/forgejo-deploy-plan.md.

Apply result

deployment.apps/forgejo configured
middleware.traefik.io/forgejo-faceted-query-limit created
ingressroute.traefik.io/forgejo-faceted-query created

forgejo-6fb99c98fc-8hsfp (3 restarts) replaced by forgejo-86b7b6f66f-qk7th, 0 restarts, live limit 4Gi, container memory 171 MB.

After-state, verified end-to-end

forgejo.coilysiren.me        200 0.115s   (baseline 0.112s)
eco-gnome.coilysiren.me      200 0.111s   (baseline 0.166s)
factory.coilysiren.me        200 0.097s   (baseline 0.133s)
galaxy-gen.coilysiren.me     200 0.084s   (baseline 0.200s)
website.coilysiren.me        200 0.102s   (baseline 0.149s)
auth.coilysiren.me           200 0.073s   (baseline 0.127s)

git-upload-pack  HTTP/2 200  content-type: application/x-git-upload-pack-advertisement

Guard behaviour:

CONTROL  ?state=closed            x8   200 200 200 200 200 200 200 200
GUARD    faceted permutation      x20  200 x15, then 429 200 429 200 429

The control path is unthrottled, so ordinary filtering and browsing are unaffected. The faceted path does 429 under repetition. The route priority is correct and the middleware is attached.

The honest caveat: this is not attributable to the guard

The crawl stopped on its own at 01:40 UTC, 46 minutes before the guard was applied. repo.Issues hits per 5 minutes:

01:25 UTC  16842
01:30 UTC   8082
01:35 UTC   5790
01:40 UTC      1     <- crawl ends
02:20 UTC      1
02:25 UTC     25     <- my own verification requests
guard applied ~02:26 UTC

Traefik has issued exactly 3 429s on forgejo.coilysiren.me in the last 30 minutes, and all 3 are mine. The crawler has not been subject to this guard even once.

So Forgejo is stable right now because the crawler left, not because of anything applied here. The guard is verified as a mechanism and unverified as a mitigation. The real test is the next wave, and the numbers (average: 2, burst: 10) may need revisiting once there is production evidence rather than a synthetic curl loop.

Worth noting the crawl ended right after the 01:36:21 OOMKill, which is also roughly what happened on 2026-08-24. Whether the crawler backs off when the origin dies, or simply finished its pass, is not established.

Containment status

Closed by this change:

  • Memory drift between live and tracked is reconciled at 4Gi in both.
  • The faceted query space now has an enforced guard, per the posture decision above.
  • The externalTrafficPolicy: Local and robots.txt dead ends are recorded in docs/forgejo-deploy-plan.md so they stop being re-proposed.

Still open, unchanged:

  • Client source IP is still masked by klipper-lb. The rate bucket remains global, so a heavy crawl can still cost a human an occasional 429 on a filtered issue page. Needs ServiceLB bypassed or MetalLB.
  • The Forgejo Deployment is still hand-applied and outside GitOps (item 5).
  • #910, the loadBalancerIP landmine on any HelmChartConfig/traefik edit or k3s upgrade, is untouched.

Leaving this issue open: the mitigation has not met an adversarial test yet.

## Applied: #939 merged and live. Guard works mechanically, but is NOT yet proven against the crawler. Kai merged #939 and applied it at ~02:26 UTC. ### Gate check before apply `main` still carried the stale `1500Mi` until the merge landed, so the first `kubectl diff` after `git pull` proposed **3Gi -> 1500Mi**, a reduction on a service that was OOMing at 3Gi. Caught before apply. Post-merge the diff read correctly: ``` - memory: 3Gi + memory: 4Gi + traefik.io.v1alpha1.Middleware.forgejo.forgejo-faceted-query-limit + traefik.io.v1alpha1.IngressRoute.forgejo.forgejo-faceted-query ``` Anyone repeating this should read the diff rather than trusting the command sequence. The check is now in `docs/forgejo-deploy-plan.md`. ### Apply result ``` deployment.apps/forgejo configured middleware.traefik.io/forgejo-faceted-query-limit created ingressroute.traefik.io/forgejo-faceted-query created ``` `forgejo-6fb99c98fc-8hsfp` (3 restarts) replaced by `forgejo-86b7b6f66f-qk7th`, 0 restarts, live limit 4Gi, container memory 171 MB. ### After-state, verified end-to-end ``` forgejo.coilysiren.me 200 0.115s (baseline 0.112s) eco-gnome.coilysiren.me 200 0.111s (baseline 0.166s) factory.coilysiren.me 200 0.097s (baseline 0.133s) galaxy-gen.coilysiren.me 200 0.084s (baseline 0.200s) website.coilysiren.me 200 0.102s (baseline 0.149s) auth.coilysiren.me 200 0.073s (baseline 0.127s) git-upload-pack HTTP/2 200 content-type: application/x-git-upload-pack-advertisement ``` Guard behaviour: ``` CONTROL ?state=closed x8 200 200 200 200 200 200 200 200 GUARD faceted permutation x20 200 x15, then 429 200 429 200 429 ``` The control path is unthrottled, so ordinary filtering and browsing are unaffected. The faceted path does 429 under repetition. The route priority is correct and the middleware is attached. ### The honest caveat: this is not attributable to the guard **The crawl stopped on its own at 01:40 UTC, 46 minutes before the guard was applied.** `repo.Issues` hits per 5 minutes: ``` 01:25 UTC 16842 01:30 UTC 8082 01:35 UTC 5790 01:40 UTC 1 <- crawl ends 02:20 UTC 1 02:25 UTC 25 <- my own verification requests guard applied ~02:26 UTC ``` Traefik has issued exactly 3 `429`s on `forgejo.coilysiren.me` in the last 30 minutes, and all 3 are mine. The crawler has not been subject to this guard even once. So Forgejo is stable right now because the crawler left, not because of anything applied here. The guard is verified as a mechanism and unverified as a mitigation. The real test is the next wave, and the numbers (`average: 2, burst: 10`) may need revisiting once there is production evidence rather than a synthetic curl loop. Worth noting the crawl ended right after the 01:36:21 OOMKill, which is also roughly what happened on 2026-08-24. Whether the crawler backs off when the origin dies, or simply finished its pass, is not established. ### Containment status Closed by this change: * Memory drift between live and tracked is reconciled at 4Gi in both. * The faceted query space now has an enforced guard, per the posture decision above. * The `externalTrafficPolicy: Local` and `robots.txt` dead ends are recorded in `docs/forgejo-deploy-plan.md` so they stop being re-proposed. Still open, unchanged: * Client source IP is still masked by klipper-lb. The rate bucket remains global, so a heavy crawl can still cost a human an occasional 429 on a filtered issue page. Needs ServiceLB bypassed or MetalLB. * The Forgejo Deployment is still hand-applied and outside GitOps (item 5). * #910, the `loadBalancerIP` landmine on any `HelmChartConfig/traefik` edit or k3s upgrade, is untouched. Leaving this issue open: the mitigation has not met an adversarial test yet.
Author
Owner

Outage: the hostNetwork bypass failed. ~25 minutes of total public ingress loss, rolled back.

I caused this one. Recording it fully.

Timeline, 2026-08-26 UTC

03:53  baseline captured, six public hostnames 200, git-upload-pack 200 in 0.11s
~04:14  Kai converged #941 via ansible; svclb torn down, new hostNetwork pod created
04:35  discovered: all public hostnames timing out, new traefik pod CrashLoopBackOff x8
04:39  rollback applied (previous HelmChartConfig)
04:40  full recovery verified

Roughly 25 minutes with no external path at all to any public service.

Cause

error while building entryPoint web: error preparing server:
error opening listener: listen tcp :80: bind: permission denied

The chart runs Traefik as UID 65532 with allowPrivilegeEscalation: false. That sets NoNewPrivs, and NoNewPrivs makes the kernel ignore file capabilities, so the NET_BIND_SERVICE capability I added never became effective. Kubernetes has no ambient-capability support, so there is no way to grant a working CAP_NET_BIND_SERVICE to a non-root process through the pod spec alone.

The svclb ordering trap fired as predicted in #941, and made it a full outage rather than a failed rollout: FailedScheduling ... didn't have free ports while svclb still held 80/443, then once the Service flipped to ClusterIP and svclb was torn down, external traffic had no path while the replacement pod was still crash-looping.

Where my verification was insufficient

I verified by helm template plus a server-side dry run of the rendered Service, and stated in #941 that this was the verification. It was not. Both validate the manifest. Whether a non-root process can actually acquire a capability is kernel behaviour, observable only once the container starts. I treated manifest correctness as runtime correctness.

The correction for next time is concrete: validate a privileged-port bind on a throwaway workload before touching a shared ingress. That check costs a minute and would have caught this before any outage.

Rollback

kubectl apply of the previous HelmChartConfig. Helm upgraded back to a LoadBalancer Service, svclb returned within seconds, and the old Traefik pod was still Running 1/1 the whole time, so traffic resumed as soon as the path existed. That the old pod survived is why recovery took under a minute once the right lever was pulled.

After-state against the 03:53 baseline:

forgejo.coilysiren.me      200 0.097s   (baseline 0.087s)
eco-gnome.coilysiren.me    200 0.115s   (baseline 0.199s)
factory.coilysiren.me      200 0.078s   (baseline 0.385s)
galaxy-gen.coilysiren.me   200 0.096s   (baseline 0.125s)
website.coilysiren.me      200 0.142s   (baseline 0.141s)
auth.coilysiren.me         200 0.091s   (baseline 0.118s)

git-upload-pack  HTTP/2 200  content-type: application/x-git-upload-pack-advertisement

Forgejo was never affected: forgejo-86b7b6f66f-qk7th, 0 restarts, 134 minutes at the time of check. The 4Gi limit and the faceted-query guard are untouched and still in place.

Flux both GitRepositories went False with connect: connection refused during the window, as in the original incident. Expected to self-recover as they did on 2026-08-24, but that is an assumption rather than something I have observed yet, so it wants a check.

Still open

#947 reverts the template to the parity state. It is deliberately not git revert e1db70e, because that commit squashed two changes and reverting it wholesale would re-arm the #910 landmine. Until #947 merges and converges, main and the node's manifest file both still hold the hostNetwork version, and a k3s or node restart would repeat this outage unattended.

Source IP is still masked by klipper-lb, so the original problem in this issue is exactly where it was. The retry shape recorded in docs/k3s-deploy-notes-manifests.md is hostPort: 80/443 onto unprivileged container ports 8000/8443, which keeps the container non-root and needs no capabilities, or MetalLB.

## Outage: the hostNetwork bypass failed. ~25 minutes of total public ingress loss, rolled back. I caused this one. Recording it fully. ### Timeline, 2026-08-26 UTC ``` 03:53 baseline captured, six public hostnames 200, git-upload-pack 200 in 0.11s ~04:14 Kai converged #941 via ansible; svclb torn down, new hostNetwork pod created 04:35 discovered: all public hostnames timing out, new traefik pod CrashLoopBackOff x8 04:39 rollback applied (previous HelmChartConfig) 04:40 full recovery verified ``` Roughly 25 minutes with **no external path at all** to any public service. ### Cause ``` error while building entryPoint web: error preparing server: error opening listener: listen tcp :80: bind: permission denied ``` The chart runs Traefik as UID 65532 with `allowPrivilegeEscalation: false`. That sets `NoNewPrivs`, and **NoNewPrivs makes the kernel ignore file capabilities**, so the `NET_BIND_SERVICE` capability I added never became effective. Kubernetes has no ambient-capability support, so there is no way to grant a working `CAP_NET_BIND_SERVICE` to a non-root process through the pod spec alone. The svclb ordering trap fired as predicted in #941, and made it a full outage rather than a failed rollout: `FailedScheduling ... didn't have free ports` while svclb still held 80/443, then once the Service flipped to ClusterIP and svclb was torn down, external traffic had no path while the replacement pod was still crash-looping. ### Where my verification was insufficient I verified by `helm template` plus a server-side dry run of the rendered Service, and stated in #941 that this was the verification. **It was not.** Both validate the manifest. Whether a non-root process can actually acquire a capability is kernel behaviour, observable only once the container starts. I treated manifest correctness as runtime correctness. The correction for next time is concrete: validate a privileged-port bind on a throwaway workload before touching a shared ingress. That check costs a minute and would have caught this before any outage. ### Rollback `kubectl apply` of the previous HelmChartConfig. Helm upgraded back to a LoadBalancer Service, svclb returned within seconds, and the **old Traefik pod was still Running 1/1 the whole time**, so traffic resumed as soon as the path existed. That the old pod survived is why recovery took under a minute once the right lever was pulled. After-state against the 03:53 baseline: ``` forgejo.coilysiren.me 200 0.097s (baseline 0.087s) eco-gnome.coilysiren.me 200 0.115s (baseline 0.199s) factory.coilysiren.me 200 0.078s (baseline 0.385s) galaxy-gen.coilysiren.me 200 0.096s (baseline 0.125s) website.coilysiren.me 200 0.142s (baseline 0.141s) auth.coilysiren.me 200 0.091s (baseline 0.118s) git-upload-pack HTTP/2 200 content-type: application/x-git-upload-pack-advertisement ``` Forgejo was never affected: `forgejo-86b7b6f66f-qk7th`, 0 restarts, 134 minutes at the time of check. The 4Gi limit and the faceted-query guard are untouched and still in place. Flux both GitRepositories went `False` with `connect: connection refused` during the window, as in the original incident. Expected to self-recover as they did on 2026-08-24, but that is an assumption rather than something I have observed yet, so it wants a check. ### Still open #947 reverts the template to the parity state. It is deliberately **not** `git revert e1db70e`, because that commit squashed two changes and reverting it wholesale would re-arm the #910 landmine. Until #947 merges and converges, `main` and the node's manifest file both still hold the hostNetwork version, and a k3s or node restart would repeat this outage unattended. Source IP is still masked by klipper-lb, so the original problem in this issue is exactly where it was. The retry shape recorded in `docs/k3s-deploy-notes-manifests.md` is `hostPort: 80/443` onto unprivileged container ports 8000/8443, which keeps the container non-root and needs no capabilities, or MetalLB.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-flight-deck/infrastructure#909
No description provided.