Table of contents
Open Table of contents
1. MTU — 어느 계층의 개념인가
MTU는 네트워크 인터페이스가 한 번에 보낼 수 있는 최대 패킷 크기다. 이더넷 기본값은 1500바이트.
MTU는 TCP 설정이 아니다. L2(데이터 링크 계층)에 속하는, 인터페이스에 붙어있는 물리적/논리적 제한이다. 각 계층이 MTU와 관련해서 하는 일은 다르다.
L2 (이더넷) → MTU: 인터페이스의 최대 전송 크기. 이더넷 기본값 1500
L3 (IP) → MTU 초과 시 분할하거나, DF bit가 있으면 드롭
L4 (TCP) → MSS: MTU에서 헤더를 빼고 실제 데이터 크기를 협상
즉 MTU는 TCP/UDP 구분 없이 인터페이스를 통과하는 모든 패킷에 영향을 준다. TCP만 MSS라는 메커니즘으로 미리 크기를 맞추는 것이고, 나머지 프로토콜은 그런 장치가 없어서 L3에서 처리된다.
┌─────────────────── MTU (1500바이트) ───────────────────┐
│ IP 헤더 (20B) │ TCP 헤더 (20B) │ 페이로드 (최대 1460B) │
└──────────────────────────────────────────────────────┘
TCP는 연결을 시작할 때 자기 인터페이스의 MTU를 보고 MSS를 정한다. MTU가 1500이면 MSS는 1460, MTU가 1450이면 MSS는 1410이다. 양쪽이 SYN 패킷에서 MSS를 교환하고, 둘 중 작은 값을 쓴다.
중요한 건 이 협상이 양 끝단의 MTU만 보고 결정된다는 점이다. 중간 경로의 MTU는 고려하지 않는다.
2. 중간 경로의 MTU가 더 작으면
양 끝단이 MTU 1500으로 MSS 1460을 협상했는데, 중간에 MTU 1450인 구간이 있으면 어떻게 될까.
송신자 수신자
MTU: 1500 MTU: 1500
MSS: 1460 MSS: 1460
│ │
│ ┌─────────────────────┐ │
│─────────│ 중간 경로 MTU: 1450 │─────────────────────│
│ └─────────────────────┘ │
│ │
│ 1500B 패킷 ──→ 여기서 막힘 │
│ 1450B 패킷 ──→ ──→ ──→ ──→ 통과 │
경로 전체가 같은 MTU를 쓴다는 보장은 없다. VPN, 오버레이 네트워크, 터널 등 캡슐화가 개입된 구간은 헤더 오버헤드만큼 MTU가 줄어든다. 양 끝단은 이 사실을 모른 채 자기 MTU 기준으로 MSS를 협상한다.
현대 TCP는 IP 패킷에 DF(Don’t Fragment) 비트를 설정한다. 원래 IP에는 큰 패킷을 중간에서 쪼개서 보내는 분할(fragmentation) 기능이 있지만, 조각 하나만 유실돼도 전체를 재전송해야 하는 등 비효율이 크다. 그래서 “쪼개지 말고, 안 맞으면 알려줘. 내가 처음부터 크기를 맞출게”라는 전략을 쓴다.
DF가 설정된 패킷이 중간 경로의 MTU를 초과하면, 그 장비는 패킷을 버리고 ICMP “Fragmentation Needed” 메시지를 송신자에게 보낸다. 송신자는 이걸 받고 패킷 크기를 줄인다. 이것이 Path MTU Discovery(PMTUD)다.
일반적으로는 이 자가 복구가 동작한다. 중간 경로의 MTU가 작아도, ICMP가 정상적으로 전달되면 송신자가 크기를 줄여서 해결된다. MTU가 안 맞는 것 자체는 흔하고, 보통은 문제가 되지 않는다.
왜 "더 크게 보내도 된다"는 메시지는 없는가
줄이라는 메시지가 있으면, 반대로 늘려도 된다는 메시지도 있어야 하지 않을까?
없다. 네트워크는 “이 크기는 너무 크다”는 실패 정보는 줄 수 있지만, “더 크게 보내도 된다”는 성공 정보를 신뢰성 있게 전달하지 않는다. RFC 1191에 따르면, 그런 메시지는 오래된 경로 정보일 수도 있고 공격자가 위조한 것일 수도 있기 때문이다. 늘리기는 ICMP가 아니라 직접 더 큰 패킷을 보내보고, 에러가 안 오면 성공으로 간주하는 방식이다. 침묵이 곧 성공 신호.
작은 패킷은 왜 통과하는가
작은 패킷이 성공하는 이유는 누군가가 자동으로 크기를 맞춰주기 때문이 아니다. 경로 중 가장 작은 MTU보다 작으면 그 패킷은 그냥 통과한다. 반대로 그보다 큰 패킷만 중간에서 드롭된다. 수신 서버가 큰 패킷을 못 읽는 게 아니라, 큰 패킷이 수신 서버까지 도달하지 못하는 것이다.
ICMP마저 도착하지 않으면 — MTU 블랙홀
PMTUD는 ICMP 응답에 의존한다. 그런데 “이건 안 돼”조차 도착하지 않는 경우가 있다. 방화벽이 ICMP를 차단하거나, 오버레이 네트워크에서 캡슐화 계층에 묻히면 송신자는 패킷이 왜 안 가는지 모른다. 같은 크기로 계속 재전송하다가 TCP가 포기한다.
이것을 MTU 블랙홀이라고 부른다. 밖에서 보면 “작은 요청은 되는데 큰 전송만 오래 멈췄다가 죽는다”로 보인다.
3. 실험 — MTU 경계에서 패킷이 사라지는 것을 직접 확인
이론으로만 설명하면 와닿지 않는다. 직접 MTU 불일치를 만들어서 확인해봤다.
구성
네트워크 네임스페이스 2개를 veth pair로 연결하고, 한쪽만 MTU를 1450으로 낮춘다.
# 네임스페이스 생성
ip netns add sender
ip netns add receiver
# veth pair 연결
ip link add veth-s type veth peer name veth-r
ip link set veth-s netns sender
ip link set veth-r netns receiver
# IP 할당
ip netns exec sender ip addr add 10.0.0.1/24 dev veth-s
ip netns exec sender ip link set veth-s up
ip netns exec receiver ip addr add 10.0.0.2/24 dev veth-r
ip netns exec receiver ip link set veth-r up
# receiver 쪽 MTU만 1450으로 낮춤
ip netns exec receiver ip link set veth-r mtu 1450
이 상태에서 sender의 MTU는 1500, receiver의 MTU는 1450이다.
sender (veth-s) receiver (veth-r)
MTU: 1500 ←→ MTU: 1450
10.0.0.1 10.0.0.2
결과
ping의 -s 옵션으로 페이로드 크기를 지정하고, -M do로 DF 비트를 강제한다.
# 작은 패킷 (100바이트 페이로드 → 128바이트 IP 패킷)
$ ip netns exec sender ping -c 1 -s 100 -M do 10.0.0.2
108 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=0.038 ms
1 packets transmitted, 1 received, 0% packet loss
# 큰 패킷 (1472바이트 페이로드 → 1500바이트 IP 패킷)
$ ip netns exec sender ping -c 1 -s 1472 -M do 10.0.0.2
1 packets transmitted, 0 received, 100% packet loss
# MTU 이내 (1422바이트 페이로드 → 1450바이트 IP 패킷)
$ ip netns exec sender ping -c 1 -s 1422 -M do 10.0.0.2
1430 bytes from 10.0.0.2: icmp_seq=1 ttl=64 time=0.023 ms
1 packets transmitted, 1 received, 0% packet loss
| 페이로드 | IP 패킷 크기 | 결과 |
|---|---|---|
| 100B | 128B | 통과 |
| 1472B | 1500B | 100% loss |
| 1422B | 1450B | 통과 |
1450바이트까지는 통과하고, 1500바이트는 드롭된다. MTU 경계에서 패킷이 정확히 잘린다.
에러 메시지도, 타임아웃 경고도 없다. 패킷이 조용히 사라진다.
DinD 환경에서 재현
같은 실험을 실제 쿠버네티스 클러스터에서 DinD Pod로 해봤다. --mtu 설정 없이 Docker 데몬을 띄우면 docker0 bridge가 기본값 1500으로 만들어진다.
# Pod 네임스페이스 안의 인터페이스
$ ip addr show eth0 | head -2
eth0@if163: mtu 1450 # CNI가 만든 것
$ ip addr show docker0 | head -2
docker0: mtu 1500 # Docker가 만든 것 (기본값)
# Docker 컨테이너의 MTU
$ docker exec test-box cat /sys/class/net/eth0/mtu
1500 # docker0을 따라감
Pod의 eth0과 docker0이 같은 네임스페이스에 공존하지만, MTU가 다르다. Docker 컨테이너에서 외부로 나가는 패킷은 docker0 → NAT(MASQUERADE) → eth0 → 오버레이 경로를 거친다.
여기서 방향에 따라 결과가 달랐다.
나가는 방향 — 컨테이너가 큰 패킷을 보내면:
$ docker exec test-box ping -c 1 -s 1472 -M do 8.8.8.8
From 172.17.0.1 icmp_seq=1 Frag needed and DF set (mtu = 1450)
커널이 docker0 → eth0(1450) 경계에서 로컬로 ICMP를 반환한다. 컨테이너는 MTU 제한을 알 수 있다.
들어오는 방향 — Pod의 eth0에서 tcpdump로 ICMP를 실시간 관찰하면서, 동시에 컨테이너에서 대용량 다운로드를 시도했다:
# 터미널 1: Pod eth0에서 ICMP 실시간 관찰
$ tcpdump -i eth0 icmp
(대기 중...)
# 터미널 2: 동시에 컨테이너에서 대용량 다운로드
$ docker exec test-box curl -o /dev/null --max-time 15 https://github.com/...
HTTP 000, 0 bytes, 15.001s # 15초 타임아웃, 데이터 0바이트
# 터미널 1 결과:
0 packets captured # ICMP 한 건도 안 옴
15초 동안 멈추다가 실패, ICMP도 없다. 외부 서버(github.com)가 1500바이트로 응답을 보내지만, 경로 어딘가에서 드롭되고 ICMP “Too Big”이 github.com까지 도달하지 않는다. github.com은 크기를 줄여야 한다는 걸 모르고 같은 크기로 재전송한다.
| 방향 | 결과 | ICMP | 이유 |
|---|---|---|---|
| 나감 (ping) | 로컬에서 즉시 실패 | ✅ 커널이 반환 | docker0 → eth0 경계를 커널이 직접 봄 |
| 들어옴 (curl) | 15초 hang → 실패 | ❌ 없음 | 노드 → github.com 경로에서 ICMP 소실 |
이것이 MTU 블랙홀이다. 나가는 건 커널이 잡아주지만, 들어오는 건 아무도 못 잡는다.
4. 쿠버네티스 오버레이 네트워크에서 MTU가 줄어드는 이유
이전 글에서 다뤘듯이, VXLAN 같은 오버레이 네트워크는 원래 패킷을 한 번 더 감싸서 보낸다. 캡슐화 오버헤드가 약 50바이트이므로, 물리 MTU가 1500이면 내부 패킷이 쓸 수 있는 공간은 1450바이트다.
┌───────────────────────────────────────────────┐
│ 외부 IP (20B) │ UDP (8B) │ VXLAN (8B) │ ← 캡슐화 오버헤드
├───────────────────────────────────────────────┤
│ 원래 패킷 (최대 1450바이트) │
└───────────────────────────────────────────────┘
그래서 쿠버네티스 오버레이 네트워크의 Pod 인터페이스 MTU가 1450인 것이다. 이건 오버레이를 쓰는 한 피할 수 없다.
5. 실제로 이런 일이 일어났다 — GitHub Actions DinD
사이드 프로젝트에 CI를 붙이고 있었다. 홈 클러스터에 ARC로 셀프 호스팅 러너를 올렸고, 통합 테스트에서 testcontainers로 PostgreSQL 컨테이너를 띄워야 해서 DinD 모드로 구성했다.
첫 워크플로우를 돌렸는데, actions/checkout 단계에서 멈췄다. 이 단계는 워크플로우의 container: golang:1.24 안에서 실행된다 — 즉, dind가 만든 Docker 브릿지 네트워크 위에서 돌아간다.
[command]/usr/bin/git -c protocol.version=2 fetch --no-tags --prune --depth=1 origin +6445b0e:refs/remotes/pull/19/merge
2분 30초 동안 아무 출력 없이 멈췄다가:
fatal: unable to access 'https://github.com/cagojeiger/authgate/': Recv failure: Connection reset by peer
처음엔 다른 걸 의심했다
러너 Pod에서 curl을 해봤다.
$ curl -s https://github.com | head -1
<!DOCTYPE html>
된다. DNS도, HTTPS도, github.com 연결도 문제없다. 그런데 git fetch만 안 된다.
처음부터 MTU를 떠올린 건 아니었다. GitHub 일시적 문제, 러너 설정, 인증서, 프록시 같은 쪽이 더 먼저 의심됐다.
그런데 몇 가지를 확인할수록 이상한 패턴이 보였다.
- 작은 요청은 된다
- 큰 전송만 실패한다
- 바로 실패하지 않고, 한참 멈췄다가 죽는다
3장의 실험에서 본 것과 같은 패턴이었다.
Pod MTU는 맞지만 Docker 브릿지가 달랐다
$ kubectl exec -it <runner-pod> -c runner -- cat /sys/class/net/eth0/mtu
1450
$ kubectl exec -it <runner-pod> -c dind -- cat /sys/class/net/eth0/mtu
1450
Pod도 1450, dind도 1450. 여기까진 정상이다.
문제는 dind 안의 Docker 데몬이 만드는 브릿지 네트워크였다. 이 브릿지의 MTU는 Docker 기본값 1500을 따른다. Pod의 네트워크 설정을 자동으로 따라가지 않는다.
처음에는 “같은 Pod 안인데 네트워크도 같겠지”라고 생각했다. 나중에 보니 그 가정이 틀렸다. runner 컨테이너와 워크플로우의 container:는 같은 Pod 안에 있어도 같은 네트워크 계층 위에서 실행되는 게 아니었다.
┌─ Runner Pod (eth0 MTU: 1450) ───────────────────────────┐
│ │
│ ┌─ dind container ──────────────────────────────────┐ │
│ │ Docker daemon │ │
│ │ ┌─ docker0 bridge (MTU: 1500) ────────────────┐ │ │
│ │ │ │ │ │
│ │ │ golang:1.24 container (eth0 MTU: 1500) │ │ │
│ │ │ │ │ │ │
│ │ │ │ git fetch → 1500B 패킷 생성 │ │ │
│ │ │ ▼ │ │ │
│ │ └─────────────────────────────────────────────┘ │ │
│ │ │ 1500B │ │
│ └───────┼───────────────────────────────────────────┘ │
│ ▼ │
│ Pod eth0 (MTU: 1450) │
│ 1500 > 1450, DF bit 설정 → 드롭 │
│ ╳ │
└──────────┼──────────────────────────────────────────────┘
▼
github.com (도달 불가)
워크플로우 컨테이너는 자기 MTU가 1500이라고 믿고 큰 패킷을 만든다. dind 내부 브릿지까지는 통과하지만, 바깥으로 나가면서 Pod eth0의 1450에 걸린다. 3장에서 실험한 것과 정확히 같은 현상이다.
curl은 왜 됐나
curl은 runner 컨테이너에서 직접 실행했다. 이 컨테이너는 Pod 네트워크를 바로 쓴다. MTU는 1450이고 오버레이 환경과 맞는다.
반면 git fetch는 워크플로우의 container: 안에서 실행됐다. 이쪽은 dind가 만든 Docker 브릿지를 거친다. 여기서 MTU가 1500으로 어긋나 있었다.
같은 Pod 안에서도 어느 네트워크 계층 위에서 실행되느냐가 달랐던 것이다.
이 문제는 Docker-in-Docker 자체보다는, 이미 MTU가 줄어든 Pod 네트워크 위에 Docker 브릿지를 한 번 더 만들면서 생긴 불일치에 가까웠다.
PMTUD가 해결해줬어야 하지 않나?
사실 이 상황은 2장에서 설명한 PMTUD가 정확히 해결하도록 설계된 케이스다. 양 끝단(컨테이너, github.com)은 MTU 1500이고 중간(Pod eth0)만 1450. PMTUD가 정상 동작했다면 github.com이 ICMP “Too Big”을 받고 패킷 크기를 줄였을 것이다.
3장의 DinD 실험에서 확인했듯이, 나가는 방향은 커널이 로컬에서 잡아준다. 하지만 git fetch는 대용량 데이터를 다운로드하는 작업이다. 큰 패킷을 보내는 쪽은 github.com이고, 경로 어딘가에서 드롭된 패킷에 대한 ICMP가 github.com까지 도달하지 못했다. 3장에서 curl로 재현한 것과 같은 현상이다 — 15초 hang, ICMP 0건.
6. 수정
들어오는 방향의 ICMP가 왜 소실되는지 정확히 특정하기는 어렵다. 노드 방화벽, ISP, 오버레이 경로 등 여러 가능성이 있다. 하지만 ICMP 소실 원인을 고치는 것보다 더 확실한 방법이 있다 — PMTUD가 필요 없게 만드는 것이다. 컨테이너의 MTU를 경로 최솟값에 맞추면 MSS가 처음부터 올바르게 협상되고, 중간에서 드롭될 일이 없다.
dind의 Docker 데몬이 브릿지를 만들 때 MTU를 Pod와 맞추면 된다.
args:
- dockerd
- --host=unix:///var/run/docker.sock
- --mtu=1450
- --default-network-opt=bridge=com.docker.network.driver.mtu=1450
--mtu=1450: Docker 기본 브릿지의 MTU를 1450으로 맞춘다--default-network-opt: 새로 생성되는 브릿지 네트워크에도 같은 설정을 적용한다
수정 후 Docker 브릿지 MTU를 확인하면:
$ kubectl exec -it <runner-pod> -c dind -- docker network inspect bridge | grep mtu
"com.docker.network.driver.mtu": "1450"
actions/checkout이 바로 통과했다.
수정 전: workflow container (1500) → docker bridge (1500) → Pod eth0 (1450) → 드롭
수정 후: workflow container (1450) → docker bridge (1450) → Pod eth0 (1450) → 통과
7. 정리
당시에는 MTU라는 개념을 바로 연결하지 못했다. 증상을 좁혀 가다가 나중에야, 이 현상을 nested network 구조에서 생긴 MTU 불일치로 설명할 수 있다는 걸 알게 됐다.
다음 세 가지가 같이 보이면 MTU 불일치를 의심할 수 있다.
- 같은 목적지인데 작은 요청은 성공하고 큰 전송만 실패한다
- 즉시 실패하지 않고, 오래 멈췄다가 타임아웃이나 reset으로 끝난다
- 오버레이 네트워크, VPN, 터널, DinD처럼 네트워크 계층이 하나 더 있다
에러 메시지는 보통 친절하지 않다. 이번에도 Connection reset by peer만 보면 Git 설정이나 외부 네트워크 문제처럼 보였다.
하지만 원인은 훨씬 안쪽에 있었다. 쿠버네티스가 이상했던 게 아니라, 쿠버네티스 위에 Docker 네트워크를 하나 더 얹은 순간 MTU가 어긋났다.
알고 나면 단순하다. 모르면 git fetch가 멈춘 2분 30초를 몇 번이고 다시 보게 된다.