SSD Nodes Learn Hosting plans →
راهنماها Matt Connorتوسط Matt Connor

رفع مشکل عبور Docker از فایروال UFW

داکر با استفاده از قوانین iptables مستقیماً ترافیک را به کانتینرها هدایت می‌کند و UFW را دور می‌زند. در این راهنما نحوه مسدودسازی پورت‌ها با DOCKER-USER را بیاموزید.

چرا Docker از UFW عبور می‌کند

Docker از UFW عبور می‌کند زیرا پورت‌های منتشرشدهٔ کانتینر هرگز از قوانین فایروالی که UFW مدیریت می‌کند، عبور نمی‌کنند. هنگامی که شما docker run -p 8080:80 را اجرا می‌کنید، Docker یک قانون DNAT (ترجمه آدرس شبکه مقصد) را در زنجیره DOCKER اختصاصی خود در جدول nat هسته می‌نویسد که زنجیره PREROUTING به آن ارجاع می‌دهد. آن قانون، مقصد هر بسته را پیش از آنکه هسته تصمیم بگیرد بسته به کجا می‌رود، به آدرس خصوصی کانتینر بازنویسی می‌کند. سپس بسته بازنویسی‌شده از طریق زنجیره FORWARD که تحت کنترل Docker است، به داخل کانتینر هدایت می‌شود. قوانین UFW در زنجیره INPUT قرار دارند و بسته هرگز وارد آن نمی‌شود. بنابراین ufw status وضعیت پیش‌فرض deny را نشان می‌دهد، sudo ufw deny 8080 موفقیت‌آمیز بودن عملیات را گزارش می‌کند، اما پورت 8080 همچنان به کل اینترنت پاسخ می‌دهد.

این یک باگ در Docker نیست و UFW نیز خراب نشده است. هر دو ابزار، فایروال هسته یکسانی را برنامه‌ریزی می‌کنند. قوانین Docker صرفاً در نقطهٔ زودتری از مسیر بسته عمل می‌کنند، بنابراین UFW هرگز در جریان قرار نمی‌گیرد. این راهنما نحوهٔ این عبور را نشان می‌دهد، مکانیزم آن را توضیح می‌دهد و سپس دو راهکار عملی را پوشش می‌دهد: انتشار پورت‌ها روی 127.0.0.1 و فیلتر کردن در زنجیره DOCKER-USER. اگر UFW برای شما تازگی دارد، ابتدا آن را با راهنمای اصول فایروال UFW راه‌اندازی کنید، زیرا یک فایروال با سیاست پیش‌فرض deny، همچنان بهترین پایه برای هر چیز دیگری روی سرور است.

مشاهده دور زدن فایروال روی سرور شخصی

کار را از یک VPS شروع کنید که در آن UFW فعال است و سیاست پیش‌فرض برای ترافیک ورودی روی deny تنظیم شده است. یک کانتینر وب با پورتی منتشرشده اجرا کنید:

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 وجود ندارد. طبق گزارش خودِ فایروال، این پورت بسته است. حالا از یک ماشین دیگر، نه از خودِ سرور، تست کنید:

curl -I http://your-vps-ip:8080/
HTTP/1.1 200 OK

کانتینر پاسخ می‌دهد. یک قانون deny صریح اضافه کنید و دوباره تست کنید:

sudo ufw deny 8080/tcp

پورت همچنان پاسخ می‌دهد، زیرا قانون deny در زنجیره‌ای قرار دارد که بسته (packet) هرگز به آن نمی‌رسد. UFW شکست نخورده است. اصلاً از آن استعلام گرفته نشده است. به همین دلیل است که این مشکل به خوبی پنهان می‌ماند: هیچ خطایی در هیچ‌جا چاپ نمی‌شود، استقرار (deploy) کار می‌کند و وضعیت خروجی فایروال دقیقاً مانند یک سرور سالم و ایمن به نظر می‌رسد.

مکانیسم: PREROUTING پیش از INPUT اجرا می‌شود

هسته سیستم‌عامل بسته‌های ورودی را با ترتیبی ثابت پردازش می‌کند و کل مشکل در همین ترتیب نهفته است.

  1. PREROUTING ابتدا اجرا می‌شود. قوانینی که از اینجا فراخوانی می‌شوند ممکن است مقصد بسته را بازنویسی کنند؛ قانون Docker برای پورت‌های منتشرشده (published port) در زنجیره DOCKER که PREROUTING به آن پرش می‌کند، دقیقاً همین کار را انجام می‌دهد.
  2. تصمیم‌گیری برای مسیریابی (routing decision) در مرحله بعد قرار دارد. بسته‌ای که خطاب به خودِ میزبان باشد به زنجیره INPUT می‌رود. بسته‌ای که خطاب به هر ماشین دیگری باشد به زنجیره FORWARD می‌رود.
  3. قوانین UFW در INPUT قرار دارند. قوانین Docker در FORWARD قرار دارند.

