githublinkedinemail
← /articles

Como o kernel Linux recebe uma request HTTP

NIC → driver → IRQ → softirq → IP → TCP → socket buffer → accept queue → Puma. Seu framework é os últimos 5% do request. O resto é kernel.

5 mininfra

Como o kernel Linux recebe uma request HTTP

Sua aplicação não recebe request.

O kernel recebe.

Sua aplicação só pega o que sobra, depois que metade do trabalho já foi feito sem ela ter ideia.

E enquanto você não entende essas camadas, você acha que "Rails é lento", "Node é rápido", "Go escala". Tudo achismo.


O caminho real de um pacote

Um curl http://api.exemplo.com/users/1 não chega no seu Puma do nada.

Existe uma jornada inteira antes:

   placa de rede (NIC)
        │  pacote chega no fio
        ▼
   driver da NIC
        │  IRQ — interrupção de hardware
        ▼
   softirq (NET_RX)
        │  processa em contexto leve
        ▼
   ring buffer (RX)
        │  fila circular do driver
        ▼
   camada IP (netfilter, routing)
        │  iptables, conntrack, etc.
        ▼
   camada TCP (state machine)
        │  SYN/ACK, reassembly, congestion
        ▼
   socket buffer (sk_buff)
        │  bytes prontos pro userspace
        ▼
   accept queue
        │  conexões estabelecidas
        ▼
   accept() do seu Puma
        │
        ▼
   sua aplicação finalmente vê os bytes

Cada uma dessas camadas tem fila, tem limite, tem syscall, tem custo.

E se qualquer uma delas estiver saturada, sua aplicação parece lenta sem nem ter sido chamada.


NIC e IRQ: o ponto de entrada

A placa de rede recebe um frame Ethernet.

DMA copia o frame direto pra um buffer na RAM.

Aí a NIC dispara uma IRQ — uma interrupção de hardware. O CPU para o que estava fazendo e roda o handler do driver.

NIC ────DMA────► RAM (ring buffer)
 │
 └── IRQ ──► CPU para tudo, atende

IRQ é caro. Em servidor de alto throughput, milhões de pacotes/s, IRQ a cada um destruiria a CPU.

Por isso existe NAPI (New API): o driver entra em modo polling depois da primeira IRQ. Em vez de uma interrupção por pacote, processa um lote.

E em servidores sérios você ainda usa RSS (Receive Side Scaling) pra espalhar IRQ entre vários cores — senão um core só atende tudo enquanto os outros ficam parados.

Se você nunca olhou /proc/interrupts, nunca configurou IRQ affinity, nunca tunou ring buffer — beleza, mas saiba que existe gente fazendo isso, e é por isso que o servidor deles aguenta 100x mais carga que o seu.


Softirq: o trabalho de verdade

A IRQ marca: "tem pacote". Só.

O processamento real acontece em softirq — um contexto mais leve, agendado pelo kernel.

IRQ (curto)
   │
   └─► acorda softirq NET_RX
              │
              └─► processa pacotes do ring buffer
                  ├── parse Ethernet
                  ├── parse IP
                  ├── parse TCP
                  └── entrega no socket

Se o softirq não consegue acompanhar o ring buffer, pacote é dropado. Silenciosamente.

# pacotes dropados no nível de softirq
cat /proc/net/softnet_stat

# drops por interface
ethtool -S eth0 | grep -i drop

Junior nunca olha isso. Senior olha primeiro.


Camada IP: roteamento e filtros

O pacote sobe pra IP.

Aqui mora:

  • routing: pra onde esse pacote vai?
  • netfilter: iptables, nftables, conntrack
  • NAT: tradução de endereço
  • fragmentação: remontar pacotes grandes

conntrack é especialmente traiçoeiro. Servidor com muitas conexões curtas estoura a tabela e começa a dropar:

sysctl net.netfilter.nf_conntrack_count
sysctl net.netfilter.nf_conntrack_max

Já vi produção morrendo por isso e o time culpando "o Rails".


Camada TCP: handshake e filas

Aqui o jogo fica interessante.

TCP tem máquina de estados. E pra um socket que está em LISTEN (seu Puma esperando), o kernel mantém duas filas:

                  pacote SYN chega
                          │
                          ▼
              ┌───────────────────────┐
              │   SYN queue           │  ← conexões "meio abertas"
              │   (incomplete)        │     SYN recebido, ACK pendente
              └───────────┬───────────┘
                          │ handshake completa
                          ▼
              ┌───────────────────────┐
              │   accept queue        │  ← prontas pra accept()
              │   (complete)          │
              └───────────┬───────────┘
                          │
                          ▼
                  accept() do Puma

Duas filas. Dois lugares onde pode dar ruim.

A SYN queue enche se você apanha SYN flood ou se o backlog é pequeno demais.

A accept queue enche se sua aplicação não chama accept() rápido o bastante. Quando enche, o kernel dropa o ACK final do handshake. O cliente acha que tá conectado, mas não tá. Timeout misterioso.

# acompanha overflow da accept queue
nstat -az | grep -i listen
ss -lnt   # coluna Recv-Q = quantos esperando accept

O backlog é controlado pelo listen():

listen(sockfd, 1024);   // tamanho da accept queue

E pelo sysctl net.core.somaxconn. Se o seu Puma pede 1024 mas o sysctl está em 128, você fica com 128. Já vi isso quebrar load test mil vezes.


