registry/deploy/pkg/k8s/ingress.go
Radoslav Dimitrov f52dc8525a
fix(deploy): cache bulk registry reads (#1509)
## Summary

- cache successful public list responses for `/v0/servers` and
`/v0.1/servers` at ingress-nginx for 30 seconds
- give only those exact cached paths a 600 RPM per-controller budget
- preserve the existing 180 RPM limit for publish, auth, mutation,
detail, and other routes
- expose `X-Registry-Cache` to make cache MISS/HIT behavior directly
observable

## Why

Production ingress logs show that the current 180 RPM per-controller
limit rejects legitimate bulk pagination before it reaches the
application. The affected traffic is overwhelmingly registry-list reads,
not publishing.

A ten-minute production sample contained 17,836 successful list
responses but only 3,188 unique URLs: 82.1% were repeated requests,
representing roughly 1 GB of duplicate response bodies. Raising the list
limit without caching would increase pressure on the application and its
shared PostgreSQL pool. The short ingress cache absorbs repeated
cursor-page reads before raising their scoped client budget.

Publish latency is a separate concern. Reducing read pressure may help
indirectly, but slow external package validation is intentionally out of
scope for this PR.

Relates to #826.

## Safety

- caching is limited to exact public GET list paths and keyed by host
plus full request URI
- only 200 responses are cached
- cache lifetime is 30 seconds; newly published entries can therefore
take up to 30 seconds to appear on a cached page
- cache locking coalesces simultaneous misses
- stale cached responses may be served while refreshing or during
transient upstream 5xx/timeouts
- each ingress replica uses at most 1 GB of ephemeral on-disk cache

## Validation

- `go test ./...` in `deploy/`
- `go vet ./...` in `deploy/`
- `git diff --check`
- ingress-nginx chart 4.13.0 in a disposable kind cluster
- generated NGINX configuration passed `nginx -t`
- repeated list request verified `X-Registry-Cache: MISS` followed by
`HIT`
- non-list route verified uncached
- exact list locations verified with the higher rate zone while the
catch-all retained 180 RPM

Nothing was applied to production during investigation or validation.
2026-08-07 02:16:52 +03:00

141 lines
5.3 KiB
Go

package k8s
import (
"strings"
v1 "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/pulumi/pulumi/sdk/v3/go/pulumi/config"
"github.com/modelcontextprotocol/registry/deploy/infra/pkg/providers"
)
// SetupIngressController sets up the NGINX Ingress Controller
func SetupIngressController(ctx *pulumi.Context, cluster *providers.ProviderInfo, environment string) (*helm.Chart, error) {
conf := config.New(ctx, "mcp-registry")
provider := conf.Get("provider")
if provider == "" {
provider = "local"
}
// Create namespace for ingress-nginx
ingressNginxNamespace, err := v1.NewNamespace(ctx, "ingress-nginx", &v1.NamespaceArgs{
Metadata: &metav1.ObjectMetaArgs{
Name: pulumi.String("ingress-nginx"),
},
}, pulumi.Provider(cluster.Provider))
if err != nil {
return nil, err
}
// Usually we should expose the ingress to a LoadBalancer
// This works in GCP and most local setups e.g. minikube (with minikube tunnel)
// Kind unfortunately does not support LoadBalancer type, and hangs indefinitely. This is a workaround for that.
serviceType := cluster.Name.ApplyT(func(name string) string {
if name == "kind-kind" {
return "NodePort"
}
return "LoadBalancer"
}).(pulumi.StringOutput)
// Configure replicas based on environment
// Staging: 1 replica (sufficient for testing, allows brief downtime during deploys)
// Production: 2 replicas (HA, zero-downtime deploys, node-level resilience)
replicaCount := 1
if environment == "prod" {
replicaCount = 2
}
// Install NGINX Ingress Controller
ingressNginx, err := helm.NewChart(ctx, "ingress-nginx", helm.ChartArgs{
Chart: pulumi.String("ingress-nginx"),
Version: pulumi.String("4.13.0"),
FetchArgs: helm.FetchArgs{
Repo: pulumi.String("https://kubernetes.github.io/ingress-nginx"),
},
Namespace: ingressNginxNamespace.Metadata.Name().Elem(),
Values: pulumi.Map{
"controller": pulumi.Map{
"replicaCount": pulumi.Int(replicaCount),
"service": pulumi.Map{
"type": serviceType,
"externalTrafficPolicy": pulumi.String("Local"),
"annotations": pulumi.Map{},
},
"config": pulumi.Map{
// Cache the public list endpoints at the ingress. Registry consumers
// repeatedly walk the same cursor pages, so sending every walk to the
// application needlessly consumes application CPU and database pool
// connections. The cache is intentionally short-lived so newly
// published servers become visible quickly.
//
// Use a variable cache zone so this global location snippet remains a
// no-op for publish, auth, mutation, and individual-server routes.
"http-snippet": pulumi.String(`
proxy_cache_path /tmp/registry-read-cache levels=1:2 keys_zone=registry_read_cache:20m max_size=1g inactive=5m use_temp_path=off;
map "$request_method:$uri" $registry_read_cache_zone {
default off;
~^GET:/v0(?:\.1)?/servers$ registry_read_cache;
}
`),
"location-snippet": pulumi.String(`
proxy_cache $registry_read_cache_zone;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 30s;
proxy_cache_lock on;
proxy_cache_lock_timeout 5s;
proxy_cache_background_update on;
proxy_cache_use_stale updating error timeout http_500 http_502 http_503 http_504;
add_header X-Registry-Cache $upstream_cache_status always;
`),
// Disable strict path validation, to work around a bug in ingress-nginx
// https://cert-manager.io/docs/releases/release-notes/release-notes-1.18/#acme-http01-challenge-paths-now-use-pathtype-exact-in-ingress-routes
// https://github.com/kubernetes/ingress-nginx/issues/11176
"strict-validate-path-type": pulumi.String("false"),
// Do NOT use forwarded headers with L4 load balancer
// GCP L4 Passthrough Network Load Balancer does not set X-Forwarded-For
// Real client IP comes from TCP connection source with externalTrafficPolicy: Local
"use-forwarded-headers": pulumi.String("false"),
// Set rate limit rejection status code to 429 (Too Many Requests)
"limit-req-status-code": pulumi.String("429"),
},
},
},
}, pulumi.Provider(cluster.Provider))
if err != nil {
return nil, err
}
// Extract ingress IPs from the Helm chart's controller service
ingressIps := ingressNginx.Resources.ApplyT(func(resources interface{}) interface{} {
// Look for the ingress-nginx-controller service
resourceMap := resources.(map[string]pulumi.Resource)
for resourceName, resource := range resourceMap {
if strings.Contains(resourceName, "ingress-nginx-controller") &&
!strings.Contains(resourceName, "admission") &&
strings.Contains(resourceName, "Service") {
if svc, ok := resource.(*v1.Service); ok {
// Return the LoadBalancer ingress IPs
return svc.Status.LoadBalancer().Ingress().ApplyT(func(ingresses []v1.LoadBalancerIngress) []string {
var ips []string
for _, ingress := range ingresses {
if ip := ingress.Ip; ip != nil && *ip != "" {
ips = append(ips, *ip)
}
}
return ips
})
}
}
}
// Return empty array if no matching service found
return []string{}
})
ctx.Export("ingressIps", ingressIps)
return ingressNginx, nil
}