Wiring a 레지덴셜 프록시를 컨테이너에 연결하는 일은 문제가 생기기 전까지는 간단해 보인다. HTTP_PROXY를 설정하고 이미지를 실행해도 요청은 여전히 호스트 IP로 나간다. 또는 프록시는 작동하지만 내부 서비스 호출이 이제 다른 국가의 레지덴셜 종료 지점을 통해 터널링되면서 헬스 체크가 실패하기 시작한다. 혹은 최악의 경우, 자격 증명이 이미지 레이어에 그대로 굳어버린다.
이런 일들은 특이한 사례가 아니다. 이들은 프록시를 컨테이너에 넣는 팀이 반드시 겪게 되는 네 가지 문제다: 설정이 어디에 적용되는지, 무엇이 우회해야 하는지, 시크릿이 어떻게 들어오는지, 그리고 아이덴티티가 컨테이너에 어떻게 매핑되는지. 이 가이드는 DevOps와 플랫폼 엔지니어를 위해 이 각각을 실제로 동작하는 설정과 함께 다룬다.
The one distinction that explains most confusion
Docker에는 완전히 별개인 두 가지 프록시 개념이 있으며, 이 둘을 혼동하는 것이 “왜 프록시가 작동하지 않는가” 티켓 대부분의 근본 원인이다:
- 빌드 타임 / 데몬 프록시 —
~/.docker/config.json에서 설정하거나--build-arg를 통해 설정한다. 이는 Docker 데몬(이미지 풀링)과 빌드 프로세스(RUN apt-get install)를 관장한다. 앱의 런타임 트래픽과는 아무 관련이 없다. - 런타임 프록시 — 실행 중인 컨테이너 내부의 환경 변수다. 애플리케이션 코드가 보는 것이 바로 이것이다.
~/.docker/config.json에 프록시를 설정하고 Python 스크레이퍼가 이를 사용하기를 기대했다면, 그것이 버그다. 그런 설정은 컨테이너의 런타임 환경에 절대 도달하지 않는다.
한 가지 더: 런타임에서조차 HTTP_PROXY는 강제가 아니라 관례다. 커널이 이를 통해 트래픽을 라우팅하지 않는다. 각 라이브러리가 이를 따를지 말지를 스스로 선택한다:
| 클라이언트 | HTTP_PROXY/HTTPS_PROXY를 따르는가? |
|---|---|
curl, wget | 예 |
Python requests, httpx | 예 (기본값) |
Node fetch / undici | 아니요, 명시적인 dispatcher/agent가 필요 |
Go net/http | 예, http.ProxyFromEnvironment을 통해 (기본 transport) |
| Chromium / Playwright / Puppeteer | 아니요, 실행 플래그나 proxy 옵션이 필요 |
따라서 디버깅할 때 첫 번째로 물어야 할 질문은 “환경 변수가 설정되어 있는가?”가 아니라 “이 클라이언트가 이를 읽는가?”이다.
Runtime proxy: the basic setup
프록시는 빌드 타임이 아니라 런타임에 전달하고, 타겟팅 정보가 사용자 이름에 인코딩된 게이트웨이를 참조한다:
docker run --rm \ -e HTTP_PROXY="http://${SHIFTER_USER}-country-us:${SHIFTER_PASS}@p.shifter.io:443" \ -e HTTPS_PROXY="http://${SHIFTER_USER}-country-us:${SHIFTER_PASS}@p.shifter.io:443" \ -e NO_PROXY="localhost,127.0.0.1,postgres,redis,.internal,169.254.169.254" \ my-scraper:latest사용 중인 라이브러리가 확실하지 않다면 대문자와 소문자 형태(HTTP_PROXY와 http_proxy) 모두 설정해두는 것이 좋다. 관례가 서로 다르며, 일부 도구는 둘 중 하나만 읽는다. HTTPS_PROXY가 여전히 http:// URL을 가리키는 것은 정상이다: 이 스킴은 프록시와 통신하는 방식을 나타내며, HTTPS는 CONNECT를 통해 그 위로 터널링된다.
Compose에서는 값 자체를 파일 밖에 둔다:
services: scraper: image: my-scraper:latest environment: HTTP_PROXY: "http://${SHIFTER_USER}-country-us:${SHIFTER_PASS}@p.shifter.io:443" HTTPS_PROXY: "http://${SHIFTER_USER}-country-us:${SHIFTER_PASS}@p.shifter.io:443" NO_PROXY: "localhost,127.0.0.1,postgres,redis,.internal" depends_on: [postgres, redis]이 변수들은 셸이나 커밋되지 않는 .env 파일에서 값을 채워 넣는다.
NO_PROXY: the step people skip, and regret
이것이 혼란스러운 실패를 일으키는 원인이다. HTTP_PROXY가 설정되면, 이를 따르는 모든 클라이언트는 데이터베이스, 캐시, 내부 API, 클라우드 메타데이터 엔드포인트에 대한 호출을 포함해 모든 것을 프록시를 통해 보낸다. 그 결과: 내부 트래픽이 네트워크를 벗어났다가 다시 돌아오고(느리고 때로는 깨지며), 헬스 체크가 실패하고, 애초에 호스트를 벗어나지 말았어야 할 트래픽에 GB당 대역폭 비용을 낭비하게 된다.
항상 다음을 포괄하도록 NO_PROXY를 설정한다:
localhost,127.0.0.1,::1- Compose/Kubernetes 서비스 이름 (
postgres,redis,api.default.svc.cluster.local) - 내부 도메인과 프라이빗 범위 (
.internal,10.0.0.0/8) - 클라우드 메타데이터:
169.254.169.254
알아둘 만한 두 가지 주의점: NO_PROXY 매칭은 라이브러리마다 일관되게 구현되어 있지 않다(특히 CIDR 지원이 들쭉날쭉하고, 일부는 접미사를 매칭하는 반면 다른 일부는 앞에 점이 필요하다), 그러니 가정하지 말고 확인한다. 그리고 Kubernetes에서는 .svc.cluster.local과 파드/서비스 CIDR도 NO_PROXY에 포함되어야 한다.
Secrets: don’t bake credentials into the image
프록시 자격 증명은 시크릿이다. 규칙은 다음과 같다:
Dockerfile에서 ENV나 ARG를 통해 넣지 말 것, 둘 다 이미지 레이어에 그대로 남으며 docker history가 이를 아무렇지 않게 출력한다. 이미지를 풀할 수 있는 사람은 누구나 자격 증명을 갖게 된다.
대신 런타임에 주입한다. Compose라면 git에 넣지 않는 env 파일을, 오케스트레이션이라면 실제 시크릿 저장소를 사용한다:
# Kubernetes: 매니페스트가 아니라 Secret에서 가져오는 자격 증명env: - name: SHIFTER_USER valueFrom: secretKeyRef: { name: proxy-creds, key: username } - name: SHIFTER_PASS valueFrom: secretKeyRef: { name: proxy-creds, key: password }그런 다음 앱 내부에서 이 두 변수로 프록시 URL을 조합해, 전체 자격 증명 문자열이 매니페스트, 로그 라인, docker inspect에 절대 노출되지 않게 한다. 빌드 중에(패키지 설치 등) 프록시가 필요하다면 ARG 대신 BuildKit 시크릿(--mount=type=secret)을 사용해 레이어에 아무것도 남지 않게 한다.
또한: 로그에서 프록시 URL을 제거한다. 유효 설정을 출력하는 크래시 덤프는 user:pass@host를 그대로 로그 수집기에 유출시킬 것이다.
Mapping identity to containers
여기가 컨테이너 아키텍처와 프록시 아키텍처가 만나는 지점이다. 타겟팅이 사용자 이름 안에 담겨 있기 때문에, 각 컨테이너는 서로 다른 환경 변수를 받는 것만으로 자신만의 아이덴티티를 가질 수 있다. 별도의 엔드포인트도, IP 목록도 필요 없다.
대부분의 필요는 두 가지 패턴으로 충족된다:
컨테이너 하나, 지역 하나. 국가 플래그를 바꿔가며 시장별 워커를 실행한다:
docker run -d -e HTTP_PROXY="http://${U}-country-us:${P}@p.shifter.io:443" scraper:latestdocker run -d -e HTTP_PROXY="http://${U}-country-de:${P}@p.shifter.io:443" scraper:latest컨테이너 하나, 스티키 세션 하나. 각 레플리카에 고유한 sid를 부여해 자신만의 종료 IP를 유지하게 한다. 컨테이너가 여러 단계로 이루어진 흐름을 담당할 때 유용하다:
services: worker: image: scraper:latest environment: # {{.Task.Slot}}는 각 Swarm 레플리카에 고유하고 안정적인 세션 id를 부여한다 HTTP_PROXY: "http://${U}-country-us-sid-w{{.Task.Slot}}-ttl-600:${P}@p.shifter.io:443" deploy: replicas: 4주의할 점: 컨테이너 수준 환경 변수는 해당 컨테이너의 수명 동안 정적인 아이덴티티다. 워크로드가 요청 단위나 작업 단위로 로테이션이 필요하다면 컨테이너를 재시작하는 방식으로 이를 흉내 내지 말고, 작업별로 sid를 바꿀 수 있는 애플리케이션 코드에서 프록시를 설정한다(로드 밸런싱 글의 패턴 참고). 환경 변수는 거친 단위인 컨테이너별 아이덴티티에 적합한 도구이고, 코드는 세밀한 로테이션에 적합한 도구다.
Clients that ignore the environment
컨테이너 내부에서 흔히 마주치는 두 가지 경우가 있다:
Node의 fetch/undici는 프록시 환경 변수를 읽지 않는다. 명시적으로 연결해야 한다:
import { ProxyAgent, setGlobalDispatcher } from "undici";setGlobalDispatcher(new ProxyAgent(process.env.HTTP_PROXY));헤드리스 브라우저도 이를 무시한다. Chromium은 프록시를 실행 시점에 전달받아야 하고, 자격 증명은 프레임워크 자체 메커니즘을 통해 처리해야 한다(Playwright 글이 Chromium에서 인라인 자격 증명이 실패하는 이유를 포함한 세부 사항을 다룬다). Python의 requests/httpx는 환경 변수를 따르긴 하지만, 프록시를 명시적으로 전달하는 편이 더 명확하다(Python 가이드).
또 하나 알아둘 점: 컨테이너 내 클라이언트들 사이에서 환경 변수를 통한 SOCKS5는 신뢰하기 어렵다. 대부분의 컨테이너 워크로드에서는 HTTP 프록시를 사용하는 것이 올바른 기본값이다(SOCKS5 트레이드오프).
Verify it from inside the container
가정하지 말고, 실행 중인 컨테이너 내부에서 종료 IP를 확인한다:
docker exec -it my-scraper sh -c \ 'curl -s http://ip-api.com/json | head -c 200'# 호스트 IP가 아니라 타겟팅한 국가의 레지덴셜 IP가 나와야 한다.호스트 IP가 반환된다면 클라이언트가 환경 변수를 따르지 않는 것이다(위 표 참고). 잘못 설정된 배포가 데이터센터 IP로 조용히 스크래핑하는 대신 요란하게 실패하도록, 스테이징에서 이를 시작 시점 검증(assertion)으로 추가하고, 내부 서비스 호출이 프록시를 거치지 않는지 확인해 NO_PROXY가 제대로 동작하는지 검증한다. 더 폭넓은 측정 방법은 프록시 속도, 성공률, 위치 정확도 테스트 방법에 있다.
Container gotchas that waste an afternoon
localhost는 컨테이너 자신을 의미한다. 호스트에 있는 프록시는 컨테이너 내부에서127.0.0.1로는 접근할 수 없다.host.docker.internal(Docker Desktop) 또는 호스트의 네트워크 주소를 사용한다.- 빌드 타임과 런타임 프록시는 서로 다르다. 하나를 설정한다고 다른 하나가 설정되지 않는다.
- 환경 변수 변경에는 재생성이 필요하다. Compose에서 환경 변수를 수정하면 재시작이 아니라
up --force-recreate가 필요하다. - DNS 해석이 이루어지는 위치는 제각각이다. 일부 클라이언트는 로컬에서 해석하고, 다른 일부는 프록시가 해석하게 둔다. 지역에 민감한 결과가 이상하게 나온다면 이를 의심해봐야 한다.
- 이미지에 CA 인증서가 없을 수 있다. slim/alpine 베이스 이미지는 프록시를 통한 HTTPS가 검증되려면
ca-certificates설치가 필요한 경우가 있다. - GB당 과금은 컨테이너별로 발생한다. 전체 페이지를 가져오는 레플리카가 10개면 대역폭도 10배가 된다. 대역폭 비용 절감은 레플리카별로 적용된다.
FAQ
HTTP_PROXY를 설정했는데도 왜 내 컨테이너가 프록시를 사용하지 않는가?
HTTP_PROXY는 라우팅이 아니라 관례이기 때문이다. 라이브러리가 이를 따라야만 한다. Node의 fetch와 헤드리스 브라우저는 따르지 않고, curl, Python requests, Go의 기본 transport는 따른다. 먼저 클라이언트를 확인한 다음, 컨테이너 내부에서 종료 IP를 확인한다.
Dockerfile에 프록시를 설정해야 하는가?
아니다. ENV/ARG 값은 이미지 레이어에 남아 docker history에 나타나며, 이미지를 풀할 수 있는 누구에게나 자격 증명을 유출시킨다. 시크릿 저장소의 환경 변수를 통해 런타임에 주입하고, 빌드 중 프록시가 필요하면 BuildKit 시크릿을 사용한다.
내부 트래픽이 프록시를 거치지 않게 하려면 어떻게 해야 하는가?
localhost, 서비스 이름, 내부 도메인, 프라이빗 범위, 169.254.169.254를 포함해 NO_PROXY를 설정한다. 그렇지 않으면 데이터베이스와 헬스 체크 트래픽이 레지덴셜 종료 지점을 통해 터널링되며, 이는 느리고 취약하고 과금 대상이 된다.
각 컨테이너가 서로 다른 IP나 국가를 가질 수 있는가? 그렇다. 타겟팅이 프록시 사용자 이름에 인코딩되어 있으므로, 컨테이너마다 다른 환경 변수를 주면 추가 엔드포인트 없이 각자 다른 국가나 스티키 세션을 갖게 할 수 있다. 요청 단위 로테이션이 필요하다면 대신 애플리케이션 코드에서 프록시를 설정한다.
docker build에서도 프록시가 작동하는가?
빌드/데몬 프록시를 별도로 설정한 경우에만 작동한다(~/.docker/config.json 또는 build args). 런타임 컨테이너 환경 변수는 빌드에 영향을 주지 않으며, 빌드 타임 자격 증명은 ARG로 전달해서는 안 된다.
The bottom line
대부분의 Docker 프록시 문제는 네 가지로 귀결된다. 설정이 어디에 적용되는지(빌드 대 런타임, 그리고 애초에 어떤 클라이언트가 환경 변수를 따르는지) 파악하고, 내부 트래픽이 내부에 머물도록 **NO_PROXY**를 설정하고, 자격 증명을 이미지 레이어 밖에 두고 런타임에 주입하며, 아이덴티티가 컨테이너에 어떻게 매핑되는지(거친 단위의 컨테이너별 아이덴티티는 환경 변수로, 요청 단위 로테이션은 애플리케이션 코드로) 의도적으로 결정한다. 그런 다음 설정을 그냥 믿기보다 컨테이너 내부에서 종료 IP를 확인한다.
이것들을 제대로 해두면 컨테이너화된 수집 작업은 가장 좋은 의미로 지루해진다. 레지덴셜 게이트웨이는 타겟팅이 사용자 이름에 담겨 있기 때문에 여기서 도움이 된다: 엔드포인트는 하나뿐이고, 어떤 컨테이너의 지역이나 세션이든 그저 다른 환경 변수일 뿐이다. 얼마나 자주 재시도해야 하는지는 여전히 풀 품질이 결정하며(IP 평판), 가격 페이지에는 GB당 요금제가 있다. 대역폭은 레플리카 수에 비례해 늘어난다는 점을 기억해둘 만하다.