Skip to content

Commit c152877

Browse files
authored
Graceful startup/shutdown + zero-downtime deployments (#469)
* graceful startup + shutdown * doc: example config * opt-in api not sending bids during shutdown
1 parent fab68c4 commit c152877

12 files changed

+300
-91
lines changed

README.md

+15-13
Original file line numberDiff line numberDiff line change
@@ -118,21 +118,23 @@ redis-cli DEL boost-relay/sepolia:validators-registration boost-relay/sepolia:va
118118

119119
#### General
120120

121-
* `ACTIVE_VALIDATOR_HOURS` - number of hours to track active proposers in redis (default: 3)
122-
* `API_TIMEOUT_READ_MS` - http read timeout in milliseconds (default: 1500)
123-
* `API_TIMEOUT_READHEADER_MS` - http read header timeout in milliseconds (default: 600)
124-
* `API_TIMEOUT_WRITE_MS` - http write timeout in milliseconds (default: 10000)
125-
* `API_TIMEOUT_IDLE_MS` - http idle timeout in milliseconds (default: 3000)
126-
* `API_MAX_HEADER_BYTES` - http maximum header byted (default: 60kb)
127-
* `BLOCKSIM_MAX_CONCURRENT` - maximum number of concurrent block-sim requests (0 for no maximum, default: 4)
128-
* `BLOCKSIM_TIMEOUT_MS` - builder block submission validation request timeout (default: 3000)
121+
* `ACTIVE_VALIDATOR_HOURS` - number of hours to track active proposers in redis (default: `3`)
122+
* `API_MAX_HEADER_BYTES` - http maximum header bytes (default: `60_000`)
123+
* `API_TIMEOUT_READ_MS` - http read timeout in milliseconds (default: `1_500`)
124+
* `API_TIMEOUT_READHEADER_MS` - http read header timeout in milliseconds (default: `600`)
125+
* `API_TIMEOUT_WRITE_MS` - http write timeout in milliseconds (default: `10_000`)
126+
* `API_TIMEOUT_IDLE_MS` - http idle timeout in milliseconds (default: `3_000`)
127+
* `API_SHUTDOWN_WAIT_SEC` - how long to wait on shutdown before stopping server, to allow draining of requests (default: `30`)
128+
* `API_SHUTDOWN_STOP_SENDING_BIDS` - whether API should stop sending bids during shutdown (nly useful in single-instance/testnet setups, default: `false`)
129+
* `BLOCKSIM_MAX_CONCURRENT` - maximum number of concurrent block-sim requests (0 for no maximum, default: `4`)
130+
* `BLOCKSIM_TIMEOUT_MS` - builder block submission validation request timeout (default: `3000`)
129131
* `DB_DONT_APPLY_SCHEMA` - disable applying DB schema on startup (useful for connecting data API to read-only replica)
130132
* `DB_TABLE_PREFIX` - prefix to use for db tables (default uses `dev`)
131-
* `GETPAYLOAD_RETRY_TIMEOUT_MS` - getPayload retry getting a payload if first try failed (default: 100)
133+
* `GETPAYLOAD_RETRY_TIMEOUT_MS` - getPayload retry getting a payload if first try failed (default: `100`)
132134
* `MEMCACHED_URIS` - optional comma separated list of memcached endpoints, typically used as secondary storage alongside Redis
133-
* `MEMCACHED_EXPIRY_SECONDS` - item expiry timeout when using memcache (default: 45)
134-
* `MEMCACHED_CLIENT_TIMEOUT_MS` - client timeout in milliseconds (default: 250)
135-
* `MEMCACHED_MAX_IDLE_CONNS` - client max idle conns (default: 10)
135+
* `MEMCACHED_EXPIRY_SECONDS` - item expiry timeout when using memcache (default: `45`)
136+
* `MEMCACHED_CLIENT_TIMEOUT_MS` - client timeout in milliseconds (default: `250`)
137+
* `MEMCACHED_MAX_IDLE_CONNS` - client max idle conns (default: `10`)
136138
* `NUM_ACTIVE_VALIDATOR_PROCESSORS` - proposer API - number of goroutines to listen to the active validators channel
137139
* `NUM_VALIDATOR_REG_PROCESSORS` - proposer API - number of goroutines to listen to the validator registration channel
138140
* `NO_HEADER_USERAGENTS` - proposer API - comma separated list of user agents for which no bids should be returned
@@ -181,7 +183,7 @@ By default, the execution payloads for all block submission are stored in Redis
181183
to provide redundant data availability for getPayload responses. But the database table is not pruned automatically,
182184
because it takes a lot of resources to rebuild the indexes (and a better option is using `TRUNCATE`).
183185

184-
Storing all the payloads in the database can lead to terrabytes of data in this particular table. Now it's also possible
186+
Storing all the payloads in the database can lead to terabytes of data in this particular table. Now it's also possible
185187
to use memcached as a second data availability layer. Using memcached is optional and disabled by default.
186188

187189
To enable memcached, you just need to supply the memcached URIs either via environment variable (i.e.

cmd/api.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ var apiCmd = &cobra.Command{
139139
}
140140

141141
log.Info("Setting up datastore...")
142-
ds, err := datastore.NewDatastore(log, redis, mem, db)
142+
ds, err := datastore.NewDatastore(redis, mem, db)
143143
if err != nil {
144144
log.WithError(err).Fatalf("Failed setting up prod datastore")
145145
}

common/common.go

+4
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ var (
1919
DurationPerEpoch = DurationPerSlot * time.Duration(SlotsPerEpoch)
2020
)
2121

22+
func SlotToEpoch(slot uint64) uint64 {
23+
return slot / SlotsPerEpoch
24+
}
25+
2226
// HTTPServerTimeouts are various timeouts for requests to the mev-boost HTTP server
2327
type HTTPServerTimeouts struct {
2428
Read time.Duration // Timeout for body reads. None if 0.

common/test_utils.go

+15
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package common
22

33
import (
4+
"bytes"
45
"compress/gzip"
6+
"encoding/base64"
57
"encoding/json"
68
"io"
79
"os"
@@ -98,3 +100,16 @@ func LoadGzippedJSON(t *testing.T, filename string, dst any) {
98100
err := json.Unmarshal(b, dst)
99101
require.NoError(t, err)
100102
}
103+
104+
func MustB64Gunzip(s string) []byte {
105+
b, _ := base64.StdEncoding.DecodeString(s)
106+
gzreader, err := gzip.NewReader(bytes.NewReader(b))
107+
if err != nil {
108+
panic(err)
109+
}
110+
output, err := io.ReadAll(gzreader)
111+
if err != nil {
112+
panic(err)
113+
}
114+
return output
115+
}

common/utils.go

+14
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import (
1111
"math/big"
1212
"net/http"
1313
"os"
14+
"strconv"
1415
"strings"
1516
"testing"
17+
"time"
1618

1719
"github.com/attestantio/go-builder-client/api/capella"
1820
v1 "github.com/attestantio/go-builder-client/api/v1"
@@ -233,3 +235,15 @@ func CreateTestBlockSubmission(t *testing.T, builderPubkey string, value *big.In
233235

234236
return payload, getPayloadResponse, getHeaderResponse
235237
}
238+
239+
// GetEnvDurationSec returns the value of the environment variable as duration in seconds,
240+
// or defaultValue if the environment variable doesn't exist or is not a valid integer
241+
func GetEnvDurationSec(key string, defaultValueSec int) time.Duration {
242+
if value, ok := os.LookupEnv(key); ok {
243+
val, err := strconv.Atoi(value)
244+
if err != nil {
245+
return time.Duration(val) * time.Second
246+
}
247+
}
248+
return time.Duration(defaultValueSec) * time.Second
249+
}

datastore/datastore.go

+14-18
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,6 @@ type GetPayloadResponseKey struct {
3535

3636
// Datastore provides a local memory cache with a Redis and DB backend
3737
type Datastore struct {
38-
log *logrus.Entry
39-
4038
redis *RedisCache
4139
memcached *Memcached
4240
db database.IDatabaseService
@@ -46,11 +44,13 @@ type Datastore struct {
4644
knownValidatorsLock sync.RWMutex
4745
knownValidatorsIsUpdating uberatomic.Bool
4846
knownValidatorsLastSlot uberatomic.Uint64
47+
48+
// Used for proposer-API readiness check
49+
KnownValidatorsWasUpdated uberatomic.Bool
4950
}
5051

51-
func NewDatastore(log *logrus.Entry, redisCache *RedisCache, memcached *Memcached, db database.IDatabaseService) (ds *Datastore, err error) {
52+
func NewDatastore(redisCache *RedisCache, memcached *Memcached, db database.IDatabaseService) (ds *Datastore, err error) {
5253
ds = &Datastore{
53-
log: log.WithField("component", "datastore"),
5454
db: db,
5555
memcached: memcached,
5656
redis: redisCache,
@@ -66,7 +66,7 @@ func NewDatastore(log *logrus.Entry, redisCache *RedisCache, memcached *Memcache
6666
// For the CL client this is an expensive operation and takes a bunch of resources.
6767
// This is why we schedule the requests for slot 4 and 20 of every epoch, 6 seconds
6868
// into the slot (on suggestion of @potuz). It's also run once at startup.
69-
func (ds *Datastore) RefreshKnownValidators(beaconClient beaconclient.IMultiBeaconClient, slot uint64) {
69+
func (ds *Datastore) RefreshKnownValidators(log *logrus.Entry, beaconClient beaconclient.IMultiBeaconClient, slot uint64) {
7070
// Ensure there's only one at a time
7171
if isAlreadyUpdating := ds.knownValidatorsIsUpdating.Swap(true); isAlreadyUpdating {
7272
return
@@ -75,19 +75,19 @@ func (ds *Datastore) RefreshKnownValidators(beaconClient beaconclient.IMultiBeac
7575

7676
headSlotPos := common.SlotPos(slot) // 1-based position in epoch (32 slots, 1..32)
7777
lastUpdateSlot := ds.knownValidatorsLastSlot.Load()
78-
log := ds.log.WithFields(logrus.Fields{
79-
"method": "RefreshKnownValidators",
80-
"headSlot": slot,
81-
"headSlotPos": headSlotPos,
82-
"lastUpdateSlot": lastUpdateSlot,
78+
log = log.WithFields(logrus.Fields{
79+
"datastoreMethod": "RefreshKnownValidators",
80+
"headSlot": slot,
81+
"headSlotPos": headSlotPos,
82+
"lastUpdateSlot": lastUpdateSlot,
8383
})
8484

8585
// Only proceed if slot newer than last updated
8686
if slot <= lastUpdateSlot {
8787
return
8888
}
8989

90-
// // Minimum amount of slots between updates
90+
// Minimum amount of slots between updates
9191
slotsSinceLastUpdate := slot - lastUpdateSlot
9292
if slotsSinceLastUpdate < 6 {
9393
return
@@ -143,6 +143,7 @@ func (ds *Datastore) RefreshKnownValidators(beaconClient beaconclient.IMultiBeac
143143
ds.knownValidatorsByIndex = knownValidatorsByIndex
144144
ds.knownValidatorsLock.Unlock()
145145

146+
ds.KnownValidatorsWasUpdated.Store(true)
146147
log.Infof("known validators updated")
147148
}
148149

@@ -189,13 +190,8 @@ func (ds *Datastore) SaveValidatorRegistration(entry types.SignedValidatorRegist
189190
}
190191

191192
// GetGetPayloadResponse returns the getPayload response from memory or Redis or Database
192-
func (ds *Datastore) GetGetPayloadResponse(slot uint64, proposerPubkey, blockHash string) (*common.VersionedExecutionPayload, error) {
193-
log := ds.log.WithFields(logrus.Fields{
194-
"method": "GetGetPayloadResponse",
195-
"slot": slot,
196-
"proposerPubkey": proposerPubkey,
197-
"blockHash": blockHash,
198-
})
193+
func (ds *Datastore) GetGetPayloadResponse(log *logrus.Entry, slot uint64, proposerPubkey, blockHash string) (*common.VersionedExecutionPayload, error) {
194+
log = log.WithField("datastoreMethod", "GetGetPayloadResponse")
199195
_proposerPubkey := strings.ToLower(proposerPubkey)
200196
_blockHash := strings.ToLower(blockHash)
201197

datastore/datastore_test.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@ func setupTestDatastore(t *testing.T, mockDB *database.MockDB) *Datastore {
1818
redisDs, err := NewRedisCache("", redisTestServer.Addr(), "")
1919
require.NoError(t, err)
2020

21-
ds, err := NewDatastore(common.TestLog, redisDs, nil, mockDB)
21+
ds, err := NewDatastore(redisDs, nil, mockDB)
2222
require.NoError(t, err)
2323

2424
return ds
2525
}
2626

2727
func TestGetPayloadFailure(t *testing.T) {
2828
ds := setupTestDatastore(t, &database.MockDB{})
29-
_, err := ds.GetGetPayloadResponse(1, "a", "b")
29+
_, err := ds.GetGetPayloadResponse(common.TestLog, 1, "a", "b")
3030
require.Error(t, ErrExecutionPayloadNotFound, err)
3131
}
3232

@@ -44,7 +44,7 @@ func TestGetPayloadDatabaseFallback(t *testing.T) {
4444
},
4545
}
4646
ds := setupTestDatastore(t, mockDB)
47-
payload, err := ds.GetGetPayloadResponse(1, "a", "b")
47+
payload, err := ds.GetGetPayloadResponse(common.TestLog, 1, "a", "b")
4848
require.NoError(t, err)
4949
require.Equal(t, "0x1bafdc454116b605005364976b134d761dd736cb4788d25c835783b46daeb121", payload.Capella.Capella.BlockHash.String())
5050
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
# On graceful service startup and shutdown, and zero-downtime deployments
2+
3+
2023-06-19, by [@metachris](https://twitter.com/metachris)
4+
5+
---
6+
7+
This document explains the details of API service startup and shutdown behavior, in particular related to:
8+
- Proposer API
9+
- Needing data before being able to handle `getPayload` requests (known validators)
10+
- Draining getPayload and other requests before shutting down
11+
- Zero-downtime deployments
12+
13+
---
14+
15+
### TL;DR
16+
17+
- We've added two endpoints: `/livez` and `/readyz` (per [k8s docs](https://kubernetes.io/docs/reference/using-api/health-checks/)):
18+
- On startup:
19+
- `/livez` is immediately available and positive, and will stay so until the service is shut down
20+
- `/readyz` starts negative, until all information is loaded to safely process requests (known validators for the proposer API)
21+
- Configure your orchestration tooling to route traffic to the service only if and when `/readyz` is positive!
22+
- On shutdown:
23+
- `/readyz` returns a negative result
24+
- Wait a little and drain all requests
25+
- Stop the webserver, and stop the program
26+
- See also: https://kubernetes.io/docs/reference/using-api/health-checks/
27+
28+
---
29+
30+
### Kubernetes background about health-checks
31+
32+
There are three types of health-checks (probes): [k8s docs](https://kubernetes.io/docs/reference/using-api/health-checks/)
33+
34+
1. Startup probe
35+
2. Liveness probe (`/livez`)
36+
3. Readiness probe (`/readyz`)
37+
38+
(All of these can be HTTP requests or commands)
39+
40+
1. startup check:
41+
- only for the startup phase
42+
- confirm that pod has started
43+
- if it fails, k8s will destroy and recreate
44+
2. liveness check:
45+
- indicated whether the service is alive. if `false`, then k8s should destroy & recreate the pods
46+
- based on rules, timeouts, etc
47+
- status exposed via `/livez`
48+
3. readiness check:
49+
- Applications may be temporarily unable to serve traffic.
50+
- An application might need to load large data or configuration files during startup or depend on external services after startup.
51+
- In such cases, you don't want to kill the application, but you don't want to send it requests either.
52+
- https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-readiness-probes
53+
- status exposed via `/readyz`
54+
- if that is `false`, then k8s will stop sending traffic to that pod but doesn't touch it otherwise
55+
56+
---
57+
58+
### API Startup + Shutdown Sequence
59+
60+
The proposer API needs to load all known validators before serving traffic, otherwise, there's a risk of missed slots due to `getPayload` not having all the information it needs to succeed.
61+
62+
**Correct startup sequence:**
63+
1. Service starts
64+
2. Does minimal initial checks
65+
3. Starts HTTP server (`live=true`, `ready=false`)
66+
4. Updates known validators from CL client (can take 10-30 sec)
67+
5. Sets `ready=true`, and starts receiving traffic
68+
69+
At this point, the pod is operational and can service traffic.
70+
71+
**Correct shutdown sequence:**
72+
73+
1. Shutdown initiated (through signals `syscall.SIGINT` or `syscall.SIGTERM`)
74+
2. Set `ready=false` to stop receiving new traffic
75+
3. Wait some time
76+
4. Drain pending requests
77+
5. Shut down (setting `live=false` is not necessary anymore)
78+
79+
80+
---
81+
82+
### Example k8s + AWS configuration
83+
84+
```yaml
85+
metadata:
86+
name: boost-relay-api-proposer
87+
+ annotations:
88+
+ alb.ingress.kubernetes.io/healthcheck-interval-seconds: 10
89+
+ alb.ingress.kubernetes.io/healthcheck-path: /readyz
90+
+ alb.ingress.kubernetes.io/healthcheck-port: 8080
91+
spec:
92+
template:
93+
spec:
94+
containers:
95+
- name: boost-relay-api-proposer
96+
+ livenessProbe:
97+
+ httpGet:
98+
+ path: /livez
99+
+ port: 8080
100+
+ initialDelaySeconds: 5
101+
+ readinessProbe:
102+
+ httpGet:
103+
+ path: /readyz
104+
+ port: 8080
105+
+ initialDelaySeconds: 30
106+
```
107+
108+
---
109+
110+
See also:
111+
112+
- https://kubernetes.io/docs/reference/using-api/health-checks/
113+
- https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/
114+
- https://komodor.com/blog/kubernetes-health-checks-everything-you-need-to-know/
115+
- https://kubernetes-sigs.github.io/aws-load-balancer-controller/v2.2/guide/ingress/annotations/

services/api/optimistic_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ func startTestBackend(t *testing.T) (*phase0.BLSPubKey, *bls.SecretKey, *testBac
118118
require.NoError(t, err)
119119
mockRedis, err := datastore.NewRedisCache("", redisTestServer.Addr(), "")
120120
require.NoError(t, err)
121-
mockDS, err := datastore.NewDatastore(backend.relay.log, mockRedis, nil, mockDB)
121+
mockDS, err := datastore.NewDatastore(mockRedis, nil, mockDB)
122122
require.NoError(t, err)
123123

124124
backend.relay.datastore = mockDS

0 commit comments

Comments
 (0)