به قانون Docker برای کانتینری که به‌تازگی اجرا کرده‌اید نگاه کنید:

sudo iptables -t nat -L DOCKER -n
Chain 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 یک رابط کاربری برای nftables است، اما ترتیب زنجیره‌ها و نتیجه نهایی یکسان است. UFW و Docker هر دو در یک خط لوله (pipeline) بسته‌های هسته می‌نویسند و نقطه ورود Docker زودتر است. این موضوع مختص UFW نیست: firewalld روی یک VPS با سیستم‌عامل Rocky یا AlmaLinux نیز در همان نقطه از خط لوله فیلتر می‌کند و توسط همان قانون DNAT دور زده می‌شود؛ بنابراین راه‌حل‌های زیر برای آنجا نیز کاربرد دارند.

راهکار روزمره: انتشار پورت‌ها روی 127.0.0.1

بیشتر کانتینرها در وهلهٔ اول نیازی به عمومی بودن ندارند. یک پایگاه داده، یک سرور برنامه در پشت یک reverse proxy، یک پنل مدیریتی یا یک endpoint برای متریک‌ها: هیچ‌کدام از این‌ها نباید مستقیماً به اینترنت پاسخ دهند. آن‌ها را روی آدرس 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 است. در Docker Engine 28.0.0 و نسخه‌های پس از آن، Docker همچنین قانونی را در زنجیره PREROUTING از جدول raw اضافه می‌کند که هر بسته‌ای را که برای آن پورت loopback از رابطی غیر از loopback برسد، حذف (drop) می‌کند. در این نسخه‌ها، پورت فقط از طریق میزبان (host) قابل دسترسی است و از هیچ جای دیگری در دسترس نیست. در نسخه‌های قدیمی‌تر از 28.0.0، Docker مستند کرده است که میزبان‌های موجود در همان بخش L2 (مثلاً ماشین‌های متصل به یک سوییچ شبکه) همچنان می‌توانند به پورت‌های منتشر شده روی localhost دسترسی داشته باشند؛ بنابراین پیش از تکیه بر این روش، docker version را بررسی و نسخه را ارتقا دهید. اتصال را تأیید کنید:

sudo ss -tlnp | grep 8080

شما باید 127.0.0.1:8080 را در خروجی ببینید، نه 0.0.0.0:8080 یا [::]:8080. سپس از یک ماشین دیگر تأیید کنید که curl http://your-vps-ip:8080/ هیچ پاسخی دریافت نمی‌کند. بسته‌ای که به آدرس عمومی شما ارسال می‌شود دیگر توسط Docker بازنویسی نمی‌شود، بنابراین به INPUT می‌رسد، جایی که سیاست پیش‌فرض deny در UFW و قانون deny 8080/tcp که پیش‌تر ذکر شد، آن را به‌جای reject، به‌صورت بی‌صدا drop می‌کنند. در نتیجه، دستور curl معلق می‌ماند و با خطای connection timeout مواجه می‌شود، نه "connection refused".

برای سرویس‌هایی که باید در معرض اینترنت باشند، یک reverse proxy اجرا کنید که مالک پورت‌های 80 و 443 باشد و مسیریابی را بر اساس نام دامنه انجام دهد؛ هیچ پورت دیگری را منتشر نکنید. این همان الگویی است که در راهنمای reverse proxy با Traefik ساخته می‌شود و روشی است که یک برنامه self-hosted مانند Nextcloud روی VPS را به‌جز از طریق proxy، غیرقابل دسترس نگه می‌دارد. نحوه تعریف ورودی‌های ports: و باقی گردش‌کار Compose در راهنمای اصول اولیه Docker Compose پوشش داده شده است.

با قرار گرفتن تمام کانتینرهای داخلی روی loopback، وظیفه UFW به حالت عادی بازمی‌گردد: محافظت از پورت‌هایی که خودِ میزبان ارائه می‌دهد. مجموعه قوانین را در اینجا بسازید و سپس دستورات را به ترتیب اجرا کنید:

ToolUFW rule generator

