본문 바로가기
🐧Linux

Docker는 방화벽을 무시한다 - ufw/firewalld가 외부 노출을 막지 못하는 이유

by 캔 2026. 9. 26.

오늘 있었던 일

홈서버에서 스프링(Spring) 배치/스케줄러 앱을 도커 컴포즈(Docker Compose)로 띄워놓고 쓰고 있다. 이 앱은 8080 포트로 열려 있고, 필요할 때 curl로 특정 엔드포인트를 호출해서 배치 작업을 트리거하는 구조다.

 

서버는 ufw로 방화벽을 켜놓은 상태였고, 8080 포트는 ufw 허용 목록에 올려둔 적이 없었다. 그래서 당연히 외부에서는 접근이 불가능하다고 생각하고 있었다.

 

그런데 오늘 우연히 톰캣(Tomcat) 로그에서 이런 에러를 발견했다.

 

java.lang.IllegalArgumentException: Invalid character found in method name
[0x16 0x03 0x01 ...]. HTTP method names must be tokens

 

처음엔 이게 뭔가 싶었는데, 뜯어보니 0x16 0x03 0x01은 TLS 핸드셰이크(Handshake) 패킷의 시작 바이트였다. 즉 누군가 이 포트에 HTTPS로 접속을 시도했고, 그 암호화된 바이너리가 그대로 HTTP 파서에 들어가버리면서 난 에러였다.

 

문제는 여기서부터였다. 도대체 누가 이 포트에 접속을 시도할 수 있었던 걸까 싶어서 docker ps를 다시 열어봤다.

 

app-service   0.0.0.0:8080->8080/tcp, [::]:8080->8080/tcp
db-service    0.0.0.0:5432->5432/tcp, [::]:5432->5432/tcp

 

스프링 앱뿐 아니라 DB까지 0.0.0.0으로 퍼블리시되어 있는 건 원래 알고 있던 상태였다. 다만 8080과 5432를 ufw 허용 목록에 올려둔 적이 없으니, 방화벽이 알아서 외부 접근을 막아줄 거라고 막연히 생각하고 있었던 거다. 그런데 실제로는 그 가정 자체가 틀렸던 것이다. ufw를 켜놨는데도 이 포트들은 처음부터 외부에 그대로 열려 있었던 것이다.

 

왜 ufw가 도커 포트를 막지 못했나

결론부터 말하면, ufw가 감시하는 경로와 도커(Docker)가 만든 트래픽 경로 자체가 다르기 때문이다. 이걸 이해하려면 리눅스 커널의 넷필터(netfilter)가 패킷을 어떻게 분류하는지부터 봐야 한다.

 

넷필터에는 대표적으로 다섯 개의 체인이 있다.

 

패킷 도착 → PREROUTING → 라우팅 결정 → FORWARDING → POSTROUTING → 실제 전송
                               │                                                      │
                               │                                               2차 라우팅
                               │                                                      │
                           INPUT                                              OUTPUT
                               │                                                      │
                               └─────────┬─────────┘
                                                           │
                                                  로컬 프로세스

 

  • INPUT: 목적지가 이 호스트 자신인 패킷이 지나가는 체인
  • FORWARD: 목적지가 이 호스트가 아닌 다른 곳(다른 네트워크 네임스페이스 포함)인 패킷이 지나가는 체인

 

ufw와 firewalld는 기본적으로 INPUT 체인만 관리한다. 즉 "호스트 자신에게 오는 트래픽"만 제어할 수 있다는 뜻이다.

 

문제는 도커 컨테이너로 가는 트래픽이 INPUT이 아니라 FORWARD를 탄다는 데 있다. 그 이유는 도커가 PREROUTING 단계에서 미리 손을 써두기 때문이다.

 

도커는 데몬이 뜰 때 iptables의 nat 테이블에 자기 전용 체인(DOCKER)을 만들어두고, PREROUTING 체인 맨 앞에 그 체인으로 점프하는 규칙을 끼워넣는다. 그리고 컨테이너 포트를 퍼블리시할 때마다 이 DOCKER 체인 안에 DNAT(Destination NAT) 규칙을 하나씩 추가해서, 패킷의 destination IP 주소를 호스트 IP에서 컨테이너 내부 IP로 직접 변경한다.

 

PREROUTING → DOCKER 체인 진입 → DNAT 적용
(목적지 IP: 호스트 공인IP → 컨테이너 내부IP, 예: 172.17.0.2)

 

