לדלג לתוכן

תת-מערכת הרשת היא אחת המורכבות ביותר בקרנל של לינוקס. אלפי קבצי קוד, מאות אלפי שורות, ועשרות פרוטוקולים. אבל הרעיון הבסיסי הוא פשוט - חבילת רשת צריכה לעבור מהתוכנה שלכם לכרטיס הרשת (שליחה), או מכרטיס הרשת לתוכנה שלכם (קבלה). בפרק הזה נעקוב אחרי המסע הזה.

מחסנית הרשת - networking stack

ממשק הsocket מצד היוזר

לפני שנצלול לתוך הקרנל, בואו נזכיר בקצרה את הsyscalls שתוכנה ביוזר מוד משתמשת בהם לתקשורת רשת (אם עשיתם את קורס הרשתות, זה חזרה):

int sockfd = socket(AF_INET, SOCK_STREAM, 0);   // יצירת socket
bind(sockfd, &addr, sizeof(addr));                // שיוך לכתובת ופורט
listen(sockfd, backlog);                          // הפיכה לשרת TCP
int client = accept(sockfd, &client_addr, &len);  // קבלת חיבור נכנס
connect(sockfd, &server_addr, sizeof(addr));       // חיבור לשרת
send(sockfd, data, len, 0);                        // שליחת מידע
recv(sockfd, buf, len, 0);                         // קבלת מידע

כל אלה הם syscalls שמגיעים לקוד הרשת בקרנל. עכשיו נראה מה קורה בצד של הקרנל.


שכבות הרשת בקרנל

הקרנל מאורגן בשכבות שמתאימות למודל TCP/IP:

+------------------------------------------+
|     שכבת הsocket - Socket Layer          |  <-- ממשק ליוזר
+------------------------------------------+
|     שכבת התעבורה - Transport Layer       |  <-- TCP, UDP
|     (struct sock)                         |
+------------------------------------------+
|     שכבת הרשת - Network Layer            |  <-- IP, ניתוב
+------------------------------------------+
|     שכבת הקישור - Link Layer             |  <-- Ethernet, ARP
|     (struct net_device)                   |
+------------------------------------------+
|     דרייבר התקן הרשת                      |  <-- הדרייבר מפרק 6.8
+------------------------------------------+
|     חומרה - NIC                           |
+------------------------------------------+

כל חבילה שעוברת בstack עולה או יורדת דרך השכבות האלה.


מבנה הנתונים המרכזי - sk_buff

המבנה struct sk_buff (שנקרא בקיצור SKB) הוא מבנה הנתונים הכי חשוב בתת-מערכת הרשת. כל חבילת רשת שעוברת בקרנל מיוצגת על ידי sk_buff.

מה יש ב-sk_buff?

struct sk_buff {
    // פוינטרים לנתוני החבילה
    unsigned char *head;    // תחילת הבאפר המוקצה
    unsigned char *data;    // תחילת הנתונים הנוכחיים
    unsigned char *tail;    // סוף הנתונים הנוכחיים
    unsigned char *end;     // סוף הבאפר המוקצה

    // מידע על החבילה
    struct net_device *dev;     // ההתקן שממנו הגיעה / אליו תישלח
    struct sock *sk;            // הsocket שהחבילה שייכת אליו
    __be16 protocol;            // פרוטוקול (IP, ARP, ...)

    // מידע על headers
    __u16 transport_header;     // offset של header שכבת התעבורה (TCP/UDP)
    __u16 network_header;       // offset של header שכבת הרשת (IP)
    __u16 mac_header;           // offset של header שכבת MAC (Ethernet)

    // ועוד הרבה שדות...
};

הטריק של ארבעת הפוינטרים

הדבר הכי אלגנטי בsk_buff הוא הפוינטרים head, data, tail, end:

