Per-node TCP load balancer for Kubernetes API server high availability, inspired by Talos KubePrism.
extractedprism runs as a DaemonSet on every node in a Kubernetes cluster, providing a local TCP proxy on 127.0.0.1:7445 that routes traffic to healthy API server endpoints. Each node connects to the API server through its own local proxy, eliminating the need for a shared Virtual IP (VIP) and the associated VRRP/keepalived infrastructure.
Key properties:
- No VIP dependency -- each node connects to
localhost, removing the single point of failure inherent in shared VIP solutions - Two-level endpoint discovery -- static bootstrap endpoints for immediate availability at boot, plus dynamic Kubernetes EndpointSlice watching for runtime updates
- CNI-independent bootstrap -- runs with
hostNetwork: trueand uses static endpoints, so it works before the CNI plugin starts - Health-checked upstreams -- only routes traffic to API servers that pass TCP health checks
- Minimal footprint -- scratch container image, non-root, no capabilities required
Traditional Kubernetes control plane HA solutions rely on a shared Virtual IP managed by keepalived (VRRP), kube-vip, or similar tools. These approaches have inherent limitations:
- Single point of failure at the network level -- all nodes depend on a single VIP; if VRRP failover is slow or fails, the entire cluster loses API access
- Fragile failover with NIC driver bugs -- hardware-level issues (e.g., NIC driver crashes) can silently break the VIP holder without triggering VRRP failover, leaving the VIP unreachable
- Requires privileged capabilities -- keepalived and kube-vip need
NET_ADMINandNET_RAWto manipulate network interfaces and ARP tables - VRRP protocol complexity -- split-brain scenarios, gratuitous ARP propagation delays, and L2 domain restrictions add operational burden
extractedprism takes a different approach: each node runs its own load balancer bound to localhost. There is no shared state, no leader election, and no network-level failover. If one node's proxy fails, only that node is affected. The proxy starts with static endpoints (no cluster access needed), then discovers API server changes at runtime via the Kubernetes API.
+-------------------------------------------+
| Kubernetes Cluster |
| |
+--------------------+ | +--------------+ +--------------+ |
| Node (any) | | | CP Node 1 | | CP Node 2 | |
| | | | API :6443 | | API :6443 | |
| kubelet / pods | | +--------------+ +--------------+ |
| | | | ^ ^ |
| v | | | | |
| 127.0.0.1:7445 ----+-+----------+-------------------+ |
| [extractedprism] | | health-checked TCP proxy |
| | | | ^ ^ |
| v | | | | |
| 127.0.0.1:7446 | | +--------------+ +--------------+ |
| [health server] | | | CP Node 3 | | CP Node N | |
| /healthz /readyz | | | API :6443 | | API :6443 | |
+--------------------+ | +--------------+ +--------------+ |
+-------------------------------------------+
extractedprism ships its own TCP L4 proxy (internal/proxy): an accept loop forwarding to a health-checked set of upstream endpoints, with per-upstream connection tracking, Prometheus metrics, and graceful connection draining. No external load-balancing library is involved.
- Static (bootstrap): Control plane IPs provided via the
--endpointsflag. Available immediately at startup. No cluster access or CNI required. - Dynamic (runtime): Watches Kubernetes EndpointSlice resources for the
kubernetesservice in thedefaultnamespace (label selector:kubernetes.io/service-name=kubernetes). Automatically detects control plane membership changes. Reconnects with exponential backoff on Watch errors: the delay starts at 1s and doubles up to 30s, and each delay gets up to 25% random jitter on top. A watch that stayed open for at least 30s and then closed without an error is re-opened immediately. Any other error after such a watch, except 410 Gone, starts the backoff from 1s again. On 410 Gone (etcd compaction), performs a full re-list to rebuild the endpoint cache. After a successful re-list, the next watch starts at once if the 410 ended a watch that was open for at least 30s, or hit the re-watch right after such a watch closed without an error. Otherwise it waits for the backoff.
The merged provider deduplicates endpoints across all sub-providers and never sends an empty list to the load balancer, ensuring at least the static endpoints are always present.
- k3s/kubelet starts on the node, API server listens on port 6443
- extractedprism DaemonSet starts with
hostNetwork: trueand static endpoints - Load balancer on
127.0.0.1:7445begins proxying to healthy API servers - CNI plugin (Cilium, Calico, etc.) starts and can use
localhost:7445for API access - Kubernetes EndpointSlice discovery activates for dynamic endpoint updates
The recommended deployment method is via the Helm chart published as an OCI artifact:
helm install extractedprism oci://ghcr.io/lexfrei/charts/extractedprism \
--namespace kube-system \
--set endpoints="10.0.0.1:6443,10.0.0.2:6443,10.0.0.3:6443"The chart creates a DaemonSet with:
hostNetwork: truefor CNI-independent operationsystem-node-criticalPriorityClass- Proper RBAC for EndpointSlice access
- Health probes on port 7446 (
/healthzfor liveness,/readyzfor readiness) - Non-root execution (UID 65534) with read-only root filesystem
- No
NET_ADMINorNET_RAWcapabilities
Chart source: github.com/lexfrei/charts (directory charts/extractedprism/)
Pre-built multi-arch container images are available:
docker pull ghcr.io/lexfrei/extractedprism:latestThe image is built FROM scratch with only the static binary. No shell, CA certificates, or other tools are included.
git clone https://github.com/lexfrei/extractedprism.git
cd extractedprism
go build \
-ldflags="-s -w -X main.Version=$(git describe --tags) -X main.Revision=$(git rev-parse --short HEAD)" \
-trimpath \
-o extractedprism \
./cmd/extractedprismRequires Go 1.26 or later.
All flags are bound to environment variables with the EP_ prefix. For example, --bind-address maps to EP_BIND_ADDRESS.
| Flag | Environment Variable | Default | Description |
|---|---|---|---|
--bind-address |
EP_BIND_ADDRESS |
127.0.0.1 |
Address for the TCP load balancer listener |
--bind-port |
EP_BIND_PORT |
7445 |
Port for the TCP load balancer listener |
--health-port |
EP_HEALTH_PORT |
7446 |
Port for the health check HTTP server |
--health-bind-address |
EP_HEALTH_BIND_ADDRESS |
(same as --bind-address) |
Address for the health check HTTP server |
--endpoints |
EP_ENDPOINTS |
(required) | Comma-separated list of API server endpoints (host:port) |
--health-interval |
EP_HEALTH_INTERVAL |
20s |
Interval between upstream health checks |
--health-timeout |
EP_HEALTH_TIMEOUT |
15s |
Timeout for each upstream health check |
--enable-discovery |
EP_ENABLE_DISCOVERY |
true |
Enable Kubernetes EndpointSlice discovery |
--log-level |
EP_LOG_LEVEL |
info |
Log level (debug, info, warn, error, dpanic, panic, fatal) |
--liveness-interval |
EP_LIVENESS_INTERVAL |
5s |
Heartbeat probe interval for liveness detection |
--liveness-threshold |
EP_LIVENESS_THRESHOLD |
15s |
Maximum time since last heartbeat before liveness fails |
--drain-timeout |
EP_DRAIN_TIMEOUT |
30s |
Grace period for connections to removed endpoints, and with readyz to unready ones while another upstream is ready, before force close (0 closes immediately) |
--upstream-selection |
EP_UPSTREAM_SELECTION |
random |
How new connections pick an upstream: random (uniform among pickable upstreams) or latency (prefer the lowest-latency tier, see below) |
--health-check |
EP_HEALTH_CHECK |
tcp |
Upstream health check: tcp (connect only) or readyz (connect, then prefer upstreams answering 200 on GET /readyz, see below) |
--health-check-ca-file |
EP_HEALTH_CHECK_CA_FILE |
CA bundle that verifies the upstream serving certificates; required with readyz |
|
--health-check-cert-file |
EP_HEALTH_CHECK_CERT_FILE |
Client certificate for the readyz probe |
|
--health-check-key-file |
EP_HEALTH_CHECK_KEY_FILE |
Client key for the readyz probe; may be the same file as the certificate |
|
--health-check-token-file |
EP_HEALTH_CHECK_TOKEN_FILE |
Bearer token for the readyz probe |
|
--health-check-server-name |
EP_HEALTH_CHECK_SERVER_NAME |
kubernetes.default.svc |
Name the upstream serving certificates are verified against |
--bind-addressand--health-bind-addressmust each be a valid IP address or RFC 1123-compliant hostname--endpointsmust not be empty; each entry must be a validhost:portpair where the host is a valid IP address or RFC 1123 hostname and port is a number in the range 1-65535--bind-portand--health-portmust be in the range 1-65535 and must differ--health-intervaland--health-timeoutmust be at least 1 second--health-timeoutmust be less than--health-interval--log-levelmust be one ofdebug,info,warn,error,dpanic,panic,fatal--liveness-intervaland--liveness-thresholdmust be at least 1 second--liveness-thresholdmust be greater than--liveness-interval--drain-timeoutmust not be negative--upstream-selectionmust berandomorlatency--health-checkmust betcporreadyz;readyzrequires--health-check-ca-file--health-check-cert-fileand--health-check-key-filemust be set together--health-check-server-namemust not be empty
- Shared, SNI-routed control plane endpoint -- route kubelet through extractedprism to a shared endpoint that selects the backend by TLS SNI (for example nginx-ingress
ssl-passthrough), using kubelet'stls-server-namewhile extractedprism stays a pure passthrough. - Talos Linux static pod -- replace the built-in KubePrism with extractedprism on Talos Linux.
The TCP load balancer is implemented in internal/proxy. It accepts a channel of upstream endpoint lists and routes incoming TCP connections to healthy backends. The proxy applies:
- Configurable dial timeout (matches
--health-timeout) - TCP keepalive on both client and upstream connections: probes start after 30 seconds idle, with Go's default probe interval and count
- TCP user timeout of 30 seconds on both client and upstream connections (Linux only)
- Periodic health checks at the configured interval and timeout; two consecutive failures exclude an upstream, one success re-includes it. Failed and successful client dials count toward the same streak
When an endpoint disappears from the upstream list (node cordon, API server rolling restart), the proxy stops routing new connections to it but lets existing connections finish within --drain-timeout. Remaining connections are force-closed when the timeout expires. Re-adding the endpoint before the timeout aborts the drain. A timeout of 0 closes connections to removed endpoints immediately. A new connection whose endpoint starts draining while it is being dialed gets one retry on another pickable endpoint, if there is one.
With --upstream-selection=latency, every successful health check feeds its connect time into a smoothed average (EWMA) per upstream; failed checks do not. For an endpoint given as a hostname, the connect time includes name resolution. Each sample is capped at four times the current average (but never below 2ms), so one slow check, such as a retransmitted SYN, moves the average by a bounded step instead of jumping to the retransmission time, while a lasting increase still shows up within a few checks. A sample below a quarter of the average replaces it, so a first check that took more than four times the usual connect time is undone by the next normal one. Upstreams with a sample are grouped into tiers by latency alone, healthy or not: tier 0 holds every upstream whose average is at most max(best + 2ms, 2 × best), where best is the lowest average of all; tier 1 applies the same rule to the rest, and so on. An upstream leaves its tier only when its average exceeds the limit by a further 25%, so an upstream near the boundary does not flap. Membership follows the group rather than the tier number, so a tier appearing or disappearing above does not split a group. New connections are picked at random among the pickable upstreams of the lowest tier that has one, together with pickable upstreams that have no sample yet. With --health-check=readyz only ready upstreams count here while any is ready, so the tier is the lowest one with a ready upstream (see Readiness-aware selection). Unhealthy and draining upstreams keep their tier, so losing some upstreams of a tier does not let a lower tier in, and losing all of them hands over to the next tier. An upstream removed from the endpoint list leaves the tiers once its drain completes, and the tiers are rebuilt without it. A new upstream takes picks before its first successful check; if it is unreachable, it keeps an equal share of that tier's picks, not of all upstreams as in random mode, until the consecutive-failure threshold excludes it. Every upstream within 2ms of the best one is in tier 0, so a single site with sub-millisecond differences behaves like random. Only health checks feed the average, so tiers follow latency changes at the pace of --health-interval. A change of tier does not move existing connections.
A TCP check only proves that the API server process accepts connections. An API server whose etcd member has lost quorum still accepts them, but fails every request that needs storage, so with --health-check=tcp the node keeps sending part of its traffic to it. This happens when a network partition leaves a control-plane node in the minority: the node's own API server stays reachable, the healthy ones may be too, and random selection splits requests between them.
With --health-check=readyz, every successful TCP check is followed by an HTTPS GET /readyz; one --health-timeout deadline covers both the connect and the request. Any status other than 200 (redirects are not followed), a TLS error, or a timeout counts as a failed probe. As with TCP checks, two consecutive failures mark the upstream not ready and one success restores it. A new upstream, and one that becomes reachable again after a TCP failure, counts as not ready until its next probe passes; that probe runs right after the successful connect, so a restarted API server still cut off from etcd does not get new connections in between. New connections go to ready upstreams only. If no healthy upstream is ready, selection falls back to every TCP-healthy upstream, so losing quorum everywhere degrades to the tcp behavior instead of leaving the node without an API path. Readiness filters before latency tiers: with --upstream-selection=latency the tier is chosen among the ready upstreams only, so a ready upstream in a farther tier wins over an unready one in a closer tier, and with none ready the tiers apply to all TCP-healthy upstreams.
Readiness also moves existing connections. A client such as the kubelet keeps one long-lived HTTP/2 connection with watches on it, which never goes idle, and an API server without etcd quorum still answers HTTP/2 pings, so without help the client would stay on it for the whole outage. When an upstream has failed its probes while another healthy upstream is ready, its connections are closed after --drain-timeout, the same grace period a removed endpoint gets (0 closes them at once), and the clients reconnect to a ready upstream. The closing is called off if, before the timeout, the upstream turns ready again or no other healthy upstream is ready any more. With no upstream ready, existing connections stay where they are, and so do the connections of an upstream whose first probe after a reset is still pending. The /readyz endpoint of extractedprism itself is unaffected.
The probe connects to the endpoint address and verifies the serving certificate against --health-check-ca-file and --health-check-server-name. GET /readyz is allowed to any authenticated client through the default system:public-info-viewer ClusterRole. If anonymous authentication is disabled, configure a credential: a client certificate, such as the node's kubelet client certificate (see the Talos example), or a bearer token. The client certificate and the token file are re-read on every probe, so rotation is picked up without a restart, and a credential that does not exist yet fails the probe instead of startup. On a joining node the kubelet obtains its certificate through this proxy, which works because of the fallback above.
The merged discovery provider runs all configured sub-providers concurrently and combines their results:
- Static provider: Sends the configured endpoints once at startup, then blocks until shutdown. Provides the baseline set of API server addresses that is always available.
- Kubernetes provider: Lists EndpointSlice resources on startup, then establishes a Watch. It maintains a local cache of all known EndpointSlice objects; on each Watch event (add, modify, delete), the cache is updated and the full endpoint list is recomputed from all cached slices. Endpoints where
Conditions.Readyis explicitlyfalseare excluded from the list. This ensures that updating one slice does not lose endpoints from other slices. On Watch errors, it reconnects with exponential backoff and jitter. A stream that stayed open for at least 30 seconds and then closed without an error is re-opened immediately. On 410 Gone (etcd compaction), it performs a full re-list to rebuild the cache. After a successful re-list it starts the next watch at once if the 410 ended a watch that was open for at least 30 seconds, or hit the re-watch right after such a watch closed without an error; otherwise it waits for the backoff. The Kubernetes client connects through the local load balancer (bypassing thekubernetes.default.svcClusterIP) to avoid a circular dependency with the CNI plugin and to benefit from failover across all configured endpoints. When running outside a cluster, the provider gracefully degrades and the server operates with static endpoints only.
The merged provider deduplicates endpoints across all sub-providers and sends the combined list to the load balancer. If the merged list would be empty, the update is skipped to prevent routing to zero backends. Provider failures are isolated: if the Kubernetes provider fails, the static provider continues operating independently. The merged provider only returns an error if all sub-providers fail.
The health HTTP server exposes two probe endpoints on the configured health port, plus /metrics (see below):
/healthz(liveness): Returns HTTP 200 while the Run loop and load balancer goroutine are responsive. Returns HTTP 503 if the heartbeat probe detects the system is no longer alive (e.g., deadlocked LB, crashed Run loop, exited discovery pipeline)./readyz(readiness): Queries the proxy'sHealthy()method. Returns HTTP 200 if at least one upstream is healthy and not draining, HTTP 503 otherwise.
Both endpoints accept only GET and HEAD requests. Other methods return 405 Method Not Allowed. OPTIONS returns 204 with the allowed methods.
The health server also exposes Prometheus metrics at /metrics. It binds to --bind-address by default, which is 127.0.0.1; set --health-bind-address to an address Prometheus can reach to scrape it.
| Metric | Type | Labels | Description |
|---|---|---|---|
extractedprism_upstreams_active |
Gauge | Upstreams eligible for new connections (healthy, not draining) | |
extractedprism_upstreams_total |
Gauge | Total known upstreams, including unhealthy and draining | |
extractedprism_connections_active |
Gauge | TCP connections currently passing through the proxy | |
extractedprism_connections_total |
Counter | Connections established to upstreams since start | |
extractedprism_connection_errors_total |
Counter | upstream |
Failed connection attempts (none when no upstream was available); failures caused by proxy shutdown are not counted. The series of an upstream is deleted once it is drained and removed, and failures reported after that are not counted until the address is added again |
extractedprism_discovery_updates_total |
Counter | provider |
Endpoint list updates received (static, kubernetes) |
extractedprism_discovery_errors_total |
Counter | provider |
Discovery errors: provider failures, failed Watch calls, watch error events and failed re-lists. A watch stream ending is not counted, including on a dropped connection, and neither is 410 Gone expiry; a dead upstream shows in health_check_status instead |
extractedprism_upstream_rtt_seconds |
Gauge | upstream |
Smoothed health check connect time, including name resolution for hostname endpoints; only with --upstream-selection=latency, after the first successful check |
extractedprism_upstream_latency_tier |
Gauge | upstream |
Latency tier of the upstream, 0 being the closest group; new connections go to the lowest tier with a healthy, non-draining upstream (with --health-check=readyz, a ready one while any is ready), together with upstreams that have no sample yet. Only with --upstream-selection=latency, after the first successful check |
extractedprism_upstream_ready |
Gauge | upstream |
/readyz probe state after the consecutive-failure threshold (1 ready, 0 not ready); new connections prefer ready upstreams, and an unready upstream loses its connections while another is ready. Only with --health-check=readyz, after the first probe |
extractedprism_health_check_status |
Gauge | upstream |
Upstream health state after the consecutive-failure threshold, fed by health checks and client dials (1 healthy, 0 unhealthy). Health checks stop while an upstream drains, so the value holds until it is removed or re-added |
On SIGINT or SIGTERM, the server cancels its context, which stops endpoint discovery, shuts down the TCP proxy (closing the listener, force-closing in-flight connections, and waiting up to 5 seconds for proxy goroutines to exit), and initiates a 5-second graceful shutdown of the health HTTP server.
- Non-root execution: Runs as UID 65534 (nobody) with no privilege escalation
- Read-only filesystem: Container runs with a read-only root filesystem
- No capabilities: Does not require
NET_ADMIN,NET_RAW, or any other Linux capabilities - Scratch image: Built
FROM scratch-- no shell, no package manager, no tools. Contains only the static binary - Localhost binding: Binds to
127.0.0.1by default, not accessible from outside the node - Minimal dependencies: Uses only well-established Go libraries; binary is statically compiled with CGO disabled
| Feature | extractedprism | keepalived/VIP | kube-vip | Talos KubePrism |
|---|---|---|---|---|
| Architecture | Per-node local proxy | Shared VIP (VRRP) | Shared VIP (BGP/ARP) | Per-node local proxy |
| VIP-free | Yes | No | No | Yes |
| Per-node fault isolation | Yes | No | No | Yes |
| CNI-independent bootstrap | Yes (hostNetwork + static) | Yes | Partial | Yes (built into Talos) |
| Capabilities required | None | NET_ADMIN, NET_RAW | NET_ADMIN, NET_RAW | N/A (kernel-level) |
| Runtime endpoint discovery | EndpointSlice Watch | N/A | N/A | Talos cluster discovery |
| Standalone binary | Yes | No | Yes | No (Talos only) |
| Works with any K8s distro | Yes | Yes | Yes | Talos only |
| Protocol | TCP L4 | L2/L3 (VRRP) | L2/L3 (ARP/BGP) | TCP L4 |
| Failure blast radius | Single node | All nodes | All nodes | Single node |
cmd/extractedprism/main.go # CLI entry point (cobra/viper), signal handling
internal/
├── config/config.go # Config struct, validation, base defaults
├── discovery/
│ ├── provider.go # EndpointProvider interface
│ ├── static/static.go # Static endpoints from --endpoints flag
│ ├── kubernetes/kubernetes.go # EndpointSlice Watch-based discovery
│ └── merged/merged.go # Merge and deduplicate multiple providers
├── health/health.go # HTTP /healthz, /readyz, and /metrics server
├── metrics/metrics.go # Prometheus metric set
├── proxy/proxy.go # TCP data path: accept, dial, health checks, draining
└── server/server.go # Orchestrates proxy, discovery, and health
Containerfile # Multi-stage build (golang:alpine -> scratch)
Contributions are welcome. To get started:
- Fork the repository
- Create a feature branch
- Make your changes
- Ensure tests pass:
go test ./... - Ensure linting passes:
golangci-lint run - Submit a pull request
Requirements:
- Go 1.26 or later
- golangci-lint v2 (the project uses strict linting with nearly all linters enabled)
BSD 3-Clause License. See LICENSE for the full text.
