Docker가 UFW 방화벽을 우회하는 이유와 해결 방법
Docker는 iptables를 사용하여 UFW 규칙을 건너뛰고 컨테이너 포트를 외부에 노출합니다. UFW 상태가 거부로 표시되어도 실제로는 포트가 열려 있는 원인과 이를 방지하기 위한 DOCKER-USER 체인 설정 및 포트 바인딩 수정 방법을 상세히 설명합니다.
Docker가 UFW를 우회하는 이유
Docker가 UFW를 우회하는 이유는 공개된 컨테이너 포트가 UFW에서 관리하는 방화벽 규칙을 통과하지 않기 때문이다. docker run -p 8080:80을 실행하면 Docker는 커널의 nat 테이블에 있는 자체 DOCKER 체인에 DNAT(destination network address translation) 규칙을 기록하고, PREROUTING 체인이 이 체인으로 점프한다. 이 규칙은 커널이 패킷의 목적지를 결정하기 전에 각 패킷의 목적지를 컨테이너의 사설 주소로 다시 지정한다. 그런 다음 변경된 패킷은 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이(가) 먼저 실행된다. 여기에서 도달한 규칙은 패킷의 목적지를 다시 쓸 수 있다.PREROUTING이(가) 이동하는DOCKER체인에 있는 게시된 포트용 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으로 들어오는 모든 패킷의 목적지가 172.17.0.2:80, 즉 Docker의 비공개 브리지 네트워크에서 컨테이너에 할당된 주소로 다시 쓰인다. 다시 쓰기가 끝나면 패킷의 목적지는 더 이상 호스트가 아니다. 따라서 라우팅 결정에 따라 패킷은 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로 향하는 패킷에만 일치하기 때문입니다. Docker Engine 28.0.0 이상 버전부터는 Docker가 raw 테이블의 PREROUTING 체인에 규칙을 추가하여, 루프백 인터페이스가 아닌 곳에서 들어오는 해당 루프백 포트용 패킷을 모두 차단합니다. 해당 버전에서는 호스트에서만 포트에 접근할 수 있으며, 다른 곳에서는 접근이 불가능합니다. 28.0.0 이전 버전의 경우, 동일한 L2 세그먼트 내의 호스트(예: 같은 네트워크 스위치에 연결된 장비)가 localhost에 게시된 포트에 접근할 수 있다고 Docker 문서에 명시되어 있으므로, 이 방식을 신뢰하기 전에 docker version을 확인하고 업그레이드하십시오. 바인딩 상태를 확인하려면 다음 명령을 사용합니다.
sudo ss -tlnp | grep 8080출력 결과에서 0.0.0.0:8080나 [::]:8080이 아닌 127.0.0.1:8080이 나타나야 합니다. 그런 다음 다른 장비에서 curl http://your-vps-ip:8080/로 접속했을 때 응답이 없는지 확인하십시오. 공인 IP 주소로 전송된 패킷은 더 이상 Docker에 의해 재작성되지 않으므로 INPUT에 도달하게 되며, 여기서 UFW의 기본 거부 정책과 앞서 설정한 deny 8080/tcp 규칙에 의해 패킷이 거부(reject)되는 대신 조용히 폐기(drop)됩니다. 따라서 curl 명령은 "connection refused"가 아닌 연결 시간 초과(connection timeout) 상태로 멈추게 됩니다.
인터넷에 노출되어야 하는 서비스의 경우, 80번과 443번 포트를 점유하고 호스트 이름에 따라 라우팅하는 리버스 프록시를 하나만 실행하고 다른 포트는 게시하지 마십시오. 이것이 Traefik 리버스 프록시 가이드에서 구축하는 패턴이며, VPS에서의 Nextcloud와 같은 자가 호스팅 앱이 프록시를 통해서만 접근 가능하도록 유지하는 방법입니다. ports: 항목을 선언하는 방법과 나머지 Compose 워크플로우는 Docker Compose 기초 가이드에서 다룹니다.
모든 내부 컨테이너를 루프백에 배치하면 UFW는 호스트 자체가 제공하는 포트를 보호하는 본연의 역할을 다시 수행하게 됩니다. 여기서 규칙 세트를 구축한 다음, 명령어를 순서대로 실행하십시오.
실제 필터링: DOCKER-USER 체인
컨테이너 포트를 네트워크에 게시한 상태로 제한해야 하는 경우가 있다. 예를 들어 데이터베이스 복제 포트에 특정 사무실 주소 하나만 접근하도록 제한할 수 있다. Docker는 이를 위해 DOCKER-USER 체인을 제공한다. 컨테이너로 향하는 모든 패킷은 Docker의 자체 허용 규칙에 도달하기 전에 DOCKER-USER을 통과한다. 이 체인은 사용자가 규칙을 추가하는 용도이며, Docker는 daemon을 다시 시작해도 이 규칙을 삭제하거나 수정하지 않는다.
이 체인은 Docker의 기본 iptables 방화벽 backend에 속한다. Docker Engine 29에는 실험적인 nftables backend가 추가되었다. 이 backend는 firewall-backend daemon 옵션으로 활성화해야 하며, 여기에는 DOCKER-USER 체인이 없다. 사용자 지정 필터링은 별도의 nftables table에 추가하고, forward hook에 base chain을 만들어야 한다. 해당 옵션을 변경하지 않았다면 iptables를 사용 중이므로 아래 내용이 모두 적용된다. 두 도구의 차이는 iptables와 nftables 비교 가이드에서 설명한다.
명령을 실행하기 전에 주의할 점이 있다. 패킷이 DOCKER-USER에 도달할 때는 DNAT 변환이 이미 완료된 상태다. 패킷의 목적지 포트는 게시된 포트인 8080이 아니라 컨테이너 포트인 80이다. 따라서 --dport 8080과 일치하는 규칙은 어떤 패킷도 찾지 못한다. 신뢰할 수 있는 방법은 클라이언트가 원래 접속한 포트를 기준으로 일치시키는 것이다. kernel의 connection tracker가 이 값을 기억한다.
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP이 규칙은 다음과 같이 해석한다. eth0로 들어왔고 원래 목적지 포트가 8080인 connection에 속하는 패킷 중 10.0.0.10에서 전송되지 않은 모든 패킷을 drop한다. --ctdir ORIGINAL match는 규칙을 client-to-container 방향으로 제한한다. 따라서 reply packet이 실수로 차단되지 않는다. eth0을 public interface로 바꾼다. ip route | grep default로 interface 이름을 확인할 수 있다. 이전과 같은 방법으로 테스트한다. 허용된 주소에서 curl을 실행하면 성공하고, 다른 곳에서는 connection이 timeout된다. 이 hang은 뒤에 서비스가 없는 포트가 아니라 DROP 규칙이 정상적으로 동작하고 있다는 표시다. connection refused와 timeout의 차이를 확인하면 필터링된 포트와 단순히 listening 중이 아닌 서비스를 가장 빠르게 구분할 수 있다.
iptables command로 추가한 규칙은 reboot하면 사라진다. UFW가 이미 이 방화벽을 관리하고 있으므로 규칙을 영구적으로 저장할 적절한 위치는 /etc/ufw/after.rules이다. 파일의 끝에 block을 추가한다.
*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는 reload할 때마다, 그리고 boot할 때마다 이 파일을 다시 적용한다. 따라서 컨테이너 필터링이 방화벽의 나머지 설정과 같은 위치에서 관리되며, reboot와 Docker upgrade 후에도 유지된다.
Docker의 iptables 통합을 비활성화하면 안 되는 이유
이 문제에 대한 오래된 답변에서는 { "iptables": false }을 /etc/docker/daemon.json에 설정하라고 제안합니다. 그렇게 하지 마십시오. Docker의 방화벽 규칙은 포트를 공개하는 것보다 훨씬 많은 작업을 수행합니다. masquerade 규칙은 컨테이너가 호스트의 주소를 통해 외부 인터넷에 접속하도록 합니다. 따라서 통합을 비활성화하면 컨테이너가 패키지 미러에 접속하거나 외부 API(application programming interface)를 호출할 수 없습니다. DNAT 규칙과 격리 규칙도 사라집니다. Docker 문서에서는 대체 규칙이 없으면 로컬 네트워크의 호스트가 컨테이너 포트에 접근할 수 있다고 경고합니다. 우회 접근을 막으려다 컨테이너 네트워킹을 중단하게 되며, 해당 규칙을 모두 직접 작성하고 유지 관리해야 합니다. 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 ...를 사용해 규칙을 동일하게 추가하고, 예를 들어 curl -6 http://[2001:db8:2a::1]:8080/처럼 서버의 공용 IPv6 주소를 대상으로 curl을 실행해 외부에서 테스트한다. 이는 Docker의 기본 iptables backend를 사용할 때 적용된다. 실험적인 nftables backend에서는 두 스택 모두에 DOCKER-USER 체인이 없으며, IPv6 필터링은 사용자가 직접 관리하는 ip6 nftables 테이블에서 수행한다.
IPv6가 없는 네트워크에서는 IPv6 클라이언트를 대신 docker-proxy가 처리한다. docker-proxy는 [::]:8080에서 수신 대기하는 일반 사용자 공간 프로세스이며, 트래픽을 IPv4를 통해 컨테이너로 전달한다. 호스트 프로세스로 향하는 트래픽은 실제로 INPUT를 통과하므로 UFW가 해당 경로를 필터링할 수 있다. 단, UFW가 IPv6를 관리하는 경우에만 가능하다. UFW가 IPv6를 관리하는지 여부와 VPS에서 IPv6 보안 공백이 발생하는 다른 경로는 UFW 및 IPv6 가이드에서 설명한다.
loopback에 게시하면 이 문제 전체를 피할 수 있다. -p 127.0.0.1:8080:80는 IPv4 loopback에만 바인딩하므로 IPv6 listener가 없다. 또한 Docker Engine 28.0.0 이상에서는 어느 스택에서도 외부에서 접근할 대상이 없다.
견고하게 유지되는 패턴
- Docker Engine 28.0.0 이상에서는 모든 내부 포트를
127.0.0.1에 게시하여 처음부터 외부에 노출되지 않게 한다(이전 릴리스에서는 동일한 L2 세그먼트의 호스트가 해당 포트에 접근할 수 있다). - 외부 공개는 포트 80과 443을 관리하는 하나의 리버스 프록시에 맡긴다.
- 호스트의 UFW 기본 정책은 deny로 유지하고, SSH와 프록시 포트만 허용한다.
- Docker의 기본 iptables 백엔드에서는 실제로 공개해야 하는 컨테이너 포트를 원래 대상 포트를 기준으로
DOCKER-USER에서 필터링하고, 설정을/etc/ufw/after.rules에 저장한다. - Docker의 iptables 통합은 활성화된 상태로 둔다.
한 번 설정하면 예기치 않은 노출을 방지할 수 있다. ufw status은 호스트를 설명하고, DOCKER-USER는 컨테이너를 설명한다. 실수로 게시되는 항목이 없으며, 다음에 입력하는 docker run -p는 의도한 항목만 정확히 외부에 노출한다.
FAQ
Docker가 UFW에서 포트를 차단해도 컨테이너에 연결할 수 있는 이유는 무엇입니까?
Docker가 nat 테이블의 DOCKER 체인에 DNAT 규칙으로 포트를 게시하기 때문입니다. 이 경로는 PREROUTING에서 도달하며, 필터링이 수행되기 전에 패킷의 목적지를 컨테이너 주소로 다시 씁니다. 그런 다음 패킷은 FORWARD 경로를 통과합니다. UFW 규칙은 INPUT에 있으므로 패킷이 이 체인에 진입하지 않습니다. 방화벽은 전혀 참조되지 않으므로 UFW의 거부 규칙은 게시된 컨테이너 포트에 영향을 주지 않습니다.
UFW가 Docker의 게시된 포트를 차단하게 하려면 어떻게 해야 합니까?
UFW 자체로는 차단할 수 없습니다. UFW 규칙이 잘못된 체인에 있기 때문입니다. Docker Engine 28.0.0 이상에서는 포트를 127.0.0.1:8080:80로 게시하여 호스트만 해당 포트에 연결할 수 있게 하거나, conntrack을 통해 원래 목적지 포트와 일치하는 iptables 규칙을 DOCKER-USER 체인에 추가해 필터링해야 합니다. 재부팅과 ufw reload 후에도 유지되도록 해당 규칙을 /etc/ufw/after.rules에 저장합니다.
Docker의 daemon.json에서 "iptables": false를 설정해야 합니까?
아니요. 이 설정은 Docker의 방화벽 및 NAT 규칙 대부분을 제거하므로, 우회 문제보다 훨씬 많은 기능이 중단됩니다. masquerade 규칙이 사라져 컨테이너가 외부 인터넷에 연결할 수 없게 됩니다. 또한 Docker는 대체 규칙이 없으면 로컬 네트워크의 호스트가 컨테이너 포트에 접근할 수 있다고 경고합니다. 대신 loopback 게시와 DOCKER-USER 체인을 사용합니다. 이렇게 하면 컨테이너 네트워킹을 중단하지 않고 노출 문제를 해결할 수 있습니다.
Docker는 IPv6에서도 UFW를 우회합니까?
Docker Engine 27 이상에서는 ip6tables 관리가 기본적으로 활성화되어 있습니다. 따라서 IPv6가 활성화된 Docker 네트워크에서 게시한 포트는 IPv4와 동일하게 UFW를 우회하도록 다시 작성됩니다. Docker의 기본 iptables backend에서는 동일한 DOCKER-USER 규칙을 ip6tables로 복제해야 합니다. Engine 29의 experimental nftables backend에는 DOCKER-USER 체인이 없으므로, 해당 필터링은 자체 nftables 테이블에 구성해야 합니다. IPv6가 없는 네트워크에서는 docker-proxy 프로세스가 [::]에서 수신 대기하며, 이 트래픽은 INPUT을 통과합니다. UFW가 IPv6를 관리하는 경우 여기에서 필터링할 수 있습니다. 127.0.0.1에 게시하면 두 경우를 모두 피할 수 있습니다. IPv6에서 수신 대기하는 항목이 전혀 없기 때문입니다.