Izgradite skalabilnu Go WebSocket uslugu: regulacija protoka, autentifikacija i pouzdanost
WebSocket poslužitelj lako je demonstrirati, a iznenađujuće ga je teško pokretati. Rukovanje je rijetko problem. Pravi inženjerski izazovi počinju kada tisuće klijenata ostanu povezane, jedan čitač prestane primati podatke, Redis nestane, vjerodajnice isteknu ili implementacija zatraži od svakog čvora da se isključi bez curenja goroutina.
Ovaj vodič izrađuje Go servis spreman za produkciju s čvrstim ograničenjem broja veza po čvoru, ograničenim izlaznim redovima, isključivanjem sporih klijenata, autentificiranim rukovanjima, ping/pong otkucajima srca, kontroliranim gašenjem, provjerama zdravlja, metrikama i Redisom podržanim raspršivanjem poruka među replikama.
Arhitektura i ugovor isporuke
Svaki proces upravlja svojim lokalnim WebSocket vezama. Poruka klijenta se provjerava, označava autentificiranim identitetom i čvorom koji je objavljuje, a zatim se objavljuje u Redis. Svaki proces pretplaćen je na isti kanal i prosljeđuje svaki događaj svojim lokalnim klijentima.
Dizajn namjerno koristi Redis Pub/Sub. Brz je i prikladan za efemernu prisutnost, nadzorne ploče, obavijesti i ažuriranja suradnje uživo, ali nije trajan. Isključeni pretplatnik može propustiti događaje. Aplikacije koje zahtijevaju ponavljanje trebaju koristiti trajni zapis kao što je Redis Streams ili drugi posrednik te uvesti ID-jeve poruka, potvrde primitka i zadržavanje.
Memorija ostaje ograničena na dva mjesta:
MAX_CLIENTSograničava veze koje prihvaća jedan proces.- Svaki klijent dobiva izlazni kanal fiksnog kapaciteta. Ako se ispuni, taj se klijent isključuje umjesto da uspori cijelo središte ili troši neograničenu memoriju.
Svi čvorovi primaju poruke putem Redisa, uključujući čvor koji ih je izvorno poslao. Taj jedinstveni put izbjegava dvostruku lokalnu isporuku i održava semantiku redoslijeda dosljednom unutar ograničenja Pub/Suba.
Preduvjeti i raspored projekta
Potrebni su vam Go 1.22 ili noviji, Docker s Composeom za primjer implementacije i OpenSSL za generiranje tajne. Aplikacija koristi github.com/gorilla/websocket 1.5.3 i github.com/redis/go-redis/v9 9.7.0.
realtime/
├── cmd/
│ ├── check/main.go
│ └── token/main.go
├── Dockerfile
├── compose.yaml
├── go.mod
├── haproxy.cfg
└── main.go
Izradite go.mod:
module example.com/realtime
go 1.22.0
require (
github.com/gorilla/websocket v1.5.3
github.com/redis/go-redis/v9 v9.7.0
)
Implementacija WebSocket servisa
Poslužitelj održava točno jedan čitač i jedan pisač po vezi, u skladu s ugovorom konkurentnosti Gorilla WebSocketa. Samo pumpa za čitanje parsira okvire klijenta. Samo pumpa za pisanje šalje obične okvire i otkucaje srca.
package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/gorilla/websocket"
"github.com/redis/go-redis/v9"
)
const (
writeWait = 10 * time.Second
pongWait = 60 * time.Second
pingEvery = 25 * time.Second
maxFrame = 4096
queueSize = 64
)
type counters struct {
connected, accepted, published, publishErrors, slowDisconnects atomic.Int64
}
type config struct {
addr, redisURL, channel, node string
secret []byte
maxClients int
origins map[string]struct{}
}
type app struct {
ctx context.Context
cfg config
redis *redis.Client
hub *hub
slots chan struct{}
metrics *counters
pumps sync.WaitGroup
}
type hub struct {
register, unregister chan *client
inbound chan []byte
metrics *counters
}
type client struct {
app *app
conn *websocket.Conn
send chan []byte
user string
}
func env(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
func loadConfig() config {
maxClients, err := strconv.Atoi(env("MAX_CLIENTS", "5000"))
if err != nil || maxClients < 1 {
panic("MAX_CLIENTS must be a positive integer")
}
secret := []byte(os.Getenv("AUTH_SECRET"))
if len(secret) < 32 {
panic("AUTH_SECRET must contain at least 32 characters")
}
origins := make(map[string]struct{})
for _, origin := range strings.Split(env("ALLOWED_ORIGINS", ""), ",") {
if origin = strings.TrimSpace(origin); origin != "" {
origins[origin] = struct{}{}
}
}
host, _ := os.Hostname()
return config{
addr: env("LISTEN_ADDR", ":8080"),
redisURL: env("REDIS_URL", "redis://127.0.0.1:6379/0"),
channel: env("REDIS_CHANNEL", "ws:broadcast"),
node: env("NODE_ID", host),
secret: secret,
maxClients: maxClients,
origins: origins,
}
}
func (a *app) authenticate(r *http.Request) (string, error) {
scheme, token, ok := strings.Cut(r.Header.Get("Authorization"), " ")
if !ok || !strings.EqualFold(scheme, "Bearer") {
return "", fmt.Errorf("missing bearer token")
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", fmt.Errorf("malformed token")
}
signingInput := parts[0] + "." + parts[1]
supplied, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return "", fmt.Errorf("malformed signature")
}
mac := hmac.New(sha256.New, a.cfg.secret)
mac.Write([]byte(signingInput))
if subtle.ConstantTimeCompare(supplied, mac.Sum(nil)) != 1 {
return "", fmt.Errorf("invalid signature")
}
subjectBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return "", fmt.Errorf("malformed subject")
}
subject := string(subjectBytes)
if subject == "" || len(subject) > 128 {
return "", fmt.Errorf("invalid subject")
}
expiry, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || time.Now().Unix() >= expiry {
return "", fmt.Errorf("expired token")
}
return subject, nil
}
func (h *hub) run(ctx context.Context) {
clients := make(map[*client]struct{})
drop := func(c *client, slow bool) {
if _, exists := clients[c]; !exists {
return
}
delete(clients, c)
close(c.send)
c.conn.Close()
h.metrics.connected.Add(-1)
if slow {
h.metrics.slowDisconnects.Add(1)
}
}
for {
select {
case c := <-h.register:
clients[c] = struct{}{}
h.metrics.connected.Add(1)
case c := <-h.unregister:
drop(c, false)
case message := <-h.inbound:
for c := range clients {
select {
case c.send <- message:
default:
drop(c, true)
}
}
case <-ctx.Done():
for c := range clients {
drop(c, false)
}
return
}
}
}
func (c *client) readPump() {
defer c.conn.Close()
c.conn.SetReadLimit(maxFrame)
_ = c.conn.SetReadDeadline(time.Now().Add(pongWait))
c.conn.SetPongHandler(func(string) error {
return c.conn.SetReadDeadline(time.Now().Add(pongWait))
})
for {
messageType, data, err := c.conn.ReadMessage()
if err != nil {
return
}
if messageType != websocket.TextMessage {
continue
}
var input struct {
Body string `json:"body"`
}
if json.Unmarshal(data, &input) != nil || input.Body == "" {
continue
}
event := struct {
Type, User, Body, Node, At string
}{
Type: "message", User: c.user, Body: input.Body,
Node: c.app.cfg.node, At: time.Now().UTC().Format(time.RFC3339Nano),
}
payload, err := json.Marshal(event)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(c.app.ctx, 1500*time.Millisecond)
err = c.app.redis.Publish(ctx, c.app.cfg.channel, payload).Err()
cancel()
if err != nil {
c.app.metrics.publishErrors.Add(1)
slog.Error("publish failed", "user", c.user, "error", err)
_ = c.conn.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(1011, "broker unavailable"),
time.Now().Add(writeWait),
)
return
}
c.app.metrics.published.Add(1)
}
}
func (c *client) writePump() {
ticker := time.NewTicker(pingEvery)
defer ticker.Stop()
for {
select {
case message, ok := <-c.send:
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if !ok {
_ = c.conn.WriteControl(
websocket.CloseMessage,
websocket.FormatCloseMessage(1001, "server closing"),
time.Now().Add(writeWait),
)
return
}
if c.conn.WriteMessage(websocket.TextMessage, message) != nil {
return
}
case <-ticker.C:
_ = c.conn.SetWriteDeadline(time.Now().Add(writeWait))
if c.conn.WriteMessage(websocket.PingMessage, nil) != nil {
return
}
case <-c.app.ctx.Done():
return
}
}
}
func (a *app) serveWS(w http.ResponseWriter, r *http.Request) {
user, err := a.authenticate(r)
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
select {
case a.slots <- struct{}{}:
default:
w.Header().Set("Retry-After", "5")
http.Error(w, "connection capacity reached", http.StatusServiceUnavailable)
return
}
upgrader := websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true
}
_, allowed := a.cfg.origins[origin]
return allowed
},
}
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
<-a.slots
return
}
c := &client{app: a, conn: conn, send: make(chan []byte, queueSize), user: user}
select {
case a.hub.register <- c:
case <-a.ctx.Done():
conn.Close()
<-a.slots
return
}
a.metrics.accepted.Add(1)
var once sync.Once
cleanup := func() {
once.Do(func() {
conn.Close()
select {
case a.hub.unregister <- c:
case <-a.ctx.Done():
}
<-a.slots
})
}
a.pumps.Add(2)
go func() {
defer a.pumps.Done()
c.readPump()
cleanup()
}()
go func() {
defer a.pumps.Done()
c.writePump()
cleanup()
}()
}
func subscribe(ctx context.Context, rdb *redis.Client, channel string, inbound chan<- []byte) {
backoff := time.Second
for ctx.Err() == nil {
pubsub := rdb.Subscribe(ctx, channel)
_, err := pubsub.Receive(ctx)
if err == nil {
backoff = time.Second
for {
message, receiveErr := pubsub.ReceiveMessage(ctx)
if receiveErr != nil {
err = receiveErr
break
}
select {
case inbound <- []byte(message.Payload):
case <-ctx.Done():
pubsub.Close()
return
}
}
}
pubsub.Close()
if ctx.Err() != nil {
return
}
slog.Error("subscription interrupted", "error", err, "retry_in", backoff)
timer := time.NewTimer(backoff)
select {
case <-timer.C:
case <-ctx.Done():
timer.Stop()
return
}
if backoff < 15*time.Second {
backoff *= 2
}
}
}
func main() {
cfg := loadConfig()
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
options, err := redis.ParseURL(cfg.redisURL)
if err != nil {
panic(err)
}
options.DialTimeout = 2 * time.Second
options.ReadTimeout = 2 * time.Second
options.WriteTimeout = 2 * time.Second
options.PoolTimeout = 2 * time.Second
options.PoolSize = 20
rdb := redis.NewClient(options)
startupCtx, startupCancel := context.WithTimeout(ctx, 2*time.Second)
err = rdb.Ping(startupCtx).Err()
startupCancel()
if err != nil {
panic(fmt.Sprintf("Redis unavailable: %v", err))
}
metrics := &counters{}
h := &hub{
register: make(chan *client), unregister: make(chan *client),
inbound: make(chan []byte, 1024), metrics: metrics,
}
a := &app{
ctx: ctx, cfg: cfg, redis: rdb, hub: h,
slots: make(chan struct{}, cfg.maxClients), metrics: metrics,
}
go h.run(ctx)
go subscribe(ctx, rdb, cfg.channel, h.inbound)
mux := http.NewServeMux()
mux.HandleFunc("/ws", a.serveWS)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
checkCtx, checkCancel := context.WithTimeout(r.Context(), 500*time.Millisecond)
defer checkCancel()
if rdb.Ping(checkCtx).Err() != nil {
http.Error(w, "not ready", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/metrics", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
fmt.Fprintf(w,
"ws_connected %d\nws_accepted_total %d\nws_published_total %d\n"+
"ws_publish_errors_total %d\nws_slow_disconnects_total %d\n",
metrics.connected.Load(), metrics.accepted.Load(),
metrics.published.Load(), metrics.publishErrors.Load(),
metrics.slowDisconnects.Load(),
)
})
server := &http.Server{
Addr: cfg.addr, Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 16 << 10,
}
serverErrors := make(chan error, 1)
go func() {
slog.Info("server listening", "address", cfg.addr, "node", cfg.node)
serverErrors <- server.ListenAndServe()
}()
select {
case <-ctx.Done():
case err := <-serverErrors:
if err != nil && err != http.ErrServerClosed {
slog.Error("HTTP server stopped", "error", err)
}
cancel()
}
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second)
_ = server.Shutdown(shutdownCtx)
shutdownCancel()
cancel()
done := make(chan struct{})
go func() {
a.pumps.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(5 * time.Second):
slog.Warn("timed out waiting for connection pumps")
}
_ = rdb.Close()
}
Poslužitelj namjerno izostavlja http.Server.WriteTimeout. Uobičajeni kratki vremenski rok odgovora može prekinuti dugotrajne nadograđene veze. Umjesto toga, WebSocket zapisi pojedinačno su ograničeni rokovima.
Izdavanje kratkotrajnih autentifikacijskih tokena
Ovaj sažeti format HMAC tokena prikladan je kada jedna pouzdana autentifikacijska usluga izdaje vjerodajnice ovom WebSocket sloju. Sadrži base64url subjekt, Unix vrijeme isteka i SHA-256 HMAC. Nije predstavljen kao JWT i ne bi se trebao miješati s JWT alatima.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"fmt"
"os"
"time"
)
func main() {
if len(os.Args) != 3 {
panic("usage: token SUBJECT DURATION")
}
secret := []byte(os.Getenv("AUTH_SECRET"))
if len(secret) < 32 {
panic("AUTH_SECRET must contain at least 32 characters")
}
duration, err := time.ParseDuration(os.Args[2])
if err != nil || duration <= 0 {
panic("duration must be positive, for example 15m")
}
subject := base64.RawURLEncoding.EncodeToString([]byte(os.Args[1]))
unsigned := fmt.Sprintf("%s.%d", subject, time.Now().Add(duration).Unix())
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(unsigned))
fmt.Printf("%s.%s\n", unsigned,
base64.RawURLEncoding.EncodeToString(mac.Sum(nil)))
}
JavaScript u pregledniku ne može postaviti proizvoljno zaglavlje Authorization tijekom izvornog WebSocket rukovanja. Za implementacije u pregledniku, zamijenite uobičajenu autentificiranu sesiju kratkotrajnim jednokratnim tiketom i proslijedite taj tiket u URL-u. Osigurajte da rubni proxy skriva nizove upita iz zapisnika. Ne stavljajte ponovno upotrebljive API ključeve u URL-ove.
Kontejnerizacija i horizontalno skaliranje
Kontejner radi bez root privilegija. HAProxy završava javnu HTTP vezu i raspoređuje nadograđene veze među replikama aplikacije; WebSocketima nisu potrebne ljepljive sesije jer svaki čvor koristi zajednički posrednik.
FROM golang:1.22-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/realtime .
FROM alpine:3.20
RUN addgroup -S app && adduser -S -G app app
COPY --from=build /out/realtime /usr/local/bin/realtime
USER app
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/realtime"]
Izradite haproxy.cfg:
global
log stdout format raw local0
defaults
mode http
log global
option httplog
timeout connect 3s
timeout client 1h
timeout server 1h
timeout http-request 10s
resolvers docker
nameserver dns 127.0.0.11:53
resolve_retries 3
timeout resolve 1s
timeout retry 1s
hold valid 10s
frontend public
bind *:8080
default_backend websocket_nodes
backend websocket_nodes
balance leastconn
option httpchk GET /readyz
http-check expect status 200
server-template app 1-10 app:8080 check resolvers docker init-addr libc,none
Izradite compose.yaml:
services:
app:
build: .
environment:
AUTH_SECRET: "${AUTH_SECRET:?set AUTH_SECRET}"
REDIS_URL: "redis://redis:6379/0"
REDIS_CHANNEL: "ws:broadcast"
ALLOWED_ORIGINS: "https://app.example.com"
MAX_CLIENTS: "5000"
expose:
- "8080"
depends_on:
redis:
condition: service_healthy
ulimits:
nofile:
soft: 65536
hard: 65536
restart: unless-stopped
redis:
image: redis:7.4-alpine
command: ["redis-server", "--save", "", "--appendonly", "no"]
expose:
- "6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 1s
retries: 20
restart: unless-stopped
proxy:
image: haproxy:3.0-alpine
volumes:
- "./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro"
ports:
- "127.0.0.1:8080:8080"
depends_on:
- app
restart: unless-stopped
Generirajte ovisnosti i pokrenite tri replike aplikacije:
go mod tidy
export AUTH_SECRET="$(openssl rand -base64 32)"
docker compose up --build --scale app=3
Primjer povezuje HAProxy samo s povratnom petljom. U produkciji završite TLS na ojačanom rubnom proxyju, kroz vatrozid glavnog računala izložite samo namjeravani HTTPS port i zadržite Redis na privatnoj mreži. Udaljena implementacija Redisa trebala bi koristiti autentificirani URL rediss:// s provjerom certifikata. Ne izlažite /metrics javno; ograničite ga mrežnom politikom ili zasebnim internim osluškivačem.
Provjera isporuke među vezama
Dodajte cmd/check/main.go. Otvara dvije autentificirane veze, objavljuje preko jedne i potvrđuje primitak preko druge.
package main
import (
"fmt"
"net/http"
"os"
"time"
"github.com/gorilla/websocket"
)
func connect(token string) *websocket.Conn {
headers := http.Header{"Authorization": {"Bearer " + token}}
conn, _, err := websocket.DefaultDialer.Dial("ws://127.0.0.1:8080/ws", headers)
if err != nil {
panic(err)
}
return conn
}
func main() {
if len(os.Args) != 2 {
panic("usage: check TOKEN")
}
first := connect(os.Args[1])
second := connect(os.Args[1])
defer first.Close()
defer second.Close()
if err := first.WriteJSON(map[string]string{"body": "cross-node hello"}); err != nil {
panic(err)
}
_ = second.SetReadDeadline(time.Now().Add(5 * time.Second))
_, message, err := second.ReadMessage()
if err != nil {
panic(err)
}
fmt.Println(string(message))
}
TOKEN="$(go run ./cmd/token alice 15m)"
go run ./cmd/check "$TOKEN"
curl -fsS http://127.0.0.1:8080/readyz
curl -fsS http://127.0.0.1:8080/metrics
Polje node događaja identificira repliku koja objavljuje poruku. Uz nekoliko dugotrajnih klijenata, HAProxyjeva politika najmanjeg broja veza raspoređuje veze među zdravim čvorovima, dok Redis čini isporuku neovisnom o tome koja replika posjeduje bilo koju vezu.
Simulacije kvarova i operativno prilagođavanje
Pauzirajte Redis naredbom docker compose pause redis. Spremnost bi trebala vratiti 503, petlje pretplate trebale bi pokušavati ponovno uz ograničeni eksponencijalni povratni odmak, a klijent koji pokuša objaviti poruku trebao bi primiti kod zatvaranja 1011 umjesto lažnog uspjeha. Nastavite ga naredbom docker compose unpause redis; pretplate bi se trebale automatski oporaviti.
Testirajte povratni pritisak klijentom koji dovrši rukovanje, ali nikada ne čita. Kada se njegov red od 64 poruke ispuni, ws_slow_disconnects_total bi se trebao povećati, a zdravi klijenti trebali bi nastaviti primati poruke. To je ključno svojstvo izolacije: jedan spori potrošač košta jednu vezu, a ne cijelo središte.
Planiranje kapaciteta mora uzeti u obzir deskriptore datoteka, stogove goroutina, TLS međuspremnike na rubu, redove klijenata s 64 mjesta i maksimalni okvir od 4 KiB. Datoteka Compose povećava ograničenje kontejnera, ali administrator također mora pregledati i konfigurirati ograničenje deskriptora datoteka glavnog računala. Povećavajte ograničenja tek nakon mjerenja memorije i ponašanja pri ponovnom povezivanju.
Dodajte ograničavanje brzine rukovanja na rubnoj razini, ograničenja veza po identitetu ili izvoru te ograničavanje izdavanja tokena. Rotirajte autentifikacijske tajne strategijom preklapanja, umjesto trenutačne zamjene jedinog ključa. Za snažnije upravljanje ključevima koristite verzionirane ključeve za potpisivanje i uključite identifikator ključa u format tokena.
Uobičajeni produkcijski kvarovi
- Svi klijenti dijele jednog pisača: blokirani socket zaustavlja nepovezane korisnike. Zadržite jednog ograničenog pisača po vezi.
- Izlazni redovi dinamički rastu: iscrpljivanje memorije samo dolazi kasnije. Fiksirajte kapacitet i definirajte politiku isključivanja.
- Omogućen je samo TCP keepalive: mrtvi ravnopravni čvorovi mogu predugo preživjeti kroz proxyje. Koristite rokove ping/pong na razini aplikacije.
- Čvor koji je poslao poruku emitira lokalno i preko Redisa: lokalni klijenti dobivaju duplikate. Koristite jedan put isporuke.
- Redis Pub/Sub opisuje se kao pouzdana pohrana: pruža raspršivanje uživo, a ne ponavljanje ili trajnu potvrdu primitka.
- Provjere izvora su onemogućene: autentificirane sesije preglednika postaju ranjive na otmicu WebSocketa između različitih web-mjesta.
- Spremnost i živost su identične: nezdrava ovisnost može ostaviti repliku da prima nove veze. Držite zdravlje procesa odvojenim od spremnosti za promet.
Završni kontrolni popis provjere
- Nevažeći, izmijenjeni i istekli tokeni primaju HTTP 401 prije nadogradnje.
- Nedopušteni izvori preglednika ne prolaze rukovanje.
- Proces odbija veze iznad
MAX_CLIENTSs HTTP 503. - Ping/pong rokovi uklanjaju mrtve ravnopravne čvorove.
- Spori se potrošači isključuju bez blokiranja zdravih klijenata.
- Redis operacije imaju eksplicitne rokove za povezivanje, skup, čitanje, pisanje i svaku pojedinačnu objavu.
- Više replika razmjenjuje poruke putem zajedničkog kanala.
- Kvar Redisa uzrokuje neuspjeh provjere spremnosti, a objavljivanje se pošteno zatvara.
- SIGTERM zaustavlja prihvaćanje, zatvara klijente i kratko čeka pumpe.
- TLS, pravila vatrozida, pristup metrikama i izloženost Redisa kontroliraju se na granicama implementacije.
Skalabilan WebSocket servis nije definiran brzinom kojom prihvaća nadogradnju. Definiran je ograničenjima koja provodi kada mreža postane nepravedna. Ograničite svaki resurs, učinite kvar vidljivim i isključite klijente kojima sustav ne može sigurno služiti. Ti izbori pretvaraju trajni socket iz operativne obveze u pouzdanu produkcijsku osnovu.