فیلترینگ واقعی: زنجیره DOCKER-USER

گاهی اوقات لازم است پورت یک کانتینر روی شبکه باز بماند اما دسترسی به آن محدود شود؛ برای مثال، پورت یک دیتابیس replica که فقط یک آدرس IP خاص در دفتر کار باید به آن دسترسی داشته باشد. برای این منظور، Docker زنجیره DOCKER-USER را ارائه می‌دهد. هر بسته‌ای که به سمت هر کانتینری می‌رود، پیش از اعمال قوانین پذیرش خودِ Docker، از DOCKER-USER عبور می‌کند. این زنجیره برای قوانین شما در نظر گرفته شده است و Docker آن‌ها را حذف یا تغییر نمی‌دهد؛ این موضوع حتی پس از راه‌اندازی مجدد daemon نیز صادق است.

این زنجیره متعلق به backend پیش‌فرض فایروال iptables در Docker است. Docker Engine 29 یک backend آزمایشی nftables اضافه کرده است که باید آن را با گزینه daemon به نام firewall-backend فعال کنید. در آن حالت، زنجیره DOCKER-USER وجود ندارد و فیلترینگ سفارشی باید در یک جدول nftables مجزا که خودتان می‌سازید، با یک زنجیره پایه روی hook به نام forward قرار گیرد. اگر این گزینه را تغییر نداده‌اید، شما از iptables استفاده می‌کنید و تمام موارد زیر برای شما صدق می‌کند. تفاوت بین این دو ابزار در راهنمای مقایسه iptables و nftables پوشش داده شده است.

یک نکته مهم پیش از اجرای دستور: زمانی که بسته به DOCKER-USER می‌رسد، بازنویسی DNAT قبلاً انجام شده است. پورت مقصد بسته، پورت کانتینر (در مثال ما 80) است، نه پورت منتشرشده (8080). بنابراین، قانونی که با --dport 8080 مطابقت داده شود، هیچ چیزی را پیدا نخواهد کرد. روش مطمئن، تطبیق با پورتی است که کلاینت در ابتدا به آن متصل شده است؛ پورتی که connection tracker هسته آن را به خاطر می‌سپارد:

sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP

این دستور را این‌گونه بخوانید: برای بسته‌هایی که از طریق eth0 وارد شده‌اند و متعلق به اتصالی هستند که پورت مقصد اصلی آن 8080 بوده است، هر بسته‌ای که از 10.0.0.10 ارسال نشده باشد را drop کنید. تطبیق --ctdir ORIGINAL قانون را به جهت کلاینت به کانتینر محدود می‌کند تا بسته‌های پاسخ به‌اشتباه توسط این قانون گرفتار نشوند. به جای eth0، رابط شبکه عمومی خود را قرار دهید؛ ip route | grep default نام آن را مشخص می‌کند. آن را به همان روش قبل تست کنید: curl از آدرس مجاز موفقیت‌آمیز خواهد بود و از هر جای دیگری، اتصال با timeout مواجه می‌شود. این وقفه (hang)، نشانه عملکرد صحیح قانون DROP است، نه اینکه پورت خالی باشد. همچنین تفاوت بین اتصال رد شده (refused) و اتصال دارای timeout سریع‌ترین راه برای تشخیص یک پورت فیلترشده از سرویسی است که اصلاً در حال گوش دادن نیست.

قوانینی که با دستور iptables اضافه می‌شوند، پس از reboot از بین می‌روند. از آنجا که UFW مدیریت این فایروال را بر عهده دارد، مکان تمیز برای دائمی کردن آن‌ها فایل /etc/ufw/after.rules است. یک بلوک را به انتهای این فایل اضافه کنید:

*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
COMMIT

سپس sudo ufw reload را اجرا کنید. UFW این فایل را در هر بار reload و هر بار بوت مجدداً اعمال می‌کند؛ بنابراین فیلترینگ کانتینر شما اکنون در همان جایی قرار دارد که سایر تنظیمات فایروال شماست و هم در برابر reboot و هم در برابر ارتقای Docker پایدار می‌ماند.

چرا نباید یکپارچگی iptables در Docker را غیرفعال کنید

