File: grpc_load_balancing.md

package info (click to toggle)
gitlab-shell 14.35.0%2Bds1-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 23,652 kB
  • sloc: ruby: 1,129; makefile: 583; sql: 391; sh: 384
file content (320 lines) | stat: -rw-r--r-- 14,979 bytes parent folder | download | duplicates (4)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
# gRPC load balancing

gRPC protocol is fast protocol built on top of HTTP2. We use gRPC
extensively to act as the main communication between Gitaly and other
services. When Gitaly scaled up, we needed to add more servers and build a smart
proxy (Praefect) to distribute the requests to corresponding nodes.

Essentially, Praefect deploys a collection of servers to avoid being the
bottleneck for the whole Gitaly Cluster. So, it's essential that Praefect
effectively load balance requests.

Like other normal application-layer (L7) protocols, there are two major
approaches:

- Put Praefect servers behind a load balancer. This load balancer can be at L3/L4
  (transport level) or L7 (application level).
- Apply client-side load balancing.

For more information on each approach, see the
[official documentation](https://grpc.io/blog/grpc-load-balancing/).

gRPC supports complete, sophisticated, and flexible client-side load balancing.
However, the official gRPC documentation doesn't cover it. So this documentation
covers how gRPC client-side load balancing works.

## How gRPC client-side load balancing works

```mermaid
flowchart TD
Target["dns://8.8.8.8:53/gitaly.consul.internal"]--Pick by URI scheme\nOr grpc.WithResolvers--> Builder
Builder--> Resolver
subgraph ClientConn
Resolver -.Refresh.-> Resolver
Resolver -- Update state --> LoadBalancer
LoadBalancer --> SubChannel1
LoadBalancer --> SubChannel2
LoadBalancer --> SubChannel3
SubChannel1 -. Report .-> LoadBalancer
SubChannel2 -. Report .-> LoadBalancer
SubChannel3 -. Report .-> LoadBalancer
end
subgraph Gitaly
Gitaly1
Gitaly2
Gitaly3
end
SubChannel1 -- TCP --> Gitaly1
SubChannel2 -- TCP --> Gitaly2
SubChannel3 -- TCP --> Gitaly3
Resolver --> net.Resolver
net.Resolver -.If specify authority.-> Authority[Authority Nameserver\n8.8.8.8:53]
net.Resolver -..-> Authority2[OS's configured nameserver]
net.Resolver -..-> /etc/resolv.conf
```

Though load balancing is a generic concept, each implementation has some minor
differences and capabilities. This documentation focuses on the `grpc-go`
implementation.

In general, client-side load balancing is managed by
[`grpc.ClientConn`](https://pkg.go.dev/google.golang.org/grpc#ClientConn). When
a client performs `grpc.Dial`, the target URL must be resolved by a resolver. gRPC:

- Supports many built-in resolvers, including a
  [DNS resolver](https://github.com/grpc/grpc-go/blob/master/internal/resolver/dns/dns_resolver.go).
- Provides
  a [powerful framework](https://pkg.go.dev/google.golang.org/grpc/resolver) to
  build a custom resolver. Building client-side load balancing involves three
  main components: builder, resolver, and load balancer.

### Builder

A builder creates a resolver object and handles a particular
scheme (`tcp`, `dns`, `tls`, or any custom scheme). Each `resolver.Builder` is
registered with `resolver.Register` which adds the
builder to global builder map keyed by its
scheme. [`grpc.WithResolvers`](https://pkg.go.dev/google.golang.org/grpc#WithResolvers)
can also be used to locally register a resolver with the connection. The
connection target's scheme is used to select the correct builder to create
a `resolver.Resolver`. Each client connection maintains a single resolver
object.

### Resolver

A resolver is responsible for name resolution and translates a
target (`scheme://host:port`) to a list of IP addresses and port numbers. gRPC
depends on the target scheme to determine the corresponding
resolver. For more information, see
the [relevant documentation](https://github.com/grpc/grpc/blob/master/doc/naming.md).
gRPC libraries support a number of built-in resolvers for most use cases.

The output of the resolver is a list of addresses. After the resolver fetches
the list for the first time or detects any change, it notifies client connection
or channel to create a new load balancer with corresponding subchannels
reflecting the new network topology. This update is handled gracefully so that
the in-flight calls are handled by the old connections until finished. The new
one handles all sequential calls.

#### Passthrough resolver

By default, if the scheme is not specified or unsupported, gRPC falls back
to the `passthrough` resolver, which is the simplest resolver. This resolver
supports both IP address and URL host and always creates a TCP connection
regardless of how many IP addresses resolved from the URL host. The
passthrough resolver doesn't watch for any later IP address changes.

#### DNS resolver

Another popular resolver is the DNS resolver. The target looks something
like `dns:///gitaly.service.dc1.consul` (note three slashes - it's
a [naming convention](https://github.com/grpc/grpc/blob/master/doc/naming.md)).
After starting, this resolver repeatedly issues DNS query requests to DNS
authorities. If it detects any changes, it notifies the client connection to
update network topology.

This resolver always watches for A records. If there is more than one A
record, it fetches the full list. A load balancer is responsible for
distributing the load from this list.

Support for
[SRV records](https://developer.hashicorp.com/consul/docs/discovery/dns#service-lookups)
is deprecated.

#### Other resolvers

gRPC supports other advanced resolvers, such
as [xDS](https://github.com/grpc/grpc/blob/master/doc/grpc_xds_features.md) (
popularized
by [Envoy](https://www.envoyproxy.io/docs/envoy/latest/api-docs/xds_protocol)).
It also let us create a custom resolver to side-load the list of gRPC servers
as we wish. Unfortunately, the support for resolver customization is limited.

### Load balancer

The load balancer is responsible for managing connections and distributing
workloads, and is created from the list of addresses from the resolver. For
each address, the balancer creates a subchannel, which is a TCP connection to a
destination node. Multiple calls to the same node share the same underlying TCP
connection thanks to HTTP2 framing multiplexing. The load balancer constantly
watches the connectivity status of subchannels:

- Subchannels are established asynchronously. The load balancer performs
  initialization promptly to ensure the connection is ready before use.
- When any of the subchannels are disrupted, the load balancer attempts to
  re-connect. After some failed attempts, the load balancer removes the
  subchannel. The sequential requests are redirected to other healthy
  subchannels.
- If the retry policy is set, the load balancer replays the corrupted requests
  to other subchannels automatically. More about this in a later section.
- The load balancer attempts to reconnect the failed node occasionally. When
  the node is back again, the requests are rebalanced to that subchannel again.

Each load balancer implements a strategy for picking the subchannel. By default,
the default load balancer strategy is `pick_first`. This strategy picks the
first address from the resolver and rejects the rest. It creates and maintains a
single subchannel for the first address only.

When the connection is disrupted, the load balancer gives up and lets the upper
layers handle the situation. Even if we use DNS service discovery, all requests
are routed to the first node (likely the first record returned by the DNS
server). The support for client-side load balancing varies between libraries (
for
example, [grpc-go](https://github.com/grpc/grpc-go/tree/09fc1a349826f04420292e0915fd11a5ce3dc347/balancer), [grpc-core](https://github.com/grpc/grpc/blob/7eb99baad858625699071d18f636dff268aa9b45/src/core/plugin_registry/grpc_plugin_registry.cc#L83)).
In general, they support basic load balancing strategies,
especially `round_robin`.

When a client stub issues a call, the load balancer picks a subchannel and
issues a stream. All messages are exchanged in the same stream until completed.

## Configure gRPC load balancing

Use [Service Config](https://github.com/grpc/grpc/blob/master/doc/service_config.md) to
configure client-side load balancing. This approach provides flexibility in controlling
how clients should behave in a cross-platform environment. Multiple load-balancing strategies can
co-exist in the same process.

```go
roundrobinConn, err := grpc.Dial(
  "dns://127.0.0.1:53/grpc.test:50051",
  grpc.WithDefaultServiceConfig(`{
    "loadBalancingConfig": [{"round_robin":{}}],
  }`),
  grpc.WithTransportCredentials(insecure.NewCredentials()),
)
```

In the example above, the target for the gRPC call is set to
`dns://127.0.0.1:53/grpc.test:50051`, where:

- `127.0.0.1:53` is the name server to resolve `grpc.test`. The
  `dns:///grpc.test:50051` part is optional. If not set, Go uses the
  operating systems's DNS.
- `grpc.test` is the service discovery interface.
- `round_robin` is the configured load balancer. It distributes the workload to
  resolved IPs in a round-robin fashion.

## Client retries

While error-handling should be handled by clients, gRPC library does sort of
have automatic error handling. For more information on the different layers of
error handing, see
the [relevant documentation](https://github.com/grpc/proposal/blob/master/A6-client-retries.md#integration-with-service-config).

gRPC is capable of transparent retrying. It means gRPC library handles retrying
automatically without the control of clients. Transparent retrying is done when
gRPC considers the retry is "safe". Some examples of safe retry:

- Transient failure. The failure caused by connectivity changes. For example, a
  subchannel is not contactable before a stub issues a call.
- Requests are written to the wires, but never leave the client.
- Requests reach server, but are not handled by RPC handlers.
- For other RPC failures, the library doesn't retry automatically. Instead, it
  provides a mechanism for us to prompt the retrying process. We can
  configure `retryPolicy` per service and method in the service configuration
  next to load balancing.

The `retryPolicy` depends on returned status codes. Each service and method can
configure the retriable status codes and other parameters.

The following code snippet is an example of how to configure auto-retry.

```go
roundrobinConn, err := grpc.Dial(
  "dns://127.0.0.1:53/grpc.test:50051",
  grpc.WithDefaultServiceConfig(`{
    "loadBalancingConfig": [{"round_robin":{}}],
    "methodConfig": [
      {
        "name": [
          { "service": "grpc.examples.echo.Echo" }
        ],
        "retryPolicy": {
          "maxAttempts": 3,
          "initialBackoff": "0.1s",
          "maxBackoff": "1s",
          "backoffMultiplier": 2,
          "retryableStatusCodes": ["UNAVAILABLE", "CANCELLED", "RESOURCE_EXHAUSTED", "DEADLINE_EXCEEDED"]
        }
      }
    ]
  }`), // This sets the initial balancing policy.
  grpc.WithTransportCredentials(insecure.NewCredentials()),
)
```

## Gitaly client-side load balancing

Although gRPC includes a built-in DNS resolver, that resolver has some
significant drawbacks:

- The resolver only resolves the DNS for the first connection. After that, it
  does not update the list of addresses until the client connection triggers it
  actively. The client connection does this only when it detects that some of
  its subchannels are permanently unavailable. Therefore, as soon as the client
  connection is stable, it isn't aware of any new hosts added to the cluster via
  DNS service discovery. This behavior can lead to unexpected stickiness and
  workload skew, especially after a failover.
- A new connection still resolves the DNS again and gets an up-to-date list of
  addresses. Unfortunately, most of Gitaly clients use long-running connections.
  They run multiple calls on the same connections. They barely create new
  connections in normal conditions. Hence, it's unlikely for them to detect DNS
  state changes.
- The support for SRV records is currently in a weird state. This record type is
  only supported when the `grpclb` load balancing strategy is enabled.
  Unfortunately, this strategy is deprecated, and its behavior is not as we
  expected. In the short-term, we would like to use the round-robin strategy. In
  the longer term, we may have a custom strategy for Raft-based clusters.
  Therefore, SRV service discovery is crucial for the future.

In [epic 8971](https://gitlab.com/groups/gitlab-org/-/epics/8971), we added
support for Praefect service discovery:

- We implemented a replacement for default DNS resolver. This resolver
  periodically resolves the DNS for new states. This home-grown resolver helps
  to distribute the workload better and remove skewing situation.
- We use the default round-robin load balancer.

Clients can opt-in to this option
using the [`WithGitalyDNSResolver` dial option](https://gitlab.com/gitlab-org/gitaly/-/blob/220959f0ffc3d01fa448cc2c7b45b082d56690ef/client/dial.go#L91).
All major Gitaly clients already used this option.

## What's next for Gitaly Cluster?

Routing is a crucial aspect of the proposal
in [epic 8903](https://gitlab.com/groups/gitlab-org/-/epics/8903).
The proposal implies we need to implement a smart routing mechanism. Before an
actual RPC is fired, clients have to contact an arbitrary Gitaly node asking for
the responsible node. The built-in load-balancing framework works perfectly in
this case:

- The resolver is responsible for fetching the initial coordinating node. This
  node is responsible for repository-node queries. Occasionally, the resolver
  switches to another node.
- The load balancer finds the actual destination by contacting the coordinating
  node. It maintains all established subchannels, maybe with LRU, to prevent a
  client opens too many connections to Gitaly nodes. The balancer may also set a
  connection to a less-common node to idle state, and wake it up whenever
  needed.

While `grpc-go` can follow this approach, it's not the case for GitLab
Rails. GitLab Rails uses `grpc-ruby` instead of `grpc-core`. This stack doesn't
support custom load balancing implementation. This doesn't mean we are unable to
rebuild this mechanism at a higher layer. In fact, we already did service
discovery and connection management
in [database load balancing](https://gitlab.com/gitlab-org/gitlab/-/tree/92ffb941dcb7d82a71f7bfbcef1059a161e368ac/lib/gitlab/database/load_balancing).
However, there are some downsides:

- Maintenance cost is high. It's highly likely the logic codes are duplicated
  between Go clients and Ruby clients.
- Built-in load-balancing kit hooks deep into the connectivity management at the
  right layer. A side-loaded load balancer at higher layer does not yield the
  same effect.

Therefore, the sidecar smart router is a reasonable solution:

- Gitaly comes with a sidecar proxy.
- Clients establish a dummy connection to this proxy over a single TCP connection.
- This proxy then performs all mentioned smart routing mechanism on the behalf of clients.

This approach comes with plenty of benefits, especially for GitLab Rails. However, there maybe some concerns about the performance.