Ein Scraper, der auf einem Laptop funktioniert, trifft in CI meist auf eine von zwei Situationen. Entweder fügt jemand das Proxy-Passwort in die Workflow-Datei ein, „nur damit es grün wird“, oder die Pipeline führt bei jedem Push einen vollständigen Live-Scrape aus und verbraucht bis Mittwoch still und heimlich die Bandbreite eines ganzen Monats. Beides lässt sich vermeiden, und die Lösung besteht meist darin, im Voraus zu entscheiden, welche Läufe überhaupt einen Live-Proxy benötigen.
Dieses Tutorial behandelt, wo Proxy-Zugangsdaten in CI liegen sollten, wie man sie aus Logs heraushält, wie man Tests aufteilt, damit die meisten Läufe nie mit dem Netzwerk in Berührung kommen, und wie man eine Zugangsdaten rotiert, ohne in Hektik zu verfallen. Die Beispiele verwenden GitHub Actions und GitLab CI mit Shifters residentiellem Gateway, aber die Struktur lässt sich auf jeden Runner übertragen.
Was schiefgeht
Vier Fehlermuster machen fast jeden CI-Zugangsdaten-Vorfall bei Scrapern aus:
| Fehler | Wie es passiert |
|---|---|
| Zugangsdaten committed | Eine .env-Datei oder eine hartcodierte Proxy-URL landet im Repository |
| Zugangsdaten in Logs | Eine Debug-Zeile gibt die Proxy-URL aus, oder ein HTTP-Client loggt Request-Header |
| Zugangsdaten für nicht vertrauenswürdigen Code sichtbar | Ein Pull Request von außerhalb des Teams läuft mit verfügbaren Secrets |
| Bandbreite verbraucht | Jeder Push führt einen Live-Scrape gegen echte Websites aus |
Die ersten drei sind Sicherheitsprobleme. Das vierte ist ein Kostenproblem, das die Pipeline zusätzlich instabil macht, denn echte Websites ändern sich, und Ihr Build sollte nicht fehlschlagen, weil sich die Seite eines anderen geändert hat.
Schritt 1: Zugangsdaten als Secret speichern, niemals im Code
Ihr residentieller Benutzername und Passwort finden sich auf der Plan-Seite im Panel. Speichern Sie diese als zwei CI-Secrets:
SHIFTER_USERNAME: der vollständige angezeigte Benutzername, zum Beispielcustomer-USERNAMESHIFTER_PASSWORD: das Passwort
GitHub Actions. Fügen Sie beide unter den Repository-Einstellungen, Secrets and variables, Actions hinzu. GitHub schwärzt Secret-Werte in Logs, und mit Ausnahme von GITHUB_TOKEN werden Secrets nicht an den Runner übergeben, wenn ein Workflow von einem geforkten Repository ausgelöst wird. Diese zweite Regel ist bei öffentlichen Repositories wichtig: Der Pull Request eines externen Mitwirkenden kann Ihr Proxy-Passwort nicht lesen, kann aber auch Ihre Live-Tests nicht ausführen, was Sie in Schritt 3 behandeln werden.
GitLab CI. Fügen Sie beide als CI/CD-Variablen hinzu, markieren Sie sie als masked, und markieren Sie sie als protected, damit sie nur für Pipelines auf geschützten Branches oder Tags verfügbar sind. Ein Haken ist spezifisch genug, um erwähnt zu werden: GitLab kann nur einen Wert maskieren, der eine einzelne Zeile mit 8 oder mehr Zeichen ist. Shifters residentielle Passwörter können kürzer sein, und GitLab wird sich weigern, sie zu maskieren. Die Lösung besteht darin, das Paar stattdessen als eine maskierte Variable zu speichern:
SHIFTER_PROXY_AUTH=customer-USERNAME:PASSWORD
Dieser Wert liegt bequem über 8 Zeichen und verwendet nur Zeichen, die GitLab in maskierten Variablen erlaubt. Trennen Sie ihn in Ihrem Code am letzten Doppelpunkt.
Fügen Sie .env im selben Commit zur .gitignore hinzu, damit eine lokale Datei die Zugangsdaten niemals ins Repository mitzieht.
Schritt 2: die Proxy-URL im Code zusammensetzen, und niemals ausgeben
Setzen Sie die Proxy-URL zur Laufzeit aus der Umgebung an einer einzigen Stelle zusammen, damit Targeting- und Session-Flags konsistent hinzugefügt werden und nichts anderes je mit dem rohen Passwort in Berührung kommt:
import os
GATEWAY = "p.shifter.io:443"
def shifter_credentials():
if "SHIFTER_PROXY_AUTH" in os.environ:
username, password = os.environ["SHIFTER_PROXY_AUTH"].rsplit(":", 1)
return username, password
return os.environ["SHIFTER_USERNAME"], os.environ["SHIFTER_PASSWORD"]
def proxy_url(country=None, session=None, ttl=None):
username, password = shifter_credentials()
if country:
username += f"-country-{country}"
if session:
username += f"-sid-{session}"
if ttl:
username += f"-ttl-{ttl}"
return f"http://{username}:{password}@{GATEWAY}"
def redact(url):
# Safe to log: keeps the flags, drops the password.
creds, host = url.rsplit("@", 1)
return f"{creds.rsplit(':', 1)[0]}:***@{host}"
Loggen Sie redact(url), wenn Sie sehen müssen, welche Flags ein Lauf verwendet hat. Loggen Sie niemals die URL selbst.
Die Secret-Maskierung hat eine Schwachstelle, die man kennen sollte. Sie erkennt den gespeicherten Wert, sodass ein transformierter Wert durchschlüpft. Die Proxy-Authentifizierung wird als Proxy-Authorization-Header gesendet, der die Base64-Kodierung von username:password enthält, und ein Debug-Log der Request-Header gibt diese Kodierung aus, die kein CI-Maskierer erkennen wird. Halten Sie das Debug-Logging auf Header-Ebene in CI ausgeschaltet. Wenn Sie eine sensible Zeichenkette zusammensetzen müssen, die selbst kein Secret ist, registrieren Sie sie mit GitHubs ::add-mask::-Befehl, bevor irgendetwas sie ausgeben kann.
Übergeben Sie Secrets an Ihren Scraper als Umgebungsvariablen, nicht als Kommandozeilenargumente. GitHubs eigene Empfehlung lautet, das Übergeben von Secrets zwischen Prozessen auf der Kommandozeile zu vermeiden, wo immer möglich. Argumente sind in Prozesslisten leicht sichtbar und landen häufig in Shell-Traces.
Schritt 3: Tests aufteilen, damit die meisten Läufe keinen Proxy benötigen
Dieser Schritt eliminiert den Großteil der Kosten und der Instabilität. Teilen Sie Scraper-Tests in drei Stufen ein:
| Stufe | Was geprüft wird | Braucht einen Proxy | Wann sie läuft |
|---|---|---|---|
| Parser-Tests | Extraktionslogik gegen gespeicherte HTML-Fixtures | Nein | Bei jedem Push und Pull Request |
| Live-Smoke-Test | Eine Handvoll echter Requests durch das Gateway | Ja | Main-Branch und ein Zeitplan |
| Vollständiger Lauf | Der eigentliche Scrape | Ja | Eigener Zeitplan, oder manueller Trigger |
Parser-Tests machen den Großteil Ihrer Abdeckung aus. Speichern Sie echte Antworten als Fixture-Dateien und testen Sie, ob Ihre Selektoren die richtigen Felder daraus extrahieren. Sie laufen in Sekunden, kosten nichts, benötigen keine Secrets, und schlagen nur fehl, wenn Ihr Code falsch ist. Wenn eine Website ihr Layout ändert, speichern Sie eine frische Fixture und aktualisieren Sie den Parser im selben Pull Request.
Der Live-Smoke-Test bestätigt, dass Zugangsdaten, Targeting und Konnektivität funktionieren, nicht dass jede Seite parst. Beschränken Sie ihn auf wenige Requests. Er sollte übersprungen werden, statt fehlzuschlagen, wenn das Secret fehlt, was genau die Situation bei einem Fork-Pull-Request ist:
import os
import pytest
import requests
from scraper.proxy import proxy_url
live = pytest.mark.skipif(
not (os.environ.get("SHIFTER_PASSWORD") or os.environ.get("SHIFTER_PROXY_AUTH")),
reason="no proxy credentials in this environment",
)
@live
def test_gateway_exits_in_requested_country():
url = proxy_url(country="de")
r = requests.get("https://ipinfo.io/json", proxies={"http": url, "https": url}, timeout=30)
assert r.status_code == 200
assert r.json()["country"] == "DE"
Der vollständige Lauf gehört auf einen Zeitplan, nicht auf Push, damit ein Merge keinen produktionsgroßen Scrape auslöst.
Schritt 4: eine Session pro Job, niemals gemeinsam genutzt
Sticky Sessions binden einen Lauf an eine Exit-IP, was für mehrstufige Abläufe wie Pagination wünschenswert ist. Shifters Session-ID ist eine beliebige von Ihnen gewählte Zeichenkette mit einer Standardlebensdauer von 120 Sekunden, die ttl überschreibt. Die Warnung der Dokumentation gilt direkt für CI: Verwenden Sie keine Session-ID über gleichzeitig laufende Workflows hinweg wieder, denn Requests von verschiedenen Jobs, die auf derselben IP landen, wirken auf die meisten Anti-Bot-Systeme verdächtig.
CI liefert bereits einen eindeutigen Wert pro Lauf. Verwenden Sie diesen, plus den Job-Index, wenn Sie eine Matrix ausführen:
import os
run = os.environ.get("GITHUB_RUN_ID") or os.environ.get("CI_PIPELINE_ID", "local")
job = os.environ.get("JOB_INDEX", "0")
url = proxy_url(country="us", session=f"ci{run}j{job}", ttl=600)
Halten Sie die ID alphanumerisch, da der Benutzername Bindestriche verwendet, um Flags zu trennen. Für Requests, die unabhängig voneinander sind, lassen Sie die Session ganz weg, dann rotiert jeder Request zu einer frischen IP.
Schritt 5: Kontingent vor einem großen Lauf prüfen
Ein geplanter Scrape, dem auf halbem Weg die Bandbreite ausgeht, ist schlimmer als einer, der nie gestartet ist. Shifters Usage and Quota API gibt zurück, was von einem Plan übrig ist, sodass ein Preflight-Schritt den Lauf saubar überspringen kann:
import os
import sys
import requests
MIN_GB = float(os.environ.get("MIN_REMAINING_GB", "5"))
r = requests.get(
f"https://shifter.io/api/v1/memberships/{os.environ['SHIFTER_MEMBERSHIP']}/usage",
params={"api_token": os.environ["SHIFTER_API_TOKEN"]},
timeout=30,
)
r.raise_for_status()
plan = r.json()["data"]
if plan["metered"] and plan["remaining_gb"] < MIN_GB:
print(f"Only {plan['remaining_gb']} GB left, resets {plan['resets_at']}. Skipping run.")
sys.exit(78)
Das API-Token wird im Panel unter Account, API Tokens erzeugt. Speichern Sie es als Secret wie das Passwort: Es gehört zu Ihrem Konto und nicht zu einem einzelnen Workspace, daher liest es die Nutzung für jeden Workspace, dessen Mitglied Sie sind. Der Endpunkt ist auf 60 Requests pro Minute begrenzt, weit mehr, als ein Preflight benötigt.
Wenn ein Lauf den Plan tatsächlich ausschöpft, während Extra Traffic deaktiviert ist, gibt das Gateway 509 Bandwidth Limit Exceeded zurück. Behandeln Sie das als Stoppbedingung, nicht als etwas, das man erneut versuchen sollte.
Schritt 6: bei Authentifizierungsfehlern schnell fehlschlagen
Retries sind richtig bei vorübergehenden Netzwerkfehlern und falsch bei Zugangsdaten-Fehlern. Ein 407 Proxy Authentication Required bedeutet, dass der Benutzername, das Passwort oder ein Flag falsch ist, und es wird beim nächsten Versuch genauso falsch sein. In CI verwandelt eine Retry-Schleife um einen 407 einen einsekündigen Fehlschlag in einen zehnminütigen mit einem verwirrenden Log. Lassen Sie Ihren Client 407 als fatal behandeln, die geschwärzte Proxy-URL ausgeben und anhalten. Die üblichen Ursachen und die Reihenfolge, in der man sie prüft, werden in Beheben von 407-Proxy-Authentifizierungsfehlern behandelt.
Ein vollständiger GitHub Actions Workflow
name: scraper
on:
push:
pull_request:
schedule:
- cron: "0 6 * * *"
workflow_dispatch:
jobs:
parser-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest tests/parsers
smoke-test:
if: github.ref == 'refs/heads/main'
needs: parser-tests
runs-on: ubuntu-latest
env:
SHIFTER_USERNAME: ${{ secrets.SHIFTER_USERNAME }}
SHIFTER_PASSWORD: ${{ secrets.SHIFTER_PASSWORD }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest tests/live
full-run:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
needs: smoke-test
runs-on: ubuntu-latest
concurrency: scraper-full-run
env:
SHIFTER_USERNAME: ${{ secrets.SHIFTER_USERNAME }}
SHIFTER_PASSWORD: ${{ secrets.SHIFTER_PASSWORD }}
SHIFTER_API_TOKEN: ${{ secrets.SHIFTER_API_TOKEN }}
SHIFTER_MEMBERSHIP: ${{ vars.SHIFTER_MEMBERSHIP }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: python scripts/check_quota.py
- run: python -m scraper.run
Die Parser-Tests laufen überall ohne Secrets. Der Smoke-Test und der vollständige Lauf existieren nur dort, wo Secrets existieren. Die concurrency-Gruppe verhindert, dass sich zwei geplante Läufe überlappen und sich ein IP-Budget teilen. Die Membership-ID ist nicht geheim, daher liegt sie in einer normalen Repository-Variable.
Eine Anpassung ist erwähnenswert: So wie geschrieben, lässt der Exit-Code 78 des Quota-Skripts den Job fehlschlagen. Wenn Sie stattdessen lieber einen übersprungenen Lauf sehen als einen roten, geben Sie dem Prüfschritt eine id, lassen Sie ihn ein Flag in $GITHUB_OUTPUT schreiben, und knüpfen Sie den Scrape-Schritt an diese Ausgabe.
Rotation der Zugangsdaten
Rotieren Sie nach einem Zeitplan, und sofort immer dann, wenn ein Secret durchgesickert sein könnte: ein öffentliches Log, ein ausscheidender Mitarbeiter, ein geforkter Workflow, bei dem Sie sich unsicher sind.
Bei Shifter kann der Kontoinhaber oder ein Workspace-Admin ein neues residentielles Passwort auf der Plan-Seite im Panel erzeugen. Viewer- und Billing-Mitglieder können das nicht. Das Gateway übernimmt das neue Passwort sofort, und das alte funktioniert im selben Moment nicht mehr, planen Sie also die Reihenfolge:
- Pausieren Sie den geplanten Workflow, oder akzeptieren Sie, dass ein laufender Lauf mit einem 407 fehlschlägt.
- Erzeugen Sie das neue Passwort im Panel.
- Aktualisieren Sie das CI-Secret sofort.
- Aktualisieren Sie jeden anderen Nutzer desselben Plans. Das Passwort gehört zum Plan, nicht zu einer Pipeline, daher bricht alles andere, was es verwendet, im selben Moment.
- Führen Sie den Smoke-Test erneut aus, um zu bestätigen.
Punkt 4 ist der Grund, warum es sich lohnt zu wissen, wo die Zugangsdaten eines Plans verwendet werden, bevor Sie sie rotieren müssen. Wenn mehrere unabhängige Pipelines sich einen Plan teilen, betrifft eine einzelne Rotation alle davon.
Fazit
Der Großteil der Arbeit beim Ausführen von Scrapern aus CI besteht darin, den Proxy von Läufen fernzuhalten, die ihn nicht benötigen. Parser-Tests gegen Fixtures decken die Logik bei jedem Push ab, ohne Secrets und ohne Bandbreite. Ein kleiner Live-Smoke-Test auf main beweist, dass die Zugangsdaten noch funktionieren. Der eigentliche Scrape läuft nach einem Zeitplan, prüft zuerst sein Kontingent, verwendet eine Session-ID, die niemand anderes teilt, und stoppt beim ersten Authentifizierungsfehler, statt ihn erneut zu versuchen.
Die Zugangsdaten selbst leben im CI-Secret-Store, werden in einer einzigen Funktion zusammengesetzt und niemals ausgegeben, auch nicht in Base64. Für länger laufende Erfassungen wird die operative Seite der Überwachung einer Pipeline in Überwachung einer Web-Scraping-Pipeline behandelt, und das Verteilen einer solchen über Regionen in Residentieller Proxy-Failover für Multi-Region-Pipelines. Client-seitiges Proxy-Setup für browserbasierte Scraper findet sich in Konfigurieren residentieller Proxys in Selenium und Playwright.