Laravel Octane: auditando a eficiência térmica e de memória

Laravel Octane: auditando a eficiência térmica e de memória

Por Marcelo Jean de Almeida Pena Especialista em Desenvolvimento e Ecossistema Web

Você migrou para Laravel Octane porque leu que é 5x, 10x, até 50x mais rápido que FPM. Seu dashboard agora renderiza em 30 ms ao invés de 300ms. Seus usuários comemoram. Você dormiu bem na primeira noite.

Na segunda semana, seu servidor começa a ficar lento. Não é gradual — é um penhasco. Requisições que respondiam em 40 ms agora levam 2 segundos. Você reinicia o Octane. Rápido de novo. Uma hora depois, colapso novamente.

Você não tem um problema de performance. Você tem um vazamento de memória.

Aqui está a verdade que ninguém enfatiza o suficiente: Octane rodando sob Swoole é um processo persistente que nunca “morre” entre requisições. Ao contrário do FPM, que spawna um novo worker para cada requisição e descarta toda a memória alocada depois, Swoole mantém a mesma instância de PHP viva durante horas. Cada requisição que vaza 1 MB de memória não-liberada é 1 MB perdido permanentemente até você reiniciar o servidor.

Este artigo não é sobre como usar Octane. É sobre como auditar sua aplicação para identificar exatamente onde o sangramento de memória está acontecendo, entender por que concorrentes com FPM dormem tranquilos enquanto você sua frio às 3 da manhã, e implementar monitoramento que realmente funciona em produção.

Sumário

O paradigma de memória: por que seu código PHP tradicional falha em Octane

A ilusão da “limpeza automática” do FPM

Em um cenário FPM típico, sua aplicação segue este ciclo:

  1. Worker nasce → Carrega o autoloader, bootstrap da aplicação
  2. Requisição chega → Código executa
  3. Resposta é enviada → Worker morre
  4. Memória é devolvida ao SO → Limpa automaticamente

Esse modelo é uma bênção mascarada. Mesmo que seu código seja um desastre de vazamentos de memória—variáveis globais não-zeradas, closures capturando $this, listeners de eventos acumulando em arrays estáticos—tudo desaparece em 300ms.

O mundo persistente do Swoole: uma aplicação que nunca “respira”

Quando você inicia php artisan octane:start --server=swoole, você está criando processos de trabalho que permanecem vivos indefinidamente. Ao invés do ciclo “nascer → executar → morrer”, você tem:

  1. Worker nasce uma vez → Carrega a aplicação
  2. Requisição 1 chega → Código executa → Memória é alocada
  3. Requisição 2 chega → Mesmo worker, mesma memória → Se a req1 não limpou tudo, os dados permanecem
  4. Requisição 3 chega → Mesmo problema, mas agora temos 2x vazamento
  5. Após 1000 requisições → O servidor consumiu 1-2GB de RAM que nunca será devolvido

O problema não é que Octane seja “inseguro”. O problema é que Octane expõe cada ineficiência de memória que seu código FPM disfarçava.

Antes de escalar seu Octane, entenda como o Auto-scaling reativo pode chegar tarde demais se sua memória já estiver saturada

Onde o vazamento acontece: os 6 padrões mais silenciosos

Padrão 1: Listeners de eventos com closures que capturam $this

// Model.php - Esse é um dos piores culpritos
class Order extends Model
{
    protected static function boot()
    {
        parent::boot();

        static::created(function ($order) {
            // ⚠️ PROBLEMA: $this (a instância do Model) é capturada pelo listener
            // Em FPM, isso desaparece quando a requisição morre
            // Em Octane, o closure permanece vivo, referenciando a instância criada
            $order->notifyCustomer();
        });
    }
}

Por que é um problema em Octane: Cada Model criado em uma requisição fica na memória através do listener. A closure captura referências que impedem garbage collection. Após 1000 Orders criadas, você tem 1000 instâncias mortas flotando na RAM.

Verdade técnica: O Swoole não tem ciclo de garbage collection entre requisições (diferente de alguns servidores Node.js). A responsabilidade é 100% sua.

Padrão 2: Arrays estáticos que acumulam sem nunca limpar

// Cache implementado de forma amadora
class RequestCache
{
    private static array $cache = [];

    public static function set($key, $value)
    {
        self::$cache[$key] = $value; // Cresce infinitamente
    }
}

// Em uma rota
RequestCache::set('user_' . $user->id, $user);

Em produção: Após 10.000 requisições de usuários diferentes, você tem 10.000 usuários inteiros gravados em memória.

Padrão 3: builders do Eloquent que não são resetados

class UserRepository
{
    private $query; // ⚠️ Mantém estado entre requisições!

    public function __construct()
    {
        $this->query = User::query();
    }

    public function findActive()
    {
        return $this->query->where('active', true)->get();
    }
}

