같은 머신의 브라우저에서는 아무 불평 없이 열리는 API에 대해 Compose 서비스만 시간 초과가 납니다. 그 차이가 단서입니다. 성공하는 요청은 VPN 터널이 있는 호스트에서 나가고, 멈추는 요청은 자체 네트워크 스택을 가진 컨테이너에서 나갑니다. 컨테이너 내부에서는 아무것도 터널의 경로를 모르므로 이름을 어떻게 적든 사설 목적지에는 닿을 수 없습니다. 해결책은 호스트를 거치는 의도적인 한 번의 도약이며, 더 영리한 라우팅 테이블이 아닙니다.

두 경로가 왜 다른지부터 보고, 그다음 터널이 어디에 있어야 할지 정하세요.

호스트는 되고 컨테이너는 안 되는 이유

터널을 올리면 호스트에는 인터페이스, 경로 집합, 그리고 보통 VPN이 제공하는 리졸버 항목이 생깁니다. 컨테이너는 그중 아무것도 물려받지 않습니다. 엔진 브리지 위의 인터페이스 하나와 그 브리지를 가리키는 기본 경로로 시작할 뿐입니다. 호스트에 설치된 경로는 그 네임스페이스 안에서 도는 무엇에도 보이지 않습니다.

그래서 증상은 단순히 고장이 아니라 앞뒤가 맞지 않게 보입니다. 호스트는 사설 이름을 해석해 연결합니다. 이름과 경로가 모두 거기에 있기 때문입니다. 컨테이너는 브리지 경로에서 아무것도 닿을 수 없는 사설 주소로 패킷을 보내고 요청은 시간 초과가 됩니다. 컨테이너의 hosts 파일에 이름을 추가해도 아무것도 달라지지 않습니다. 주소 자체가 컨테이너가 있는 곳에서 닿을 수 없기 때문입니다.

컨테이너 안에서 호스트에 닿기

컨테이너 안의 프로세스가 localhost를 호출하면 컨테이너 자신을 호출하는 것입니다. Docker Desktop은 host.docker.internal을 엔진을 실행하는 머신을 가리키는 이름으로 게시합니다. Linux 엔진은 서비스가 요청하지 않으면 이를 정의하지 않은 채 두는데, 아래 매핑이 바로 그 요청을 합니다. 그러면 애플리케이션은 사설 주소 대신 이름과 포트를 겨냥합니다.

services:
  app:
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      INTERNAL_API_HOST: "host.docker.internal:8666"

터널은 이 도약의 호스트 쪽에 남고, 그 변수의 포트가 프록시가 대기할 포트입니다. 터널을 잃은 컨테이너는 이제 한 번도 가진 적 없는 경로를 시도하는 대신 알려진 한 번의 도약에서 실패합니다.

터널이 어디에 있을지 고르기

사설 API가 필요한 Compose 서비스에는 세 가지 형태가 가능하며, 어느 프로세스가 터널을 소유하는지에서 갈립니다. VPN 클라이언트를 컨테이너 이미지 안에서 돌릴 수도 있는데, 그러려면 터널 장치를 노출하고 그에 따르는 네트워크 관리 권한을 부여해야 합니다. 애플리케이션이 별도 터널 컨테이너의 네트워크 네임스페이스를 공유할 수도 있고, 그러면 두 프로세스가 하나의 스택과 하나의 라우팅 테이블을 봅니다. 또는 터널을 호스트에 두고 호스트에서 컨테이너로 가는 좁은 전달자를 둘 수 있습니다.

앞의 둘은 프로필 파일, 재연결 동작, 리졸버 정책을 프로젝트로 끌어들이고 파일 안의 모든 서비스를 터널 상태에 의존하게 만듭니다. API 호출 하나에 너무 많은 결합입니다. 내부 엔드포인트 하나만 읽는 개발 스택이라면 호스트 쪽 전달자가 가장 작은 변경이며, 자격 증명은 플랫폼 팀이 관리하는 곳에 그대로 둡니다.

호스트에 좁은 경로 하나만 게시하기

전달자가 물려받는 것부터 시작하세요. 터널을 소유한 계정으로 실행하면 나가는 소켓이 호스트의 경로와 호스트의 리졸버를 물려받습니다. nginx location 블록 하나가 경로 하나를 담당합니다.

server {
    listen 8666;
    location /search/ {
        proxy_pass http://10.20.30.40:8090/;
        proxy_set_header Host internal-api.example;
        proxy_read_timeout 60s;
    }
}

주소와 포트를 임시값으로 보고 API가 실제로 대기하는 쌍을 넣으세요. 추가 upstream마다 별도의 location 블록을 두고, 각 블록은 이 서비스가 호출하는 경로만 허용해야 합니다. 그렇지 않으면 프록시가 사설 네트워크로 통하는 일반적인 문이 됩니다. host-gateway로 들어오는 클라이언트에는 브리지 주소에, 클라이언트가 호스트의 네트워크 네임스페이스를 공유할 때만 loopback에 바인딩하고, 0.0.0.0에는 절대 바인딩하지 마세요. 비어 있는 포트도 고르세요. 이미 쓰이는 번호를 고르면 컨테이너가 계속 시간 초과가 나는 동안 프록시는 대기하지 않아, 처음의 네트워크 장애와 구별할 수 없습니다.

포트, 이름, 그리고 TLS 결정

이름 해석도 라우팅과 같은 방식으로 갈립니다. 컨테이너의 공개 조회는 엔진의 리졸버를 지나므로, 호스트에서 해석되는 사설 이름은 컨테이너 안에서 해석되지 않습니다. 사설 이름을 해석하는 것은 프록시이며, 호스트의 리졸버를 물려받기 때문입니다.

TLS는 미뤄 두었다가 다음 장애를 설명하는 결정입니다. 프록시가 바이트를 그대로 전달하면 컨테이너는 API 자체의 인증서를 보고 신뢰 저장소에 내부 CA가 필요합니다. 프록시가 TLS를 종료하면 그 신뢰는 호스트로 옮겨가고 애플리케이션은 받은 host 헤더로 링크를 만들기 시작합니다. 하나로 정해 모든 리소스에 적용하세요. 다른 쪽이 내는 리디렉션은 컨테이너가 해석할 수 없는 호스트 이름을 가리킵니다.

서비스 안에서 검증하기

요청은 호스트가 아니라 컨테이너에서 보내세요. 프록시 경로에 대한 평범한 curl, 그다음 애플리케이션 자체의 상태 확인입니다. 프록시는 응답하는데 애플리케이션이 계속 실패하면 세 가지를 비교하세요. 애플리케이션이 만드는 기본 URL, 환경의 프록시 변수, 신뢰하는 CA입니다. 모두 컨테이너의 환경과 로그에 있고, 어느 하나를 고쳐도 VPN은 건드리지 않습니다.

고른 구성을 아직 기억이 생생할 때 프로젝트 README에 적으세요. 그러지 않으면 이 시간 초과를 만나는 다음 사람이 터널이 호스트에 있다는 사실을 다시 발견하며 같은 오후를 보냅니다.

관련 글