지식

쿠버네티스로 레지덴셜 프록시 스크래핑 워크로드를 확장하는 방법

쿠버네티스에서 스크래핑을 실행하는 방법: Secrets로 관리하는 프록시 자격 증명, Downward API를 통한 파드별 세션 식별, 큐 기반 오토스케일링, 그리고 정상적인 종료 처리까지.

Chris Collins

Chris Collins

2026년 7월 28일 · 8 분 소요

스크래핑 워크로드는 이상적인 Kubernetes 테넌트에 가깝다. 당황스러울 정도로 병렬적이고, 태스크별로 대부분 상태를 갖지 않으며, 오토스케일링을 필요로 할 만큼 트래픽이 몰아친다. 프록시를 컨테이너 안에서 동작시키는 방법을 익혔다면, 실제 물량 단위로 수집을 운영하는 사람에게 클러스터로 옮기는 것은 자연스러운 다음 단계다. 하지만 프록시 계층은 평소라면 별생각 없이 사용했을 기본값 몇 가지를 바꿔놓는다. 파드마다 아이덴티티를 어떻게 할당할지, 어떤 신호를 기준으로 오토스케일링할지, 그리고 파드를 늘리는 것이 대상 사이트 입장에서 왜 공짜가 아닌지가 그것이다.

이 글은 단일 컨테이너 구성 위에 있는 오케스트레이션 계층을 다룬다. 이미 스크래퍼를 레지덴셜 게이트웨이에 연결하는 방법을 알고 있다고 가정하고, 여기서는 이런 스크래퍼 다수를 Kubernetes에서 안전하게 동시에 운영하는 데 초점을 맞춘다.

전체적으로 게이트웨이는 Shifter의 것을 사용한다. 엔드포인트는 p.shifter.io:443 하나이고, 타겟팅 정보는 사용자명에 인코딩된다(customer-USERNAME-country-us-sid-abc123-ttl-600). 호스트와 자격 증명만 다른 프로바이더의 것으로 바꾸면 되며, 패턴 자체는 동일하다.

워크로드의 형태

거의 모든 경우를 두 가지 패턴이 커버한다.

  • 큐에서 태스크를 가져오는 장시간 실행 워커의 Deployment. 지속적인 수집에 기본이 되는 방식이다. 파드는 계속 떠 있으면서 작업 큐를 소진하고, 백로그에 맞춰 스케일한다.
  • 유한한 배치를 위한 Job 또는 CronJob. 야간 크롤링이나 일회성 백필 같은 경우다. Kubernetes는 N개의 파드를 완료까지 실행하고 종료한다.

둘 다 아래에서 다루는 동일한 빌딩 블록을 공유한다. 큐 기반 Deployment가 대부분의 팀이 원하는 방식이므로, 여기서 다루는 매니페스트는 이를 대상으로 하며, Job이 다른 부분은 별도로 표시한다.

프록시 자격 증명은 이미지가 아니라 Secret에 둔다

첫 번째 규칙은 지루하지만 타협의 여지가 없다. 프록시 자격 증명은 이미지에도, ConfigMap에도, 매니페스트에 하드코딩해서도 안 된다. Secret에 넣고, 런타임에 환경 변수(또는 파일)로 마운트해야 한다.

apiVersion: v1
kind: Secret
metadata:
name: proxy-credentials
type: Opaque
stringData:
SHIFTER_USER: "customer-yourname"
SHIFTER_PASS: "your-gateway-password"

자격 증명을 이미지에 구워 넣는다는 것은 그 이미지를 pull할 수 있는 누구나 당신의 프록시 계정을 갖게 된다는 뜻이다. Secret은 이를 레지스트리와 git 히스토리에서 분리하고, 재빌드 없이 회전(rotate)할 수 있게 해준다. 프로덕션에서는 실수로 커밋할 수 있는 평범한 매니페스트 대신 실제 시크릿 매니저(External Secrets Operator, Vault, 또는 클라우드의 CSI 드라이버)로 이를 뒷받침해야 한다.

Downward API로 파드마다 고유한 아이덴티티 부여하기