// Em seu container, isso é um singleton
app()->singleton(UserRepository::class);

O que acontece: Req 1 tem um builder, Req 2 ainda tem resquícios da Req 1, Octane acumula bindings e estados parciais.

Padrão 4: Listeners de banco de dados não despachados

// Seu middleware registra um listener de query para logging
DB::listen(function ($query) {
    Log::debug($query->sql);
});

Cada middleware que roda (e não remove o listener) deixa uma cópia daquele callback na memória.

Padrão 5: Middleware que manipula $request->attributes de forma Permanente

class AuthMiddleware
{
    public function handle(Request $request, $next)
    {
        $user = auth()->user();
        $request->attributes->put('user_data', $user); // Fica na requisição
        return $next($request);
    }
}

Se a Request não é descartada corretamente entre ciclos, esses attributes ocupam espaço.

Padrão 6: Conexões de Banco de Dados Abertas Implicitamente

// Seu code abre uma conexão PDO dentro de um comando artisan
// Se o comando roda múltiplas vezes, você acumula conexões
$pdo = new PDO('mysql:host=...', 'user', 'pass');

Em Octane, se isso rodar dentro de um job processado pelo worker, a conexão pode permanecer aberta.

Auditoria: montando o sistema de detecção de Memory Leaks

Aqui é onde a maioria dos artigos genéricos falha. Vou te mostrar exatamente como capturar dados de vazamento em tempo real.

Passo 1 – Instrumentação básica com Memory Profiling

Crie um middleware que registra o consumo de memória por requisição:

// app/Http/Middleware/MemoryProfiler.php
namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class MemoryProfiler
{
    private static ?int $startMemory = null;
    private static ?int $peakMemory = null;

    public function handle(Request $request, Closure $next)
    {
        self::$startMemory = memory_get_usage(true);

        $response = $next($request);

        $endMemory = memory_get_usage(true);
        $diff = $endMemory - self::$startMemory;

        // Log somente se houver vazamento
        if ($diff > 100 * 1024) { // > 100KB
            Log::warning('Memory leak detected', [
                'path' => $request->path(),
                'method' => $request->method(),
                'memory_allocated_kb' => $diff / 1024,
                'peak_memory_mb' => memory_get_peak_usage(true) / (1024 * 1024),
                'timestamp' => now(),
            ]);
        }

        return $response;
    }
}

Registre no kernel:

protected $middleware = [
    // ... outros middlewares
    \App\Http\Middleware\MemoryProfiler::class,
];

O que isso vai revelar: Quais rotas estão comendo memória.

Passo 2 – Profiling granular com Debug Bar customizado

Para Octane, o Laravel Debugbar tradicional é problemático (ele próprio acumula dados). Use uma abordagem mais leve:

// app/Services/MemoryAuditService.php
namespace App\Services;

use Illuminate\Support\Facades\Cache;

class MemoryAuditService
{
    public static function checkpointMemory(string $label): void
    {
        $current = memory_get_usage(true) / (1024 * 1024); // MB
        $peak = memory_get_peak_usage(true) / (1024 * 1024);

        $data = Cache::remember(
            "memory_audit_{$label}",
            60,
            fn () => []
        );

        $data[] = [
            'timestamp' => microtime(true),
            'current_mb' => $current,
            'peak_mb' => $peak,
        ];

        Cache::put("memory_audit_{$label}", $data, 60);
    }

    public static function getReport(string $label): array
    {
        return Cache::get("memory_audit_{$label}", []);
    }
}

Use assim:

class OrderController
{
    public function create(Request $request)
    {
        MemoryAuditService::checkpointMemory('before_order_create');

        $order = Order::create($request->validated());

        MemoryAuditService::checkpointMemory('after_order_create');
        MemoryAuditService::checkpointMemory('after_notification');

        return response()->json($order);
    }
}

Depois acesse: /debug/memory-audit?label=after_order_create para ver o crescimento.

Passo 3 – Capturando listeners fantasmas com Reflection

Este é o culpado número 1 em apps legadas. Vamos exposá-lo:

// app/Commands/AuditListenersCommand.php
namespace App\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Events\Dispatcher;
use ReflectionFunction;

class AuditListenersCommand extends Command
{
    protected $signature = 'audit:listeners';
    protected $description = 'Detect accumulated listeners that might cause memory leaks';