SO_REUSEPORT: a peça que falta

Tradicionalmente, um socket = uma accept queue.

Múltiplos workers do Puma compartilham o mesmo socket, e o kernel acorda um pra fazer accept. Mas quando vários workers competem, dá thundering herd: todos acordam, um pega, o resto volta a dormir. Desperdício.

SO_REUSEPORT muda o jogo:

sem SO_REUSEPORT:           com SO_REUSEPORT:

  ┌─ socket único ─┐         ┌─ socket ─┐ ┌─ socket ─┐ ┌─ socket ─┐
  │  accept queue  │         │   queue  │ │   queue  │ │   queue  │
  └────────┬───────┘         └────┬─────┘ └────┬─────┘ └────┬─────┘
           │                      │            │            │
   ┌───┬───┼───┬───┐          worker 1     worker 2     worker 3
   w1  w2  w3  w4              (kernel faz hash do 4-tuple
   (todos competem)             e distribui sem competição)

Cada worker tem sua accept queue. O kernel distribui conexões por hash do (src_ip, src_port, dst_ip, dst_port). Sem competição. Sem thundering herd.

Servidor moderno sério usa isso por padrão. Puma suporta. Nginx suporta. Se você não está usando, está deixando throughput na mesa.


Socket buffer: onde os bytes esperam

Cada socket TCP tem um buffer no kernel:

   rede ──► sk_buff (recv buffer) ──► read()/recv() da app
                  │
                  └── tamanho controla janela TCP

net.core.rmem_max, net.ipv4.tcp_rmem — esses controlam quanto pode ficar bufferizado.

Se a aplicação não lê rápido, o buffer enche, a janela TCP fecha, o sender para de mandar. Backpressure puro.

E é por isso que bloquear thread do Puma com IO síncrono é tão ruim: o socket fica cheio, o cliente fica esperando, a accept queue enche, e o servidor inteiro engasga.


E o epoll?

Antes de chamar accept(), o Puma usa epoll pra saber quando tem coisa pra aceitar.

epoll_ctl(epfd, EPOLL_CTL_ADD, listen_sock, &ev);
epoll_wait(epfd, events, max, timeout);
// volta dizendo: "tem conexão na accept queue"
accept4(listen_sock, ...);

Sem epoll, você teria que ficar chamando accept() em loop (busy wait) ou bloqueando uma thread por socket. Ambos péssimos.

epoll é o que permite um único thread observar milhares de sockets. É o motor da concorrência em Linux. Vale um artigo só dele — e tem.


Userspace finalmente entra

Depois de tudo isso:

accept() retorna ──► novo file descriptor
       │
       ▼
   Puma worker pega o fd
       │
       ▼
   read() bytes do request HTTP
       │
       ▼
   parser HTTP (em Ruby ou C)
       │
       ▼
   monta env Rack
       │
       ▼
   Rails.application.call(env)

É aqui que seu "framework" começa.

Tudo antes disso foi feito pelo kernel, pelo driver, pela placa de rede, pelo Puma. Você não tinha controle nenhum.


Sintomas vs causa real

A diferença entre senior e junior aqui é simples:

Junior: "Rails tá lento, vou trocar o framework."

Senior: "Accept queue tá enchendo, vou aumentar backlog, ver por que worker tá lento pra aceitar, e ligar SO_REUSEPORT."

Cenários que parecem "aplicação lenta" mas são kernel:

  • Timeout em handshake → accept queue overflow
  • Conexões sumindo aleatoriamente → conntrack cheio
  • Throughput batendo em teto → ring buffer pequeno, IRQ num core só
  • Latência alta sob carga → bufferbloat, RTT inflado por fila

Nenhum desses se resolve trocando ORM.


Comandos pra ter no bolso

ss -lnt                          # listening sockets, Recv-Q, Send-Q
ss -s                            # resumo de TCP states
nstat -az | grep -i drop         # drops em várias camadas
cat /proc/net/softnet_stat       # softirq stats
ethtool -S eth0 | grep drop      # drops da NIC
sysctl net.core.somaxconn        # max accept queue
sysctl net.ipv4.tcp_max_syn_backlog
cat /proc/interrupts             # IRQ distribuídas por CPU

Aprenda esses. São muito mais úteis que 90% das gems que você instala.


A virada de chave

A request HTTP que sua app vê é o fim de uma jornada, não o começo.

Antes do seu controller existir:

  • a NIC pegou o frame
  • o driver moveu pra RAM
  • softirq parseou TCP/IP
  • o kernel completou o handshake
  • a accept queue guardou a conexão
  • o epoll acordou o worker
  • o accept() retornou um fd
  • o read() tirou os bytes do buffer
  • o parser HTTP montou o env

Aí, e só aí, o Rails.application.call(env) roda.


Conclusão

Seu framework de backend é os últimos 5% do ciclo de vida da request.

Os outros 95% são kernel, driver, NIC, fila, syscall, scheduler.

Quem só entende o framework debuga no escuro quando a coisa aperta.

Quem entende o stack inteiro abre ss, abre nstat, abre /proc/net/softnet_stat e em dois minutos sabe se o problema é Ruby ou se é o kernel gritando que a accept queue está estourando.

Linux não é fundo de palco.

Linux é o palco.

Seu Rails só dança em cima.

Como o kernel Linux recebe uma request HTTP
Como o kernel Linux recebe uma request HTTP