Skip to content

feat: run Gitaly as a three-node cluster behind Praefect - #26

Merged
NWarila merged 1 commit into
mainfrom
feat/gitaly-cluster
Oct 3, 2026
Merged

NWarila merged 1 commit into
mainfrom
feat/gitaly-cluster

Conversation

@NWarila

@NWarila NWarila commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

What

Region 3.1 of the GitLab HA layout replaces the single Gitaly node with Gitaly Cluster: three Praefect nodes in
front of three Gitaly nodes, which Praefect keeps as replicas of every repository. The stack becomes 9 nodes:

  • 2 Rails;
  • 3 Gitaly (t3.medium);
  • 3 Praefect (t3.small);
  • 1 Redis.

It keeps the same RDS instance and S3 bucket.

  • Load balancer. It gains listener 2305 and a praefect target group, with client IP preservation off and a TCP
    health check. Rails reaches Praefect only through it, and Praefect alone reaches Gitaly on 8075.
  • Praefect's database. praefect_production belongs to its own unprivileged praefect role on the existing RDS
    instance. The master user bootstraps it on tcnaw-praefect01 with the existing bootstrap code. Praefect connects
    directly, with no PgBouncer, over verify-full TLS, and the database's only extension is pgaudit.
  • Secrets. Two Praefect tokens (external and internal, required to differ) and Praefect's database password are
    run secrets, carried as R2's are. pgx v5.10.0 never prints the password: pgconn/errors.go ConnectError names
    only the user and database, and ParseConfigError passes the DSN through redactPW.
  • Play order. A new play runs praefect check on all three Praefect nodes before any Rails node converges, so a
    broken Praefect→Gitaly path is named first. Rails' ?all=1 readiness cannot see that path: Praefect answers the
    health check itself.
  • IAM. No IAM or estate change; the runner's grants already cover the shape.

R2's whole proof runs unchanged through Praefect: the degenerate-case regression gate. Region 3.2 adds the cluster
proof (stop the primary Gitaly mid-push; the push still succeeds; the node rejoins and reconciles), developed on this
change's held stack.

Review

  • Plan, gate r1, four domain audits, Revision 2 and gate r2, then confirmation audits: zero open findings.
  • The owner accepted every plan recommendation: Praefect on dedicated t3.small nodes, the same RDS instance, a direct
    connection, and no IAM change.

Proof

Local (no AWS):

  • the Terraform mock plan against 9b5e6ae: 9 instances, 3 target groups, 3 listeners and 7 attachments, plus
    security-group rule checks and two mutations that fail;
  • every node role renders and passes ruby -c, and the all-in-one render is byte-identical to R0's;
  • an eval of the Praefect render: booleans and integers, three nodes, virtual storage default;
  • nft -c on every role;
  • the inventory contract in six cases;
  • all 9 hosts' role inputs pass validate.yml;
  • the database bootstrap twice on PostgreSQL 17 (changed, then 0);
  • the asserts against the cited 19.4.1 output strings;
  • an argument smoke run on every new task;
  • yamllint, ansible-lint, syntax-check, actionlint, the validator and the manifest;
  • the conditional scan: 378 items, all strings.

Live: this merge is followed by a 240-minute hold dispatch, the region's first live run. R3.2's proof is built on
that held stack.

Prove live:

  • Praefect's TLS 1.3 to RDS through the FIPS Go toolchain;
  • two LISTEN backends per Praefect node;
  • praefect check before any Rails node exists;
  • the 3-healthy TCP target wait;
  • converge time (estimated 75–78 min);
  • RDS log size with Praefect's connection churn.

GitLab's minimal Gitaly Cluster puts three Praefect nodes between the
Gitaly clients and three Gitaly nodes, which Praefect keeps as
replicas of every repository. It replaces the single Gitaly node, so
the stack is nine nodes: two Rails, three Gitaly, three Praefect and
one Redis, on the same RDS instance and S3 bucket.

terraform/aws.tfvars declares it for the pinned framework:

- tcnaw-gitaly01 to 03 (t3.medium) and tcnaw-praefect01 to 03
  (t3.small), on the SSM-only org profile, spread over the two Rails
  zones: 01 and 03 in us-east-1c, 02 in us-east-1a;
- the load balancer gains listener 2305 and a praefect target group,
  attached by Function tag. Client IP preservation is off on it,
  unlike 80 and 22, and its health check is a TCP connect (interval
  10, timeout 5, thresholds 2), as the reference architecture's
  HAProxy checks Praefect, which serves no HTTP;
- a Praefect node admits 2305 from the load balancer alone and reaches
  only the database and the Gitaly nodes. It carries gitlab-db-client,
  the group the database admits;
- a Rails node reaches Praefect through the load balancer on 2305 and
  has no route to Gitaly at all, so a Gitaly node's 8075 serves only
  Praefect and its peers, which replicate from one another;
- a Gitaly node reaches GitLab's internal API on 80 and Praefect on
  2305 through the load balancer, the route GitLab's Gitaly Cluster
  firewall table requires; nothing here exercises it yet. Only
  tcnaw-gitaly01, which runs the proofs, keeps SSH to the load
  balancer.

