네트워킹 기초 다시 쌓기 — fd, 파이프, 소켓에서 인터페이스까지 (VXLAN 3부작 ①)
VXLAN 개념을 다시 파고들다가, 정작 그 아래 깔린 IP·MAC·소켓·인터페이스 같은 기초 용어부터 흔들리고 있다는 걸 깨달았다. 파일 기술자(fd) 하나에서 출발해 파이프, 소켓, 그리고 커널 안의 net_device 구조체까지 이어지는 흐름을 정리한, VXLAN 3부작의 1편.
VXLAN 포스팅을 다시 읽다가, 정작 그 안에 나오는 IP, MAC, 소켓, 인터페이스, 프로토콜 같은 단어들의 기초부터 흔들리고 있다는 걸 깨달았습니다. 이 글은 VXLAN을 잠깐 미뤄두고, 그 아래 깔린 커널 기초를 처음부터 다시 쌓은 기록입니다.
닻으로 삼은 것: 소켓/파이프 프로그래밍 경험이 거의 없어서, 누구나 한 번쯤 써본 "파일 열고 읽고 쓰기"에서 출발해 소켓까지 확장하는 순서로 갔습니다.
1. 모든 건 "숫자 하나"에서 시작한다
어떤 언어로든 파일을 다뤄본 적은 있을 겁니다 — open("data.txt")처럼요. 이때 커널이 돌려주는 건 실제로는 정수 하나입니다. 이 정수를 **파일 기술자(file descriptor, fd)**라고 부릅니다.
프로세스 커널
┌─────────────┐ ┌──────────────────────────┐
│ fd 3 ───────┼───────────────▶│ [3] → "data.txt 읽기/쓰기 상태" │
└─────────────┘ └──────────────────────────┘
fd 자체는 파일이 아니라 커널이 관리하는 어떤 것을 가리키는 번호표일 뿐입니다. 그 "어떤 것"이 실제 파일일 수도, 파이프일 수도, 소켓일 수도 있습니다. fd에 대해 프로그램이 할 수 있는 동작은 거의 항상 같습니다 — read(fd, ...), write(fd, ...), close(fd). 리눅스는 "파일이든 파이프든 소켓이든, 프로그램 입장에서는 같은 인터페이스로 다루자"는 설계를 깔고 있는 셈입니다.
2. 파이프 — 번호표 두 개가 버퍼 하나를 공유
ls | grep foo 같은 셸 파이프를 실행하면, 커널은 디스크에 아무것도 쓰지 않습니다. 그냥 메모리 안에 링버퍼 하나를 만들고, 양쪽 프로세스에게 그 버퍼로 통하는 fd를 하나씩 쥐여줄 뿐입니다.
프로세스 A (ls) 프로세스 B (grep)
┌──────────┐ 커널 메모리 안 링버퍼 하나 ┌──────────┐
│ fd 1(쓰기)┼───────▶ [ □ □ □ □ □ □ ] ───────────────▶┼fd 0(읽기) │
└──────────┘ (파일도 디스크도 아님, └──────────┘
그냥 메모리 큐)
파이프는 "두 프로세스가 같은 커널 메모리 조각을 fd 두 개로 나눠 쥐고 있는 것"뿐입니다.
3. 소켓 — 파이프의 확장판, 다른 컴퓨터까지
소켓은 이 아이디어의 확장입니다: **"버퍼 반대쪽 끝이 내 컴퓨터의 다른 프로세스가 아니라, 네트워크 건너 다른 컴퓨터의 프로세스"**인 경우입니다.
파이프: [프로세스A] ── 커널 메모리 버퍼 ── [프로세스B] (한 컴퓨터 안)
소켓: [프로세스A] ── 커널 메모리 버퍼 ── (네트워크로 포장) ──▶ 다른 컴퓨터의 [프로세스B]
socket(...)을 호출하면 역시 정수 하나(fd)를 받고, 여기 write(정확히는 send)하면 파이프처럼 커널 버퍼에 들어갑니다. 다른 점은, 이 버퍼에 들어간 데이터를 커널이 그냥 메모리에 두지 않고 헤더를 감싸서 실제 전선으로 내보낸다는 것입니다. 이 "헤더를 감싸는 규칙"이 프로토콜(UDP/TCP/IP)이고, 이걸 담당하는 커널 안의 자료구조가 있습니다.
소켓 fd 하나 = 커널 안 struct sock 인스턴스 하나
struct sock 안에 들어있는 것 (핵심만):
- 어떤 프로토콜 쓰는지 (UDP? TCP?)
- 로컬 포트 번호, 상대 IP:포트
- 아직 못 보낸/못 읽은 데이터가 쌓이는 버퍼
ss -tulnp는 바로 "지금 커널 안에 떠 있는 struct sock 목록"을 보여주는 명령입니다.
ss socket statistics — 소켓 상태 조회
-t TCP 소켓만
-u UDP 소켓만
-l listen 중인(연결 기다리는) 소켓만
-n 포트 번호를 이름으로 안 바꾸고 숫자 그대로
-p 어느 프로세스가 이 소켓을 쥐고 있는지도 같이
4. 소켓에 쓴 데이터가 밖으로 나가는 순간
프로그램: send(fd, "hello", 5)
│
▼ (fd → struct sock을 통해 어떤 프로토콜인지 확인)
UDP 함수: 포트 번호 헤더를 붙인다 [UDP헤더][hello]
│
▼
IP 함수: 출발지/목적지 IP 헤더를 붙이고,
라우팅 테이블을 봐서 어느 인터페이스로 나갈지 정한다
[IP헤더][UDP헤더][hello]
│
▼
net_device(예: wlan0)의 전송 함수 호출 → 실제로 전선/전파로 나감
파이프에서 "버퍼 하나를 공유"했던 것처럼, 소켓도 결국 "버퍼 + 그 버퍼를 실제 전선으로 밀어내는 규칙(프로토콜)의 조합"일 뿐입니다.
프로토콜은 약속/규격입니다. UDP, IP, 이더넷은 규칙집의 이름이고, 그 규칙대로 데이터를 만들고 읽는 건 커널 안의 별도 코드입니다. 인터페이스는 컴퓨터가 네트워크와 데이터를 주고받는 출입구입니다. 물리적인 것(랜포트)일 수도, 커널이 소프트웨어로만 흉내 낸 가상 출입구(flannel.1, cni0, veth...)일 수도 있습니다.
/sys/class/net/은 커널이 메모리에 들고 있는 인터페이스 정보를 파일처럼 흉내 내서 보여주는 특수 경로(sysfs)입니다. wlan0, flannel.1, veth... 같은 이름 하나하나가 커널 안의 struct net_device 인스턴스입니다.
5. net_device — MAC과 IP는 서로 다른 자리에 있다
struct net_device {
char name[16]; // "wlan0" — /sys/class/net/의 그 이름
u8 dev_addr[6]; // MAC 주소 (6바이트)
unsigned mtu; // 한 번에 내보낼 수 있는 최대 바이트
void *ndo_start_xmit; // 실제로 전선/전파에 실어 보내는 함수 포인터
...
};
이 구조체 어디에도 IP 주소 필드가 없습니다. MAC(dev_addr)은 net_device 소유물이지만, IP는 별도 구조체로 매달려 있습니다.
한 줄로 요약하면: net_device는 고유한 MAC 하나(dev_addr, 단일 필드)와, 자유롭게 추가·삭제할 수 있는 IP 여러 개(in_ifaddr 리스트, CRUD 가능)를 갖습니다. 이 "1개 vs N개"라는 비대칭 구조가, 뒤에서 볼 neighbour 조회가 왜 IP→MAC 방향으로만 깔끔하게 되는지의 근본 이유입니다.
struct net_device (wlan0)
│
├─ dev_addr = MAC 주소 ← 인터페이스 본연의 것 (L2)
│
└─ ip_ptr → struct in_device
└─ ifa_list → struct in_ifaddr { IP 주소들, ... } ← 나중에 얹힌 것 (L3)
ifa_list가 리스트라서, 한 인터페이스에 IP를 여러 개 붙일 수도 있습니다(ip addr add). 실제로 많이 씁니다 — 서버 한 대에서 IP를 여러 개 달아 사이트를 여러 개 서비스하거나, 장애조치용 가상 IP(VIP)를 옮겨 붙이거나, flannel.1이 노드별 사설 IP(10.42.0.0, 10.42.1.0...)를 받는 방식도 이겁니다.
다만 이미 다른 누군가가 쓰고 있는 IP를 중복해서 달면, 그 즉시 남의 조회 테이블(뒤에서 다룰 라우팅 테이블/neighbour/FDB)이 꼬입니다. "자유롭게 여러 개 붙일 수 있다"는 "아무 값이나 붙여도 안전하다"와는 다른 얘기입니다.
헷갈리기 쉬운 지점: "IP는 MAC에 붙는 거 아닌가?" — 아닙니다. 위 그림을 다시 보면, dev_addr(MAC)과 ip_ptr(IP 목록)은 둘 다 net_device라는 같은 객체에 매달린 서로 다른 필드일 뿐, MAC이 IP를 담는 그릇이 아닙니다. IP는 "이 MAC 값에" 붙는 게 아니라 "이 net_device라는 자리에" 붙습니다. 실제로 확인할 수 있습니다 — MAC을 바꿔도 IP는 그대로 남습니다.
ip addr show wlan0 # IP: 172.30.1.94
ip link set dev wlan0 address aa:bb:cc:dd:ee:ff # MAC을 강제로 바꿈
ip addr show wlan0 # IP: 172.30.1.94 ← 그대로!
만약 IP가 MAC에 붙는 거였다면 MAC을 바꾸는 순간 IP도 같이 사라지거나 바뀌어야 맞는데, 안 그렇습니다. IP는 net_device라는 "자리"에 붙어있고, MAC은 그 자리의 또 다른 속성일 뿐이라 서로 독립적으로 바뀝니다. in_ifaddr이 리스트인 것도 같은 이유입니다 — MAC은 인터페이스당 하나(단일 필드)지만 IP는 그 인터페이스에 여러 개 매달 수 있는 리스트라서, 앞서 본 "IP 여러 개 붙이기"가 가능한 겁니다.
MAC, 제대로 알기
IP는 우편번호+도로명 주소, 라우터를 여러 개 거쳐 장거리 배달이 가능합니다. MAC은 같은 네트워크(같은 스위치 구간) 안에서만 의미가 있고 라우터를 못 넘습니다. 바로 옆 구간(한 홉)을 이동할 때 MAC으로 상대를 식별합니다.
"MAC = 물리 주소"라는 번역이 오해를 만듭니다. MAC = Media Access Control. "물리(Physical)"라는 단어는 원래 이름에 없습니다. 옛날 이더넷은 여러 장치가 케이블(동축 케이블) 하나를 공유하는 구조였습니다. "지금 이 공유 케이블을 누가 쓸 차례인지, 이 프레임이 여럿 중 누구한테 가는 건지"를 통제(control)하는 규칙 모음이 Media Access Control이고, MAC 주소는 원래 "공유 매체 위에서 여러 참가자 중 수신자를 구별하는 식별자"입니다. 대부분의 물리 랜카드가 이 값을 공장에서 새겨서 주다 보니 나중에 "물리 주소"라는 별명이 붙은 것뿐입니다.
OSI 7계층과 캡슐화
| 계층 | 이름 | 예 |
|---|---|---|
| 4 | 전송 | UDP, TCP, 포트 8472 |
| 3 | 네트워크 | IP |
| 2 | 데이터링크 | MAC |
| 1 | 물리 | wlan0의 전파 |
계층마다 데이터 뭉치를 부르는 이름도 다릅니다. L2 프레임(출발지MAC+목적지MAC+내용물) ⊃ L3 패킷(출발지IP+목적지IP+내용물) ⊃ L4 세그먼트(출발지포트+목적지포트+내용물). 러시아 인형처럼 겹겹이 포장돼 있습니다.
부록 — 지금까지 등장한 구조체를 한 흐름으로 꿰어보기
이번 편에서 나온 구조체들과, 다음 편에서 다룰 구조체들을 미리 한 줄로 이어서 보면 전체 그림이 잡힙니다. send() 한 번이 실제로 커널 속에서 어떤 구조체들을 순서대로 거쳐가는지가 핵심입니다.
send(fd, "hello", 5)
│
▼ fd가 가리키는 것
struct sock ← 소켓 하나의 상태 (프로토콜, 포트, 버퍼)
│
▼ UDP/IP 헤더를 붙인 뒤, "어디로 나갈지" 결정하려고 조회
struct net_device ← 인터페이스 하나 (이번 편의 주인공)
│
├─ dev_addr ← 이 인터페이스의 MAC
└─ ip_ptr → struct in_ifaddr ← 이 인터페이스에 붙은 IP들
① struct sock — 소켓 fd 하나가 가리키는 커널 내부 상태입니다.
struct sock {
unsigned short sk_protocol; // UDP인지 TCP인지
__be16 sk_num; // 로컬 포트 번호
struct sk_buff_head sk_receive_queue; // 아직 못 읽은 데이터가 쌓이는 버퍼
...
};
ss -tulnp가 보여주는 목록이 바로 이 구조체들의 모음입니다. fd 하나 = struct sock 인스턴스 하나라는 관계를 기억하면 됩니다.
② struct net_device — 이번 편에서 계속 다룬 인터페이스 자체입니다.
struct net_device {
char name[16]; // "wlan0"
u8 dev_addr[6]; // MAC 주소
void *ndo_start_xmit; // 실제 전송을 담당하는 함수 포인터
void *ip_ptr; // → struct in_device로 이어짐
...
};
③ struct in_ifaddr — net_device에 매달리는 IP 하나하나입니다. 리스트라서 여러 개 매달 수 있다는 게 앞서 본 "IP 여러 개 붙이기"의 근거였습니다.
struct in_ifaddr {
__be32 ifa_address; // IP 주소 값 (예: 172.30.1.94)
struct in_ifaddr *ifa_next; // 다음 IP로 이어지는 리스트 포인터
...
};
여기까지가 "인터페이스 하나가 자기 자신을 어떻게 표현하는지"입니다. 다음 편에서 다룰 나머지 구조체들도 지금 이름만 걸어두면, 다음 편을 읽을 때 훨씬 수월합니다 — 전부 "누가 무엇을 어떻게 조회하는가"에 대한 답을 담은 표들입니다.
질문①: 목적지 IP → 다음 홉 IP는? struct fib_table(→fib_alias→fib_info→fib_nh) (라우팅 테이블)
질문②: 다음 홉 IP → MAC은? struct neighbour (neighbour 테이블)
질문③: 그 MAC → 어느 인터페이스? struct net_bridge_fdb_entry (브릿지 FDB)
질문③': VXLAN에서 그 MAC → 물리 IP? struct vxlan_fdb (VXLAN 전용 FDB)
라우팅 테이블(FIB, Forwarding Information Base)과 FDB(Forwarding Database)는 이름부터 사촌 관계입니다 — 둘 다 "무언가를 전달(forwarding)하기 위한 정보를 담은 표"라는 뜻이고, 키가 IP냐 MAC이냐만 다릅니다.
④ struct neighbour — IP 하나를 MAC 하나로 바꿔주는 항목입니다.
struct neighbour {
__be32 primary_key; // IP 주소 (다음 홉 IP)
u8 ha[6]; // 그 IP를 가진 net_device의 dev_addr
};
⑤ struct net_bridge_port — 브릿지(cni0 같은 것)에 net_device 하나를 편입시킨 걸 나타냅니다(리눅스 CLI는 이걸 "포트"라 부르지만, 실체는 net_device 그 자체라 이 글에서는 "인터페이스"로 통일합니다). 새로운 실체가 아니라 기존 net_device를 감싸는 얇은 포장입니다.
struct net_bridge_port {
struct net_device *dev; // veth-a1 같은 net_device를 가리키는 포인터일 뿐
struct net_bridge *br; // 어느 브릿지 소속인지
};
⑥ struct net_bridge_fdb_entry — 브릿지가 지나가는 트래픽을 곁눈질로 엿보고 스스로 채워넣는 "MAC → 인터페이스" 매핑입니다.
struct net_bridge_fdb_entry {
u8 addr[6]; // MAC 주소
struct net_bridge_port *dst; // 어느 인터페이스로 나가야 하는지
};
⑦ struct vxlan_fdb — VXLAN 인터페이스(flannel.1 같은 것)가 쓰는, ⑥과 이름은 비슷하지만 완전히 별개인 구조체입니다. 브릿지는 "거느린 인터페이스 중 어디로?"를 답하지만, VXLAN 장치는 거느린 인터페이스가 없어서 "물리 IP 어디로 포장해서 보낼까?"를 답합니다.
struct vxlan_fdb {
u8 eth_addr[6]; // neighbour가 알려준 상대 VTEP MAC
struct vxlan_rdst rdst; // 그 MAC이 실제로 어느 물리 IP 뒤에 있는지
};
①~③′ 네 구조체가 정확히 어떤 순서로, 왜 그렇게 나뉘어 조회되는지는 다음 편에서 라우팅 테이블·neighbour·FDB를 하나씩 실제 값과 함께 풀어봅니다.
다음 편에서
여기까지가 "소켓에 쓴 데이터가 net_device를 통해 나가기까지"의 기초입니다. 다음 편에서는 이 net_device가 실제로 목적지를 어떻게 찾아가는지(라우팅 테이블 → neighbour → FDB, 세 개의 조회 테이블), 그리고 같은 노드 안에서 파드들을 묶는 브릿지(cni0)와 노드 사이를 잇는 VXLAN 캡슐화까지 이어서 봅니다.