DEV Community

Luis Gustavo S. Barreto
Luis Gustavo S. Barreto

Posted on • Edited on

Dominando SCM_RIGHTS: Transferência de WebSocket entre processos no Linux

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:

  1. Lidos do kernel para o espaço do usuário (Nginx)
  2. Processados pelo Nginx
  3. Escritos de volta ao kernel
  4. 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:

  1. read(5) - ler do cliente
  2. write(6) - escrever para o backend
  3. read(6) - ler resposta do backend
  4. 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:

  1. O kernel não transfere apenas o número do descriptor
  2. Ele cria uma nova entrada na tabela de FDs do processo receptor
  3. Ambas as entradas apontam para a mesma estrutura de arquivo interno
  4. O contador de referência é incrementado
  5. 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:

  1. Nginx recebe a conexão e realiza o handshake WebSocket completo
  2. Extraímos o file descriptor da estrutura interna do Nginx usando FFI
  3. Transferimos esse FD para o backend Go via Unix Domain Socket usando SCM_RIGHTS
  4. Go recebe o FD e converte em uma conexão TCP nativa
  5. Nginx substitui o FD original por um dummy e sai de cena
  6. 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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Proxy Tradicional (/ws/proxy): Nginx faz proxy das mensagens
  2. 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

  1. Escalabilidade Horizontal: Nginx pode aceitar muito mais conexões
  2. Latência Reduzida: Eliminação do hop intermediário
  3. Eficiência de Recursos: Melhor utilização de CPU e memória
  4. Separação de Responsabilidades: Nginx foca em roteamento, backend em lógica

Limitações

  1. Complexidade: Implementação significativamente mais complexa
  2. Dependência de Plataforma: Específico para sistemas Unix/Linux
  3. Debugging: Mais difícil rastrear conexões após handoff
  4. 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)