    public function handle(Dispatcher $dispatcher)
    {
        // Acessa o array privado de listeners
        $listeners = (fn() => $this->listeners)->bindTo($dispatcher, $dispatcher)();

        $problematic = [];

        foreach ($listeners as $event => $callbacks) {
            if (count($callbacks) > 5) {
                $problematic[$event] = count($callbacks);
            }

            foreach ($callbacks as $callback) {
                if (is_array($callback)) {
                    [$object, $method] = $callback;
                    $this->line("Event: <fg=yellow>$event</> | Listener: " . get_class($object) . "@$method");
                } elseif ($callback instanceof \Closure) {
                    $reflection = new ReflectionFunction($callback);
                    $uses = $reflection->getStaticVariables();

                    if (count($uses) > 0) {
                        $this->line("<fg=red>⚠️ CLOSURE CAPTURES VARIABLES:</> $event");
                        foreach ($uses as $varName => $value) {
                            $this->line("  - \$$varName: " . get_class($value));
                        }
                    }
                }
            }
        }

        if (!empty($problematic)) {
            $this->warn("\n⚠️ EVENTS WITH EXCESSIVE LISTENERS:");
            foreach ($problematic as $event => $count) {
                $this->line("  <fg=red>$event</>: $count listeners");
            }
        }
    }
}

Execute: php artisan audit:listeners

Isso vai expor exatamente quais eventos têm closures capturando $this.

Passo 4 – Monitoramento em tempo real com Systemd + Script

Para produção, você precisa de alertas que não dependam da sua app:

#!/bin/bash
# /home/deploy/monitor-octane.sh

OCTANE_PID=$(pgrep -f "php artisan octane:start")
MEMORY_THRESHOLD_MB=1000 # Alerta se > 1GB

while true; do
    if [ ! -z "$OCTANE_PID" ]; then
        MEMORY=$(ps -p $OCTANE_PID -o rss= | awk '{print int($1/1024)}')
        TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')

        echo "[$TIMESTAMP] Octane Memory: ${MEMORY}MB"

        if [ $MEMORY -gt $MEMORY_THRESHOLD_MB ]; then
            echo "ALERT: Memory threshold exceeded!" | mail -s "Octane Memory Leak Alert" ops@company.com
            # Reinicia gracefully
            kill -TERM $OCTANE_PID
            sleep 5
            cd /path/to/app && php artisan octane:start --server=swoole &
        fi
    fi
    sleep 30
done

Coloque em systemd:

# /etc/systemd/system/octane-monitor.service
[Unit]
Description=Octane Memory Monitor
After=network.target

[Service]
Type=simple
User=www-data
ExecStart=/home/deploy/monitor-octane.sh
Restart=on-failure

[Install]
WantedBy=multi-user.target

Inicie: systemctl start octane-monitor

Otimização: Patterns que funcionam em Swoole

Não é suficiente identificar o problema. Você precisa reescrever seu código.

Padrão 1 – Listeners com Cleanup explícito

RUIMBOM
class Order extends Model { protected static function boot() { parent::boot(); static::created(function ($order) { $order->notifyCustomer(); }); } }class Order extends Model { protected static function boot() { parent::boot(); static::created( [static::class, 'notifyStatic'] ); } public static function notifyStatic($order) { notification() ->send($order->customer); } }

Razão: Listeners como classes são instanciados e descartados por requisição. Closures em boot() vivem eternamente.

Padrão 2 – Repositórios sem estado (Stateless)

RUIMBOM
class UserRepository { private $query; public function __construct() { $this->query = User::query(); } }class UserRepository { public function getActive() { return User ::where('active', true) ->get(); } public function findById($id) { return User::find($id); } }

Razão: Cada método cria um novo builder. Nenhum estado persiste.

Padrão 3 – Cache em escopo requisição (Request-Scoped)

// app/Services/RequestScopedCache.php
namespace App\Services;

class RequestScopedCache
{
    private static array $data = [];

    public static function set($key, $value)
    {
        self::$data[$key] = $value;
    }

    public static function get($key, $default = null)
    {
        return self::$data[$key] ?? $default;
    }

    public static function flush()
    {
        self::$data = [];
    }
}
// Em app/Http/Middleware/FlushRequestCache.php
class FlushRequestCache
{
    public function handle(Request $request, Closure $next)
    {
        $response = $next($request);
        RequestScopedCache::flush();
        return $response;
    }
}

Isso garante que o cache vaza apenas durante uma requisição, nunca entre elas.

Padrão 4 – Lazy loading de observers

RUIMBOM
class User extends Model { protected static function boot() { parent::boot(); static::observe( UserObserver::class ); } }class UserObserver { public function created($user) { dispatch( new SendWelcomeEmail( $user ) ); } }

Razão: Jobs são enfileirados e processados fora do ciclo de requisição. Nenhuma memória acumulada no worker.

Padrão 5 – Cleanup de conexões explícito

// app/Services/ExternalApiClient.php
class ExternalApiClient
{
    private $client;

    public function __construct()
    {
        $this->client = new HttpClient(
            ['timeout' => 10]
        );
    }