여기가 프록시에 특화된 부분이다. 모든 파드가 정확히 동일한 사용자명으로, 세션 토큰 없이 인증한다면 모두 풀에서 동일한 방식으로 뽑아 쓰는 셈이고, 이는 순수한 로테이션 목적이라면 보통 문제가 없다. 하지만 각 워커가 일관되고 구별되는 세션을 유지하기를, 즉 하나의 공유된 덩어리가 아니라 파드당 하나의 아이덴티티를 원하는 순간, 각 파드가 사용자명 안의 sid를 다르게 가져가야 한다.

Kubernetes에서 이를 깔끔하게 처리하는 방법은 Downward API다. 파드 자신의 이름을 환경 변수로 주입하고, 여기서 세션 토큰을 유도한다.

apiVersion: apps/v1
kind: Deployment
metadata:
name: scraper
spec:
replicas: 6
selector:
matchLabels: { app: scraper }
template:
metadata:
labels: { app: scraper }
spec:
terminationGracePeriodSeconds: 90 # 진행 중인 요청이 끝날 시간을 준다 (아래 참고)
containers:
- name: scraper
image: your-registry/scraper:1.4.0
env:
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name # 예: 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" }

워커 안에서는 POD_NAME으로부터 프록시 사용자명을 만들어, 각 파드가 그 수명 동안 안정적인 세션을 유지하도록 한다.

import os, hashlib
user = os.environ["SHIFTER_USER"]
pod = os.environ.get("POD_NAME", "local")
# 파드별로 결정론적인 세션 id, 파드의 수명 동안 안정적으로 유지된다.
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"
# `proxy`를 requests / httpx 등 원하는 클라이언트에 넘긴다

이제 파드 scraper-7c9f-abcdescraper-7c9f-fghij와는 다른 세션이 되며, 이는 서로 아무런 조정 없이도 결정론적으로 이루어진다. 파드 안에서 로테이션할 때는 파드가 아니라 작업 단위마다 sid를 바꾸면 된다. 고정 프록시와 로테이팅 프록시의 구분은 다른 어떤 곳에서와 마찬가지로 여기에도 그대로 적용된다. (컨테이너 안에 들어가는 일반적인 워커 코드에 대해서는 Python 가이드에서 클라이언트 설정과 로테이션을 다룬다.)

CPU가 아니라 큐 깊이를 기준으로 오토스케일링하기

기본 HorizontalPodAutoscaler는 CPU를 기준으로 스케일한다. 거의 전적으로 I/O에 묶여 있고 프록시를 거치는 네트워크 왕복을 기다리는 스크래퍼라면, 큐에 얼마나 많은 작업이 쌓여 있든 CPU는 거의 평평하게 유지된다. 이를 기준으로 스케일하는 것은 거의 쓸모가 없다. 낮은 CPU 상태로 산더미 같은 백로그를 쌓아두고도 결코 스케일 업하지 못하게 된다.

백로그 자체를 기준으로 스케일해야 한다. 가장 깔끔한 도구는 KEDA로, Redis 리스트의 길이나 큐의 깊이 같은 외부 메트릭을 기준으로 Deployment를 스케일한다.

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: scraper-scaler
spec:
scaleTargetRef:
name: scraper
minReplicaCount: 2
maxReplicaCount: 40
triggers:
- type: redis
metadata:
address: redis.default.svc:6379
listName: scrape:queue
listLength: "50" # 파드당 약 50개의 큐 대기 작업을 목표로 한다

이렇게 하면 실제로 원하는 특성을 얻는다. 작업이 있을 때 파드가 나타나고 큐가 비면 사라지므로, 유휴 워커에 비용을 지불하지도, 백로그를 굶기지도 않는다. maxReplicaCount 상한선은 선택 사항이 아니라, 다음에 다룰 문제에 대한 주된 안전장치다.

동시성은 파드 수에 비례해 늘어나고, 이것이 함정이다

가장 중요하게 새겨야 할 한 가지는 바로 이것이다. 대상에 가해지는 총 압력은 파드당 동시성에 파드 수를 곱한 값이다. 각 파드가 스스로 동시 요청 수를 5개로 얌전하게 제한한다 하더라도, 파드가 40개면 스크래핑 대상에게는 200개의 동시 요청이 가는 셈이다. 처리량 측면에서 이득처럼 느껴졌던 오토스케일링이 풀 전체를 플래그당하게 만드는 분산형 망치가 되어버린다.

