registry/deploy/pkg/k8s/postgres.go
Radoslav Dimitrov 736d376858
fix(db): raise pgxpool MaxConns + PG max_connections + set PG resources (#1221)
## Summary

Follow-up to #1215 and #1220. Both of those addressed *individual* slow
queries; this PR addresses *concurrency* — under today's scraper load
the cursor query is fast on average (mean 42ms) but the connection pool
saturates and queue depth blows up at the Go HTTP layer.

## Diagnosis

`pg_stat_statements` (added in #1215) made this possible to see:

```
max_ms   mean_ms  calls    template
10,755     41.8   94,009   plain cursor pagination, no filter
 7,277    214.7   14,770   ILIKE substring filter
 7,638    330.8    7,656   ILIKE substring + is_latest filter
```

Mean times are healthy. But `max_exec_time` of 7–10s on the cursor
query, combined with sustained ~15 req/s from scrapers (ServiceNow's
148.139.x.x range, anonymous `node` user-agent, etc) saturates
`MaxConns=30 × 2 pods = 60`. New requests queue at the Go HTTP layer;
nginx-side p99 hits 35s; eventually scrapers time out at 60s, retry, and
amplify the queue.

Today's two ongoing alerts are both this pattern:
- 17:35–17:40 UTC `Availability dropped below 95%` — 4,526 GET requests
in 5 min, hundreds of 504s
- 18:17 UTC `Publish Endpoint Latency` re-fire — same scraper
concurrency dragging the publish path

Critically, the symptoms today were also visible during yesterday's
incident, but yesterday's broken cursor (#1215) was the dominant cause.
After #1215 the cursor is fast individually; concurrency now becomes the
next bottleneck.

## Changes

### pgxpool (`internal/database/postgres.go`)

| Setting | Before | After |
|---------|-------:|------:|
| `MaxConns` | 30 | **60** per pod |
| `MinConns` | 5 | **10** per pod |
| `MaxConnIdleTime` | 30 min | (unchanged) |
| `MaxConnLifetime` | 2 h | (unchanged) |

2 pods × 60 = 120 total app connections. Cuts queue depth roughly in
half at current scraper load.

### PG cluster (`deploy/pkg/k8s/postgres.go`)

- `max_connections: 100 → 200` — required to support the larger pool.
120 (app) + ~10 (PG internals: autovacuum, replication, admin) + 70
headroom.
- Added explicit `resources:` block — previously the pod had no resource
limits, making node-level OOM behaviour unpredictable.

| | Request | Limit |
|---|--------:|------:|
| memory | 512Mi | **4Gi** |
| cpu | 200m | 1500m |

## Resource budget

| Node | Now | After |
|------|----:|------:|
| dy89 (PG node) | 39% mem (~2.4 GiB / 6 GiB) | ~65% mem worst-case
(~3.9 GiB) |
| 2yxm | 36% mem | unchanged |

Both nodes well within capacity. CPU usage <50% on both, plenty of
headroom.

PG worst-case memory math:
- 200 conns × ~15 MB per backend = 3 GiB
- + `shared_buffers` 128 MiB
- + `maintenance_work_mem`, OS overhead, etc.
- ≈ 3–4 GiB total

## Deployment caveat

`max_connections` is a postmaster-level setting → CNPG triggers a PG
restart on the change. Same in-pod restart shape as the
pg_stat_statements deploy yesterday — registry pods see ~30s of DB
unavailability, covered by v1.7.1's retry-with-backoff (8 attempts, 1→8s
capped). One registry pod may bounce once before recovering, like
yesterday.

**Time the merge for a low-traffic UTC window.**

Order of operations matters within the deploy itself:
1. Pulumi applies the new CNPG spec → PG restarts with
`max_connections=200`
2. Rolling deploy of registry pods picks up `MaxConns=60` config
3. New conns are accepted up to 200 limit

Pulumi's standard ordering does step 1 before step 2 in this scenario
(Pulumi resource graph: CNPG cluster precedes Deployment). If for any
reason it doesn't, the worst case is `too many connections` errors
during a small window — Self-correcting once the rollout completes.

## Test plan

- [x] `go build ./...` clean for app + deploy
- [x] `make lint` clean
- [x] `go test -race ./internal/database/...` green
- [ ] On merge: staging deploy applies the spec change cleanly; PG
restarts; pool reaches 60 max
- [ ] On prod deploy: same; verify no `too many connections` errors
during the rollout window
- [ ] After deploy: query `SHOW max_connections` returns 200; query
pg_stat_statements after a scraper burst, confirm `max_exec_time` for
cursor query no longer hits 10s

## Out of scope

- **ILIKE substring search** (`server_name ILIKE '%foo%'`) is
unindexable. Three pg_stat_statements variants run with means 141–333ms
and max 4–7s. Worth replacing with a `pg_trgm` GIN index or full-text
search in a separate PR.
- **Per-IP nginx rate limiting** — defends against scraper retry storms
regardless of pool size.
- **`Cache-Control` on `/v0/servers`** — let nginx absorb
scraper-repeated cursors.
- **Pre-existing `superfluous WriteHeader` warnings** — separate Huma
framework issue.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 15:21:24 -04:00

111 lines
4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package k8s
import (
"github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/apiextensions"
corev1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/core/v1"
"github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/helm/v3"
metav1 "github.com/pulumi/pulumi-kubernetes/sdk/v4/go/kubernetes/meta/v1"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
"github.com/modelcontextprotocol/registry/deploy/infra/pkg/providers"
)
// DeployPostgresDatabases deploys the CloudNative PostgreSQL operator and PostgreSQL cluster
func DeployPostgresDatabases(ctx *pulumi.Context, cluster *providers.ProviderInfo, environment string) (*apiextensions.CustomResource, error) {
// Create cnpg-system namespace
cnpgNamespace, err := corev1.NewNamespace(ctx, "cnpg-system", &corev1.NamespaceArgs{
Metadata: &metav1.ObjectMetaArgs{
Name: pulumi.String("cnpg-system"),
},
}, pulumi.Provider(cluster.Provider))
if err != nil {
return nil, err
}
// Install cloudnative-pg Helm chart
cloudNativePG, err := helm.NewChart(ctx, "cloudnative-pg", helm.ChartArgs{
Chart: pulumi.String("cloudnative-pg"),
Version: pulumi.String("v0.26.0"),
FetchArgs: helm.FetchArgs{
Repo: pulumi.String("https://cloudnative-pg.github.io/charts"),
},
Namespace: cnpgNamespace.Metadata.Name().Elem(),
Values: pulumi.Map{
"webhooks": pulumi.Map{
"replicaCount": pulumi.Int(1),
},
},
}, pulumi.Provider(cluster.Provider))
if err != nil {
return nil, err
}
// Create PostgreSQL cluster
pgCluster, err := apiextensions.NewCustomResource(ctx, "registry-pg", &apiextensions.CustomResourceArgs{
ApiVersion: pulumi.String("postgresql.cnpg.io/v1"),
Kind: pulumi.String("Cluster"),
Metadata: &metav1.ObjectMetaArgs{
Name: pulumi.String("registry-pg"),
Namespace: pulumi.String("default"),
Labels: pulumi.StringMap{
"app": pulumi.String("registry-pg"),
"environment": pulumi.String(environment),
},
},
OtherFields: map[string]any{
"spec": map[string]any{
"instances": 1,
"enablePDB": false,
"storage": map[string]any{
"size": "50Gi",
},
// Explicit resource budget. PG was previously unlimited which made
// node-level OOM behaviour unpredictable. With max_connections=200
// the worst-case per-connection memory (~10–15MB each) plus
// shared_buffers and overhead lands around 3–4GiB; 4Gi limit gives
// headroom for query workspaces (work_mem) and OS-level effects.
"resources": map[string]any{
"requests": map[string]any{
"memory": "512Mi",
"cpu": "200m",
},
"limits": map[string]any{
"memory": "4Gi",
"cpu": "1500m",
},
},
// Enable pg_stat_statements so we can attribute slow time to specific
// queries (the 2026-04-27 incident took an EXPLAIN-the-one-query-I-saw
// approach because we had no aggregate visibility). CNPG triggers a PG
// restart on shared_preload_libraries change — with instances: 1 this
// is brief downtime. CREATE EXTENSION still needs to run once as a
// superuser on existing clusters; new clusters get it via the
// postInitApplicationSQL hook below.
//
// max_connections raised from default 100 to 200 because the registry
// pgxpool was bumped from 30 to 60 per pod (120 total across 2 pods)
// + ~10 reserved for PG internals and ad-hoc admin = 130, with 70
// headroom for future scale or burst.
"postgresql": map[string]any{
"shared_preload_libraries": []any{"pg_stat_statements"},
"parameters": map[string]any{
"pg_stat_statements.track": "all",
"max_connections": "200",
},
},
"bootstrap": map[string]any{
"initdb": map[string]any{
"postInitApplicationSQL": []any{
"CREATE EXTENSION IF NOT EXISTS pg_stat_statements",
},
},
},
},
},
}, pulumi.Provider(cluster.Provider), pulumi.DependsOnInputs(cloudNativePG.Ready), pulumi.RetainOnDelete(true))
if err != nil {
return nil, err
}
return pgCluster, nil
}