이 시점이 라우팅 결정보다 먼저 일어난다는 게 핵심이다. 라우팅 결정 단계는 단순하게 "이 패킷의 목적지 주소가 내(호스트) 인터페이스에 할당된 IP인가"만 본다. 그런데 도커가 이미 목적지를 컨테이너의 내부 IP로 바꿔놨으니, 커널은 "이건 내 IP가 아니네, 다른 곳으로 전달해야겠다"고 판단하고 FORWARD로 보내버린다.

 

정리하면 흐름은 이렇다.

 

  1. 외부에서 8080으로 패킷 도착
  2. PREROUTING 단계에서 도커의 DNAT 규칙이 적용되어 목적지가 컨테이너 내부 IP로 변경
  3. 라우팅 결정: 목적지가 호스트 자신의 IP가 아니므로 FORWARD 체인으로 분류
  4. 도커가 FORWARD 체인(DOCKER-USER, DOCKER 등)에 이미 ACCEPT 규칙을 심어뒀으므로 그대로 통과
  5. ufw/firewalld는 INPUT만 감시하고 있었으므로 이 패킷을 볼 기회 자체가 없었음

 

즉 ufw가 도커 포트를 뚫린 게 아니라, 애초에 그 트래픽이 ufw의 관할 범위 밖(FORWARD)을 지나갔던 것이다. firewalld도 사정은 똑같다. firewalld에는 StrictForwardPorts라는 옵션이 있어서 이 동작을 막을 수 있지만, 기본값이 no로 되어 있어서 도커가 만든 포트포워딩을 암묵적으로 허용하게 설계되어 있다.

 

왜 이렇게 설계했을까

이건 도커 공식 이슈 트래커에서도 오랫동안 논쟁이 있었던 부분이다. 커뮤니티에서는 이걸 보안 결함(security flaw)이라고 부를 정도였지만, 도커 팀은 이 동작을 기본값으로 유지해왔다.

 

이유를 추려보면 다음과 같다.

 

  • 컨테이너는 뜨고 내릴 때마다 내부 IP가 계속 바뀌기 때문에, 외부 방화벽 관리자와 매번 동기화하는 구조는 복잡도가 크게 늘어난다
  • 도커의 초기 설계 철학 자체가 "사용자가 iptables를 몰라도 포트 매핑만 지정하면 알아서 되게 하자"는 쪽이었고, 그러려면 네트워킹 전체를 도커가 직접 소유하고 제어해야 했다
  • 도커가 처음 나온 시점에는 firewalld의 도커 연동 기능 같은 게 존재하지도 않았고, iptables를 직접 다루는 게 사실상 유일한 표준이었다

 

결국 "외부 방화벽과 매끄럽게 통합되는 정교함"보다 "컨테이너 네트워킹이 별도 설정 없이 잘 굴러가는 것"을 우선시한 결과가, 방화벽 툴과 도커가 서로의 존재를 모른 채 각자 iptables를 건드리는 지금의 구조로 이어진 셈이다.

 

해결 방법

가장 현실적인 해결책은 방화벽 설정을 손보는 게 아니라, 애초에 외부에 노출될 필요 없는 포트를 루프백(127.0.0.1)에만 바인딩하는 것이다.

 

services:
  app-service:
    ports:
      - "127.0.0.1:8080:8080"
  db-service:
    ports:
      - "127.0.0.1:5432:5432"

 

이렇게 IP를 명시하면 애초에 외부 인터페이스에서 리스닝 자체를 하지 않기 때문에, ufw 설정과 무관하게 원천적으로 외부 접근이 차단된다.

 

정리하면 포트 용도에 따라 이렇게 나눠서 생각하면 된다.

 

  • 다른 컨테이너에서만 접근하면 되는 경우(내부 DB, 캐시 등): ports 항목 자체를 생략, 같은 컴포즈 네트워크 안에서 서비스명으로 접근
  • 호스트 자신에서만 접근하면 되는 경우(SSH 터널로 붙는 DB, 로컬 트리거용 앱): 127.0.0.1:포트:포트
  • 진짜 외부에 공개해야 하는 경우(nginx의 80/443 등): 0.0.0.0:포트:포트

 

마무리

방화벽을 켜뒀다는 사실 자체가 안전을 보장해주지 않는다는 걸 이번에 제대로 체감했다. 도커처럼 커널 레벨에서 직접 iptables를 조작하는 도구를 쓰고 있다면, ufw나 firewalld의 allow/deny 목록만 보고 안심할 게 아니라 실제로 docker ps로 어떤 IP에 포트가 바인딩되어 있는지 직접 확인하는 습관이 필요하다.

 

TLS 에러 로그 하나 덕분에 DB까지 외부에 노출되어 있었다는 걸 알게 됐으니, 결과적으로는 운이 좋았던 셈이다.