מדוע Docker עוקף את UFW ואיך לתקן זאת
Docker עוקף את חוקי UFW על ידי הזרקת חוקי iptables ישירות לשרשרת PREROUTING. למדו כיצד למנוע חשיפת פורטים לא רצויים לאינטרנט באמצעות הגדרות ב-daemon.json או שימוש ב-DOCKER-USER.
מדוע Docker עוקף את UFW
Docker עוקף את UFW מכיוון שפורטים של מכולות (containers) שפורסמו לעולם אינם עוברים דרך חוקי ה-firewall שמנהל UFW. כאשר מריצים את docker run -p 8080:80, Docker כותב חוק DNAT (תרגום כתובת רשת יעד) לתוך שרשרת ה-PREROUTING בטבלת ה-nat של ה-kernel. חוק זה משכתב את יעד החבילה לכתובת הפרטית של המכולה עוד לפני שה-kernel מחליט לאן החבילה מיועדת. החבילה המשוכתבת מועברת לאחר מכן לתוך המכולה דרך שרשרת ה-FORWARD, שאותה Docker מנהל. החוקים של UFW נמצאים בשרשרת ה-INPUT, והחבילה לעולם אינה מגיעה אליה. לכן, ufw status מציג מדיניות של דחייה כברירת מחדל, sudo ufw deny 8080 מדווח על הצלחה, אך פורט 8080 עדיין מגיב לכל רחבי האינטרנט.
זו אינה תקלה ב-Docker, ו-UFW אינו פגום. שני הכלים מתכנתים את אותו ה-firewall של ה-kernel. החוקים של Docker פשוט פועלים בנקודה מוקדמת יותר במסלול החבילה, ולכן UFW כלל אינו נשאל. מדריך זה מדגים את העקיפה, מסביר את המנגנון, ולאחר מכן מכסה את שני הפתרונות שעובדים: פרסום פורטים ב-127.0.0.1, וסינון בשרשרת ה-DOCKER-USER. אם UFW חדש עבורכם, הגדירו אותו תחילה בעזרת המדריך ליסודות ה-firewall של UFW, מכיוון ש-firewall עם מדיניות דחייה כברירת מחדל הוא עדיין הבסיס הנכון לכל דבר אחר על השרת.
בדיקת המעקף בשרת שלכם
התחילו משרת VPS שבו UFW פעיל עם מדיניות ברירת מחדל של חסימת תעבורה נכנסת (deny). הריצו מכולת (container) אינטרנט עם פורט חשוף:
sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpineהפקודה ufw status verbose מציגה Default: deny (incoming), allow (outgoing) וללא חוק עבור פורט 8080. על פי הדיווח של ה-firewall עצמו, הפורט סגור. כעת, בצעו בדיקה ממכונה אחרת, לא מהשרת עצמו:
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OKהמכולה משיבה לבקשות. הוסיפו חוק חסימה מפורש ובדקו שוב:
sudo ufw deny 8080/tcpהפורט עדיין משיב, כיוון שחוק החסימה נמצא בשרשרת (chain) שהחבילה לעולם לא מגיעה אליה. UFW לא נכשל. הוא פשוט לא נשאל. זו גם הסיבה שהבעיה מוסתרת היטב: לא מודפסת שום שגיאה, הפריסה עובדת, והסטטוס של ה-firewall נראה בדיוק כמו שרת תקין ומאובטח.
המנגנון: PREROUTING פועל לפני INPUT
הליבה (kernel) מעבדת חבילות נכנסות בסדר קבוע, וכל הבעיה טמונה בסדר הזה.
PREROUTINGפועל ראשון. חוקים כאן עשויים לשכתב את יעד החבילה, והחוק של Docker עבור פורט מפורסם עושה בדיוק את זה.- החלטת הניתוב מגיעה לאחר מכן. חבילה הממוענת למארח עצמו עוברת לשרשרת
INPUT. חבילה הממוענת לכל מכונה אחרת עוברת לשרשרתFORWARD. - החוקים של UFW נמצאים ב-
INPUT. החוקים של Docker נמצאים ב-FORWARD.
הביטו בחוק של Docker עבור המכולה שהרצתם זה עתה:
sudo iptables -t nat -L DOCKER -nChain DOCKER (2 references)
target prot opt source destination
RETURN 0 -- 0.0.0.0/0 0.0.0.0/0
DNAT 6 -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80השורה DNAT היא כל הסיפור. כל חבילה שמגיעה לפורט 8080 עוברת שכתוב יעד ל-172.17.0.2:80, הכתובת של המכולה ברשת ה-bridge הפנימית של Docker. לאחר השכתוב, החבילה אינה ממוענת עוד למארח, לכן החלטת הניתוב שולחת אותה בנתיב FORWARD, שבו Docker כבר הוסיפה חוקים המאשרים תעבורה לרשתות שלה. החוק שלכם ב-deny 8080/tcp ממתין ב-INPUT לחבילה שלעולם לא תגיע.
ב-Ubuntu 24.04 הפקודה iptables היא ממשק קדמי (front end) מעל nftables, אך סדר השרשראות והתוצאה זהים. UFW ו-Docker כותבים שניהם לאותו צינור עיבוד חבילות של הליבה, ונקודת הכניסה של Docker מוקדמת יותר. שום דבר מזה אינו ספציפי ל-UFW: firewalld בשרת VPS מסוג Rocky או AlmaLinux מסנן באותה נקודה בצינור העיבוד ונעקף על ידי אותו חוק DNAT, לכן הפתרונות להלן הם אלו שתשתמשו בהם גם שם.
הפתרון היומיומי: פרסום פורטים ב-127.0.0.1
רוב המכולות לא היו צריכות להיות נגישות לציבור מלכתחילה. מסד נתונים, שרת יישומים מאחורי reverse proxy, לוח ניהול, או נקודת קצה למדדים: אף אחד מאלה לא אמור להשיב ישירות לאינטרנט. פרסמו אותם בכתובת ה-loopback:
docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpineאו בקובץ Compose:
services:
web:
image: nginx:1.29-alpine
ports:
- "127.0.0.1:8080:80"זה עובד מכיוון שחוק ה-DNAT של Docker תואם כעת רק לחבילות הממוענות ל-127.0.0.1, וחבילה מהאינטרנט לעולם לא יכולה לשאת יעד כזה באופן לגיטימי, לכן ה-kernel משליך אותה לפני שכל חוק firewall רץ. הפורט נגיש מה-host, ולא משום מקום אחר. ודאו את ה-binding:
sudo ss -tlnp | grep 8080אתם רוצים לראות 127.0.0.1:8080 בפלט, לא 0.0.0.0:8080 או [::]:8080. לאחר מכן ודאו ממכונה אחרת ש-curl http://your-vps-ip:8080/ מסורב.
עבור השירותים שצריכים לפנות לאינטרנט, הריצו reverse proxy אחד שמחזיק בפורטים 80 ו-443 ומנתב לפי שם מתחם, ואל תפרסמו שום דבר אחר. זהו הדפוס שעליו נבנה ה-מדריך ל-Traefik reverse proxy, וכך יישום בניהול עצמי כמו Nextcloud על גבי VPS נשאר בלתי נגיש אלא דרך ה-proxy שלו. האופן שבו מוצהרות רשומות ports:, ושאר תהליך העבודה ב-Compose, מכוסים ב-מדריך היסודות של Docker Compose.
כאשר כל מכולה פנימית נמצאת על ה-loopback, ה-UFW חוזר לבצע את עבודתו הרגילה: הגנה על הפורטים שה-host עצמו משרת. בנו את סט החוקים הזה כאן, ולאחר מכן הריצו את הפקודות לפי הסדר:
Real filtering: the DOCKER-USER chain
Sometimes a container port must stay published to the network but restricted, for example a database replica port that only one office address may reach. For that, Docker provides the DOCKER-USER chain. Every packet heading to any container passes through DOCKER-USER before Docker's own accept rules, and Docker never writes rules into it. The chain exists for yours, and Docker leaves its contents alone across daemon restarts.
One trap before the command: by the time a packet reaches DOCKER-USER, the DNAT rewrite has already happened. The packet's destination port is the container port (80 in our example), not the published port (8080). A rule matching --dport 8080 therefore matches nothing. The reliable way is to match the port the client originally dialled, which the kernel's connection tracker remembers:
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROPRead it as: for packets that entered on eth0 and belong to a connection whose original destination port was 8080, drop everything not sent from 10.0.0.10. The --ctdir ORIGINAL match limits the rule to the client-to-container direction, so reply packets are not caught by mistake. Replace eth0 with your public interface; ip route | grep default names it. Test it the same way as before: curl from the allowed address succeeds, and from anywhere else the connection times out. That hang is the signature of a DROP rule doing its job rather than a port with nothing behind it, and the difference between a refused connection and one that times out is the fastest way to tell a filtered port from a service that simply is not listening.
Rules added with the iptables command disappear at reboot. Since UFW already manages this firewall, the clean place to persist them is /etc/ufw/after.rules. Append a block at the end of the file:
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP
COMMITThen run sudo ufw reload. UFW replays that file on every reload and every boot, so your container filtering now lives in the same place as the rest of your firewall, and it survives both a reboot and a Docker upgrade.
מדוע אין לבטל את האינטגרציה של Docker עם iptables
תשובות ישנות לבעיה זו מציעות להגדיר את { "iptables": false } בתוך /etc/docker/daemon.json. אל תעשו זאת. חוקי ה-firewall של Docker עושים הרבה יותר מאשר פרסום פורטים. חוק ה-masquerade הוא זה שמעניק למכולות גישה לאינטרנט החוצה דרך הכתובת של המארח, לכן כאשר האינטגרציה כבויה, מכולות אינן יכולות למשוך images, להגיע למראות (mirrors) של חבילות, או לקרוא לכל API (ממשק תכנות יישומים) חיצוני. חוקי ה-DNAT הם אלו שגורמים ל--p לעבוד בכלל, ולכן פורטים שפורסמו יפסיקו לעבוד לחלוטין. גם חוקי הבידוד השומרים על רשתות Compose נפרדות יתבטלו. אתם תפתרו את המעקף על ידי שבירת הרשת של המכולות, וכל אחד מהחוקים הללו יהפוך לאחריותכם לכתיבה ולתחזוקה ידנית. התיעוד של Docker עצמו מתאר את ההגדרה הזו ככזו המיועדת לאנשים שמתכוונים לעשות בדיוק את זה. ה-chain ששמו DOCKER-USER קיים בדיוק כדי שאף אחד לא יזדקק למתג הזה.
היבט ה-IPv6 של אותה בעיה
תחילה בדקו כיצד נראה הפורט המפורסם ב-IPv6:
sudo ss -tlnp | grep 8080החל מ-Docker Engine 27, Docker מנהל את ip6tables כברירת מחדל. ברשת Docker שבה IPv6 מופעל, פורט מפורסם מקבל את אותו טיפול DNAT בטבלאות ה-IPv6, לכן אותו מעקף קיים גם שם והפתרון זהה: ה-chain של DOCKER-USER קיים גם ב-ip6tables, לכן שכפלו את החוק שלכם עם sudo ip6tables -I DOCKER-USER ... ובדקו מבחוץ באמצעות curl מול כתובת ה-IPv6 הציבורית של השרת, לדוגמה curl -6 http://[2001:db8:2a::1]:8080/.
ברשת ללא IPv6, לקוחות IPv6 מטופלים במקום זאת על ידי docker-proxy, תהליך user-space רגיל המאזין ב-[::]:8080 ומעביר את התעבורה לתוך ה-container מעל IPv4. תעבורה לתהליך מארח אכן עוברת דרך INPUT, כך ש-UFW יכול לסנן נתיב זה, אך רק כאשר UFW מנהל IPv6 בכלל. האם זה המצב, ודרכים נוספות שבהן נוצר פער IPv6 ב-VPS, הם הנושא של מדריך UFW ו-IPv6.
פרסום ב-loopback עוקף את כל הסוגיה: -p 127.0.0.1:8080:80 מבצע bind ל-loopback של IPv4 בלבד, לכן אין מאזין IPv6 ואין דבר שניתן להגיע אליו מבחוץ באף אחד מה-stacks.
התבנית שמבטיחה יציבות
- פרסמו כל פורט פנימי ב-
127.0.0.1, כך שהוא לעולם לא ייחשף מלכתחילה. - הקצו את הצד הציבורי ל-reverse proxy יחיד שמחזיק בפורטים 80 ו-443.
- שמרו על מדיניות ברירת המחדל של UFW כ-deny עבור המארח, ואפשרו רק SSH ופורטים של ה-proxy.
- סננו פורטים של מכולות שנועדו להיות ציבוריים ב-
DOCKER-USER, בהתאמה לפורט היעד המקורי, ושמרו את ההגדרות ב-/etc/ufw/after.rules. - השאירו את האינטגרציה של Docker עם iptables פעילה.
לאחר הגדרה חד-פעמית, זה מונע הפתעות: ufw status מתאר את המארח, ו-DOCKER-USER מתאר את המכולות. שום דבר לא מתפרסם בטעות, וה-docker run -p הבא שתקלידו יחשוף בדיוק את מה שהתכוונתם אליו.
FAQ
מדוע אני יכול להגיע למכולת Docker שלי כאשר UFW חוסם את הפורט?
מכיוון ש-Docker מפרסמת את הפורט באמצעות כלל DNAT בשרשרת PREROUTING, אשר משכתב את יעד החבילה לכתובת המכולה לפני שמתבצע סינון כלשהו. החבילה עוברת לאחר מכן בנתיב FORWARD, בעוד הכללים של UFW נמצאים בשרשרת INPUT, שרשרת שהחבילה לעולם אינה נכנסת אליה. חומת האש אינה נבדקת כלל, ולכן לכללי ה-deny שלה אין השפעה על פורטים של מכולות שפורסמו.
כיצד אוכל לגרום ל-UFW לחסום את הפורטים ש-Docker מפרסמת?
UFW עצמו אינו יכול לעשות זאת, כיוון שהכללים שלו נמצאים בשרשרת הלא נכונה. עליכם או להפסיק לחשוף את הפורט על ידי פרסומו כ-127.0.0.1:8080:80, כך שרק המארח יוכל להגיע אליו, או לסנן בשרשרת DOCKER-USER באמצעות כלל iptables התואם לפורט היעד המקורי דרך conntrack. שמרו את הכלל הזה ב-/etc/ufw/after.rules כדי שישרוד אתחולים ו-ufw reload.
האם עליי להגדיר "iptables": false בקובץ daemon.json של Docker?
לא. הגדרה זו מסירה את כל כללי חומת האש וה-NAT של Docker, מה שגורם לנזק רב יותר מאשר רק העקיפה. מכולות מאבדות גישה יוצאת לאינטרנט כיוון שכלל ה-masquerade מוסר, ופורטים שפורסמו מפסיקים לעבוד כיוון שכללי ה-DNAT מוסרים. השתמשו בפרסום ל-loopback ובשרשרת DOCKER-USER במקום זאת; הם פותרים את בעיית החשיפה מבלי לשבור את הרשת של המכולות.
האם Docker עוקפת את UFW גם ב-IPv6?
בגרסה 27 ומעלה של Docker Engine, ניהול ip6tables מופעל כברירת מחדל, לכן פורט שמפורסם ברשת Docker התומכת ב-IPv6 משוכתב סביב UFW בדיוק כפי שקורה ב-IPv4, והוא זקוק לאותו כלל DOCKER-USER משוכפל עם ip6tables. ברשתות ללא IPv6, התהליך docker-proxy מאזין ב-[::], ותעבורה זו אכן עוברת דרך INPUT, שם UFW יכול לסנן אותה אם UFW מנהל IPv6. פרסום ב-127.0.0.1 מונע את שני המקרים, כיוון ששום דבר אינו מאזין ב-IPv6 כלל.