HAProxy on Kubernetes
| Status | Authors | Coach | DRIs | Owning Stage | Created |
|---|---|---|---|---|---|
| proposed |
igorwwwwwwwwwwwwwwwwwwww
|
igorwwwwwwwwwwwwwwwwwwww
|
group Networking & Incident Management | 2026-09-11 |
Status
Proposed. The tracking issue is
production-engineering#29644.
The control plane this design depends on is done and documented in
A Control Plane for HAProxy.
The cutover depends on GCP behavior that has to be observed on gstg
first, see Open Questions.
Summary
GitLab.com ingress behind Cloudflare is 80 HAProxy 2.8 VMs in gprd, managed by Chef, sized for
peak traffic plus a spare zone’s worth of capacity around the clock. This
document proposes to move the same HAProxy configuration into GKE Deployments with
HPA, one Deployment per zone, deployed through argocd/apps, without changing the
proxy technology. Runtime state is already desired state in Consul KV
applied by gitlab-haproxy-agent. The pods run the same agent as a
sidecar, so the control mechanism does not change.
The main challenge is moving
traffic from the VM tier to the pod tier without a gap and with a fast
way back, across three different kinds of entry point. For HTTP on gitlab.com the cells
http-router already sits in front of us and can split traffic per request.
For raw TCP on ssh and kas nothing programmable sits in front of the GCP
address, so the switch is per port. Customers point DNS at the pages
address, so it has to move as it is. Each needs its own mechanism.
Motivation
The HAProxy fleet is the last large stateless Chef-managed fleet on GitLab.com. Postgres, Redis and Gitaly stay on VMs. The application tier, the runner managers, and most supporting services already run on Kubernetes. The two problems that make the VM fleet expensive to operate are the runtime state control path and the VMs themselves. The control plane work fixed the first, this document addresses the second.
Why invest in HAProxy at all
The plan to remove HAProxy from GitLab.com has existed for years. A Kubernetes migration was evaluated in 2022 (production-engineering#15064), and the Envoy Gateway migration is the current successor. None of these efforts has been funded through to production, and HAProxy still fronts every request to gitlab.com. Three things follow:
- Mission-critical infrastructure gets invested in for as long as it is in service, including when it is on its way out. Ingress is where a reliability problem costs the most, and “it will be replaced” is not a reason to leave a Chef-managed fleet of fixed size, upgraded by hand, in that position for another unknown number of years.
- The fleet has had incidents and is expensive to change. The server-state file cached stale backend addresses (production-engineering#12152), reloads reset canary state, and rebooted nodes lost their drain state (production-engineering#27563). The control plane work fixed those. Changes are still a role JSON edit and a Chef converge across 80 nodes, with no rendered diff to review, no rolling rollout, and no rollback except another converge. That makes every blocklist entry, canary weight and version bump rare and risky, and it has nothing to do with which proxy is running.
- A modernized fleet is easier to remove. Once the ingress addresses, the cutover procedure and the observability wiring exist for pods, replacing the proxy inside them is a much smaller change than replacing the VM fleet, and Envoy Gateway needs those parts as well.
The same reasoning held for GitLab Runner: docker-machine was deprecated and scheduled for removal, and the work to run it well on Kubernetes after years without investment was still worth it, both for reliability and for how much simpler the executor migration became.
Pain points
- Provisioning is unreliable. A new node is a Terraform apply followed by a Chef bootstrap that installs packages, clones repositories and converges the role, with no machine image at any point. The bootstrap depends on the Chef server, package mirrors and the ops git mirrors being reachable and current at that moment, and it fails often enough that adding a node is something people schedule and watch. The recorded game day runs put a replacement at 9 to 30 minutes to provision plus 13 to 24 minutes to bootstrap when it works (recovery measurements).
- No autoscaling. 60
haproxy-main, 5haproxy-pages, 6haproxy-registryand 9haproxy-ciVMs ingprd, allt2d-standard-8, sized for peak plus the loss of one zone, and running at that size all day.gstghas 10 andprehas 3, with no ci fleet. - Chef and Ubuntu Pro. The fleet depends on Chef converges, a Chef server, and paid OS support. The stateless services have already been moved off all three.
- Manual upgrades. The
haproxypackage is excluded from unattended upgrades because a binary upgrade needs a coordinated drain, so version bumps are done by hand. Node OS patching is recurring manual work (production-engineering#29158). - Per-VM identity everywhere. Dashboards, alerts, drain scripts and the Terraform instance-group constraints all assume a fixed, named set of machines.
- Configuration from five places. Role JSON in chef-repo, cookbook attributes and templates, two git repositories cloned onto every node for the deny and captcha lists, the Cloudflare IP ranges fetched by URL, and secrets from Vault. Each has its own update path and cadence.
- Zonal outage recovery. Losing a zone
takes a third of the fleet with it, and the recovery procedure is to
provision replacement VMs in the other zones. The quarterly game day on
gstgexercises that procedure and nothing else: it measures the provisioning path above, which is the weakest part, and never the traffic path. It has not been run ongprd.
Goals
- Every HAProxy flavor runs as pods on GKE with HPA, config rendered from Helm values that mirror today’s role JSON, secrets and lists from Vault through External Secrets.
- Traffic moves from VMs to pods per service and per port with a tested single-command rollback at every step, and the rollback stays available for two weeks after each production switch.
haproxy_backend_upand related metrics keep their names and thetype=frontend,tier=lblabels, so release-tools promotion checks and dashboards keep working.- HAProxy request logs keep arriving in Cloud Logging and the BigQuery
haproxy_logstable with the same fields as today. - Per flavor, the VM fleet, its Chef roles, its Terraform modules and the chef-repo helper scripts are removed after the rollback window closes.
Non-Goals
- Changing the proxy. No Envoy, no Cloudflare Load Balancer, no GCP Application Load Balancer. The configuration is ported as it is.
- Changing the control plane. ChatOps and the agent stay as they are.
- Changing what HAProxy does: blocklists, origin-pull verification and proxy-protocol handling stay as they are. Building a replacement for the two remaining per-process rate limits in the application or Cloudflare is out of scope.
- Solving cells routing for ssh. The global gitlab-shell design does that behind HAProxy and is independent of this work.
Proposal
Lift and shift the HAProxy 2.8 configuration into GKE. The points below are the current thinking. Each becomes a record under Decision log as it is settled, with the alternatives considered.
- Our own Helm chart in
gl-infra/charts,
deployed through argocd/apps, rendering
haproxy.cfgfrom values that mirror today’s role JSON. Thin images in ci-images, pulled from Artifact Registry. - One release per protocol group and address, seven per environment:
main-http,main-ssh,main-kas,main-internal,registry,pages,ci. Each with its own address and Service, on a dedicated node pool in the regional cluster. - One Deployment per zone per release, each rendering its own zone’s
backends as primary and the other two as
backup, as the VMs do today. - Certificates, credentials, deny lists and the Cloudflare IP ranges from Vault through External Secrets.
- Metrics matching the VM fleet’s labels so both fleets look the same, logs as JSON to stdout into the existing Cloud Logging sink.
- Rolling updates with a startup probe as the automated halt, Argo Rollouts for bake time and metric checks when needed.
- Ingress through GKE
LoadBalancerServices on addresses reserved in Terraform, with Cloudflare Spectrum in front as today. Traffic moves per entry point: the http-router splits HTTP per request, each Spectrum app’s origin moves per port, pages and registry keep their published addresses and hand them over in one step, internal clients move by DNS and by config. The two tiers share no GCP object. - The runner networks peer with
gprdand the ci-gateway ILB moves in front of thecipods there, pending the isolation question.cimoves last or not in this iteration.
What must keep working
/chatops run canaryand the SRE zonal-drain workflow: unchanged, they write KV and the agent applies it. The pods are more nodes running the agent.- The release-tools promotion checks:
haproxy_backend_up{backend=~"canary_.*"}withtype=frontendandtier=lb. The native prometheus exporter keeps the metric names and the scrape config keeps the labels. - Zone locality. Each VM routes to the backends in its own zone and
falls back to the other two, which keeps most traffic inside one zone
and makes a zonal drain a drain of one zone’s load balancers. One
Deployment per zone, rendering its own zone’s servers as primary and
the other two as
backup, keeps both. - Client IP handling: proxy-protocol v2 from Spectrum on 22 and kas 8150,
CF-Connecting-IPon 80 and 443, the Cloudflare-only source ACLs, and origin-pull client certificate verification on 443. - Per-process rate limits. Two are left in HAProxy: the registry
per-source-IP limit (4000 requests per 10 s) and the ssh
rate-limit sessions 110. The effective limit is the value times the instance count, which is fixed today and floats under HPA. Before the registry and ssh releases are built, each is reviewed: whether it is still needed, and if so, its per-process value is derived from the release’s replica floor. - The pages and registry addresses. Customers point custom-domain DNS at the pages address, and the registry address is published in the GitLab.com settings documentation for allowlists. Both move to the pod tier, neither changes. Nothing else has an address anyone outside sees: the Spectrum ports show Cloudflare’s addresses, and the internal gateway and ci-gateway clients use hostnames.
- ci-gateway: runner managers reach
*.ci-gateway.int.gprd.gitlab.net:8989through VPC peering into theci-gatewayVPC, with source ranges allowlisted in the frontend. Whatever replaces the VMs has to keep the peering path and the source addresses.
What changes
The configuration is ported as it is. What changes around it:
- One process per VM serving every frontend becomes one release per protocol group and address, seven per environment. Internal gateway clients get their own pods.
- A fixed fleet becomes an HPA per zone and release. A zone loss is a scale-up on the other two zones instead of a rebuild.
- A change is an MR with a rendered diff and a rolling deploy, rolled back by revert, instead of a role JSON edit and a converge. Version bumps are Renovate MRs instead of a manual drain.
- The pipelines of the repositories that own the deny lists, the recaptcha list and the Cloudflare IP ranges publish them to Vault, and External Secrets mounts them into the pods, instead of git clones and a URL fetch on every node.
- The HAProxy binary is the official 2.8 build from Artifact Registry instead of the PPA build on Ubuntu Pro.
- HAProxy writes JSON to stdout. The Cloud Logging sink and BigQuery table stay.
Risks
- Circular dependency on the cluster. The pods route to ILBs of the cluster they run in (canary, kas), so a cluster-wide incident takes ingress down with it. Accepted: a dedicated LB cluster would keep ingress up for the zonal clusters’ backends only, since canary and kas are down in that incident either way, and it is one more cluster to run against the consolidation direction.
- HPA and long-lived connections. CPU tracks TLS load, but
websockets, kas tunnels and ssh sessions do not release on scale-in.
Conservative scale-down, a per-pod
maxconnsized to requests, and the same 30 minute cutoff the VMs have throughhard-stop-after. Checked ongstgby holding websocket, kas and ssh connections open through a forced scale-in and a deploy: they end at the 30 minute cutoff and not before. - Startup window. HAProxy 2.8 treats every backend as UP until the
first health checks complete. On VMs that only happens at boot. Under
HPA it happens on every scale-up and deploy, and it matters most when
a backend ILB is down, which is exactly when HPA is likely to be
scaling. The startup probe’s initial delay is one health-check round
(
inter × fallplusspread-checks), so the pod takes no traffic until then. Checked onpreby making one backend unreachable and scaling up: the new pod’s first connections go to the other backends. - Instance identity in observability. Pod names churn and the
instance count floats. Every dashboard, alert and runbook step that
names a VM has to be updated. Done for
pre, before any traffic moves ongstg: the metrics catalog service for the pods getsprovisioning.kubernetesandkubeResourcesfor the Deployments, which generates the Kubernetes dashboards next to the HAProxy ones, and the HAProxy dashboards split the two tiers so they can be compared while both run. - Nodes leaving the load balancer. A cordoned GKE node can stay in
the load balancer’s backend list until it is deleted. New connections
sent to it find no pod, and open ones hang until they time out. This
is what caused the hour-long runner manager hangs on the kas ILB in
production-engineering#29764.
With
externalTrafficPolicy: Localthe health check should remove a node as soon as its last pod is gone. Whether that also happens on a cordoned node is checked ongstgand asked of GCP.
Design and implementation details
Architecture Overview
Runtime state (Consul KV, gitlab-haproxy-agent, agent-check) is left
out of these diagrams. It is the same on VMs and pods.
Before
Four fleets, four kinds of entry point. Every GCP address is a Terraform forwarding rule per port pointing at a target pool of VMs.
graph TB
subgraph edge ["Cloudflare"]
spec["Spectrum gitlab.com<br/>22 direct (PROXY v2), 80 http, 443 https"]
altssh["Spectrum altssh.gitlab.com<br/>443 to 22 (PROXY v2)"]
kas["Spectrum kas.gitlab.com<br/>443 to 8150 (PROXY v2)"]
end
dns_reg["DNS registry.gitlab.com<br/>not proxied"]
dns_pages["DNS *.gitlab.io + custom domains<br/>IPv4 and IPv6, not proxied"]
int["in-VPC clients<br/>Gitaly, cert-check"]
psc["runner and job VMs<br/>VPC peering to the ci-gateway VPC"]
subgraph gcp ["GCP, Terraform"]
a_main["main address<br/>22, 80, 443, 8150"]
a_reg["registry address<br/>80, 443"]
a_pages["pages address<br/>80, 443, IPv6 via TCP proxy"]
ilb_int["internal-gateway.gprd.gitlab.net ILB<br/>80, 443, 11443, 22"]
ilb_ci["ci-gateway ILB in the ci-gateway VPC<br/>regional, 8989"]
end
subgraph vms ["haproxy VMs, Chef"]
v_main["haproxy-main x60"]
v_reg["haproxy-registry x6"]
v_pages["haproxy-pages x5"]
v_ci["haproxy-ci x9<br/>second NIC in the ci-gateway VPC"]
end
spec --> a_main
altssh --> a_main
kas --> a_main
dns_reg --> a_reg
dns_pages --> a_pages
int --> ilb_int
psc --> ilb_ci
a_main --> v_main
ilb_int --> v_main
a_reg --> v_reg
a_pages --> v_pages
ilb_ci --> v_ci
v_main --> be["GKE service ILBs<br/>web, api, git, ssh (zonal), canary, kas"]
v_reg --> be
v_pages --> be
v_ci --> be
classDef old fill:#ffcdd2,stroke:#b71c1c,color:#4a0d0d
class v_main,v_reg,v_pages,v_ci old
After
Every address is reserved in Terraform. Every forwarding rule and backend
service is created by GKE for a LoadBalancer Service. Spectrum and DNS
point at the new addresses. Pages and registry keep their addresses.
graph TB
subgraph edge ["Cloudflare"]
spec["Spectrum gitlab.com<br/>22, 80, 443"]
altssh["Spectrum altssh.gitlab.com<br/>443 to 22"]
kas["Spectrum kas.gitlab.com<br/>443 to 8150"]
end
int["in-VPC clients<br/>Gitaly, cert-check"]
dns_reg["DNS registry.gitlab.com"]
dns_pages["DNS *.gitlab.io + custom domains"]
psc["runner and job VMs<br/>VPC peering"]
s_http["main-http<br/>80, 443"]
s_ssh["main-ssh<br/>22"]
s_kas["main-kas<br/>8150"]
s_int["main-internal<br/>80, 443, 11443, 22"]
s_reg["registry<br/>80, 443"]
s_pages["pages<br/>80, 443, IPv6"]
s_ci["ci<br/>8989"]
spec -->|"80, 443"| s_http
spec -->|"22"| s_ssh
altssh --> s_ssh
kas --> s_kas
int --> s_int
dns_reg --> s_reg
dns_pages --> s_pages
psc --> s_ci
s_http --> be["GKE service ILBs"]
s_ssh --> be
s_kas --> be
s_int --> be
s_reg --> be
s_pages --> be
s_ci --> be
classDef new fill:#c8e6c9,stroke:#2e7d32,color:#1b3d1f
class s_http,s_ssh,s_kas,s_int,s_reg,s_pages,s_ci new
Operations
- Every change replaces pods, nothing reloads a running process. Config
changes and image bumps are rolling updates with a startup probe that
stalls a broken config at the first pod,
preandgstgbeforegprd, rollback by revert. List changes restart the pods that mount them through Reloader. Argo Rollouts adds bake time and metric checks when that is not enough. - HPA scale-down is slow (15 minute window, 10 percent per 5 minutes) so long-lived connections drain instead of being cut in bulk. A PodDisruptionBudget per zone Deployment keeps a node drain from taking a zone below its floor.
- GKE node pool upgrades use the default surge strategy. The PDB and the 30 minute grace period are enough.
Alignment
Each decision record lists the teams whose review it needs. The GCP account team
reviews the address handover and the load balancer node membership
behavior once the gstg observations are in.
Rollout
pre, against a fleet of one VM per flavor and no ci fleet, in this order: node pool, images, chart, themain-httprelease with the rendered config, certificates and lists from Vault, the log sink, the metrics catalog and dashboard changes, then the release enabled, then its address and the cutover. This is the first place the pods run.gstg, all fleets, running alongside the Chef fleet: the http-router split, the per-port origin switch and its rollback, the address handover gap measurement on registry and pages, and/chatops run canary --gstgagainst pods.- Review of the observed behavior and the plan with the GCP account team.
gprdmain 80 and 443 through the http-router, starting at a small share of requests and one service at a time, rolled back by a Worker config change.gprdregistry, then pages: address handover. Each is one step with a measured gap, so they follow after the pod tier has served production HTTP for a while.gprdmain 80, 22, 443 and kas 8150: per-port Spectrum origin switch. The 443 switch also ends the http-router rule.gprdinternal gateway tomain-internalby weighted DNS.gprdci last, per runner shard, once its network placement is decided.- Decommission per fleet two weeks after its last switch: Chef roles, Terraform modules, chef-repo helper scripts, ChatOps Chef-API lookups that remain.
The VM fleet stays at full capacity until its rollback window closes, so both tiers run for two weeks past each fleet’s last switch. At list price the 80 gprd VMs cost about $20k per month, so the overlap is roughly $10k.
Open Questions
To observe on gstg before any production window:
- How quickly a Spectrum origin change takes effect for new connections, and what happens to connections in flight on the old origin.
- The address handover gap distribution and rollback time, on the registry and pages fleets.
- Node membership of a backend-service-based L4 load balancer when a
node is cordoned but Ready, with
externalTrafficPolicy: Local: does thehealthCheckNodePortcheck remove it once the pods are gone, and how long does that take.
To ask the GCP account team, with the gstg observations attached: the
address handover and the node membership behavior, and what they would do
differently for a customer-pinned address.
Not yet decided:
- Sizing: what replaces the divisible-by-three instance-group constraint and the rule of running at twice the needed capacity once HPA owns the replica count.
- Whether HAProxy logs should also go to Elasticsearch or the OTel/ClickHouse pipeline.
- The isolation question for
ci. - How the Cloudflare IP ranges are updated: a scheduled job publishing to Vault, or a Renovate MR.
- The metrics catalog service for the pods: the existing
frontendservice with a label to tell the tiers apart, which is what the runner manager migration did and keeps every dashboard working, or a separate service during the transition. Decided before traffic moves ongstg.
Decision log
One record per decision.
Alternative Solutions
Replace the proxy
The Envoy Gateway migration is the long-term answer, is in its non-production phase, and is not blocked by this work. The pain points above are independent of the proxy technology and are fixed with much smaller scope.
Kubernetes Ingress or Gateway API
An ingress controller is configured through route resources instead of a
config file. Most of what the config file does today (ACLs, rate
limits, TCP frontends with proxy protocol, origin-pull verification,
the agent checks for the control plane) has no equivalent in Ingress
and only a partial one in Gateway API, so this is a rewrite of the
configuration and a new control plane, not a port. It also puts the
same effort into a second proxy stack while Envoy Gateway is the
long-term answer. Putting an ingress controller in front of HAProxy
instead adds a proxy hop and cost for routing HAProxy already does. The
design keeps one LoadBalancer Service per release and HAProxy as the
only proxy.
Do nothing
Keep the VM fleet as it is until the proxy is replaced. The motivation covers why not: the replacement has been planned for years without reaching production, and the fleet’s provisioning, change process and zonal recovery are liabilities now.
0e4100ed)
