Docker 격리 규칙에 가려진 iptables ACCEPT — DOCKER-USER 체인

네트워킹28분조회 –

컨테이너는 멀쩡히 뜨지만 매핑 포트로 다른 서버에서 접속하면 전부 타임아웃이었습니다. 보안 인증 요건 때문에 이 환경을 다른 클라우드에 새로 구축하면서 Docker 도 최신 버전으로 올린 직후였습니다.

사용자가 환경을 만들면 서버에 컨테이너가 하나 뜨고, 사용자는 거기에 SSH 로 붙어 작업합니다. 여러 개가 같은 호스트에 뜨니 SSH 포트를 호스트의 임의 포트에 하나씩 매핑해 줘야 합니다.

이 매핑은 Docker 의 포트 게시(-p)가 아니라 iptables 규칙을 직접 넣는 커스텀 로직이 맡고 있어서, 컨테이너를 만들 때마다 규칙을 하나씩 추가합니다. 몇 년째 이 방식으로 돌던 코드고, 이번에 손댄 데도 없습니다.

조용히 버려지는 SYN — 호스트 내부 방화벽

증상이 어디서 갈리는지부터 좁혀 보니, 컨테이너가 뜬 서버에서 자기 자신으로 nc -zv 127.0.0.1 <port> 는 성공하는데 다른 서버에서 같은 포트로 붙으면 타임아웃입니다. 포트는 열려 있고 외부에서 오는 것만 막힌다는 뜻입니다.

바깥에서만 막히니 클라우드 쪽 방화벽이라고 봤습니다. 보안 그룹과 네트워크 ACL 을 해당 포트로 다 열어도 그대로여서, 남는 건 패킷이 실제로 어디까지 오는지 보는 일이었습니다.

In  10.x.x.160.53328 > 10.x.x.201.<port>: Flags [S]   ← SYN 도착
In  (재전송)
→ SYN-ACK 없음, RST 도 없음 (조용히 버려짐)

패킷은 NIC 까지 도착하는데 응답이 없습니다. 포트가 닫혀 있으면 보통 RST 로 거부되니, RST 조차 없이 사라지는 건 누군가 DROP 하고 있다는 신호고, 클라우드 방화벽은 이미 열었으니 남는 건 호스트 안쪽 방화벽입니다.

Docker 격리 규칙이 ACCEPT보다 앞

그래서 호스트의 nftables 규칙을 통째로 열어 봤습니다. 규칙을 넣는 코드를 안 건드렸으니 규칙 자체는 멀쩡할 거라고 생각했지만, filter 테이블의 DOCKER 체인이 이렇게 돼 있었습니다.

chain DOCKER {
    iifname != "docker0" oifname "docker0" drop      ← 카운터가 테스트 횟수와 일치
    ... tcp dport <port> accept                       ← 이 규칙까지 도달을 못 함
}

맨 앞의 DROP 은 Docker 가 자동으로 넣는 컨테이너 격리 규칙입니다. docker0 이 아닌 인터페이스로 들어와서 docker0, 즉 컨테이너 쪽으로 나가는 트래픽을 막습니다. 예전에도 있던 규칙이라 처음에는 눈에 안 들어왔습니다. 그때는 체인 뒤쪽에 있었고 최근 Docker 버전부터 DOCKER 체인 맨 앞으로 올라옵니다.

커스텀 로직은 포트 매핑 ACCEPT 규칙을 iptables -A DOCKER ..., 즉 APPEND 로 맨 끝에 넣고 있었습니다. iptables 와 nftables 는 위에서부터 순서대로 평가하고 먼저 매치되면 거기서 끝나니 맨 앞의 격리 DROP 에 걸린 패킷은 뒤에 있는 ACCEPT 까지 가지도 못합니다. 규칙은 그대로 있지만 아무 일도 안 하고 있었고, DROP 규칙 카운터가 접속 시도 횟수와 정확히 맞아떨어져서 여기가 맞다는 게 분명해졌습니다.

Docker 를 올리기 전에는 이 DROP 이 없었으니 같은 APPEND 규칙이 잘 동작했습니다. 규칙을 넣는 쪽 코드는 그대로지만 앞에 낀 규칙 하나가 생기면서 순서가 뒤집힌 겁니다.

해결 — DOCKER-USER 체인으로 이동

격리 DROP 을 지워 버리면 간단하지만 그건 Docker 가 다시 넣습니다. 그런데 Docker 는 이럴 때 쓰라고 DOCKER-USER 체인을 따로 둡니다. Docker 가 자동으로 덮어쓰거나 순서를 바꾸지 않는 커스터마이징 전용 체인입니다. FORWARD 에서 DOCKER 체인보다 먼저 호출되기 때문에, 격리 DROP 보다 앞에서 평가됩니다.

들어온 패킷격리 DROPDocker 가 맨 앞에 넣음포트 매핑 ACCEPT여기까지 못 옴들어온 패킷DOCKER-USERACCEPT 를 여기로격리 DROP이미 통과해서 안 걸림여기서 끝APPEND 로 넣었을 때DOCKER-USER 로 옮긴 뒤
규칙은 그대로인데 앞에 하나가 끼면서 순서가 뒤집힘

포트 매핑 규칙을 DOCKER 가 아니라 DOCKER-USER 에, 그것도 APPEND 가 아니라 Insert(맨 앞)로 넣도록 바꾸고, 같은 규칙이 여러 번 쌓이지 않게 중복 체크도 같이 붙였습니다.

// 기존: filter 테이블의 DOCKER 체인에 Append
// 변경: DOCKER-USER 체인에 Insert(맨 앞) + 중복 체크
chain := "DOCKER-USER"
if exists, _ := itb.Exists("filter", chain, rule...); !exists {
    itb.Insert("filter", chain, 1, rule...)
}

포트 DNAT 를 하는 nat 테이블 규칙은 이 문제와 무관해서 그대로 DOCKER 체인에 뒀고, 이번 건은 허용과 차단을 정하는 filter 쪽 순서 문제였습니다. 바꾼 뒤에는 ACCEPT 가 격리 DROP 보다 먼저 평가되면서 외부에서 매핑 포트로 바로 붙습니다.

커스텀 규칙 자리 — DOCKER가 아니라 DOCKER-USER

Docker 가 관리하는 DOCKER 체인에 직접 규칙을 APPEND 하면, Docker 가 나중에 그 앞에 넣는 규칙에 가려질 수 있으니, 컨테이너 트래픽에 손대는 커스텀 방화벽 규칙은 DOCKER-USER 에 넣는 게 맞습니다. 거기는 Docker 가 안 건드리고 격리 규칙보다 먼저 평가됩니다.

포트가 외부에서만 안 되면서 RST 도 안 온다는 증상은 방화벽 DROP 을 가리킵니다. 클라우드 보안 그룹까지 다 열고도 그렇다면 호스트 안쪽 방화벽을 봅니다. tcpdump 로 SYN 이 도착하는지, 응답이 RST 인지 무응답인지만 봐도 어느 층에서 막히는지가 절반은 갈립니다.

참고

  1. 불러오는 중