Vodiči

Build a Scalable Go WebSocket Service: Backpressure, Auth, and Reliability

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_CLIENTS ogranič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_CLIENTS s 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.

Portret autora bloga

Mihajlo

Ja sam Mihajlo — programer vođen znatiželjom, disciplinom i stalnom željom da stvorim nešto smisleno. Dijelim uvide, tutorijale i besplatne usluge kako bih pomogao drugima da pojednostave svoj rad i rastu u svijetu softvera i umjetne inteligencije koji se neprestano razvija.