함께 사용해야 할 두 가지 방어책이 있다.

  • 파드당 동시성을 제한하고 이를 소박한 수준으로 유지한다. 워커마다 작은 세마포어를 둔다.
  • 파드 단위가 아니라 전체 플릿에 걸쳐 타겟 호스트별 총 동시성을 제한한다. 이는 단일 파드가 알 수 없는 클러스터 수준의 관심사이므로, 작업 큐에 속해야 한다. 몇 개의 파드가 돌고 있든 상관없이 어떤 사이트도 감내할 수 있는 이상을 받지 않도록 대상 도메인별로 태스크를 샤딩하거나 속도를 제한한다. 이는 프록시 로드 밸런싱과 동일한 논리를 분산 버전으로 구현한 것이며, 좋은 레지덴셜 풀이 부하를 강화하는 면허가 아니라 IP 전체에 부하를 분산시키는 도구인 이유이기도 하다.

이를 건너뛰면 오토스케일링은 당신을 더 빠르게 만들어주지 않는다. 오히려 차단당하게 만든다. 대상의 허용치를 넘어서는 병렬성은 데이터가 아니라 챌린지를 불러온다.

우아한 종료: 완료하거나 재큐잉하거나, 절대 놓치지 않는다

Kubernetes는 파드를 일상적으로 죽인다. 스케일 다운, 롤링 업데이트, 노드 드레인, 스팟 회수 같은 경우다. 그럴 때 SIGTERM을 보내고, terminationGracePeriodSeconds 동안 기다린 뒤, SIGKILL을 보낸다. SIGTERM을 무시하는 스크래퍼는 죽는 순간 진행 중이던 모든 요청을 잃게 되고, 이는 데이터에 알 수 없는 공백으로 나타나며 절반만 끝난 fetch에 대역폭을 낭비하게 만든다.

시그널을 처리하라. SIGTERM을 받으면 새 작업을 가져오는 것을 멈추고, 유예 기간 안에 진행 중인 요청이 끝나도록 두며, 시간 안에 끝낼 수 없는 것은 다시 큐에 넣어 다른 파드가 처리하도록 한다.

import signal
draining = False
def handle_sigterm(signum, frame):
global draining
draining = True # 새 작업을 가져오는 것을 멈추고, 진행 중인 것을 끝낸다
signal.signal(signal.SIGTERM, handle_sigterm)
while not draining:
task = queue.pull()
if task is None:
continue
try:
process(task) # 프록시를 통해 fetch하고, 결과를 저장한다
except Exception:
queue.requeue(task) # 최소 한 번 처리: 다른 파드가 재시도하도록 한다

terminationGracePeriodSeconds는 예상되는 가장 긴 요청보다 넉넉히 크게 설정하고, 최소 한 번(at-least-once) 큐에 의존해 죽은 태스크가 유실되지 않고 재시도되도록 한다. 이는 멀티 리전 파이프라인의 페일오버의 파드 단위 짝이다. 큐는 어떤 단일 파드든 소모품으로 만들어주는 내구성 있는 버퍼다.

프록시를 이해하는 헬스 프로브

Liveness와 readiness 프로브는 파드를 유지할지, 작업을 받을지를 결정한다. 스크래퍼라면 이를 실제 상태와 연결해야 한다. 프록시 이그레스가 망가진 파드, 즉 잘못된 종료 IP, 반복되는 인증 실패, 죽은 세션을 가진 파드는 readiness에 실패해 스케줄러가 더 이상 작업을 보내지 않도록 해야 하고, 계속 망가진 상태라면 liveness에도 실패해 새로운 아이덴티티로 재시작되어야 한다.

프로브는 저렴하고 정직하게 유지하라. 매 프로브마다 프록시로 실제 호출을 하는 것이 아니라(그러면 대역폭을 소모하고 그 자체가 타임아웃될 수 있다), 최근 몇 건의 요청이 성공했는지를 확인하는 가벼운 내부 체크면 충분하다. 목표는 정상적인 IP를 통해 나가는 것을 조용히 멈춘 파드를, 큐의 상당 부분을 아무것도 반환하지 못한 채 소진하기 전에 알아채는 것이다.

리소스를 알맞게 설정하라: 스크래퍼는 I/O에 묶인다