    public function fetch($url)
    {
        try {
            return $this->client->get($url);
        } finally {
            // Force cleanup
            $this->client = null;
            gc_collect_cycles();
        }
    }
}
// Ou faça um middleware que limpa resources
class CleanupResourcesMiddleware
{
    public function handle(Request $request, Closure $next)
    {
        try {
            return $next($request);
        } finally {
            // Force garbage collection
            gc_collect_cycles();

            // Feche conexões explicilmente
            \DB::disconnect();
        }
    }
}

Cenário de uso A vs. B: quando Octane vale a pena (e quando não)

Cenário A – API Laravel com 1000+ Requisições/Minuto e dados sensíveis a latência

Perfil:

  • SPA React/Vue comunicando constantemente
  • Upload de arquivos
  • Cache frequente
  • Sem listeners complexos

Octane Vale? SIM, MUITO

Por quê:

  • Latência reduz drasticamente (300ms → 30ms)
  • A taxa de requisição amortiza overhead de monitoramento
  • Listeners simples (enfileirados) não causam problema

Setup Recomendado:

// config/octane.php
return [
    'server' => 'swoole',
    'listeners' => [
        0 => ['*'],
    ],
    'workers' => env('OCTANE_WORKERS', cpu_count()),
    'max_requests' => 500, // Reinicia a cada 500 reqs
];

A chave: max_requests baixo reduz vazamento cumulativo.

Cenário B – Sistema de relatórios + processamento em lote com Models legados

Perfil:

  • 200 requisições/dia
  • Models com 10+ observers cada um
  • Cache complexo
  • Controllers que acumulam dados em memoria

Octane Vale? TALVEZ NÃO

Por quê:

  • Ganho de latência é 100ms em requisições que levam 2s
  • Necessidade de refactor profundo
  • Risco vs. retorno baixo

Alternativa:

# Fica com FPM + Nginx cache + Redis
# Mais seguro, mais fácil de debugar

O teste do especialista: sua aplicação está pronta para Octane?

Execute este checklist com sua app:

  1. Rodar php artisan audit:listeners → Se há mais de 3 listeners por evento, RISCO
  2. Procurar por static::created() e boot() → Cada closure = vazamento potencial
  3. Rodar php artisan config:cache → Se houver erros com listeners, NÃO está pronto
  4. Testar com 10.000 requisições artificiais:ab -n 10000 -c 50 http://your-app.local/api/endpointMonitorar ps aux | grep octane → Se memória cresce acima de 2x, tem vazamento
  5. Procurar por new PDO() ou conexões não-pooled → Cause vazamento garantido

Se falhar em 3+ pontos, Octane não é a solução agora.

Infraestrutura de produção: Deploying Octane com segurança

Systemd Unit com health checks

# /etc/systemd/system/laravel-octane.service
[Unit]
Description=Laravel Octane Server
After=network.target
Wants=laravel-octane-monitor.service

[Service]
Type=notify
User=www-data
WorkingDirectory=/var/www/app
ExecStart=/usr/bin/php /var/www/app/artisan octane:start \
    --server=swoole \
    --workers=4 \
    --max-requests=1000 \
    --port=8000

ExecStartPost=/bin/bash -c 'while true; do \
    MEMORY=$(ps -p $MAINPID -o rss= | awk "{print int($1/1024)}"); \
    if [ $MEMORY -gt 1500 ]; then \
        systemctl restart laravel-octane; \
    fi; \
    sleep 60; \
done &'

Restart=on-failure
RestartSec=10

[Install]
WantedBy=multi-user.target

Nginx como Reverse Proxy com Failover

upstream octane_upstream {
    server 127.0.0.1:8000 max_fails=3 fail_timeout=30s;
    server 127.0.0.1:8001 backup;
}

server {
    listen 80;
    server_name api.company.com;

    location / {
        proxy_pass http://octane_upstream;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_connect_timeout 60s;
    }

    location /health {
        access_log off;
        return 200 "OK";
    }
}

Execute múltiplas instâncias do Octane:

php artisan octane:start --server=swoole --port=8000 &
php artisan octane:start --server=swoole --port=8001 &

Se uma vaza memória e reinicia, o Nginx já roteou para a outra.

Conclusão: Laravel Octane é um superpoder que exige responsabilidade

Laravel Octane não é “FPM mais rápido”. É um modelo de arquitetura completamente diferente que expõe cada ineficiência do seu código.

A verdade oculta que mencionei no início é real: você não pode simplesmente upgradar para Octane e esperar que funcione. Você precisa:

  1. Auditar seu código com as ferramentas que mostrei
  2. Refatorar listeners, observers, caches
  3. Monitorar continuamente em produção
  4. Reiniciar gracefully quando necessário

Se você fizer isso, Octane entrega velocidade que é genuinamente transformadora. Se não fizer, seu servidor cairá às 3 da manhã e você descobrirá por acaso no seu monitor de infraestrutura.

A escolha é sua. Mas agora você tem o mapa.