특정 인터페이스로 들어오는 멀티캐스트 UDP 를 받아 로컬 포트로 넘긴다. scapy 를 설치할 수 없는 환경이므로 표준 socket 모듈만 쓴다.
scapy 를 쓰는 패킷 캡처와는 성격이 다르다. 여기서는 애플리케이션 계층에서 페이로드만 받는다. IP 헤더와 UDP 헤더는 보이지 않으므로, 헤더까지 필요하면 tcpdump 나 raw 소켓이 따로 필요하다.
import socket
import struct
import sys
MCAST_GRP = "239.1.1.10" # 수신할 멀티캐스트 그룹
MCAST_PORT = 12345
IFACE_IP = "192.168.10.21" # eno2 에 붙은 IP. 이 인터페이스로 가입한다
DST_HOST = "127.0.0.1"
DST_PORT = 10010
recv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
recv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# 그룹 주소로 bind 하면 그 그룹으로 온 것만 받는다.
# 같은 포트를 쓰는 다른 그룹·유니캐스트를 함께 받으려면 "" 로 bind 한다.
recv.bind((MCAST_GRP, MCAST_PORT))
# 그룹 가입. 두 번째 주소가 어느 인터페이스로 가입할지 정한다.
mreq = socket.inet_aton(MCAST_GRP) + socket.inet_aton(IFACE_IP)
recv.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq)
# 수신 버퍼를 넉넉히 둔다. 기본값으로는 버스트에서 유실된다.
recv.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 4 * 1024 * 1024)
send = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
print("listening %s:%d via %s -> %s:%d" % (MCAST_GRP, MCAST_PORT, IFACE_IP, DST_HOST, DST_PORT))
try:
while True:
data, addr = recv.recvfrom(65535)
send.sendto(data, (DST_HOST, DST_PORT))
except KeyboardInterrupt:
pass
finally:
recv.setsockopt(socket.IPPROTO_IP, socket.IP_DROP_MEMBERSHIP, mreq)
recv.close()
send.close()
인터페이스 지정은 0.0.0.0 이 아니라 그 인터페이스의 IP 로 한다. mreq 의 두 번째 주소에 0.0.0.0 을 넣으면 커널이 라우팅 테이블을 보고 인터페이스를 고르므로, 멀티캐스트가 들어오는 인터페이스와 다른 곳으로 가입할 수 있다. 서버에 NIC 가 여러 개면 거의 항상 문제가 된다.
인터페이스 이름으로 직접 지정하려면 리눅스 전용 옵션을 쓴다.
recv.setsockopt(socket.SOL_SOCKET, socket.SO_BINDTODEVICE, b"eno2\0")
이 옵션은 루트 권한(또는 CAP_NET_RAW)이 필요하다.
수신 버퍼 크기를 1024 로 두지 않는다. recvfrom(1024) 는 1024바이트를 넘는 데이터그램을 잘라 버리고 나머지를 버린다. UDP 는 잘린 부분을 다시 주지 않는다. 65535 로 받는다.
SO_REUSEADDR 만으로는 같은 포트를 여러 프로세스가 받지 못하는 경우가 있다. 리눅스에서 여러 수신자가 같은 멀티캐스트 포트를 공유하려면 SO_REUSEPORT 가 필요할 수 있다.
종료할 때 그룹에서 탈퇴한다. 하지 않아도 프로세스가 끝나면 커널이 정리하지만, 명시하면 스위치의 IGMP 스누핑 상태가 빨리 갱신된다.
# 가입 상태 확인
ip maddr show dev eno2
netstat -gn
# 실제로 패킷이 오는지
tcpdump -i eno2 -n host 239.1.1.10 and udp port 12345
# 방화벽
firewall-cmd --list-all
라우터·스위치에서 IGMP 스누핑이 켜져 있으면 가입하지 않은 포트로는 트래픽이 오지 않는다. tcpdump 로 보이는데 애플리케이션이 못 받는다면 가입 인터페이스나 bind 주소가 틀린 것이다. 반대로 tcpdump 에도 안 보이면 네트워크 구간 문제다.
리눅스는 rp_filter 때문에 멀티캐스트를 버리기도 한다. NIC 가 여러 개인 서버에서 확인한다.
sysctl net.ipv4.conf.eno2.rp_filter
수신 루프에서 하는 일이 많으면 그동안 들어온 패킷이 소켓 버퍼에서 밀려 사라진다. 수신과 처리를 분리한다.
import queue
import threading
q = queue.Queue(maxsize=10000)
def receiver():
while True:
data, _ = recv.recvfrom(65535)
try:
q.put_nowait(data)
except queue.Full:
pass # 드롭을 세어 두고 계속 받는다
threading.Thread(target=receiver, daemon=True).start()
유실량은 다음으로 본다.
cat /proc/net/udp | head
ss -unm