As instâncias do ShellHub Enterprise lidam com dezenas de milhares de conexões WebSocket concorrentes, além de múltiplas requisições por segundo nos endpoints HTTP da nossa API.
Em cada instância nós temos um API proxy baseado em Nginx/Openresty que centraliza todo o tráfego HTTP do servidor, ou seja, todas as rotas públicas passam por ele, como rotas do painel de administração Web, rotas que o agent do ShellHub rodando nos dispositivos acessa, além do próprio túnel WebSocket que é estabelecido entre eles e também APIs de integração dos nossos clientes batendo em alguns endpoints.
Tudo isso gera um grande consumo de recursos do servidor como processamento e memória (geralmente o mais caro). Para nos mantermos competitivos nesse mercado ultra nichado, nossa stack foi projetada meticulosamente para ser a menor possível e entregar o máximo possível sem abrir mão da excelência técnica (foco do open source).
Embora o uso do nginx como proxy central traga essa simplicidade operacional, entregando um controle centralizado, gerenciamento automático de certificados HTTPS, roteamento para cada serviço da nossa stack, ele acaba se tornando um ponto crítico de acúmulo de carga.
E no caso específico do WebSocket (onde nossa tecnologia se baseia), essa arquitetura implica que o nginx mantém duas conexões ativas para cada cliente: uma com o cliente final (downstream) e outra com o backend (upstream). Para cada mensagem WebSocket, há cópias entre o espaço de usuário e o kernel, buffers alocados por conexão e vários context switches. Com dezenas de milhares de conexões WebSocket ativas isso consome muitos recursos.
Diante dessa problemática, gostaria de compartilhar como otimizar esse processo utilizando técnicas avançadas do próprio kernel Linux, como a passagem de file descriptors (sockets) via SCM_RIGHTS
, que são um tanto quanto desconhecidas por muitos, mas que podem ser muito úteis em diminuir consumo de recursos de um servidor.
O Problema Fundamental: Proxy como Middleman
A arquitetura tradicional de WebSockets em produção segue um padrão bem estabelecido:
Neste modelo, o Nginx atua como um intermediário constante, mantendo duas conexões para cada cliente: uma upstream e uma downstream. Para cada mensagem trocada, os dados são:
- Lidos do kernel para o espaço do usuário (Nginx)
- Processados pelo Nginx
- Escritos de volta ao kernel
- Enviados para o backend ou cliente
Esta arquitetura, embora simples e amplamente adotada, possui limitações inerentes:
- Overhead de Memória: O Nginx aloca buffers para cada conexão ativa
- Latência Adicional: Cada hop introduz latência de processamento
- CPU Overhead: Context switches entre processos e cópias desnecessárias de dados
O Nginx mantém dois file descriptors para cada cliente: um para o cliente final e outro para o backend. Cada mensagem WebSocket precisa:
-
read(5)
- ler do cliente -
write(6)
- escrever para o backend -
read(6)
- ler resposta do backend -
write(5)
- escrever para o cliente
A Solução: SCM_RIGHTS
e a transferência de file descriptors
O SCM_RIGHTS
é um mecanismo do kernel Linux que permite a transferência de file descriptors abertos entre processos através de Unix Domain Sockets. Esta funcionalidade, presente nos sistemas Unix desde os anos 90, oferece uma capacidade única: transferir a propriedade de uma conexão de rede de um processo para outro.
O que é um File Descriptor?
Um file descriptor (FD) é simplesmente um número inteiro que o kernel usa como "índice" para identificar arquivos, sockets de rede, pipes ou qualquer recurso de I/O aberto por um processo. É como um "ID único" que o processo usa para se referir a uma conexão específica.
Por exemplo: quando seu processo abre uma conexão TCP, o kernel retorna o número 5
. A partir daí, sempre que você quiser ler/escrever nessa conexão, você usa read(5)
ou write(5)
.
Como Funciona no Kernel
Quando um processo envia um file descriptor via SCM_RIGHTS
:
- O kernel não transfere apenas o número do descriptor
- Ele cria uma nova entrada na tabela de FDs do processo receptor
- Ambas as entradas apontam para a mesma estrutura de arquivo interno
- O contador de referência é incrementado
- O processo receptor pode usar o FD como se tivesse criado a conexão originalmente
Esta operação é atômica e zero-copy no nível do kernel.
A mágica do SCM_RIGHTS
O SCM_RIGHTS
permite que o processo A diga para o processo B: "Ei, pega essa conexão aqui e assume ela".
No nível do kernel, isso significa:
- Processo Nginx tem
FD=5
apontando para uma struct de socket - Via
SCM_RIGHTS
, o kernel duplica essa referência no processo do backend - Agora o processo do backend também tem um FD (digamos
FD=3
) apontando para a mesma struct de socket - É a mesma conexão TCP, só que agora dois processos podem acessá-la
Após a transferência:
O Nginx "esquece" da conexão (fecha seu FD=5), e o backend assume toda a comunicação diretamente. Zero overhead de proxy, zero cópias extras, zero context switches desnecessários.
A operação é atômica porque o kernel garante que a referência não seja perdida durante a transferência, e é zero-copy porque não há movimento de dados, apenas manipulação de ponteiros internos do kernel.
Implementação: A Stack Nginx + Lua + Go
Nossa implementação utiliza uma combinação de tecnologias especificamente escolhidas para realizar um processo em etapas bem definidas:
Visão geral do que vamos fazer:
- Nginx recebe a conexão e realiza o handshake WebSocket completo
- Extraímos o file descriptor da estrutura interna do Nginx usando FFI
- Transferimos esse FD para o backend Go via Unix Domain Socket usando SCM_RIGHTS
- Go recebe o FD e converte em uma conexão TCP nativa
- Nginx substitui o FD original por um dummy e sai de cena
- Go assume completamente a comunicação direta com o cliente
Agora vamos implementar cada etapa:
Etapa 1: Nginx - Recepção e Handshake WebSocket
O Nginx funciona como o ponto de entrada, realizando apenas o setup inicial:
location /ws/fd_transfer {
content_by_lua_block {
local websocket = require "resty.websocket.server"
local ws, err = websocket:new{
timeout = 5000,
max_payload_len = 1024
}
if not ws then
ngx.log(ngx.ERR, "failed to create websocket: ", err)
return
end
local handler = require "ws_handler"
handler.handle(ws.sock)
ws:send_close()
}
}
Quando o cliente conecta em /ws/fd_transfer
e solicita upgrade para WebSocket, o Nginx realiza o handshake WebSocket completo, estabelecendo uma conexão ativa com o cliente.
E aqui entra o ponto crítico da solução: em vez de começar a fazer proxy das mensagens entre as duas pontas, o Nginx chama handler.handle(ws.sock)
que extrai o FD da conexão TCP subjacente e transfere via SCM_RIGHTS
para o backend através de Unix socket. Então substitui o FD original por um dummy e encerra sua participação com ws:send_close()
.
Etapa 2: Extração do File Descriptor com FFI
Outro ponto importante da implementação é a extração do FD da estrutura interna do Nginx:
local function get_socket_fd(sock)
if not sock or not sock[1] then
return nil, "invalid websocket object"
end
local u = ffi.cast("ngx_http_lua_socket_tcp_upstream_s*", sock[1])
if not u or not u.peer or not u.peer.connection then
return nil, "invalid upstream socket structure"
end
return tonumber(u.peer.connection.fd)
end
Esta função utiliza FFI (Foreign Function Interface) para acessar diretamente as estruturas internas do Nginx, extraindo o file descriptor da conexão TCP subjacente. É uma operação de baixo nível que "vasculha" a memória do processo Nginx para encontrar o número do file descriptor.
Etapa 3: Transferência via SCM_RIGHTS
O fd_manager.lua
implementa o protocolo de transferência:
function M.send_fd(path, fd)
local sock = C.socket(C.AF_UNIX, C.SOCK_STREAM, 0)
-- Preparação da mensagem de controle SCM_RIGHTS
local control_len = ffi.sizeof("struct cmsghdr") + ffi.sizeof("int")
local control = ffi.new("char[?]", control_len)
local cmsg = ffi.cast("struct cmsghdr*", control)
cmsg.cmsg_len = control_len
cmsg.cmsg_level = C.SOL_SOCKET
cmsg.cmsg_type = C.SCM_RIGHTS
-- Insere o FD na mensagem de controle
ffi.cast("int*", control + ffi.sizeof("struct cmsghdr"))[0] = fd
-- Envia via sendmsg
local msg = ffi.new("struct msghdr")
msg.msg_control = control
msg.msg_controllen = control_len
return C.sendmsg(sock, msg, 0) >= 0
end
Esta função monta uma mensagem especial do tipo SCM_RIGHTS
que contém o file descriptor e a envia através de um Unix Domain Socket para o backend Go. É aqui que acontece a "transferência de propriedade" da conexão.
Etapa 4: Backend Go - Recepção e Conversão
O backend Go implementa o receptor dos file descriptors:
func receiveFD(socket *net.UnixConn) (int, error) {
buf := make([]byte, 1)
oob := make([]byte, syscall.CmsgSpace(4))
_, _, _, _, err := socket.ReadMsgUnix(buf, oob)
if err != nil {
return -1, err
}
// Parse das mensagens de controle
msgs, err := syscall.ParseSocketControlMessage(oob)
if err != nil {
return -1, err
}
// Extração do file descriptor
fds, err := syscall.ParseUnixRights(&msgs[0])
if err != nil {
return -1, err
}
return fds[0], nil
}
Uma vez recebido o FD, ele é convertido em uma net.Conn
nativa:
file := os.NewFile(uintptr(fd), "websocket_fd")
tcpConn, err := net.FileConn(file)
file.Close()
// Agora tcpConn é uma conexão TCP normal
go handleConnection(tcpConn, fd)
...
func handleConnection(conn net.Conn, fd int) {
defer conn.Close()
for {
// Leitura direta de frames WebSocket
messages, err := wsutil.ReadClientMessage(conn, nil)
if err != nil {
return
}
// Processamento frame por frame
for _, frame := range messages {
switch frame.OpCode {
case ws.OpText:
// Acesso direto ao payload sem cópias
wsutil.WriteServerText(conn, frame.Payload)
case ws.OpBinary:
wsutil.WriteServerBinary(conn, frame.Payload)
}
}
}
}
A partir desse momento, o backend Go está falando diretamente com o cliente original, como se tivesse criado a conexão desde o início.
Detalhes Técnicos Críticos
1. Substituição por Socket Dummy
Após transferir o FD, precisamos "enganar" o Nginx substituindo o file descriptor original
por um "dummy" para quando o nginx for liberar recursos não fechar o socket que agora pertence ao processo do backend.
function M.replace_with_dummy(fd)
local dummy = C.socket(C.AF_INET, C.SOCK_STREAM, 0)
if dummy == -1 then
return false, "failed to create dummy socket"
end
-- dup2 substitui o FD original pelo dummy
if C.dup2(dummy, fd) == -1 then
C.close(dummy)
return false, "dup2 failed"
end
C.close(dummy)
return true
end
2. Parsing de Frames WebSocket com gobwas/ws
Quando o backend Go recebe o file descriptor via SCM_RIGHTS
, ele herda uma conexão TCP que já está "promovida" para WebSocket pelo Nginx. Isso significa que:
- O handshake HTTP → WebSocket já foi realizado
- A conexão está no estado WebSocket
- Todos os dados subsequentes são frames WebSocket
Por isso, não podemos usar bibliotecas WebSocket convencionais como gorilla/websocket
, que esperam fazer o handshake completo desde o início. Precisamos de uma biblioteca que faça apenas o parsing dos frames WebSocket.
Benchmarking: Dados Concretos de Performance
O benchmark automatizado compara duas arquiteturas:
-
Proxy Tradicional (
/ws/proxy
): Nginx faz proxy das mensagens -
Com
SCM_RIGHTS
(/ws/fd_transfer
): Nginx transfere a conexão
Metodologia
- Ferramenta: k6 para geração de carga
- Conexões: 10.000 WebSockets simultâneas
- Duração: 60 segundos por teste
- Monitoramento: Prometheus com métricas de containers
- Espera: 15 segundos para estabilização da carga
Resultados
Endpoint | Backend CPU (cores) | Backend Mem (MB) | Nginx CPU (cores) | Nginx Mem (MB) | Active WS |
---|---|---|---|---|---|
/ws/proxy |
0.0170 | 143.54 | 0.0074 | 331.89 | 10000 |
/ws/fd_transfer |
0.0211 | 116.43 | 0.0005 | 55.03 | 10000 |
Análise dos Resultados
Memória Nginx: Redução de 331.89 MB → 55.03 MB (83.4% de redução)
- O handoff libera o Nginx de manter buffers para 10.000 conexões
- Memória residual reflete apenas overhead do processo base
CPU Nginx: Redução de 0.0074 → 0.0005 cores (93.2% de redução)
- Nginx não processa mensagens WebSocket após o handoff
- CPU residual é apenas para aceitar novas conexões
Backend: Ligeiro aumento de CPU (0.0170 → 0.0211), redução de memória (143.54 → 116.43 MB)
- Aumento de CPU esperado: backend assume todo o I/O da rede
- Redução de memória:
gobwas/ws
trabalha apenas com frames WebSocket e consome menos memória
Implicações Arquiteturais
Vantagens
- Escalabilidade Horizontal: Nginx pode aceitar muito mais conexões
- Latência Reduzida: Eliminação do hop intermediário
- Eficiência de Recursos: Melhor utilização de CPU e memória
- Separação de Responsabilidades: Nginx foca em roteamento, backend em lógica
Limitações
- Complexidade: Implementação significativamente mais complexa
- Dependência de Plataforma: Específico para sistemas Unix/Linux
- Debugging: Mais difícil rastrear conexões após handoff
- Estado Compartilhado: Coordenação entre Nginx e backend é crítica
Casos de Uso Ideais
- Aplicações de High-Frequency Trading: Onde cada microssegundo importa
- Jogos Multiplayer: Latência crítica para experiência do usuário
- Sistemas IoT: Grande volume de conexões com mensagens pequenas
- Streaming de Dados: Onde throughput máximo é essencial
Conclusão
A passagem de FD via SCM_RIGHTS
entre processos pode ajudar aplicações que exigem performance extrema. Embora adicione complexidade, os ganhos em eficiência e escalabilidade são substanciais.
Esta técnica não é adequada para todas as aplicações, mas para cenários onde performance é crítica, ela oferece uma vantagem competitiva significativa. À medida que as aplicações em tempo real se tornam mais críticas, dominar técnicas de baixo nível como SCM_RIGHTS
do kernel Linux se torna essencial para arquitetos de sistemas que buscam extrair o máximo desempenho da infraestrutura disponível.
Top comments (0)