پاسخ‌های قدیمی برای این مشکل پیشنهاد می‌کنند که { "iptables": false } را در /etc/docker/daemon.json تنظیم کنید. این کار را انجام ندهید. قوانین فایروال Docker کارهایی بسیار فراتر از انتشار پورت‌ها انجام می‌دهند. قانون masquerade همان چیزی است که به کانتینرها اجازه می‌دهد از طریق آدرس میزبان به اینترنت دسترسی خروجی داشته باشند؛ بنابراین با غیرفعال کردن این یکپارچگی، کانتینرها نمی‌توانند به مخازن بسته‌ها (package mirrors) دسترسی پیدا کنند یا هیچ API (رابط برنامه‌نویسی کاربردی) خارجی را فراخوانی کنند. قوانین DNAT و ایزولاسیون نیز حذف می‌شوند و مستندات Docker هشدار می‌دهد که بدون جایگزینی این قوانین، پورت‌های کانتینرها برای میزبان‌های موجود در شبکه محلی قابل دسترسی خواهند بود. شما با این کار، مشکل دور زدن فایروال را با از کار انداختن شبکهٔ کانتینرها حل می‌کنید و مسئولیت نوشتن و نگهداری دستی تک‌تک آن قوانین بر عهده شما خواهد بود. مستندات رسمی Docker این تنظیم را برای اکثر کاربران نامناسب می‌داند و ایجاد دستی قوانین پس از غیرفعال کردن آن را بسیار پیچیده توصیف می‌کند. زنجیره DOCKER-USER دقیقاً به همین دلیل وجود دارد تا هیچ‌کس نیازی به استفاده از این سوئیچ نداشته باشد.

بخش IPv6 همان مشکل

ابتدا بررسی کنید که پورت منتشرشده روی IPv6 چگونه به نظر می‌رسد:

sudo ss -tlnp | grep 8080

از Docker Engine 27 به بعد، Docker به‌صورت پیش‌فرض ip6tables را مدیریت می‌کند. در یک شبکه Docker که IPv6 در آن فعال است، پورت منتشرشده همان رفتار DNAT را در جدول‌های IPv6 دریافت می‌کند؛ بنابراین همان دور زدن (bypass) در آنجا نیز وجود دارد و همان راه‌حل اعمال می‌شود: زنجیره DOCKER-USER در ip6tables نیز وجود دارد، پس قانون خود را با sudo ip6tables -I DOCKER-USER ... بازتاب دهید و از خارج با استفاده از curl روی آدرس IPv6 عمومی سرور خود تست کنید، برای مثال curl -6 http://[2001:db8:2a::1]:8080/. این موضوع در backend پیش‌فرض iptables در Docker صادق است؛ در backend آزمایشی nftables، هیچ زنجیره DOCKER-USER در هیچ‌کدام از پشته‌ها وجود ندارد و فیلترینگ IPv6 در جدول nftables اختصاصی شما یعنی ip6 قرار می‌گیرد.

در شبکه‌ای بدون IPv6، کلاینت‌های IPv6 توسط docker-proxy مدیریت می‌شوند؛ یک پردازش معمولی در فضای کاربر که روی [::]:8080 گوش می‌دهد و ترافیک را از طریق IPv4 به داخل container هدایت می‌کند. ترافیک به سمت یک پردازش میزبان از طریق INPUT عبور می‌کند، بنابراین UFW می‌تواند آن مسیر را فیلتر کند، اما تنها زمانی که UFW به‌طور کلی IPv6 را مدیریت کند. اینکه آیا این کار انجام می‌شود یا خیر، و سایر روش‌هایی که باعث ایجاد شکاف IPv6 روی یک VPS می‌شوند، موضوع راهنمای UFW و IPv6 است.

انتشار روی loopback کل این مسئله را دور می‌زند: -p 127.0.0.1:8080:80 فقط به IPv4 loopback متصل می‌شود، بنابراین هیچ listener برای IPv6 وجود ندارد و در Docker Engine 28.0.0 و نسخه‌های بعد از آن، هیچ چیزی برای دسترسی از خارج روی هیچ‌کدام از پشته‌ها وجود نخواهد داشت.

الگویی که کارآمد باقی می‌ماند

  • تمام پورت‌های داخلی را روی 127.0.0.1 منتشر کنید تا در وهلهٔ اول در معرض دید قرار نگیرند؛ این کار در Docker Engine 28.0.0 یا نسخه‌های جدیدتر توصیه می‌شود (نسخه‌های قدیمی‌تر به میزبان‌های موجود در همان segment اجازه می‌دهند به آن دسترسی پیدا کنند).
  • سمت عمومی را به یک reverse proxy بسپارید که مالکیت پورت‌های 80 و 443 را در اختیار دارد.
  • سیاست پیش‌فرض UFW برای میزبان را روی deny قرار دهید و فقط اجازه دسترسی به SSH و پورت‌های proxy را بدهید.
  • پورت‌های عمومی کانتینر را در DOCKER-USER بر اساس backend پیش‌فرض iptables در داکر فیلتر کنید؛ این فیلترها باید با پورت مقصد اصلی مطابقت داشته باشند و در /etc/ufw/after.rules پایدار بمانند.
  • یکپارچگی iptables داکر را فعال نگه دارید.