+--------------------------------------------------+
|          הבאפר המוקצה (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, הנתונים נשארים באותו מקום בזיכרון. רק הפוינטרים זזים. זה חוסך המון זמן בביצועי רשת.


שליחת חבילה - המסע המלא

בואו נעקוב אחרי מה קורה כשתוכנה שולחת מידע ברשת:

send(sockfd, "Hello!", 6, 0);

שלב 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 - הקרנל מסתכל בטבלת הניתוב שלו ומחליט דרך איזה ממשק רשת (network interface) לשלוח את החבילה
- פרגמנטציה - אם החבילה גדולה מדי לMTU של הממשק, היא מפוצלת לfragments

שלב 4 - שכבת הקישור (Ethernet)

החבילה מגיעה לשכבת הqueueing ולשכבת הEthernet:
- הוספת Ethernet header - כתובת MAC מקור, כתובת MAC יעד
- פתרון ARP - אם הקרנל לא יודע את כתובת הMAC של היעד, הוא שולח בקשת ARP ומחכה לתשובה (או משתמש בcache)
- הכנסה לתור - החבילה נכנסת לתור שליחה (TX queue) של ההתקן

שלב 5 - דרייבר התקן הרשת

הדרייבר (מפרק 6.8) לוקח את הsk_buff מהתור:
- שם את החבילה בring buffer של החומרה (TX ring)
- מודיע לNIC שיש חבילה חדשה לשליחה

שלב 6 - החומרה

כרטיס הרשת קורא את החבילה מהזיכרון באמצעות DMA ושולח אותה על הכבל/האוויר.


קבלת חבילה - המסע ההפוך

עכשיו בכיוון ההפוך - חבילה מגיעה מהרשת לתוכנה שלכם:

שלב 1 - החומרה

כרטיס הרשת מקבל חבילה מהרשת. הוא מעתיק אותה לזיכרון באמצעות DMA (לring buffer של קבלה - RX ring) ושולח פסיקה (IRQ) למעבד.

שלב 2 - דרייבר התקן (top half)

הhandler של הפסיקה (שלמדנו עליו בפרק 6.7) רץ:
- מאשר את הפסיקה לחומרה
- מתזמן 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)
- שם את המידע בתור הקבלה של הsocket

שלב 7 - שכבת הsocket

הsocket מעיר את התהליך שחיכה (בrecv או בselect/poll/epoll):
- המידע מועתק מהsocket buffer ל-user space (copy_to_user)
- הsyscall recv() חוזר עם המידע


מנגנון NAPI - New API

הבעיה

ברשתות מהירות (10Gbps ומעלה), כרטיס הרשת יכול לקבל מיליוני חבילות בשנייה. אם כל חבילה גורמת לפסיקה נפרדת, המעבד יבזבז את כל הזמן שלו על טיפול בפסיקות ולא ישאר זמן לעבוד.

התופעה הזו נקראת interrupt storm או livelock - המערכת עסוקה כל כך בטיפול בפסיקות שהיא לא מספיקה לעבד את החבילות עצמן.

הפתרון - NAPI

מנגנון NAPI (New API) עובד בגישה חכמה שמשלבת שני מצבים:

מצב פסיקות (רגיל, עומס נמוך):
- כל חבילה שמגיעה גורמת לפסיקה
- הפסיקה מעבדת את החבילה ומפעילה את NAPI

מצב דגימה - polling (עומס גבוה):
- כשהעומס עולה, הקרנל מכבה את הפסיקות מכרטיס הרשת
- במקום זה, הקרנל עובר למצב polling - הוא באופן אקטיבי שואל את כרטיס הרשת "יש חבילות חדשות?"
- מעבד כמה חבילות בכל סיבוב
- כשהתור מתרוקן, חוזר למצב פסיקות

למה זה עובד?

  • בעומס נמוך: פסיקות יעילות כי המעבד לא מבזבז זמן על polling
  • בעומס גבוה: polling יעיל כי תמיד יש חבילות לעבד, ואנחנו חוסכים את הoverhead של פסיקות

מסנן החבילות - Netfilter ו-iptables/nftables

מה זה Netfilter?

מנגנון Netfilter הוא מסגרת (framework) בקרנל שמאפשרת לבדוק, לשנות, לסנן ולנתב חבילות רשת. הוא עובד על ידי נקודות עצירה (hooks) בנתיב של כל חבילה.

נקודות העצירה - hooks

                         [ROUTING]
                            |
                   +--------+--------+
                   |                 |
                   v                 v
PREROUTING --> INPUT              FORWARD --> POSTROUTING
   ^              |                              |
   |              v                              v
  NIC         תהליך מקומי                       NIC
   ^              |
   |              v
POSTROUTING <-- OUTPUT
   |
   v
  NIC

5 נקודות עצירה:
- PREROUTING - החבילה רק הגיעה מהרשת, לפני החלטת ניתוב
- INPUT - החבילה מיועדת למכונה הזו (לתהליך מקומי)
- FORWARD - החבילה לא מיועדת לנו, אנחנו מעבירים אותה (routing)
- OUTPUT - החבילה יוצאת מתהליך מקומי
- POSTROUTING - החבילה עומדת לצאת מהמכונה

iptables ו-nftables

הכלים iptables (הוותיק) ו-nftables (החדש) מאפשרים ליוזר להגדיר חוקים שנרשמים בנקודות העצירה:

# חסימת כל התעבורה הנכנסת מכתובת מסוימת
iptables -A INPUT -s 10.0.0.5 -j DROP

# הרשאת SSH בלבד
iptables -A INPUT -p tcp --dport 22 -j ACCEPT
iptables -A INPUT -j DROP

# NAT - שינוי כתובת מקור לחבילות יוצאות
iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE

ככה עובדים firewalls בלינוקס. כל חבילה שעוברת בstack נבדקת מול הכללים שהגדרנו.


מרחבי שמות של רשת - network namespaces

מרחב שמות של רשת (network namespace) הוא מנגנון שנותן לקבוצה של תהליכים stack רשת שלם ומבודד משלהם:
- ממשקי רשת משלהם
- טבלת ניתוב משלהם
- חוקי iptables משלהם
- כתובות IP משלהם

זה המנגנון שcontainers כמו Docker משתמשים בו. כל container רואה רק את ממשקי הרשת שלו, ולא את אלה של containers אחרים או של המערכת המארחת.

# יצירת network namespace
ip netns add mynamespace

# הרצת פקודה בתוך הnamespace
ip netns exec mynamespace ip addr
# רואים רק lo (loopback) - ממשק ריק לגמרי

# יצירת זוג ממשקים וירטואליים (veth pair)
ip link add veth0 type veth peer name veth1

# העברת קצה אחד לnamespace
ip link set veth1 netns mynamespace

כל namespace מיוצג בקרנל על ידי struct net שמכיל את כל מידע הרשת של אותו namespace.


כלים שמתקשרים עם הstack של הקרנל

הפקודה ip

הפקודה ip (שמחליפה את ifconfig ו-route הישנות) מתקשרת עם הקרנל דרך ממשק Netlink:

ip addr show          # הצגת כתובות IP על כל הממשקים
ip route show         # הצגת טבלת ניתוב
ip link show          # הצגת ממשקי רשת
ip neigh show         # הצגת טבלת ARP

הפקודה ss

הפקודה ss (שמחליפה את netstat) קוראת ישירות ממבני נתונים של הקרנל:

ss -tuln              # הצגת sockets שמאזינים (TCP ו-UDP)
ss -tp                # הצגת חיבורי TCP עם שמות תהליכים
ss -s                 # סטטיסטיקות כלליות

כלי לכידת חבילות - tcpdump ו-Wireshark

הכלים האלה משתמשים בsocket מסוג AF_PACKET שמאפשר לקלוט חבילות "גולמיות" (raw) ישירות משכבת הקישור:

# לכידת כל החבילות בממשק eth0
tcpdump -i eth0

# סינון רק חבילות TCP לפורט 80
tcpdump -i eth0 tcp port 80

הספרייה proc/net/

הספרייה /proc/net/ חושפת מידע על מצב הרשת בקרנל:

cat /proc/net/tcp       # כל חיבורי הTCP הפתוחים
cat /proc/net/udp       # כל sockets של UDP
cat /proc/net/arp       # טבלת ARP
cat /proc/net/dev       # סטטיסטיקות לפי ממשק (חבילות שנשלחו/התקבלו)
cat /proc/net/route     # טבלת ניתוב

לדוגמה, כשאתם מריצים 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