תת-מערכת הרשת היא אחת המורכבות ביותר בקרנל של לינוקס. אלפי קבצי קוד, מאות אלפי שורות, ועשרות פרוטוקולים. אבל הרעיון הבסיסי הוא פשוט - חבילת רשת צריכה לעבור מהתוכנה שלכם לכרטיס הרשת (שליחה), או מכרטיס הרשת לתוכנה שלכם (קבלה). בפרק הזה נעקוב אחרי המסע הזה.
מחסנית הרשת - networking stack¶
הinterface הsocket מצד היוזר¶
לפני שנצלול לתוך הקרנל, בואו נזכיר בקצרה את הsyscalls שתוכנה ביוזר מוד משתמשת בהם לתקשורת רשת (אם עשיתם את קורס הרשתות, זה חזרה):
int sockfd = socket(AF_INET, SOCK_STREAM, 0); // creating a socket
bind(sockfd, &addr, sizeof(addr)); // binding to an address and port
listen(sockfd, backlog); // turning into a TCP server
int client = accept(sockfd, &client_addr, &len); // accepting an incoming connection
connect(sockfd, &server_addr, sizeof(addr)); // connecting to a server
send(sockfd, data, len, 0); // sending data
recv(sockfd, buf, len, 0); // receiving data
כל אלה הם syscalls שמגיעים לקוד הרשת בקרנל. עכשיו נראה מה קורה בצד של הקרנל.
שכבות הרשת בקרנל¶
הקרנל מאורגן בשכבות שמתאימות למודל TCP/IP:
+------------------------------------------+
| Socket Layer | <-- interface to the user
+------------------------------------------+
| Transport Layer | <-- TCP, UDP
| (struct sock) |
+------------------------------------------+
| Network Layer | <-- IP, routing
+------------------------------------------+
| Link Layer | <-- Ethernet, ARP
| (struct net_device) |
+------------------------------------------+
| network device driver | <-- the driver from chapter 6.8
+------------------------------------------+
| hardware - NIC |
+------------------------------------------+
כל חבילה שעוברת בstack עולה או יורדת דרך השכבות האלה.
מבנה הנתונים המרכזי - sk_buff¶
המבנה struct sk_buff (שנקרא בקיצור SKB) הוא מבנה הנתונים הכי חשוב בתת-מערכת הרשת. כל חבילת רשת שעוברת בקרנל מיוצגת על ידי sk_buff.
מה יש ב-sk_buff?¶
struct sk_buff {
// pointers to the packet data
unsigned char *head; // start of the allocated buffer
unsigned char *data; // start of the current data
unsigned char *tail; // end of the current data
unsigned char *end; // end of the allocated buffer
// info about the packet
struct net_device *dev; // the device it arrived from / will be sent to
struct sock *sk; // the socket the packet belongs to
__be16 protocol; // protocol (IP, ARP, ...)
// info about headers
__u16 transport_header; // offset of the transport layer header (TCP/UDP)
__u16 network_header; // offset of the network layer header (IP)
__u16 mac_header; // offset of the MAC layer header (Ethernet)
// ... many more fields
};
הטריק של ארבעת הפוינטרים¶
הדבר הכי אלגנטי בsk_buff הוא הפוינטרים head, data, tail, end:
+--------------------------------------------------+
| the allocated buffer (head...end) |
| |
| headroom | data (data...tail) | tailroom |
| | | |
+--------------------------------------------------+
^ ^ ^ ^
head data tail end
- headroom - מקום פנוי לפני הנתונים, שם מוסיפים headers בזמן שליחה
- data - הנתונים עצמם
- tailroom - מקום פנוי אחרי הנתונים
כשרוצים להוסיף header (למשל header של TCP):
- מזיזים את data אחורה (שמאלה) - skb_push()
- עכשיו הdata מתחיל מוקדם יותר, וכולל את הheader החדש
כשרוצים להסיר header (בזמן קבלה):
- מזיזים את data קדימה (ימינה) - skb_pull()
- עכשיו הdata מתחיל אחרי הheader שהסרנו
למה זה אלגנטי? כי אין העתקה של מידע! כשחבילה עוברת דרך כל שכבות הstack, הנתונים נשארים באותו מקום בזיכרון. רק הפוינטרים זזים. זה חוסך המון זמן בביצועי רשת.
שליחת חבילה - המסע המלא¶
בואו נעקוב אחרי מה קורה כשתוכנה שולחת מידע ברשת:
שלב 1 - שכבת הsocket¶
הsyscall send() מגיע לקוד הsocket בקרנל. הקרנל יוצר (או משתמש ב) sk_buff ומעתיק את המידע "Hello!" מהuser space לתוכו (עם copy_from_user, כמו שלמדנו).
שלב 2 - שכבת התעבורה (TCP)¶
הsk_buff מגיע לקוד של TCP. כאן קורים כמה דברים:
- הוספת TCP header - באמצעות skb_push, הdata נדחף אחורה ובמקום הפנוי שנוצר נכתב header של TCP (פורט מקור, פורט יעד, sequence number, flags ועוד)
- טיפול בsegmentation - אם המידע גדול מדי, הוא מפוצל למקטעים (segments) בגודל MSS
- בקרת עומס - congestion control - TCP שולט בקצב השליחה כדי לא להציף את הרשת
- שמירת עותק - TCP שומר עותק של הsk_buff כי אולי יצטרך לשלוח אותו שוב (retransmission) אם לא מגיע ACK
שלב 3 - שכבת הרשת (IP)¶
הsk_buff ממשיך לקוד של IP:
- הוספת IP header - שוב skb_push, והפעם IP header (כתובת מקור, כתובת יעד, TTL, protocol ועוד)
- החלטת ניתוב - routing decision - הקרנל מסתכל בטבלת הניתוב שלו ומחליט דרך איזה interface רשת (network interface) לשלוח את החבילה
- פרגמנטציה - אם החבילה גדולה מדי לMTU של הinterface, היא מפוצלת לfragments
שלב 4 - שכבת הקישור (Ethernet)¶
החבילה מגיעה לשכבת הqueueing ולשכבת הEthernet:
- הוספת Ethernet header - כתובת MAC מקור, כתובת MAC יעד
- פתרון ARP - אם הקרנל לא יודע את כתובת הMAC של היעד, הוא שולח בקשת ARP ומחכה לתשובה (או משתמש בcache)
- הכנסה לqueue - החבילה נכנסת לqueue שליחה (TX queue) של ההתקן
שלב 5 - דרייבר התקן הרשת¶
הדרייבר (מפרק 6.8) לוקח את הsk_buff מהqueue:
- שם את החבילה בring buffer של החומרה (TX ring)
- מודיע לNIC שיש חבילה חדשה לשליחה
שלב 6 - החומרה¶
כרטיס הרשת קורא את החבילה מהזיכרון באמצעות DMA ושולח אותה על הכבל/האוויר.
קבלת חבילה - המסע ההפוך¶
עכשיו בכיוון ההפוך - חבילה מגיעה מהרשת לתוכנה שלכם:
שלב 1 - החומרה¶
כרטיס הרשת מקבל חבילה מהרשת. הוא מעתיק אותה לזיכרון באמצעות DMA (לring buffer של קבלה - RX ring) ושולח interrupt (IRQ) למעבד.
שלב 2 - דרייבר התקן (top half)¶
הhandler של הinterrupt (שלמדנו עליו בפרק 6.7) רץ:
- מאשר את הinterrupt לחומרה
- מתזמן NAPI poll (נסביר בהמשך) לעיבוד החבילה
- לא מעבד את החבילה עצמה - זה top half, חייב להיות מהיר
שלב 3 - עיבוד NAPI (bottom half)¶
מנגנון NAPI (נסביר בפירוט בהמשך) קורא חבילות מהring buffer של החומרה:
- יוצר sk_buff לכל חבילה
- מגדיר את הפוינטרים (head, data, tail, end)
- מעביר את הsk_buff לעיבוד
שלב 4 - שכבת הקישור¶
הקרנל בודק את הEthernet header:
- מה הפרוטוקול? (IP? ARP? משהו אחר?)
- האם החבילה מיועדת לנו? (כתובת MAC שלנו, broadcast, multicast?)
- מסיר את הEthernet header באמצעות skb_pull
שלב 5 - שכבת הרשת (IP)¶
קוד הIP בודק את הIP header:
- האם הכתובת מיועדת לנו? אם לא - אולי צריך להעביר (forward) את החבילה
- בודק את הchecksum
- מטפל בfragments (הרכבה מחדש אם צריך)
- מסיר את הIP header (skb_pull)
- מעביר לשכבה הבאה לפי שדה הprotocol (TCP? UDP? ICMP?)
שלב 6 - שכבת התעבורה (TCP/UDP)¶
הקוד מחפש את הsocket המתאים (לפי פורט מקור ויעד, כתובות IP):
- TCP: מטפל בsequence numbers, שולח ACK, מסדר חבילות שהגיעו לא בסדר, מנהל את הstate machine של TCP
- UDP: הרבה יותר פשוט - פשוט מעביר את המידע
- מסיר את הTCP/UDP header (skb_pull)
- שם את המידע בqueue הקבלה של הsocket
שלב 7 - שכבת הsocket¶
הsocket מעיר את הprocess שחיכה (בrecv או בselect/poll/epoll):
- המידע מועתק מהsocket buffer ל-user space (copy_to_user)
- הsyscall recv() חוזר עם המידע
מנגנון NAPI - New API¶
הבעיה¶
ברשתות מהירות (10Gbps ומעלה), כרטיס הרשת יכול לקבל מיליוני חבילות בשנייה. אם כל חבילה גורמת לinterrupt נפרדת, המעבד יבזבז את כל הזמן שלו על טיפול בinterrupts ולא ישאר זמן לעבוד.
התופעה הזו נקראת interrupt storm או livelock - המערכת עסוקה כל כך בטיפול בinterrupts שהיא לא מספיקה לעבד את החבילות עצמן.
הפתרון - NAPI¶
מנגנון NAPI (New API) עובד בגישה חכמה שמשלבת שני מצבים:
מצב interrupts (רגיל, עומס נמוך):
- כל חבילה שמגיעה גורמת לinterrupt
- הinterrupt מעבדת את החבילה ומפעילה את NAPI
מצב דגימה - polling (עומס גבוה):
- כשהעומס עולה, הקרנל מכבה את הinterrupts מכרטיס הרשת
- במקום זה, הקרנל עובר למצב polling - הוא באופן אקטיבי שואל את כרטיס הרשת "יש חבילות חדשות?"
- מעבד כמה חבילות בכל סיבוב
- כשהqueue מתרוקן, חוזר למצב interrupts
למה זה עובד?¶
- בעומס נמוך: הinterrupts יעילות כי המעבד לא מבזבז זמן על polling
- בעומס גבוה: polling יעיל כי תמיד יש חבילות לעבד, ואנחנו חוסכים את הoverhead של interrupts
מסנן החבילות - Netfilter ו-iptables/nftables¶
מה זה Netfilter?¶
מנגנון Netfilter הוא מסגרת (framework) בקרנל שמאפשרת לבדוק, לשנות, לסנן ולנתב חבילות רשת. הוא עובד על ידי נקודות עצירה (hooks) בנתיב של כל חבילה.
נקודות העצירה - hooks¶
[ROUTING]
|
+--------+--------+
| |
v v
PREROUTING --> INPUT FORWARD --> POSTROUTING
^ | |
| v v
NIC local process NIC
^ |
| v
POSTROUTING <-- OUTPUT
|
v
NIC
5 נקודות עצירה:
- PREROUTING - החבילה רק הגיעה מהרשת, לפני החלטת ניתוב
- INPUT - החבילה מיועדת למכונה הזו (לprocess מקומי)
- FORWARD - החבילה לא מיועדת לנו, אנחנו מעבירים אותה (routing)
- OUTPUT - החבילה יוצאת מprocess מקומי
- POSTROUTING - החבילה עומדת לצאת מהמכונה
iptables ו-nftables¶
הכלים iptables (הוותיק) ו-nftables (החדש) מאפשרים ליוזר להגדיר חוקים שנרשמים בנקודות העצירה:
# blocking all incoming traffic from a specific address
iptables -A INPUT -s 10.0.0.5 -j DROP
# allowing SSH only
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -j DROP
# NAT - changing the source address for outgoing packets
iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
ככה עובדים firewalls בלינוקס. כל חבילה שעוברת בstack נבדקת מול הכללים שהגדרנו.
מרחבי שמות של רשת - network namespaces¶
מרחב שמות של רשת (network namespace) הוא מנגנון שנותן לקבוצה של processes stack רשת שלם ומבודד משלהם:
- הinterfaces רשת משלהם
- טבלת ניתוב משלהם
- חוקי iptables משלהם
- כתובות IP משלהם
זה המנגנון שcontainers כמו Docker משתמשים בו. כל container רואה רק את interfaces הרשת שלו, ולא את אלה של containers אחרים או של המערכת המארחת.
# creating a network namespace
ip netns add mynamespace
# running a command inside the namespace
ip netns exec mynamespace ip addr
# we only see lo (loopback) - a completely empty interface
# creating a pair of virtual interfaces (veth pair)
ip link add veth0 type veth peer name veth1
# moving one end to the namespace
ip link set veth1 netns mynamespace
כל namespace מיוצג בקרנל על ידי struct net שמכיל את כל מידע הרשת של אותו namespace.
כלים שמתקשרים עם הstack של הקרנל¶
הפקודה ip¶
הפקודה ip (שמחליפה את ifconfig ו-route הישנות) מתקשרת עם הקרנל דרך interface Netlink:
ip addr show # showing IP addresses on all the interfaces
ip route show # showing the routing table
ip link show # showing network interfaces
ip neigh show # showing the ARP table
הפקודה ss¶
הפקודה ss (שמחליפה את netstat) קוראת ישירות ממבני נתונים של הקרנל:
ss -tuln # showing listening sockets (TCP and UDP)
ss -tp # showing TCP connections with process names
ss -s # general statistics
כלי לכידת חבילות - tcpdump ו-Wireshark¶
הכלים האלה משתמשים בsocket מסוג AF_PACKET שמאפשר לקלוט חבילות "גולמיות" (raw) ישירות משכבת הקישור:
# capturing all packets on interface eth0
tcpdump -i eth0
# filtering only TCP packets on port 80
tcpdump -i eth0 tcp port 80
הספרייה proc/net/¶
הספרייה /proc/net/ חושפת מידע על מצב הרשת בקרנל:
cat /proc/net/tcp # all the open TCP connections
cat /proc/net/udp # all UDP sockets
cat /proc/net/arp # the ARP table
cat /proc/net/dev # statistics per interface (packets sent/received)
cat /proc/net/route # the routing table
לדוגמה, כשאתם מריצים ss -t, הפקודה בעצם קוראת מ-/proc/net/tcp (או משתמשת בNetlink) ומציגה את המידע בצורה קריאה.
סיכום¶
- חבילת רשת עוברת דרך שכבות: socket, transport (TCP/UDP), network (IP), link (Ethernet), driver, hardware
- המבנה sk_buff מייצג חבילה בקרנל - הטריק של ארבעת הפוינטרים מאפשר הוספה/הסרה של headers בלי העתקת מידע
- שליחה: headers מתווספים בכל שכבה (skb_push), קבלה: headers מוסרים (skb_pull)
- מנגנון NAPI עובר בין interrupt mode ל-polling mode כדי להתמודד עם עומס רשת גבוה
- מנגנון Netfilter ונקודות העצירה (hooks) שלו מאפשרים סינון ושינוי חבילות - זה הבסיס לfirewalls בלינוקס
- מרחבי שמות של רשת (network namespaces) מאפשרים בידוד מלא של stack רשת - הבסיס לcontainers