Ein Scraping-Workload kommt dem idealen Kubernetes-Mieter nahe: er ist peinlich parallel, pro Aufgabe meist zustandslos und stoßweise auf eine Art, die geradezu nach Autoscaling schreit. Sobald du Proxies innerhalb eines Containers am Laufen hast, ist der Wechsel zu einem Cluster der natürliche nächste Schritt für jeden, der Sammlung in echtem Umfang betreibt. Aber die Proxy-Schicht ändert ein paar der Standardwerte, zu denen du sonst greifen würdest: wie Identität pro Pod zugewiesen wird, auf welches Signal du skalierst, und warum das Hinzufügen von Pods gegenüber den Seiten, die du triffst, kein Gratis-Mittagessen ist.
Das ist die Orchestrierungsschicht über dem Einzel-Container-Setup. Sie setzt voraus, dass du bereits weißt, wie man einen Scraper auf ein Residential Gateway richtet; hier konzentrieren wir uns darauf, viele davon gleichzeitig sicher auf Kubernetes laufen zu lassen.
Durchgehend ist das Gateway das von Shifter: ein Endpunkt, p.shifter.io:443, mit dem Targeting im Benutzernamen kodiert (customer-USERNAME-country-us-sid-abc123-ttl-600). Tausche Host und Zugangsdaten für einen anderen Anbieter; die Muster sind identisch.
Die Form des Workloads
Zwei Muster decken fast alles ab:
- Ein
Deploymentlanglebiger Worker, die Aufgaben aus einer Warteschlange ziehen. Das ist der Standard für kontinuierliche Sammlung. Pods bleiben oben, leeren eine Arbeitswarteschlange und skalieren mit dem Backlog. - Ein
JoboderCronJobfür endliche Batches: ein nächtlicher Crawl, ein einmaliger Backfill. Kubernetes lässt N Pods bis zur Fertigstellung laufen und stoppt.
Beide teilen sich dieselben Bausteine unten. Das warteschlangengesteuerte Deployment ist das, was die meisten Teams wollen, also zielen die Manifeste hier darauf ab, mit Anmerkungen, wo ein Job abweicht.
Proxy-Zugangsdaten gehören in ein Secret, nie ins Image
Die erste Regel ist langweilig und nicht verhandelbar: Proxy-Zugangsdaten gehören nicht ins Image, nicht in eine ConfigMap und nicht hartkodiert in ein Manifest. Sie gehören in ein Secret, zur Laufzeit als Umgebungsvariablen (oder eine Datei) eingehängt.
apiVersion: v1kind: Secretmetadata: name: proxy-credentialstype: OpaquestringData: SHIFTER_USER: "customer-yourname" SHIFTER_PASS: "your-gateway-password"Zugangsdaten in ein Image einzubacken bedeutet, dass jeder, der das Image pullen kann, dein Proxy-Konto hat. Ein Secret hält sie aus deiner Registry und deiner Git-Historie heraus und lässt dich rotieren, ohne neu zu bauen. In Produktion sichere das mit einem echten Secret-Manager (External Secrets Operator, Vault oder dem CSI-Treiber deiner Cloud) statt eines einfachen Manifests, das du aus Versehen committen könntest.
Gib jedem Pod seine eigene Identität mit der Downward API
Hier ist der proxy-spezifische Teil. Wenn sich jeder Pod mit dem exakt selben Benutzernamen und ohne Session-Token authentifiziert, ziehen alle gleich aus dem Pool, was für reine Rotation meist in Ordnung ist. Aber sobald du willst, dass jeder Worker eine kohärente, eindeutige Session hält, eine Identität pro Pod statt eines geteilten Blocks, musst du jeden Pod die sid in seinem Benutzernamen variieren lassen.
Der saubere Weg dafür auf Kubernetes ist die Downward API: injiziere den eigenen Namen des Pods als Umgebungsvariable und leite das Session-Token daraus ab.
apiVersion: apps/v1kind: Deploymentmetadata: name: scraperspec: replicas: 6 selector: matchLabels: { app: scraper } template: metadata: labels: { app: scraper } spec: terminationGracePeriodSeconds: 90 # lasse Anfragen in Flug fertig werden (siehe unten) containers: - name: scraper image: your-registry/scraper:1.4.0 env: - name: POD_NAME valueFrom: fieldRef: fieldPath: metadata.name # z. B. 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" }Baue im Worker den Proxy-Benutzernamen aus POD_NAME, damit jeder Pod für seine Lebensdauer eine stabile Session ist:
import os, hashlib
user = os.environ["SHIFTER_USER"]pod = os.environ.get("POD_NAME", "local")# Deterministische Session-Id pro Pod, stabil für die Lebensdauer des Pods.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"# reiche `proxy` an requests / httpx / den Client deiner Wahl weiterJetzt ist Pod scraper-7c9f-abcde eine andere Session als scraper-7c9f-fghij, deterministisch, ohne jede Koordination zwischen ihnen. Rotiere innerhalb eines Pods, indem du die sid pro Arbeitseinheit statt pro Pod änderst; die Unterscheidung Sticky vs. rotierend gilt genau wie überall sonst. (Für den Worker-Code, der in den Container geht, deckt der Python-Leitfaden Client-Setup und Rotation ab.)
Skaliere auf Warteschlangentiefe, nicht auf CPU
Der Standard-HorizontalPodAutoscaler skaliert auf CPU. Für einen Scraper, der fast vollständig I/O-gebunden ist und auf Netzwerk-Roundtrips durch den Proxy wartet, ist die CPU nahezu flach, egal wie viel Arbeit in der Warteschlange liegt. Darauf zu skalieren ist nahezu nutzlos: du sitzt bei niedriger CPU mit einem Berg Backlog und skalierst nie hoch.
Skaliere auf den Backlog selbst. Das sauberste Werkzeug ist KEDA, das ein Deployment auf externe Metriken wie die Länge einer Redis-Liste oder die Tiefe einer Warteschlange skaliert:
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" # ziele auf ~50 wartende Aufgaben pro PodDas gibt dir die Eigenschaft, die du wirklich willst: Pods erscheinen, wenn es Arbeit gibt, und laufen ab, wenn die Warteschlange sich leert, sodass du weder für untätige Worker zahlst noch einen Backlog aushungerst. Eine maxReplicaCount-Obergrenze ist nicht optional, sie ist deine primäre Schutzmaßnahme gegen das nächste Problem.
Nebenläufigkeit skaliert mit den Pods, und das ist die Falle
Das Wichtigste zum Verinnerlichen: der Gesamtdruck auf ein Ziel ist die Nebenläufigkeit pro Pod multipliziert mit der Pod-Anzahl. Jeder Pod mag sich höflich auf 5 gleichzeitige Anfragen begrenzen, aber 40 Pods sind 200 gleichzeitige Anfragen gegen das, was du auch immer scrapest. Autoscaling, das sich wie ein Durchsatz-Gewinn anfühlte, wird zu einem verteilten Hammer, der deinen gesamten Pool markieren lässt.
Zwei Verteidigungen, zusammen genutzt:
- Begrenze die Nebenläufigkeit pro Pod und halte sie bescheiden. Ein kleines Semaphore pro Worker.
- Begrenze die Gesamtnebenläufigkeit pro Ziel-Host über die Flotte hinweg, nicht nur pro Pod. Das ist ein Anliegen auf Cluster-Ebene, das ein einzelner Pod nicht sehen kann, also gehört es in die Arbeitswarteschlange: sharde oder rate-limitiere Aufgaben nach Ziel-Domain, damit keine einzelne Seite mehr erhält, als sie toleriert, egal wie viele Pods laufen. Das ist die verteilte Version derselben Logik im Proxy-Lastausgleich, und deshalb ist ein guter Residential-Pool ein Werkzeug, um Last über IPs zu verteilen, statt eine Lizenz, sie zu intensivieren.
Überspringst du das, macht dich Autoscaling nicht schneller, es macht dich geblockt. Mehr Parallelität jenseits der Toleranz eines Ziels kauft Challenges, keine Daten.
Geordnetes Herunterfahren: fertigstellen oder neu einreihen, nie fallenlassen
Kubernetes tötet Pods routinemäßig: Scale-down, Rolling Updates, Node-Drains, Spot-Reclaims. Wenn es das tut, sendet es SIGTERM, wartet terminationGracePeriodSeconds und sendet dann SIGKILL. Ein Scraper, der SIGTERM ignoriert, verliert jede Anfrage in Flug in dem Moment, in dem er getötet wird, was sich als mysteriöse Lücken in deinen Daten und verschwendete Bandbreite auf halbfertigen Fetches zeigt.
Behandle das Signal. Bei SIGTERM höre auf, neue Aufgaben zu ziehen, lasse Anfragen in Flug innerhalb der Gnadenfrist fertig werden, und reihe alles neu ein, was du nicht rechtzeitig abschließen kannst, damit ein anderer Pod es aufnimmt:
import signal
draining = False
def handle_sigterm(signum, frame): global draining draining = True # höre auf, neue Arbeit zu ziehen; beende, was in Flug ist
signal.signal(signal.SIGTERM, handle_sigterm)
while not draining: task = queue.pull() if task is None: continue try: process(task) # fetch durch den Proxy, Ergebnis speichern except Exception: queue.requeue(task) # at-least-once: lasse einen anderen Pod erneut versuchenSetze terminationGracePeriodSeconds bequem über deine längste erwartete Anfrage und verlasse dich auf eine At-least-once-Warteschlange, damit eine getötete Aufgabe erneut versucht statt verloren wird. Das ist das Pendant auf Pod-Ebene zum Failover in Multi-Region-Pipelines: die Warteschlange ist der dauerhafte Puffer, der jeden einzelnen Pod entbehrlich macht.
Health-Probes, die den Proxy verstehen
Liveness- und Readiness-Probes entscheiden, ob ein Pod gehalten wird und ob er Arbeit empfängt. Binde sie für einen Scraper an etwas Echtes. Ein Pod, dessen Proxy-Ausgang kaputt ist, falsche Exit-IP, wiederholte Auth-Fehler, eine tote Session, sollte Readiness fehlschlagen, damit der Scheduler ihm keine Aufgaben mehr sendet, und Liveness fehlschlagen, wenn er kaputt bleibt, damit er mit einer frischen Identität neu gestartet wird.
Halte die Probe billig und ehrlich: eine leichte interne Prüfung, dass die letzten paar Anfragen erfolgreich waren, kein Live-Aufruf zum Proxy bei jeder Probe (das verbraucht Bandbreite und kann selbst in Timeout laufen). Das Ziel ist, einen Pod zu bemerken, der still aufgehört hat, über eine gesunde IP auszutreten, bevor er ein Stück deiner Warteschlange verbrennt und nichts zurückgibt.
Dimensioniere Ressourcen richtig: Scraper sind I/O-gebunden
Überprovisioniere nicht. Ein Scraping-Pod verbringt den Großteil seines Lebens blockiert auf Netzwerk-I/O durch den Proxy, nicht CPU verbrennend. Bescheidene requests (ein Bruchteil eines Kerns, ein paar hundert MB) lassen den Scheduler viele Pods pro Node packen, was genau das ist, was du für eine Flotte leichter, nebenläufiger Worker willst, das Modell der unbegrenzten gleichzeitigen Verbindungen in Cluster-Form. Setze limits, um Ausreißer abzufangen, aber halte requests schlank, damit Autoscaling Pods tatsächlich platzieren kann. Achte mehr auf Speicher als auf CPU: ein Scraper, der große Antworten puffert, trifft weit eher ein OOM-Limit als ein CPU-Limit.
Rolle aus, ohne das Ziel zu überrollen
Ein Rolling Update, das 40 Pods auf einmal ersetzt, erzeugt einen Schwall frischer Sessions, die alle gleichzeitig deine Ziele treffen, eine selbstverschuldete Verkehrsspitze. Nutze ein konservatives maxSurge/maxUnavailable, damit der Ersatz graduell ist, und stütze dich auf dieselbe Warteschlangen-und-Cap-Disziplin, damit ein Deploy sich nicht in eine Anfragespitze übersetzt. Das Ziel sollte nicht merken können, dass du deployt hast.
Verifizieren und beobachten
Zwei Dinge lohnt es sich, vom ersten Tag an zu verdrahten. Erstens eine Exit-IP-Prüfung beim Pod-Start, die die ausgehende IP und das Land des Pods protokolliert, damit ein fehlkonfiguriertes Secret oder ein auf lokalem Ausgang steckender Pod sofort offensichtlich ist statt nach einem verschwendeten Lauf. Zweitens Erfolgsrate und Latenz pro Pod, denn in einer Flotte sind die Ausfälle selten gleichförmig: ein schlechter Node, eine erschöpfte Session oder ein Ziel, das anfing dich zu challengen, zeigt sich als die Metriken eines einzelnen Pods, die abweichen. Die Pool-Qualität treibt, wie oft du überhaupt erneut versuchst, also verfolge sie, saubere IPs mit guter Reputation bedeuten weniger Retries pro Pod und weniger Gesamtlast für dieselben Daten, und behalte Latenz und Timeout-Raten als Frühwarnung im Auge, dass sich die Haltung eines Ziels geändert hat.
Das Fazit
Kubernetes passt gut zum Scraping, weil der Workload parallel und stoßweise ist und die Autoscaling-, Selbstheilungs- und Rollout-Maschinerie der Plattform sauber auf eine Flotte von Workern abbildet. Die Proxy-Schicht fügt oben eine kurze Regelliste hinzu: halte Zugangsdaten in einem Secret, gib jedem Pod eine eindeutige Session über die Downward API, skaliere auf Warteschlangentiefe statt CPU, denk daran, dass der Gesamtdruck auf ein Ziel die Nebenläufigkeit pro Pod mal Pod-Anzahl ist, und begrenze ihn flottenweit, und behandle SIGTERM, damit ein getöteter Pod neu einreiht statt Arbeit fallenzulassen. Mach das richtig, und du kannst Sammlung von sechs Pods auf sechzig skalieren, ohne dass das Ziel es je spürt, was der ganze Punkt ist.
Für den Pool darunter sind unsere Residential Proxies gebaut, um die Last einer Flotte über viele saubere IPs zu verteilen, und weil sie pro Gigabyte bepreist sind, multipliziert das Skalieren von Pods deinen Durchsatz, ohne deine Proxy-Rechnung zu multiplizieren, nur die Daten, die du tatsächlich bewegst, zählen. Wenn du Sammlung in großem Maßstab betreibst, ist das die Kombination, die sie zugleich schnell und leise hält.