No rule lets a Gitaly node reach a Praefect node directly: with client
addresses off on 2305, every caller and every health check arrives
from the load balancer. No rule lets Praefect reach GitLab: GitLab's
firewall table lists Praefect to the API on 80, but Praefect 19.4.1
imports no GitLab client. No node is both a client and a target of one
listener: a Rails node is a target of 80 and 22 and a client of 2305,
a Gitaly node a client and never a target, and a Praefect node a
target of 2305 and a client of none.

A mock plan against 9b5e6ae plans 9 instances, 3 target groups, 3
listeners and 7 attachments, the praefect group on TCP 2305 with
client addresses off and a TCP check. No IAM or estate change is
needed: the runner's grants already cover the instance sizes, a third
target group and listener, and the run-scoped rules.

In the gitlab role:

- praefect is a node role. It names no role, since omnibus has none
  for it, so every service the default role would start is switched
  off by name. It listens on praefect.port for clients presenting
  praefect.token, and reaches every node in praefect.nodes with the
  internal token at a stated replication factor;
- the virtual storage is 'default' in the template, the name GitLab
  requires and the Rails storage uses, not an input that could differ;
- a Rails node's one storage is that virtual storage through the load
  balancer, and carries Praefect's token itself. The global
  gitlab_rails['gitaly_token'] is not set, so nothing falls back to it;
- a Gitaly node's storage takes its name from gitaly.storage, the
  playbook gives it the host's name, and it accepts only the internal
  token;
- the database certificate bundle and the database bootstrap run on
  any node whose service connects to the database, Rails or Praefect,
  and the bootstrap is titled for either service;
- END on a Praefect node runs praefect sql-ping as git and requires
  its OK line before it waits for port 2305: Praefect opens its
  database before it registers a listener, so a port wait alone could
  only say that nothing listens;
- validate.yml checks the database inputs for Rails and Praefect
  alike, and holds the Praefect inputs to their shapes, with the two
  tokens different. Praefect itself refuses an empty node list, a
  repeated storage and a replication factor beyond the node count.

Praefect connects to its own database, praefect_production, as its
own unprivileged role, praefect, directly and with no PgBouncer, over
verify-full against the pinned RDS bundle. Praefect opens few
connections, and the ones it holds for LISTEN need sessions of their
own. tcnaw-praefect01, the first Praefect node by name, is the only
other node given the master credentials, and on every converge it
reads Praefect's role, database and extensions as the master user and
creates whatever is absent, before its first reconfigure migrates the
database. Its only extension is pgaudit; plpgsql, which Praefect's
triggers use, is in every new database already.

The run secrets replace the one Gitaly token with Praefect's two, the
external token Gitaly clients present to Praefect and the internal token
Praefect presents to Gitaly, so no client can reach a Gitaly node past
Praefect. Praefect's database password joins them. All are generated
once into a root-only file on the Redis node, read through the
controller under no_log, and written on each node that needs one into
gitlab.rb, which holds credentials and is never diffed, and from there
by omnibus into the service's own configuration. The external token
reaches the Rails and Praefect nodes, the internal one the Praefect and
Gitaly nodes. The bootstrap gives PostgreSQL only a SCRAM verifier of
the database password, computed on the host from the password on
standard input. Praefect's connect errors cannot print the password: pgx
v5.10.0's pgconn/errors.go has ConnectError name the user and the
database only, and ParseConfigError pass the connection string through
redactPW.

The playbook runs the cluster in the only order that works: Redis,
the three Gitaly nodes, the first Praefect node, the other two, then
a new play on all three Praefect nodes that runs praefect check as
git and requires its exit status and "All checks passed.", since a
check that fails without being fatal still exits 0. It runs before
any Rails node converges, so a broken path from Praefect to the
Gitaly nodes is named first, and it is read-only, so a second converge
still changes nothing. The Rails nodes follow. The contract requires
three gitlab_gitaly and three gitlab_praefect hosts and the praefect
target group's ARN, and nftables opens 2305 on a Praefect node with no
rate limit.

A Rails node's readiness with all=1 proves its database and Redis, and
that a Praefect answers through the load balancer, but not the Gitaly
nodes: Praefect answers that health check itself. praefect check
proves the Gitaly nodes, and the proof's push proves the external
token. The proof otherwise runs as before, from the first Gitaly node,
a client of the HTTP and SSH listeners and a target of none, and first
waits for the praefect target group to report all three Praefect
nodes healthy.

The deploy workflow reads the praefect target group's ARN from
Terraform's outputs with the database and the other two target groups,
and refuses to go on without it.

The role README gains the praefect node role and a Gitaly Cluster
section; the role metadata, the Terraform README, the inventory README
and the dependencies README's load balancer specifics describe the
nine nodes, the 2305 listener and the rules. MANIFEST.sha256 is
regenerated. The all-in-one node still renders byte for byte what the
single node was proven with.
@NWarila
NWarila merged commit c7059d9 into main Oct 3, 2026
1 check passed
@NWarila
NWarila deleted the feat/gitaly-cluster branch October 3, 2026 11:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant