Изградете скалабилна Go WebSocket услуга: повратен притисок, автентикација и доверливост
WebSocket серверот лесно се демонстрира, а изненадувачки тешко се управува. Ракувањето ретко е проблемот. Вистинскиот инженеринг започнува кога илјадници клиенти остануваат поврзани, еден читач престанува да прима, Redis исчезнува, ингеренциите истекуваат или распоредувањето бара секој јазол да се исклучи без протекување на горутини.
Овој туторијал гради Go услуга ориентирана кон продукциска употреба, со строго ограничување на поврзувањата по јазол, ограничени излезни редици, исфрлање на бавни клиенти, автентицирани ракувања, ping/pong отчукувања, постепено исклучување, проверки на здравјето, метрики и fan-out поддржан од Redis низ реплики.
Архитектура и договор за испорака
Секој процес ги поседува своите локални WebSocket поврзувања. Пораката од клиентот се валидира, се означува со автентицираниот идентитет и јазолот што објавува, а потоа се објавува во Redis. Секој процес се претплатува на истиот канал и го проследува секој настан до своите локални клиенти.
Дизајнот намерно користи Redis Pub/Sub. Тој е брз и соодветен за краткотрајно присуство, контролни табли, известувања и ажурирања за соработка во живо, но не е траен. Исклучен претплатник може да пропушти настани. Апликациите што бараат репродукција треба да користат траен дневник, како Redis Streams или друг брокер, и да воведат ID на пораки, потврди и задржување.
Меморијата останува ограничена на две места:
MAX_CLIENTSги ограничува поврзувањата што ги прифаќа еден процес.- Секој клиент добива излезен канал со фиксна големина. Ако се наполни, тој клиент се исклучува наместо да го забави целиот хаб или да троши неограничена меморија.
Сите јазли примаат пораки преку Redis, вклучувајќи го и јазолот што ја испратил пораката. Тој единствен пат избегнува двојна локална испорака и ја одржува семантиката на редоследот конзистентна во рамките на ограничувањата на Pub/Sub.
Предуслови и распоред на проектот
Ви треба Go 1.22 или понов, Docker со Compose за примерот за распоредување и OpenSSL за генерирање тајна. Апликацијата користи github.com/gorilla/websocket 1.5.3 и 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
Креирајте 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
)
Имплементирајте ја WebSocket услугата
Серверот одржува точно еден читач и еден запишувач по поврзување, согласно договорот за конкурентност на Gorilla WebSocket. Само read pump обработува рамки од клиентот. Само write pump испраќа обични рамки и отчукувања.
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()
}
Серверот намерно го изоставува http.Server.WriteTimeout. Вообичаениот краток рок за одговор може да прекине долгоактивни надградени поврзувања. Наместо тоа, WebSocket запишувањата се ограничени поединечно со рокови.
Издавање краткотрајни токени за автентикација
Овој компактен HMAC формат на токен е соодветен кога една доверлива услуга за автентикација издава ингеренции за овој WebSocket слој. Содржи base64url субјект, Unix истекување и SHA-256 HMAC. Не е претставен како JWT и не треба да се меша со JWT алатки.
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 во прелистувач не може да постави произволно заглавие Authorization при природното WebSocket ракување. За распоредувања во прелистувач, заменете ја нормалната автентицирана сесија за краткотраен билет за еднократна употреба и пренесете го билетот во URL-то. Осигурете се дека граничниот прокси ги маскира низните барања од дневниците. Не ставајте API клучеви за повеќекратна употреба во URL-адреси.
Контејнеризирајте и скалирајте хоризонтално
Контејнерот работи без root привилегии. HAProxy ја завршува јавната HTTP врска и ги распределува надградените поврзувања меѓу реплики на апликацијата; на WebSocket не му требаат лепливи сесии бидејќи секој јазол го користи споделениот брокер.
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"]
Креирајте 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
Креирајте 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
Генерирајте зависности и стартувајте три реплики на апликацијата:
go mod tidy
export AUTH_SECRET="$(openssl rand -base64 32)"
docker compose up --build --scale app=3
Примерот го врзува HAProxy само за loopback. Во продукција, завршете TLS на зајакнат граничен прокси, изложете ја само наменетата HTTPS порта преку заштитниот ѕид на домаќинот и чувајте го Redis на приватна мрежа. Далечинско распоредување на Redis треба да користи автентициран rediss:// URL со проверка на сертификатот. Не го изложувајте /metrics јавно; ограничете го со мрежна политика или посебен внатрешен listener.
Потврдете ја испораката меѓу поврзувања
Додајте cmd/check/main.go. Тој отвора две автентицирани поврзувања, објавува преку едното и го потврдува приемот преку другото.
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
Полето node на настанот ја идентификува репликата што објавува. Со неколку долготрајни клиенти, политиката на HAProxy за најмалку поврзувања ги распределува поврзувањата меѓу здрави јазли, додека Redis ја прави испораката независна од тоа која реплика го поседува кое било поврзување.
Вежби за дефекти и оперативно прилагодување
Паузирајте го Redis со docker compose pause redis. Подготвеноста треба да врати 503, циклусите за претплата треба повторно да се обидуваат со ограничен експоненцијален backoff, а клиент што се обидува да објави треба да добие код за затворање 1011 наместо лажен успех. Продолжете го со docker compose unpause redis; претплатите треба автоматски да се обноват.
Тестирајте го повратниот притисок со клиент што го завршува ракувањето, но никогаш не чита. Штом неговата редица од 64 пораки се наполни, ws_slow_disconnects_total треба да се зголеми, а здравите клиенти треба да продолжат да примаат пораки. Ова е суштинското својство на изолација: еден бавен потрошувач чини едно поврзување, не целиот хаб.
Планирањето на капацитетот мора да ги земе предвид дескрипторите на датотеки, стековите на горутини, TLS баферите на работ, клиентските редици со 64 места и максималната рамка од 4 KiB. Датотеката Compose го зголемува ограничувањето на контејнерот, но плафонот за дескриптори на датотеки на домаќинот исто така мора да биде проверен и конфигуриран од администратор. Зголемувајте ги ограничувањата само откако ќе ги измерите меморијата и однесувањето при повторно поврзување.
Додајте ограничување на стапката на ракување на ниво на работ, ограничувања на поврзувања по идентитет или извор и ограничување на издавањето токени. Ротирајте ги тајните за автентикација со стратегија на преклопување, наместо моментално да го замените единствениот клуч. За посилно управување со клучеви, користете верзионирани клучеви за потпишување и вклучете идентификатор на клуч во форматот на токенот.
Вообичаени продукциски дефекти
- Секој клиент дели еден запишувач: блокиран сокет ги запира неповрзаните корисници. Задржете еден ограничен запишувач по поврзување.
- Излезните редици растат динамички: исцрпувањето на меморијата само доаѓа подоцна. Фиксирајте го капацитетот и дефинирајте политика за исфрлање.
- Овозможено е само TCP keepalive: мртвите врсници може да преживеат предолго преку проксија. Користете ping/pong рокови на ниво на апликација.
- Јазолот што ја испраќа пораката емитува локално и преку Redis: локалните клиенти примаат дупликати. Користете еден пат на испорака.
- Redis Pub/Sub се опишува како сигурно складиште: тој обезбедува fan-out во живо, не репродукција или трајна потврда.
- Проверките на потеклото се оневозможени: автентицираните сесии во прелистувачот стануваат ранливи на киднапирање на WebSocket меѓу страници.
- Подготвеноста и живоста се идентични: нездрава зависност може да остави реплика што прима нови поврзувања. Одделете го здравјето на процесот од подготвеноста за сообраќај.
Конечна листа за проверка
- Неважечките, изменетите и истечените токени добиваат HTTP 401 пред надградбата.
- Недозволените потекла од прелистувачот не успеваат при ракувањето.
- Процесот одбива поврзувања над
MAX_CLIENTSсо HTTP 503. - Ping/pong роковите ги отстрануваат мртвите врсници.
- Бавните потрошувачи се исклучуваат без да ги блокираат здравите клиенти.
- Операциите со Redis имаат експлицитни рокови за поврзување, базен, читање, запишување и секое објавување.
- Повеќе реплики разменуваат пораки преку споделениот канал.
- Дефект на Redis предизвикува неуспех на подготвеноста, а објавувањето се затвора искрено.
- SIGTERM го прекинува прифаќањето, ги затвора клиентите и кратко чека за pump-овите.
- TLS, правилата на заштитниот ѕид, пристапот до метриките и изложеноста на Redis се контролираат на границите на распоредувањето.
Скалабилна WebSocket услуга не се дефинира според тоа колку брзо прифаќа надградба. Таа се дефинира според ограничувањата што ги спроведува кога мрежата станува неправедна. Ограничете го секој ресурс, направете го дефектот видлив и исклучете ги клиентите на кои системот не може безбедно да им служи. Тие избори претвораат постојан сокет од оперативна обврска во сигурна продукциска примитива.