1

Os assassinos silenciosos: Por que seu scraper com Selenium e Docker sempre morre em produção?

Colocar uma automação baseada em Selenium e Chrome headless dentro de um container Docker parece uma tarefa trivial no ambiente local. Você escreve o script, roda o build e tudo funciona. Mas como a vida não é um morango, em produção geralmente não é tudo tão bonito assim. O container roda bem por dois dias e subitamente morre. O consumo de RAM sobe de forma assustadora sem explicação clara. Conexões travam em loops infinitos.

Rodar browsers em prod geralmente não é um problema com seu código, mas sim um problema de infraestrutura. A arquitetura do Chromium não foi desenhada para viver perfeitamente isolada e controlada dentro de containers docker por semanas. Existem gargalos estruturais que matam seu scraper silenciosamente.

Nesse post desejo detalhar as causas mais comuns dessas falhas e como mitigar o dano delas no seu projeto.

A armadilha da configuração padrão

O caminho padrão de todo dev envolve criar um Dockerfile que instala o Python/Node, baixa os binários do Google Chrome, o ChromeDriver e adiciona o Xvfb (X virtual framebuffer) para emular um display de vídeo.

O resultado costuma ser um arquivo de configuração parecido com este:

FROM python:3.10-slim

RUN apt-get update && apt-get install -y \
    wget gnupg2 unzip xvfb libxi6 libgconf-2-4 \
    default-jdk && \
    wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && \
    echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list && \
    apt-get update && apt-get install -y google-chrome-stable

COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . /app
WORKDIR /app

CMD ["sh", "-c", "Xvfb :99 -ac & python scraper.py"]

Essa configuração vai rodar perfeitamente no seu primeiro docker-compose up. O problema é que ela esconde falhas críticas de concorrência e gerenciamento de processos.

XVFB e a corrida contra o tempo

O primeiro assassino do seu container é uma race condition clássica no entrypoint. O comando Xvfb :99 -ac & python scraper.py inicia o servidor de display virtual em background e imediatamente dispara o script Python.

O Xvfb precisa de alguns milissegundos para alocar os recursos e criar o socket X11 no sistema. O Selenium, por ser extremamente rápido na inicialização do driver, tenta se conectar ao display :99 antes que ele exista. O resultado é um crash instantâneo do browser e a morte do container.

Para resolver isso, você precisa de um entrypoint customizado que garanta o bloqueio da execução até que o socket do Xvfb esteja disponível.

#!/bin/bash
# entrypoint.sh

export DISPLAY=:99
Xvfb :99 -screen 0 1280x1024x24 &

# Aqui precisamos fazer um loop de checagem com um timeout embutido, que pode ser aumentado/diminuido de acordo com a velocidade da sua maquina/imagem.
TIMEOUT=10
while [ ! -e /tmp/.X11-unix/X99 ]; do
  sleep 0.1
  TIMEOUT=$((TIMEOUT-1))
  if [ $TIMEOUT -eq 0 ]; then
    echo "Erro: Xvfb não iniciou a tempo."
    exit 1
  fi
done

exec python scraper.py

O câncer do container: processos zumbis

Chamar driver.quit() ou driver.close() no seu código não garante que os processos do Google Chrome sejam encerrados no sistema operacional. O Chromium possui uma arquitetura multiprocesso. Ele cria forks para renderização, GPU e rede.

Quando o seu script Python sofre uma exceção não tratada ou perde a comunicação com o WebDriver, esses subprocessos do Chrome se tornam órfãos. Eles se transformam em processos zumbis. Eles ficam ociosos na memória RAM. Após algumas horas de raspagem contínua, o servidor atinge o limite de memória e o OOM Killer (Out Of Memory) do Linux entra em ação matando o container.

Um workaround comum é implementar um teardown forçado usando a biblioteca psutil em Python para buscar e aniquilar qualquer processo remanescente do Chrome.

import psutil
import time

def kill_zombie_chrome_processes():
    for proc in psutil.process_iter(['pid', 'name']):
        try:
            if 'chrome' in proc.info['name'].lower() or 'chromedriver' in proc.info['name'].lower():
                proc.kill()
        except (psutil.NoSuchProcess, psutil.AccessDenied):
            pass

def close_driver(driver):
    try:
        if driver:
            driver.quit()
    finally:
        time.sleep(1)
        kill_zombie_chrome_processes()

Essa solução ajuda, mas tem um custo alto de processamento no host. Pior ainda, se o container receber um SIGKILL direto do Docker por exaustão de memória antes do seu script rodar esse método customizado, os processos zumbis continuam pendurados até a máquina host travar completamente.

Lock files em volumes persistentes

Manter sessões ativas é vital para evitar logins repetitivos. A prática comum é mapear o diretório de User Data do Chrome para um volume persistente do Docker. Se o container cair e reiniciar, o Chrome tenta carregar o perfil antigo.

O problema ocorre porque o Chrome cria um arquivo chamado SingletonLock dentro dessa pasta para garantir que apenas uma instância use o perfil. Se o container morre de forma abrupta, o arquivo de lock não é deletado.

Quando o container sobe novamente, o Chrome enxerga o SingletonLock e simplesmente recusa a inicialização. A pior parte disso é o silent failure. Os logs do WebDriver não dizem que o problema é um arquivo de lock. Você recebe apenas um erro genérico de timeout. A solução é sempre limpar a sujeira antes de instanciar o driver.

import os
import glob

def clean_profile_locks(profile_path):
    lock_files = ['SingletonLock', 'SingletonCookie', 'SingletonSocket']
    for lock in lock_files:
        file_path = os.path.join(profile_path, lock)
        if os.path.exists(file_path):
            os.remove(file_path)

O pesadelo da rotação de proxies

Se você hardcodar o IP de um proxy direto no seu código, a automação vai quebrar no momento em que a conexão do proxy cair (e proxies residenciais caem o tempo todo). Gerenciar retries, timeouts de socket e tentar recuperar o estado da página no meio da automação exige uma lógica de engenharia complexa.

A recomendação padrão é usar proxies que possuem load balancers embutidos. O tráfego bate no load balancer e é redirecionado para um nó saudável automaticamente caso o IP final caia.

O grande problema é o custo. Players grandes de mercado, como Bright Data, resolvem o failover perfeitamente, mas o modelo de negócios deles cobra em dólar por Gigabyte trafegado. Se o seu scraper baixar imagens pesadas ou assets de rede não intencionais, a fatura no final do mês consome toda a margem de lucro do seu projeto.

Conclusão

Essas são apenas as falhas estruturais básicas. Nós nem sequer abordamos bloqueios massivos via Cloudflare, mitigação de TLS fingerprinting e resolução dinâmica de CAPTCHAs. Manter clusters de Selenium, Puppeteer ou Playwright em produção é uma dor de cabeça constante de infraestrutura e DevOps. O que podemos fazer é mitigar uma boa parte desses incomodos e diminuir a quantidade de vezes que nos perguntamos "O que ******* esse app já tem?".

Pretendo fazer mais alguns posts sobre o tópico no futuro, então se for algo do seu interesse ou tiver alguma duvida/problema que não foi abordado aqui, por favor, me conta nos comentários aqui no TabNews (ou no medium se estiver lendo por lá). Além disso, caso tenha uso profissional de scraping na sua empresa ou projeto, não deixe de conferir o pain.headless, meu mais novo projeto voltado pro ramo. Um abraço a todos e até a próxima.

Carregando publicação patrocinada...