Docker가 UFW 방화벽을 우회하는 이유와 해결 방법
Docker는 iptables를 사용하여 UFW 규칙을 건너뛰고 컨테이너 포트를 외부에 노출합니다. UFW 상태가 거부로 표시되어도 실제로는 포트가 열려 있는 원인과 이를 방지하기 위한 DOCKER-USER 체인 설정 및 포트 바인딩 수정 방법을 상세히 설명합니다.
Docker가 UFW를 우회하는 이유
Docker가 게시된 컨테이너 포트를 UFW가 관리하는 방화벽 규칙을 거치지 않게 처리하므로 UFW를 우회하게 됩니다. docker run -p 8080:80을 실행하면 Docker는 커널의 nat 테이블에 있는 PREROUTING 체인에 DNAT(대상 네트워크 주소 변환) 규칙을 작성합니다. 이 규칙은 커널이 패킷의 목적지를 결정하기 전에 패킷의 목적지를 컨테이너의 사설 주소로 다시 씁니다. 재작성된 패킷은 Docker가 제어하는 FORWARD 체인을 통해 컨테이너로 전달됩니다. UFW의 규칙은 INPUT 체인에 존재하며, 패킷은 이 체인에 진입하지 않습니다. 따라서 ufw status은 기본 거부 상태를 보여주고 sudo ufw deny 8080는 성공을 보고하지만, 포트 8080은 여전히 전체 인터넷에 응답합니다.
이는 Docker의 버그가 아니며 UFW가 고장 난 것도 아닙니다. 두 도구 모두 동일한 커널 방화벽을 프로그래밍합니다. Docker의 규칙이 패킷 경로의 더 앞선 지점에서 작동하므로 UFW는 호출되지 않습니다. 이 가이드는 우회 현상을 시연하고 메커니즘을 설명한 뒤, 작동 가능한 두 가지 해결책인 127.0.0.1에 포트 게시하기와 DOCKER-USER 체인에서 필터링하기를 다룹니다. UFW 자체가 생소하다면 UFW 방화벽 기초 가이드를 통해 먼저 설정하십시오. 기본 거부 방화벽은 서버의 다른 모든 설정을 위한 올바른 기반입니다.
자신의 서버에서 우회 경로 확인하기
수신 트래픽에 대해 기본 거부(default deny) 정책이 적용된 UFW가 활성화된 VPS에서 시작합니다. 포트가 게시된 웹 컨테이너를 실행합니다.
sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpineufw status verbose는 Default: deny (incoming), allow (outgoing)을 보여주며 포트 8080에 대한 규칙이 없습니다. 방화벽 자체 보고서에 따르면 해당 포트는 닫혀 있습니다. 이제 서버 자체가 아닌 다른 머신에서 테스트합니다.
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OK컨테이너가 응답합니다. 명시적인 거부 규칙을 추가하고 다시 테스트합니다.
sudo ufw deny 8080/tcp포트는 여전히 응답합니다. 거부 규칙이 패킷이 도달하지 않는 체인에 위치하기 때문입니다. UFW는 실패하지 않았습니다. 애초에 UFW를 거치지 않았기 때문입니다. 이것이 바로 이 문제가 잘 드러나지 않는 이유입니다. 어디에도 오류가 출력되지 않고, 배포는 정상적으로 작동하며, 방화벽 상태 출력은 마치 안전하게 잠긴 서버처럼 보이기 때문입니다.
메커니즘: PREROUTING은 INPUT보다 먼저 실행됩니다
커널은 고정된 순서로 수신 패킷을 처리하며, 모든 문제는 바로 이 순서에서 발생합니다.
PREROUTING가 가장 먼저 실행됩니다. 이곳의 규칙은 패킷의 목적지를 다시 쓸 수 있으며, 포트를 공개할 때 Docker가 추가하는 규칙이 정확히 이 작업을 수행합니다.- 그다음 라우팅 결정이 이루어집니다. 호스트 자체를 목적지로 하는 패킷은
INPUT체인으로 이동합니다. 다른 머신을 목적지로 하는 패킷은FORWARD체인으로 이동합니다. - UFW 규칙은
INPUT에 위치합니다. Docker 규칙은FORWARD에 위치합니다.
방금 시작한 컨테이너에 대한 Docker의 규칙을 확인해 보십시오:
sudo iptables -t nat -L DOCKER -nChain DOCKER (2 references)
target prot opt source destination
RETURN 0 -- 0.0.0.0/0 0.0.0.0/0
DNAT 6 -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80DNAT 줄이 전체 상황을 설명합니다. 포트 8080으로 도착하는 모든 패킷은 목적지가 Docker의 사설 브리지 네트워크상에 있는 컨테이너 주소인 172.17.0.2:80으로 재작성됩니다. 재작성 후 패킷은 더 이상 호스트를 목적지로 하지 않으므로, 라우팅 결정에 따라 FORWARD 경로로 전달됩니다. 이 경로에는 Docker가 이미 자체 네트워크로 트래픽을 허용하는 규칙을 추가해 두었습니다. 사용자의 deny 8080/tcp 규칙은 INPUT에서 오지 않을 패킷을 기다리고 있는 셈입니다.
Ubuntu 24.04에서 iptables 명령어는 nftables의 프런트엔드 역할을 하지만, 체인 순서와 결과는 동일합니다. UFW와 Docker는 모두 동일한 커널 패킷 파이프라인에 규칙을 작성하며, Docker의 진입점이 더 앞서 있습니다. 이는 UFW에만 국한된 문제가 아닙니다. Rocky 또는 AlmaLinux VPS의 firewalld 역시 동일한 파이프라인 지점에서 필터링을 수행하며, 동일한 DNAT 규칙에 의해 우회됩니다. 따라서 아래의 해결 방법은 해당 환경에서도 동일하게 적용됩니다.
일상적인 해결책: 127.0.0.1에 포트 게시하기
대부분의 컨테이너는 애초에 공개될 필요가 없습니다. 데이터베이스, 리버스 프록시 뒤의 애플리케이션 서버, 관리자 패널, 메트릭 엔드포인트 등은 그 어느 것도 인터넷에 직접 응답해서는 안 됩니다. 이들을 루프백 주소에 게시하십시오:
docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpine또는 Compose 파일에서는 다음과 같이 설정합니다:
services:
web:
image: nginx:1.29-alpine
ports:
- "127.0.0.1:8080:80"이 방식이 효과적인 이유는 Docker의 DNAT 규칙이 이제 127.0.0.1로 향하는 패킷에만 일치하기 때문입니다. 인터넷에서 오는 패킷은 해당 목적지를 가질 수 없으므로, 커널은 방화벽 규칙이 실행되기 전에 패킷을 폐기합니다. 포트는 호스트에서만 접근할 수 있으며, 그 외의 곳에서는 접근할 수 없습니다. 바인딩을 확인하십시오:
sudo ss -tlnp | grep 8080출력 결과에서 0.0.0.0:8080이나 [::]:8080이 아닌 127.0.0.1:8080이 표시되어야 합니다. 그런 다음 다른 머신에서 curl http://your-vps-ip:8080/로의 연결이 거부되는지 확인하십시오.
인터넷에 노출되어야 하는 서비스의 경우, 80번과 443번 포트를 점유하고 호스트 이름에 따라 라우팅하는 리버스 프록시를 하나만 실행하고, 그 외에는 아무것도 게시하지 마십시오. 이것이 Traefik 리버스 프록시 가이드가 구축하는 패턴이며, VPS에서의 Nextcloud와 같은 자체 호스팅 앱이 프록시를 통하지 않고는 접근 불가능하게 유지되는 방식입니다. ports: 항목을 선언하는 방법과 나머지 Compose 워크플로우는 Docker Compose 기초 가이드에서 다룹니다.
모든 내부 컨테이너를 루프백에 두면, UFW는 호스트 자체가 제공하는 포트를 보호하는 본연의 역할을 다시 수행하게 됩니다. 여기서 규칙 세트를 구축한 다음, 순서대로 명령을 실행하십시오:
실제 필터링: DOCKER-USER 체인
컨테이너 포트를 네트워크에 공개하되 제한해야 하는 경우가 있습니다. 예를 들어 특정 사무실 IP에서만 접근 가능한 데이터베이스 복제 포트가 이에 해당합니다. 이를 위해 Docker는 DOCKER-USER 체인을 제공합니다. 컨테이너로 향하는 모든 패킷은 Docker의 자체 허용 규칙 이전에 DOCKER-USER를 통과하며, Docker는 이 체인에 규칙을 작성하지 않습니다. 이 체인은 사용자를 위한 공간이며, Docker는 데몬이 재시작되어도 이 체인의 내용을 변경하지 않습니다.
명령어를 실행하기 전 주의할 점이 있습니다. 패킷이 DOCKER-USER에 도달할 시점에는 이미 DNAT 재작성이 완료된 상태입니다. 따라서 패킷의 목적지 포트는 게시된 포트(8080)가 아니라 컨테이너 포트(예시의 경우 80)입니다. 그러므로 --dport 8080와 일치하는 규칙은 아무것도 걸러내지 못합니다. 커널의 연결 추적기가 기억하고 있는 클라이언트가 원래 접속을 시도한 포트를 일치시키는 것이 확실한 방법입니다.
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP이 규칙은 다음과 같이 해석됩니다. eth0를 통해 들어온 패킷 중 원래 목적지 포트가 8080인 연결에 속하는 경우, 10.0.0.10에서 보낸 것이 아니라면 모두 차단합니다. --ctdir ORIGINAL 일치 조건은 규칙을 클라이언트에서 컨테이너로 향하는 방향으로 제한하므로, 응답 패킷이 실수로 차단되지 않습니다. eth0은 공용 인터페이스로, ip route | grep default는 해당 인터페이스의 이름으로 대체하십시오. 이전과 동일한 방식으로 테스트합니다. 허용된 주소에서의 curl은 성공하며, 그 외의 주소에서는 연결 시간 초과가 발생합니다. 이 응답 지연은 포트 뒤에 아무것도 없는 상태가 아니라 DROP 규칙이 정상적으로 작동하고 있다는 신호이며, 거부된 연결과 시간 초과된 연결의 차이를 확인하는 것이 필터링된 포트와 단순히 수신 대기 중인 서비스가 없는 상태를 구분하는 가장 빠른 방법입니다.
iptables 명령어로 추가한 규칙은 재부팅 시 사라집니다. UFW가 이미 방화벽을 관리하고 있으므로, 규칙을 영구적으로 유지하려면 /etc/ufw/after.rules에 작성하는 것이 가장 깔끔합니다. 파일 끝에 다음 블록을 추가하십시오.
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP
COMMIT그런 다음 sudo ufw reload를 실행하십시오. UFW는 재로드나 부팅 시마다 해당 파일을 다시 적용하므로, 이제 컨테이너 필터링 규칙이 나머지 방화벽 설정과 같은 곳에서 관리되며 재부팅이나 Docker 업그레이드 후에도 유지됩니다.
Docker의 iptables 통합 기능을 비활성화하면 안 되는 이유
이 문제에 대한 오래된 답변들은 { "iptables": false }를 /etc/docker/daemon.json에 설정하라고 제안합니다. 그렇게 하지 마십시오. Docker의 방화벽 규칙은 단순히 포트를 게시하는 것 이상의 역할을 수행합니다. 마스커레이드(masquerade) 규칙은 컨테이너가 호스트의 주소를 통해 외부 인터넷에 접속할 수 있게 해줍니다. 따라서 이 통합 기능을 끄면 컨테이너는 이미지를 가져오거나, 패키지 미러에 접근하거나, 외부 API(application programming interface)를 호출할 수 없게 됩니다. DNAT 규칙은 -p이 정상적으로 작동하게 만드는 핵심 요소이므로, 이 기능을 끄면 게시된 포트가 완전히 작동을 멈춥니다. 서로 다른 Compose 네트워크를 분리하는 격리 규칙 또한 사라집니다. 우회 설정을 해결하려다 컨테이너 네트워킹을 망가뜨리게 되며, 그 모든 규칙을 직접 작성하고 유지 관리해야 하는 부담을 떠안게 됩니다. Docker 공식 문서에서도 이 설정은 정확히 그러한 작업을 수행하려는 사용자를 위한 것이라고 설명합니다. DOCKER-USER 체인은 바로 이러한 스위치가 필요 없도록 하기 위해 존재합니다.
동일한 문제의 IPv6 측면
먼저 공개된 포트가 IPv6에서 어떻게 보이는지 확인하십시오:
sudo ss -tlnp | grep 8080Docker Engine 27부터 Docker는 기본적으로 ip6tables를 관리합니다. IPv6가 활성화된 Docker 네트워크에서 공개된 포트는 IPv6 테이블에서도 동일한 DNAT 처리를 받으므로, 동일한 우회 문제가 존재하며 동일한 해결책이 적용됩니다. DOCKER-USER 체인 역시 ip6tables에 존재하므로, sudo ip6tables -I DOCKER-USER ...을 사용하여 규칙을 미러링하고 서버의 공인 IPv6 주소를 대상으로 curl을 사용하여 외부에서 테스트하십시오. 예시는 curl -6 http://[2001:db8:2a::1]:8080/과 같습니다.
IPv6가 없는 네트워크에서는 IPv6 클라이언트가 docker-proxy에 의해 처리됩니다. 이는 [::]:8080에서 대기하며 트래픽을 IPv4를 통해 컨테이너로 전달하는 일반 사용자 공간 프로세스입니다. 호스트 프로세스로 향하는 트래픽은 INPUT를 통과하므로 UFW가 해당 경로를 필터링할 수 있지만, 이는 UFW가 IPv6를 관리하고 있을 때만 가능합니다. UFW가 IPv6를 관리하는지 여부와 VPS에서 IPv6 보안 공백이 발생하는 다른 원인들은 UFW 및 IPv6 가이드에서 다룹니다.
루프백에 게시하면 이 모든 문제를 피할 수 있습니다. -p 127.0.0.1:8080:80는 IPv4 루프백에만 바인딩되므로 IPv6 리스너가 존재하지 않으며, 어느 스택에서도 외부에서 접근할 수 없습니다.
유지되는 패턴
- 모든 내부 포트를
127.0.0.1에 게시하여 애초에 노출되지 않도록 합니다. - 외부 공개는 80번과 443번 포트를 점유하는 리버스 프록시 하나가 담당하게 합니다.
- 호스트의 UFW 기본 정책을 거부(deny)로 유지하고, SSH와 프록시 포트만 허용합니다.
- 실제 공개가 필요한 컨테이너 포트는
DOCKER-USER에서 필터링하며, 원래 목적지 포트를 기준으로 매칭하고/etc/ufw/after.rules에 영구적으로 저장합니다. - Docker의 iptables 통합 기능을 켭니다.
한 번 설정하면 예기치 않은 상황이 사라집니다. ufw status는 호스트를 설명하고, DOCKER-USER은 컨테이너를 설명합니다. 실수로 포트가 게시되는 일은 없으며, 다음에 입력하는 docker run -p은 의도한 대로 정확히 노출됩니다.
FAQ
UFW가 포트를 차단하는데 왜 Docker 컨테이너에 접속할 수 있습니까?
Docker는 PREROUTING 체인에 DNAT 규칙을 사용하여 포트를 게시하기 때문입니다. 이 규칙은 필터링이 발생하기 전에 패킷의 목적지를 컨테이너 주소로 재작성합니다. 패킷은 FORWARD 경로를 따라 이동하며, UFW 규칙은 패킷이 도달하지 않는 INPUT 체인에 위치합니다. 방화벽은 확인 과정을 거치지 않으므로 거부 규칙이 게시된 컨테이너 포트에 영향을 주지 못합니다.
UFW가 Docker의 게시된 포트를 차단하게 하려면 어떻게 해야 합니까?
UFW 규칙은 잘못된 체인에 있으므로 UFW 자체적으로는 불가능합니다. 포트를 127.0.0.1:8080:80로 게시하여 호스트에서만 접근할 수 있도록 노출을 중단하거나, conntrack을 통해 원래 목적지 포트와 일치하는 iptables 규칙을 사용하여 DOCKER-USER 체인에서 필터링하십시오. 해당 규칙이 재부팅 후에도 유지되도록 /etc/ufw/after.rules에 저장하고 ufw reload를 수행하십시오.
Docker의 daemon.json에서 "iptables": false로 설정해야 합니까?
아닙니다. 해당 설정은 Docker의 모든 방화벽 및 NAT 규칙을 제거하므로 우회 문제보다 훨씬 더 많은 기능을 손상시킵니다. masquerade 규칙이 사라져 컨테이너의 외부 인터넷 연결이 끊기고, DNAT 규칙이 사라져 게시된 포트가 작동하지 않게 됩니다. 대신 루프백 게시와 DOCKER-USER 체인을 사용하십시오. 이는 컨테이너 네트워킹을 손상시키지 않으면서 노출 문제를 해결합니다.
Docker가 IPv6에서도 UFW를 우회합니까?
Docker Engine 27 이상에서는 ip6tables 관리가 기본적으로 활성화되어 있습니다. 따라서 IPv6가 활성화된 Docker 네트워크에 게시된 포트는 IPv4와 동일하게 UFW를 우회하여 재작성되므로, ip6tables을 사용하여 DOCKER-USER 규칙을 동일하게 적용해야 합니다. IPv6가 없는 네트워크에서는 docker-proxy 프로세스가 [::]에서 대기하며, 해당 트래픽은 UFW가 IPv6를 관리하는 경우 INPUT를 통과하므로 UFW가 필터링할 수 있습니다. 127.0.0.1에 게시하면 IPv6에서 대기하는 프로세스가 없으므로 두 경우 모두 방지할 수 있습니다.