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.
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.
