GCP VM과 WireGuard로 홈랩(라즈베리 파이 5) 집 공인 IP 완벽히 숨기기 (feat. Podman Nginx & PROXY Protocol 삽질기)

집에서 라즈베리 파이 5로 홈랩(WordPress, Gitea, MinIO 등)을 운영하다 보면 두 가지 큰 고민이 생긴다.

  1. 통신사(KT 등) 유동 IP 문제: 공유기나 모뎀이 재부팅될 때마다 IP가 바뀌어 DDNS에 의존해야 한다.
  2. 집 공인 IP 노출 및 보안 우려: 도메인에 dig나 nslookup을 때리면 집 공인 IP가 그대로 노출되고, 공유기에서 80/443 포트포워딩을 열어두어야 하므로 무차별 스캔 공격에 노출된다.

이 문제를 근본적으로 해결하기 위해 GCP(Google Cloud Platform) 서울 리전에 고정 IP를 가진 소형 VM(Edge Gateway)을 띄우고, 라즈베리 파이 5에서 WireGuard VPN 터널을 뚫어 트래픽을 중계하는 아키텍처를 구축했다. 특히 SSL 인증서 관리를 기존 라즈베리 파이 5에서 그대로 유지하기 위해 Nginx Stream(TCP 패스스루) + PROXY Protocol을 조합했고, 그 과정에서 겪은 Podman 볼륨 마운트 및 Nginx 소켓 에러 트러블슈팅까지 모두 정리해 둔다.

1. 전체 아키텍처 요약

[외부 방문자] 
   │ (HTTPS 443 / HTTP 80)
   ▼
[GCP Edge VM (고정 공인 IP)] ── Nginx Stream (L4 TCP 패스스루 + PROXY Protocol 헤더 부착)
   │ (WireGuard 암호화 터널: 10.0.0.1 -> 10.0.0.2)
   ▼
[집 공유기 (포트포워딩 0개! 인바운드 모두 닫음)]
   │ (라즈베리 파이 5가 먼저 아웃바운드로 터널 유지)
   ▼
[라즈베리 파이 5 (Rootless Podman)] ── nginx-proxy 컨테이너 (SSL 종료 + 실제 방문자 IP 복원)
  • 공유기 포트포워딩 제거: 라즈베리 파이 5가 GCP VM(Endpoint)으로 먼저 UDP 51820 아웃바운드 연결을 맺고 PersistentKeepalive = 25로 세션을 유지하므로, 집 공유기에서는 단 하나의 포트도 열 필요가 없다.
  • SSL 인증서 이원화 방지: GCP VM은 암호화된 TLS 패킷을 까보지 않고 그대로 라즈베리 파이 5로 토스(ngx_stream_module)한다. 덕분에 Let’s Encrypt 인증서 발급과 갱신은 기존처럼 라즈베리 파이 5에서만 관리하면 된다.

2. GCP VM 및 방화벽 설정

2.1 방화벽 오픈 (80, 443, WireGuard 51820)

외부에서 들어올 웹 트래픽(tcp:80, 443)과 WireGuard 핸드셰이크용 포트(udp:51820)를 열어준다.

gcloud compute firewall-rules create allow-edge-traffic \
    --allow=tcp:80,tcp:443,udp:51820 \
    --target-tags=edge-gateway \
    --description="Allow HTTP, HTTPS, and WireGuard"

2.2 기본 SSH(22번 포트) 전체 개방 차단 및 IAP 전환

GCP의 default VPC 네트워크에는 기본적으로 default-allow-ssh 규칙이 0.0.0.0/0으로 열려 있어 전 세계 봇들의 타깃이 된다. 이를 구글의 IAP(Identity-Aware Proxy) 대역(35.235.240.0/20)으로 덮어씌워 외부 포트 스캔을 원천 차단했다.

# 기존 0.0.0.0/0 SSH 허용 규칙을 구글 IAP 전용 대역으로 축소
gcloud compute firewall-rules update default-allow-ssh --source-ranges=35.235.240.0/20

# 접속 시에는 IAP 터널을 통해 안전하게 접속
gcloud compute ssh edge-gateway-vm --zone=asia-northeast3-a --tunnel-through-iap

