Uma carga de trabalho de scraping está próxima do tenant ideal para Kubernetes: é embaraçosamente paralela, majoritariamente stateless por tarefa, e tem picos de um jeito que pede autoscaling. Depois que você tem proxies funcionando dentro de um container, migrar para um cluster é o próximo passo natural para quem roda coleta em volume real. Mas a camada de proxy muda algumas das escolhas padrão que você usaria normalmente: como a identidade é atribuída por pod, qual sinal você usa para o autoscaling, e por que adicionar pods não sai de graça em relação aos sites que você acessa.
Esta é a camada de orquestração acima da configuração de container único. Ela assume que você já sabe como apontar um scraper para um gateway residencial; aqui o foco é rodar muitos deles ao mesmo tempo, com segurança, no Kubernetes.
Ao longo do texto, o gateway é o da Shifter: um único endpoint, p.shifter.io:443, com o direcionamento codificado no nome de usuário (customer-USERNAME-country-us-sid-abc123-ttl-600). Troque o host e as credenciais por outro provedor; os padrões são idênticos.
O formato da carga de trabalho
Dois padrões cobrem quase tudo:
- Um
Deploymentde workers de longa duração que puxam tarefas de uma fila. Este é o padrão para coleta contínua. Os pods ficam ativos, drenam uma fila de trabalho e escalam conforme o backlog. - Um
JobouCronJobpara lotes finitos: um crawl noturno, um backfill pontual. O Kubernetes executa N pods até a conclusão e para.
Ambos compartilham os mesmos blocos de construção abaixo. O Deployment orientado por fila é o que a maioria das equipes quer, então os manifests aqui têm esse foco, com notas onde um Job difere.
Credenciais de proxy pertencem a um Secret, nunca à imagem
A primeira regra é chata e inegociável: credenciais de proxy não vão na imagem, em um ConfigMap, nem hardcoded em um manifest. Elas vão em um Secret, montado como variáveis de ambiente (ou um arquivo) em tempo de execução.
apiVersion: v1kind: Secretmetadata: name: proxy-credentialstype: OpaquestringData: SHIFTER_USER: "customer-yourname" SHIFTER_PASS: "your-gateway-password"Embutir credenciais em uma imagem significa que qualquer um que consiga fazer pull da imagem tem acesso à sua conta de proxy. Um Secret mantém elas fora do seu registro e do seu histórico do git, e permite rotacioná-las sem rebuild. Em produção, use um gerenciador de segredos de verdade por trás disso (External Secrets Operator, Vault, ou o driver CSI da sua nuvem) em vez de um manifest simples que você poderia commitar por acidente.
Dê a cada pod sua própria identidade com a Downward API
Aqui está a parte específica de proxy. Se cada pod autentica com exatamente o mesmo nome de usuário e nenhum token de sessão, todos eles puxam do pool da mesma forma, o que geralmente é bom para rotação pura. Mas no momento em que você quer que cada worker mantenha uma sessão coerente e distinta, uma identidade por pod em vez de um blob compartilhado, você precisa que cada pod varie o sid no seu nome de usuário.
A forma correta de fazer isso no Kubernetes é a Downward API: injetar o próprio nome do pod como variável de ambiente, e derivar o token de sessão a partir dele.
apiVersion: apps/v1kind: Deploymentmetadata: name: scraperspec: replicas: 6 selector: matchLabels: { app: scraper } template: metadata: labels: { app: scraper } spec: terminationGracePeriodSeconds: 90 # let in-flight requests finish (see below) containers: - name: scraper image: your-registry/scraper:1.4.0 env: - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name # e.g. scraper-7c9f-abcde - name: SHIFTER_USER valueFrom: secretKeyRef: { name: proxy-credentials, key: SHIFTER_USER } - name: SHIFTER_PASS valueFrom: secretKeyRef: { name: proxy-credentials, key: SHIFTER_PASS } resources: requests: { cpu: "100m", memory: "192Mi" } limits: { cpu: "500m", memory: "384Mi" }No worker, construa o nome de usuário do proxy a partir de POD_NAME para que cada pod seja uma sessão estável durante seu ciclo de vida:
import os, hashlib
user = os.environ["SHIFTER_USER"]pod = os.environ.get("POD_NAME", "local")# Deterministic per-pod session id, stable for the pod's lifetime.sid = hashlib.sha1(pod.encode()).hexdigest()[:10]
proxy_user = f"{user}-country-us-sid-{sid}-ttl-600"proxy = f"http://{proxy_user}:{os.environ['SHIFTER_PASS']}@p.shifter.io:443"# hand `proxy` to requests / httpx / your client of choiceAgora o pod scraper-7c9f-abcde é uma sessão diferente do scraper-7c9f-fghij, de forma determinística, sem nenhuma coordenação entre eles. Rotacione dentro de um pod alterando o sid por unidade de trabalho em vez de por pod; a distinção entre sticky e rotativo se aplica exatamente como em qualquer outro lugar. (Para o código geral do worker que vai dentro do container, o guia de Python cobre a configuração do cliente e a rotação.)
Faça autoscaling pela profundidade da fila, não pela CPU
O HorizontalPodAutoscaler padrão escala com base na CPU. Para um scraper que é quase inteiramente limitado por I/O, esperando por idas e voltas de rede através do proxy, a CPU fica praticamente estável independentemente de quanto trabalho está na fila. Escalar com base nela é quase inútil: você ficará com CPU baixa e uma montanha de backlog e nunca escalará para cima.
Escale com base no próprio backlog. A ferramenta mais limpa é o KEDA, que escala um Deployment com base em métricas externas como o tamanho de uma lista Redis ou a profundidade de uma fila:
apiVersion: keda.sh/v1alpha1kind: ScaledObjectmetadata: name: scraper-scalerspec: scaleTargetRef: name: scraper minReplicaCount: 2 maxReplicaCount: 40 triggers: - type: redis metadata: address: redis.default.svc:6379 listName: scrape:queue listLength: "50" # aim for ~50 queued tasks per podIsso te dá a propriedade que você realmente quer: pods surgem quando há trabalho e somem quando a fila esvazia, então você não está pagando por workers ociosos nem deixando um backlog faminto. Um teto de maxReplicaCount não é opcional, é sua principal proteção contra o próximo problema.
A concorrência escala com os pods, e essa é a armadilha
A coisa mais importante para internalizar: a pressão total sobre um alvo é a concorrência por pod multiplicada pelo número de pods. Cada pod pode educadamente se limitar a 5 requisições simultâneas, mas 40 pods são 200 requisições simultâneas contra o que quer que você esteja fazendo scraping. O autoscaling que parecia uma vitória em throughput vira um martelo distribuído que faz seu pool inteiro ser sinalizado.
Duas defesas, usadas em conjunto:
- Limite a concorrência por pod e mantenha-a modesta. Um pequeno semáforo por worker.
- Limite a concorrência total por host de destino em toda a frota, não apenas por pod. Isso é uma preocupação de nível de cluster que um único pod não consegue enxergar, então isso pertence à fila de trabalho: divida ou aplique rate-limit nas tarefas por domínio de destino para que nenhum site receba mais do que tolera, não importa quantos pods estejam rodando. Esta é a versão distribuída da mesma lógica em balanceamento de carga de proxy, e é por isso que um bom pool residencial é uma ferramenta para distribuir carga entre IPs, não uma licença para intensificá-la.
Se você pular isso, o autoscaling não te deixa mais rápido, te deixa bloqueado. Mais paralelismo além da tolerância de um alvo compra desafios, não dados.
Encerramento gracioso: terminar ou reenfileirar, nunca descartar
O Kubernetes mata pods rotineiramente: scale-down, atualizações contínuas, drenagem de nós, reclamações de spot. Quando faz isso, envia SIGTERM, espera terminationGracePeriodSeconds, depois envia SIGKILL. Um scraper que ignora SIGTERM perde toda requisição em andamento no instante em que é morto, o que aparece como lacunas misteriosas nos seus dados e largura de banda desperdiçada em buscas semi-concluídas.
Trate o sinal. Ao receber SIGTERM, pare de puxar novas tarefas, deixe as requisições em andamento terminarem dentro do período de tolerância, e reenfileire qualquer coisa que você não consiga completar a tempo para que outro pod a pegue:
import signal
draining = False
def handle_sigterm(signum, frame): global draining draining = True # stop pulling new work; finish what is in flight
signal.signal(signal.SIGTERM, handle_sigterm)
while not draining: task = queue.pull() if task is None: continue try: process(task) # fetch through the proxy, store result except Exception: queue.requeue(task) # at-least-once: let another pod retryDefina terminationGracePeriodSeconds confortavelmente acima da sua requisição mais longa esperada, e confie em uma fila com garantia de pelo menos uma entrega para que uma tarefa interrompida seja reprocessada em vez de perdida. Este é o complemento em nível de pod ao failover em pipelines multirregionais: a fila é o buffer durável que torna qualquer pod individual descartável.
Probes de saúde que entendem o proxy
As probes de liveness e readiness decidem se um pod é mantido e se ele recebe trabalho. Para um scraper, ligue-as a algo real. Um pod cuja saída pelo proxy está quebrada, IP de saída errado, falhas de autenticação repetidas, uma sessão morta, deveria falhar no readiness para que o scheduler pare de enviar tarefas a ele, e falhar no liveness se continuar quebrado para que seja reiniciado com uma identidade nova.
Mantenha a probe barata e honesta: uma verificação interna leve de que as últimas requisições foram bem-sucedidas, não uma chamada ao vivo ao proxy a cada probe (isso gasta largura de banda e pode dar timeout por si só). O objetivo é notar um pod que silenciosamente parou de sair por um IP saudável antes que ele consuma boa parte da sua fila sem retornar nada.
Dimensione os recursos corretamente: scrapers são limitados por I/O
Não superprovisione. Um pod de scraping passa a maior parte de sua vida bloqueado em I/O de rede através do proxy, não queimando CPU. requests modestos (uma fração de um core, algumas centenas de MB) permitem que o scheduler empacote muitos pods por nó, que é exatamente o que você quer para uma frota de workers leves e concorrentes, o modelo de conexões simultâneas ilimitadas na forma de cluster. Defina limits para pegar descontroles, mas mantenha requests enxutos para que o autoscaling realmente consiga posicionar os pods. Observe a memória mais do que a CPU: um scraper que armazena em buffer respostas grandes tem muito mais chance de atingir um limite de OOM do que um de CPU.
Faça o rollout sem sobrecarregar o alvo
Uma atualização contínua que substitui 40 pods de uma vez cria uma explosão de sessões novas atingindo seus alvos simultaneamente, um pico de tráfego autoinfligido. Use um maxSurge/maxUnavailable conservador para que a substituição seja gradual, e apoie-se na mesma disciplina de fila e limite para que um deploy não se traduza em um pico de requisições. O alvo não deveria conseguir perceber que você fez um deploy.
Verifique e observe
Duas coisas valem a pena serem conectadas desde o primeiro dia. Primeiro, uma verificação de IP de saída na inicialização do pod que registra o IP e o país de saída do pod, para que um Secret mal configurado ou um pod preso na saída local fique óbvio imediatamente, em vez de após uma execução desperdiçada. Segundo, taxa de sucesso e latência por pod, porque em uma frota as falhas raramente são uniformes: um nó ruim, uma sessão esgotada, ou um alvo que começou a te desafiar aparece como as métricas de um único pod divergindo. A qualidade do pool determina com que frequência você tenta novamente, então acompanhe-a, IPs limpos com boa reputação significam menos tentativas por pod e menos carga total para os mesmos dados, e fique de olho na latência e nas taxas de timeout como seu alerta antecipado de que a postura de um alvo mudou.
Conclusão
O Kubernetes é uma boa opção para scraping porque a carga de trabalho é paralela e tem picos, e o maquinário de autoscaling, autocorreção e rollout da plataforma se encaixa perfeitamente em uma frota de workers. A camada de proxy adiciona uma lista curta de regras por cima: mantenha as credenciais em um Secret, dê a cada pod uma sessão distinta via Downward API, faça autoscaling com base na profundidade da fila em vez da CPU, lembre-se de que a pressão total sobre um alvo é a concorrência por pod multiplicada pelo número de pods e limite isso em nível de frota, e trate o SIGTERM para que um pod encerrado reenfileire em vez de descartar trabalho. Acerte essas coisas e você pode escalar a coleta de seis pods para sessenta sem que o alvo jamais perceba, que é todo o objetivo.
Para o pool por trás disso, nossos proxies residenciais são construídos para distribuir a carga de uma frota entre muitos IPs limpos, e como são precificados por gigabyte, escalar pods multiplica seu throughput sem multiplicar sua conta de proxy, apenas os dados que você realmente move contam. Se você está rodando coleta em larga escala, essa é a combinação que mantém tudo rápido e discreto.