با یک بار تنظیم، این روش غافلگیری‌ها را حذف می‌کند: ufw status میزبان را توصیف می‌کند و DOCKER-USER کانتینرها را. هیچ پورتی به‌طور تصادفی منتشر نمی‌شود و دستور docker run -p بعدی که تایپ می‌کنید، دقیقاً همان چیزی را در معرض دید قرار می‌دهد که قصدش را داشتید.

FAQ

چرا وقتی UFW پورت را مسدود کرده است، باز هم می‌توانم به container داکر دسترسی داشته باشم؟

زیرا داکر پورت را با یک قانون DNAT در زنجیره DOCKER از جدول nat منتشر می‌کند که از PREROUTING قابل دسترسی است. این کار باعث می‌شود مقصد بسته پیش از هرگونه فیلترینگ، به آدرس container بازنویسی شود. سپس بسته مسیر FORWARD را طی می‌کند، در حالی که قوانین UFW در زنجیره INPUT قرار دارند؛ زنجیره‌ای که بسته هرگز وارد آن نمی‌شود. فایروال هرگز در این فرآیند دخالت داده نمی‌شود، بنابراین قوانین deny آن هیچ اثری بر پورت‌های منتشرشدهٔ container ندارند.

چگونه می‌توانم UFW را وادار کنم پورت‌های منتشرشدهٔ داکر را مسدود کند؟

خود UFW نمی‌تواند این کار را انجام دهد، زیرا قوانین آن در زنجیره اشتباهی قرار دارند. یا انتشار پورت را متوقف کنید و آن را به صورت 127.0.0.1:8080:80 منتشر کنید تا فقط میزبان (host) بتواند در Docker Engine 28.0.0 و نسخه‌های بعد به آن دسترسی داشته باشد، یا در زنجیره DOCKER-USER با استفاده از یک قانون iptables که با پورت مقصد اصلی از طریق conntrack مطابقت دارد، فیلترینگ را انجام دهید. این قانون را در /etc/ufw/after.rules دائمی کنید تا پس از راه‌اندازی مجدد و ufw reload باقی بماند.

آیا باید در فایل daemon.json داکر، گزینه "iptables": false را تنظیم کنم؟

خیر. این تنظیم اکثر قوانین فایروال و NAT داکر را حذف می‌کند که باعث بروز مشکلاتی بسیار فراتر از دور زدن فایروال می‌شود. دسترسی خروجی اینترنت containerها به دلیل حذف قانون masquerade از بین می‌رود و داکر هشدار می‌دهد که بدون قوانین جایگزین، پورت‌های container برای میزبان‌های موجود در شبکه محلی قابل دسترسی خواهند بود. از انتشار روی loopback و زنجیره DOCKER-USER استفاده کنید؛ این روش‌ها مشکل دسترسی را بدون آسیب زدن به شبکهٔ containerها حل می‌کنند.

آیا داکر UFW را در IPv6 نیز دور می‌زند؟

در Docker Engine 27 و نسخه‌های بعد، مدیریت ip6tables به‌طور پیش‌فرض فعال است، بنابراین پورتی که در یک شبکه داکر با قابلیت IPv6 منتشر می‌شود، دقیقاً مانند IPv4 از دور UFW بازنویسی می‌شود. در backend پیش‌فرض iptables داکر، نیاز به همان قانون DOCKER-USER با معادل ip6tables برای IPv6 است. در backend آزمایشی nftables از Engine 29، زنجیره DOCKER-USER وجود ندارد، بنابراین فیلترینگ باید در جدول nftables اختصاصی خودتان انجام شود. در شبکه‌های بدون IPv6، فرآیند docker-proxy روی [::] گوش می‌دهد و آن ترافیک از INPUT عبور می‌کند، جایی که اگر UFW مدیریت IPv6 را بر عهده داشته باشد، می‌تواند آن را فیلتر کند. انتشار روی 127.0.0.1 هر دو حالت را برطرف می‌کند، زیرا در این صورت هیچ چیزی روی IPv6 گوش نمی‌دهد.