3. WireGuard 터널 구축 (GCP VM ↔ 라즈베리 파이 5)

양쪽 서버에 wireguard를 설치하고(sudo apt install -y wireguard), wg genkey | tee privatekey | wg pubkey > publickey로 각각 키 쌍을 생성한다.

3.1 GCP VM 설정 (/etc/wireguard/wg0.conf)

라즈베리 파이 5는 유동 IP 환경이므로 GCP 쪽에는 Endpoint를 적지 않는다.

[Interface]
Address = 10.0.0.1/24
ListenPort = 51820
PrivateKey = <GCP_VM_개인키>

[Peer]
PublicKey = <라즈베리파이5_공개키>
AllowedIPs = 10.0.0.2/32

3.2 라즈베리 파이 5 설정 (/etc/wireguard/wg0.conf)

집 공유기 NAT 테이블이 만료되지 않도록 PersistentKeepalive = 25를 반드시 넣어준다.

[Interface]
Address = 10.0.0.2/24
PrivateKey = <라즈베리파이5_개인키>

[Peer]
PublicKey = <GCP_VM_공개키>
Endpoint = <GCP_VM_외부_고정_IP>:51820
AllowedIPs = 10.0.0.1/32
PersistentKeepalive = 25

양쪽 모두 서비스를 활성화한다.

sudo systemctl enable --now wg-quick@wg0

ping 10.0.0.1(파이 5에서 실행)과 sudo wg show의 latest handshake가 정상적으로 찍히면 L3 터널 개통은 완료다.

4. GCP VM: Nginx Stream 패스스루 및 PROXY Protocol 설정

GCP VM에서 단순히 TCP 패킷만 토스하면, 라즈베리 파이 5의 Nginx 입장에서는 모든 접속자의 IP가 10.0.0.1(GCP VM)로 찍히는 문제가 발생한다. 이를 해결하기 위해 PROXY Protocol을 활성화하여 패킷 맨 앞에 실제 방문자의 IP 정보를 심어서 보낸다.

먼저 GCP VM에 Nginx와 Stream 모듈을 설치한다.

sudo apt install -y nginx libnginx-mod-stream

/etc/nginx/nginx.conf 파일 최하단(http { ... } 블록의 바깥)에 아래 stream 블록을 추가한다.

stream {
    upstream rpi_http {
        server 10.0.0.2:80;
    }
    upstream rpi_https {
        server 10.0.0.2:443;
    }

    server {
        listen 80;
        proxy_pass rpi_http;
        proxy_protocol on;
    }

    server {
        listen 443;
        proxy_pass rpi_https;
        proxy_protocol on;
    }
}

기존 sites-enabled/default가 80 포트를 잡고 충돌하지 않도록 제거한 뒤 재시작한다.

sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl restart nginx

5. 라즈베리 파이 5 (Podman Nginx): PROXY Protocol 수신 및 실 IP 복원

라즈베리 파이 5에서는 podman-compose를 통해 nginx-proxy 컨테이너를 브릿지 모드로 구동 중이다. 여기서 수정해야 할 포인트는 크게 두 가지다.

5.1 루트 nginx.conf의 http 블록 설정 (실제 IP 복원)

루트 nginx.conf의 http { ... } 블록 내부에 신뢰할 수 있는 프록시 대역(set_real_ip_from)과 헤더 종류(real_ip_header proxy_protocol)를 지정한다.

여기서 중요한 점은, 컨테이너가 Podman 브릿지 네트워크 위에서 돌고 있기 때문에 패킷이 컨테이너로 들어올 때 출발지 IP가 WireGuard의 10.0.0.1이 아닌 Podman 게이트웨이 대역(10.89.0.0/16)으로 SNAT 될 수 있다는 점이다. 따라서 두 대역을 모두 신뢰 목록에 넣어야 한다.