과도하게 프로비저닝하지 마라. 스크래핑 파드는 대부분의 시간을 CPU를 태우는 것이 아니라 프록시를 통한 네트워크 I/O를 기다리며 보낸다. 소박한 requests(코어의 일부, 몇백 MB 정도)는 스케줄러가 노드당 많은 파드를 밀집시킬 수 있게 해주며, 이는 가볍고 동시성 있는 워커 플릿에 정확히 필요한 것, 즉 무제한 동시 연결 모델의 클러스터 버전이다. 폭주를 막기 위해 limits는 설정하되, 오토스케일링이 실제로 파드를 배치할 수 있도록 requests는 가볍게 유지하라. CPU보다 메모리를 더 눈여겨보라. 큰 응답을 버퍼링하는 스크래퍼는 CPU 한계보다 OOM 한계에 부딪힐 가능성이 훨씬 높다.

대상을 놀라게 하지 않으면서 롤아웃하기

40개의 파드를 한 번에 교체하는 롤링 업데이트는 새 세션들이 한꺼번에 대상에 몰아치는 버스트를 만들어내며, 이는 자초한 트래픽 스파이크다. 교체가 점진적으로 이루어지도록 보수적인 maxSurge/maxUnavailable을 사용하고, 배포가 요청 스파이크로 이어지지 않도록 동일한 큐 및 상한 규율에 의존하라. 대상 입장에서 당신이 배포했다는 사실을 알아챌 수 없어야 한다.

검증하고 관찰하라

첫날부터 연결해둘 가치가 있는 두 가지가 있다. 첫째, 파드 시작 시 종료 IP를 확인하고 파드의 아웃바운드 IP와 국가를 로깅하는 체크로, 잘못 구성된 Secret이나 로컬 이그레스에 갇힌 파드가 낭비된 실행 이후가 아니라 즉시 명백히 드러나도록 한다. 둘째, 파드별 성공률과 지연 시간이다. 플릿 안에서는 실패가 균일한 경우가 드물기 때문이다. 하나의 나쁜 노드, 하나의 소진된 세션, 혹은 챌린지를 시작한 하나의 대상은 단일 파드의 메트릭이 벗어나는 형태로 나타난다. 풀의 품질은 애초에 얼마나 자주 재시도해야 하는지를 좌우하므로 이를 추적하라. 좋은 평판을 가진 깨끗한 IP는 파드당 재시도를 줄이고 동일한 데이터에 대한 총 부하를 줄인다. 그리고 대상의 태도가 바뀌었다는 조기 경보로서 지연 시간타임아웃 비율을 계속 지켜보라.

결론

Kubernetes는 워크로드가 병렬적이고 트래픽이 몰아친다는 점에서 스크래핑에 잘 맞는 플랫폼이며, 이 플랫폼의 오토스케일링, 자가 복구, 롤아웃 메커니즘은 워커 플릿에 깔끔하게 대응된다. 프록시 계층은 그 위에 짧은 규칙 목록을 더한다. 자격 증명은 Secret에 두고, Downward API로 각 파드에 고유한 세션을 부여하고, CPU가 아니라 큐 깊이를 기준으로 오토스케일링하고, 대상에 가해지는 총 압력이 파드당 동시성에 파드 수를 곱한 값임을 기억해 플릿 전체 단위로 이를 제한하고, SIGTERM을 처리해 죽은 파드가 작업을 놓치는 대신 재큐잉되도록 하라. 이것들을 제대로 해내면 대상이 전혀 눈치채지 못한 채 수집 규모를 파드 6개에서 60개로 늘릴 수 있으며, 이것이 바로 핵심이다.

그 아래에 있는 풀에 관해서라면, 우리의 레지덴셜 프록시는 플릿의 부하를 많은 깨끗한 IP에 분산시키도록 만들어져 있으며, 기가바이트 단위로 가격이 책정되어 있기 때문에 파드를 늘려 스케일하는 것이 프록시 비용을 곱절로 늘리지 않는다. 오직 실제로 이동한 데이터만 계산된다. 대규모로 수집을 운영하고 있다면, 이것이 빠르면서도 조용하게 유지되도록 해주는 조합이다.

시작할 준비가 되셨나요?

205M개 이상의 IP, 195개 이상의 국가를 지원하는 Shifter의 레지덴셜 프록시를 $0.75/GB부터 이용해보세요.

시작하기