http {
    # ... (생략) ...

    # Real IP 복원 (Podman 내부 브릿지 대역 + WireGuard GCP 대역 모두 허용)
    set_real_ip_from 10.89.0.0/16;
    set_real_ip_from 10.0.0.1;
    real_ip_header proxy_protocol;

    include /etc/nginx/conf.d/*.conf;
}

5.2 각 도메인별 conf.d/*.conf의 listen 지시어 수정

set_real_ip_from은 http 블록에 한 번만 선언하면 하위 서버 블록에 모두 상속되지만, 포트 수신 방식을 결정하는 listen 지시어는 각 server 블록마다 proxy_protocol 키워드를 명시해 주어야 한다.

호스트에 마운트된 conf.d 디렉토리에서 sed를 이용해 모든 80, 443 리스너 끝에 proxy_protocol을 일괄 부착했다.

# 호스트의 conf.d 마운트 폴더에서 실행
sed -i -E 's/(listen\s+(80|443)[^;]*);/\1 proxy_protocol;/g' *.conf

적용 후 각 파일의 listen 구문은 아래와 같은 형태가 된다.

server {
    listen 443 ssl proxy_protocol;
    server_name example.com;
    # ...
}

6. 주요 트러블슈팅 (삽질 기록)

이슈 1: curl: (35) OpenSSL: error:0A00010B:SSL routines::wrong version number

DNS 전파 전 아래 명령어로 강제 라우팅 테스트를 진행했을 때 발생했다.

curl -I --resolve example.com:443:<GCP_고정_IP> https://example.com
  • 원인: GCP VM의 Nginx는 proxy_protocol on; 설정에 따라 패킷 맨 앞에 PROXY TCP4 ...라는 평문 헤더를 붙여서 보냈으나, 라즈베리 파이 5의 Nginx listen 443 ssl; 구문에 proxy_protocol이 빠져 있을 때 발생한다. 라즈베리 파이 Nginx가 이 헤더를 깨진 HTTP 요청으로 판단하고 암호화되지 않은 평문 400 Bad Request 응답을 뱉어버리자, 클라이언트의 OpenSSL이 TLS 버전 에러를 뿜은 것이다.
  • 확인 방법: 컨테이너 내부 로그(podman exec nginx-proxy tail /var/log/nginx/access.log)를 확인해 보면 "PROXY TCP4 10.0.0.1 10.0.0.2 ... 443" 400 로그가 찍혀 있다.
  • 해결: 80과 443 포트를 공유하는 모든 server 블록의 listen 구문 끝에 빠짐없이 proxy_protocol을 추가하여 해결했다.

이슈 2: 호스트에서 nginx.conf를 수정하고 nginx -s reload를 했는데 반영이 안 되는 문제

  • 원인: 리눅스 컨테이너에 디렉토리가 아닌 단일 파일(nginx.conf)을 볼륨 마운트한 상태에서, 호스트 측 에디터(vim, sed -i 등)로 파일을 수정하면 파일이 새로 쓰여지면서 inode 번호가 변경된다. 실행 중인 컨테이너는 여전히 이전 inode(수정 전 파일)를 물고 있기 때문에 nginx -s reload만으로는 변경 사항이 보이지 않고, 컨테이너 내부에서 sed -i를 치면 마운트 제약이나 재시작 시 초기화 문제가 생긴다.
  • 해결: 호스트의 원본 파일을 수정한 뒤에는 reload가 아니라 podman restart nginx-proxy로 컨테이너를 재시작해 새 inode를 마운트하도록 조치했다.

7. 마무리 및 검증

  1. DNS 레코드 변경: 도메인 등록 대행사(Porkbun)에서 루트 도메인(@)의 A 레코드를 집 유동 IP에서 GCP 고정 IP로 변경했다.
  2. 공유기 포트포워딩 삭제: 집 공유기 설정에서 라즈베리 파이 5로 향하던 80, 443 포트포워딩 규칙을 완전히 제거했다.
  3. 실제 IP 로깅 확인: 스마트폰 Wi-Fi를 끄고(LTE/5G 외부망) 접속했을 때, 라즈베리 파이 5의 Nginx access.log에 10.0.0.1이 아닌 스마트폰 통신사의 실제 공인 IP가 정확히 기록되는 것을 확인했다.

Gemini 가 작성함

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다