راهنمای کامل شبکهسازی در Docker Compose
با نحوه عملکرد شبکه پیشفرض در Docker Compose، ارتباط سرویسها با نام و اشتراکگذاری شبکه بین پروژهها آشنا شوید. همچنین بررسی میکنیم چرا پورتهای منتشر شده از UFW عبور میکنند.
آنچه Compose پیش از شروع برنامه شما میسازد
شبکهسازی در Docker Compose با یک قانون آغاز میشود: docker compose up یک شبکه خصوصی برای پروژه ایجاد میکند، تمام سرویسها را به آن متصل میسازد و به آنها اجازه میدهد از طریق نام سرویس به یکدیگر دسترسی پیدا کنند. برای این کار نیازی به نوشتن حتی یک خط networks: نیست. بخش بزرگی از سردرگمیها پیرامون شبکهسازی Compose ناشی از عدم اطلاع از وجود این تنظیمات پیشفرض است.
این یک فایل کوچک است. آن را با نام compose.yaml در پوشهای به نام shop ذخیره کنید.
services:
web:
image: nginx:1.27
ports:
- "8080:80"
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: exampleآن را بالا بیاورید و ببینید Docker چه چیزی ساخته است:
docker compose up -d
docker network lsاین لیست اکنون شامل شبکهای به نام shop_default است. Compose نام آن را <project>_default میگذارد و نام پروژه بهصورت پیشفرض همان نام پوشه با حروف کوچک است. میتوانید این نام را با docker compose -p myproject up -d یا با یک name: myproject در سطح بالای فایل تغییر دهید. درایور آن bridge است که یک سوییچ مجازی در داخل میزبان (host) محسوب میشود. هر کانتینر یک آدرس در یک زیرشبکه خصوصی دریافت میکند و ترافیک خروجی در مسیر خروج به آدرس میزبان ترجمه میشود.
docker compose down آن شبکه را دوباره حذف میکند. به همین دلیل است که یک کانتینر قدیمی از پروژهای دیگر میتواند یک شبکه را باز نگه دارد: Docker با خطای error while removing network: network shop_default has active endpoints مانع حذف میشود و راهحل آن، متوقف کردن یا حذف کانتینری است که هنوز به آن شبکه متصل است.
اگر با Compose تازه آشنا شدهاید، مطالعه ساختار فایل Compose و دستورات چرخه حیات پیش از ادامه توصیه میشود، زیرا تمام مطالب زیر فرض را بر این میگذارند که شما قادر به شروع و توقف یک پروژه هستید.
DNS بر اساس نام سرویس، نکتهای است که مبتدیان از آن غافل میشوند
در هر شبکه تعریفشده توسط کاربر، Docker یک سرور DNS داخلی اجرا میکند که هر کانتینر آن را در 127.0.0.11 میبیند. این سرور نامهای سرویس را به آدرسهای کانتینر فعلی ترجمه میکند. بنابراین web بدون هیچ پیکربندی خاصی، به دیتابیس در نام میزبان db و روی پورت 5432 دسترسی پیدا میکند.
docker compose exec web getent hosts dbاین دستور خطی مانند 172.18.0.2 db را چاپ میکند. اگر خروجی خالی باشد، یعنی دو سرویس در یک شبکه نیستند.
اشتباهی که تقریباً همه یکبار مرتکب میشوند، استفاده از localhost در پیکربندی برنامه است. در داخل یک کانتینر، localhost همان کانتینر است، نه میزبان (host) و نه سرویس دیگر. کلاینتهای Postgres این موضوع را بهوضوح گزارش میدهند:
could not connect to server: Connection refused
Is the server running on host "localhost" (127.0.0.1) and accepting
TCP connections on port 5432?رشته اتصال باید postgresql://postgres:example@db:5432/postgres باشد. بخش میزبان، همان نام سرویس است.
دو جزئیات که بعداً در زمان شما صرفهجویی میکند: نامها به هر چیزی که در حال اجراست ترجمه میشوند، بنابراین docker compose up -d --scale web=3 یک نام با سه آدرس میدهد و کلاینتی که DNS را برای همیشه کش (cache) کند، خود را به یک کانتینر از کار افتاده متصل میکند. همچنین شبکه قدیمی bridge که توسط یک docker run ساده و بدون --network استفاده میشود، اصلاً نمیتواند نام کانتینرها را ترجمه کند: کانتینرها در آنجا فقط از طریق آدرس IP یکدیگر را پیدا میکنند و نامهای میزبان خارجی را از طریق کپی فایل /etc/resolv.conf میزبان جستجو میکنند. به همین دلیل است که توصیههای مربوط به لینکهای کانتینر در سال 2016 با آنچه امروز میبینید، مطابقت ندارد.
برای اتصال دو سرویس به یکدیگر نیازی به ports: ندارید
ports: یک پورت کانتینر را روی میزبان (host) منتشر میکند. این کار برای ترافیکی است که از خارج از Docker میرسد. این موضوع هیچ ارتباطی به ترافیک بینسرویسی ندارد، چرا که ترافیک بینسرویسی در شبکه پروژه، روی تمام پورتها برقرار است.
بنابراین، ports: - "5432:5432" که بسیاری از افراد به سرویس دیتابیس خود اضافه میکنند، نه تنها فایدهای ندارد بلکه مضر است: این کار باعث میشود Postgres روی رابط عمومی سرور در دسترس قرار گیرد. آن را حذف کنید. اگر میخواهید برای انجام migration از روی لپتاپ خود به آن دسترسی داشته باشید، آن را با استفاده از "127.0.0.1:5432:5432" به loopback متصل کنید و از طریق یک SSH tunnel به آن وصل شوید. تفاوت بین یک listening socket، یک پورت منتشرشده و یک قانون فایروال در نحوه عملکرد پورتها و سرویسهای در حال گوش دادن در لینوکس توضیح داده شده است.
expose: در Compose صرفاً جنبه مستندسازی دارد. این دستور هیچ پورتی را باز نمیکند، زیرا هیچ ارتباطی بین کانتینرهای موجود در یک شبکه بسته نشده است.
چه زمانی network_mode host مناسب است و چه هزینهای دارد
حالت host فضای نام شبکه کانتینر را حذف میکند و به پردازش اجازه میدهد مستقیماً از رابطهای میزبان استفاده کند.
services:
probe:
image: alpine:3.20
network_mode: host
command: sleep infinityدلایل واقعی برای این کار وجود دارد. پردازشی که نیاز به مشاهده ترافیک broadcast یا multicast در شبکه محلی دارد، مانند کشف دستگاه برای یک مدیا سرور یا هاب اتوماسیون خانگی، نمیتواند آن را از پشت یک bridge ببیند، زیرا bridge آن ترافیک را به کانتینر هدایت نمیکند. یک عامل مانیتورینگ که شمارندههای رابط میزبان را میخواند، به رابطهای میزبان نیاز دارد. همچنین شما از مرحله ترجمه آدرس (NAT) عبور میکنید که در نرخهای بالای بسته (packet) اهمیت دارد.
هزینهها مشخص هستند.
ports: از کار میافتد. Docker هشدار میدهد که پورتهای منتشر شده (published ports) هنگام استفاده از حالت شبکه host نادیده گرفته میشوند و کانتینر به هر پورتی که پردازش آن متصل شود، bind میشود. دو کانتینر در حالت host که هر دو بخواهند از پورت 8080 استفاده کنند، دچار تداخل میشوند و دومی با خطای bind: address already in use متوقف میشود.
ترجمه نام بر اساس نام سرویس در هر دو جهت از بین میرود. کانتینر در شبکه پروژه نیست، بنابراین نمیتواند db را ترجمه کند و سایر سرویسها نیز نمیتوانند آن را ترجمه کنند. کانتینر همچنان میتواند از طریق پورتهای منتشر شده روی میزبان، معمولاً در 127.0.0.1 به آنها دسترسی داشته باشد. در لینوکس، کانتینر همچنین میتواند مستقیماً به یک کانتینر bridge در آدرس IP آن کانتینر روی هر پورت در حال گوش دادن متصل شود، زیرا میزبان دسترسی باز به کانتینرها در شبکههای bridge خود دارد و یک کانتینر در حالت host، پشته شبکه میزبان را به اشتراک میگذارد. آن آدرسها هنگام بازسازی کانتینرها تغییر میکنند، بنابراین پورت منتشر شده روی loopback مسیر پایداری است.
ایزولاسیون از بین میرود. پردازشی که 0.0.0.0 را در داخل یک کانتینر با حالت host متصل میکند، روی تمام رابطهای سرور شما، از جمله رابط عمومی، در حال گوش دادن است؛ دقیقاً مانند بستهای که با apt نصب شده باشد. این یک مزیت دارد: این ترافیک مسیر ورودی عادی را دنبال میکند، بنابراین قوانین UFW برای آن اعمال میشوند، که در مورد پورتهای منتشر شده صادق نیست.
حالت host یک ویژگی Linux Docker Engine است. Docker Desktop فقط از نسخه 4.34 به بعد و تنها پس از فعالسازی آن را پشتیبانی میکند، با این محدودیت که کانتینرها نمیتوانند به آدرسهای IP میزبان bind شوند و فقط TCP و UDP مدیریت میشوند. اگر نیمی از تیم شما روی سرورهای لینوکسی و نیمی روی Docker Desktop هستند، انتظار داشته باشید که یک فایل مشابه، رفتار متفاوتی داشته باشد.
زمانی که به رابطهای میزبان نیاز دارید به سراغ حالت host بروید. برای حل مشکل اتصال به سراغ آن نروید، زیرا معمولاً یک مشکل را با مشکلی سختتر جایگزین میکند.
اتصال دو پروژه Compose با یک شبکه خارجی
شبکهای که توسط یک پروژه ایجاد شده است، برای پروژه دیگر قابل مشاهده نیست. به همین دلیل است که یک reverse proxy در proxy/compose.yaml نمیتواند برنامهای را در app/compose.yaml ببیند، حتی اگر هر دو روی یک سرور باشند. راهحل، استفاده از شبکهای است که متعلق به هیچکدام از این دو پروژه نباشد.
آن را یکبار به صورت دستی ایجاد کنید:
docker network create edgeسپس آن را در هر پروژه به عنوان external معرفی کنید. سمت پروکسی:
services:
proxy:
image: traefik:v3.1
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- edge
networks:
edge:
name: edge
external: trueسمت برنامه:
services:
app:
image: nginx:1.27
networks:
- edge
- internal
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: example
networks:
- internal
networks:
edge:
name: edge
external: true
internal:عبارت external: true به Compose دستور میدهد که به جای ایجاد یک شبکه جدید، به یک شبکه موجود متصل شود و هنگام اجرای docker compose down، آن را حذف نکند. کلید مجزای name: اهمیت بیشتری از آنچه به نظر میرسد دارد: بدون آن، Compose به دنبال شبکهای میگردد که دقیقاً نامش edge باشد، اما با وجود آن میتوانید نام شبکه را در فایل خود یک چیز و در میزبان (host) چیز دیگری بگذارید.
اگر شبکه وجود نداشته باشد، Compose از شروع کار خودداری کرده و گزارش میدهد که شبکه به عنوان external اعلام شده اما پیدا نشده است. ابتدا آن را ایجاد کنید.
به کاری که فایل برنامه با internal انجام میدهد دقت کنید. پایگاه داده فقط روی آن شبکه محلیِ پروژه قرار دارد، بنابراین پروکسی نمیتواند به آن دسترسی داشته باشد و فقط app قادر به این کار است. افزودن internal: true در زیر یک شبکه، گامی فراتر میرود و مسیر آن به دنیای خارج را بهطور کامل حذف میکند. این یک تنظیم پیشفرض مناسب برای پایگاه داده است، با یک نکته که پیش از اعمال باید بدانید: کانتینری که در یک شبکه داخلی قرار دارد نمیتواند هیچ فایلی را دانلود کند، بنابراین اگر entrypoint در زمان شروع، دستور apt-get update یا pip install را اجرا کند، متوقف شده و در نهایت با خطای timeout شکست میخورد.
برای مشاهده یک نمونه کامل عملیاتی همراه با قوانین مسیریابی و گواهیها، به اجرای چندین برنامه پشت یک نمونه Traefik مراجعه کنید.
پورتهای منتشرشده UFW را دور میزنند
این بخشی از شبکهسازی Compose است که به یک رخداد امنیتی تبدیل میشود. شما پورتی را منتشر میکنید، بررسی میکنید که UFW فعال است و همه چیز بهجز SSH را مسدود کرده است، اما سرویس همچنان از طریق اینترنت در دسترس است.
sudo ufw status
curl http://203.0.113.10:8080UFW میگوید پورت مسدود است. با این حال curl صفحه را برمیگرداند. هیچ چیزی خراب نیست. Docker قوانین ترجمه آدرس و فورواردینگ خود را مستقیماً در iptables مینویسد و ترافیک به سمت پورت کانتینر منتشرشده، بهجای تحویل به میزبان، به کانتینر فوروارد میشود؛ بنابراین ترافیک هرگز از زنجیرهای که UFW برای ترافیکهای مقصدِ محلی مدیریت میکند، عبور نمیکند. قوانین Docker همچنین پیش از قوانین UFW مطابقت داده میشوند.
راهکار سریع این است که پورتها را فقط در جایی که نیاز دارید منتشر کنید:
ports:
- "127.0.0.1:8080:80"این کار سمت میزبان را به loopback متصل میکند، بنابراین پورت فقط از خود سرور و از طریق یک تونل SSH قابل دسترسی است و از هیچ جای دیگری در دسترس نخواهد بود. نقطه ورود عمومی را پشت یک reverse proxy قرار دهید که پورتهای 80 و 443 را بهصورت هدفمند منتشر میکند. توضیح کامل، شامل زنجیره DOCKER-USER برای مواردی که باید یک پورت منتشرشده را فیلتر کنید، در چرا Docker پورتها را مستقیماً و بدون توجه به UFW منتشر میکند و چگونه آن را اصلاح کنیم آمده است.
چگونه با چهار دستور آن را عیبیابی کنیم
با پرسیدن اینکه هر کانتینر واقعاً در چه شبکهای است، شروع کنید:
docker network inspect shop_defaultبلاک Containers هر کانتینر متصل را به همراه آدرس آن فهرست میکند. سرویسی که در آن لیست نباشد، یا در شبکه دیگری است، یا در حالت host است، یا در حال اجرا نیست.
ترجمه نام را از یک کانتینر موقت که به همان شبکه متصل است تست کنید تا نیازی به هیچ ابزاری در داخل تصاویر خود نداشته باشید:
docker run --rm --network shop_default busybox nslookup db
docker run --rm --network shop_default busybox nc -zv db 5432شکست در nslookup به معنای مشکل در ترجمه نام یا عضویت در شبکه است. موفقیت nslookup در حالی که nc شکست میخورد، به این معنی است که نام و شبکه مشکلی ندارند و مشکل خودِ اتصال است. اگر nc گزارش Connection refused میدهد، دلیل معمول این است که سرویس روی آن پورت گوش نمیدهد، یا به جای 0.0.0.0، روی 127.0.0.1 در داخل کانتینر خود گوش میدهد. مورد آخر در سرورهای توسعه رایج است و راهحل آن در آدرس bind برنامه است، نه در Docker. اگر nc معلق میماند و زمان آن به پایان میرسد (timeout)، به دنبال چیزی باشید که ترافیک را در مسیر مسدود میکند، مانند یک قانون فایروال، یا کانتینری که متوقف شده یا پاسخ نمیدهد.
یک خطای دیگر که شبیه باگ Docker به نظر میرسد: اگر کانتینرها میتوانند با یکدیگر صحبت کنند اما نمیتوانند به ماشینی در شبکه اداری یا VPN شما دسترسی پیدا کنند، احتمالاً زیرشبکه (subnet) داکر با آن شبکه همپوشانی دارد. Docker بهطور پیشفرض از 172.17.0.0/16 به بالا تخصیص میدهد. استخر (pool) را در /etc/docker/daemon.json تغییر دهید:
{
"default-address-pools": [
{ "base": "10.200.0.0/16", "size": 24 }
]
}سپس sudo systemctl restart docker را اجرا کنید و شبکههای تحت تأثیر را بازسازی کنید، زیرا یک شبکه موجود، زیرشبکهای را که با آن ایجاد شده است حفظ میکند.
FAQ
چرا کانتینرهای من نمیتوانند از طریق نام سرویس به یکدیگر متصل شوند؟
آنها در شبکه یکسانی قرار ندارند. ابزار Compose بهطور خودکار هر سرویس را در <project>_default قرار میدهد، اما به محض اینکه لیستی از networks: را به یک سرویس اضافه کنید، آن لیست به مجموعه کامل شبکههای آن سرویس تبدیل شده و شبکه پیشفرض دیگر لحاظ نمیشود. دستور docker network inspect <network> را اجرا کنید و بررسی کنید که هر دو کانتینر در بلوک Containers ظاهر شوند. همچنین اطمینان حاصل کنید که هیچکدام از سرویسها از network_mode: host استفاده نمیکنند، زیرا کانتینری که در حالت host اجرا میشود در هیچ شبکه Docker قرار ندارد و نمیتواند نام سرویسها را resolve کند.
آیا برای اینکه یک سرویس به سرویس دیگر دسترسی داشته باشد، باید پورتها را publish کنم؟
خیر. در یک شبکه Compose، تمام پورتهای هر کانتینر برای سایر کانتینرهای موجود در آن شبکه قابل دسترسی هستند. ports: تنها برای در معرض قرار دادن کانتینر در برابر ترافیک خارج از Docker وجود دارد و expose: صرفاً جنبه مستندسازی دارد. publish کردن پورت دیتابیس یک عادت رایج و پرهزینه است، زیرا دیتابیس را روی اینترفیس عمومی سرور شما قرار میدهد.
تفاوت بین شبکه bridge و host چیست؟
حالت bridge به کانتینر یک فضای نام شبکه (network namespace) و آدرس اختصاصی روی یک سوییچ مجازی میدهد که امکان resolve کردن نام بین کانتینرها و ترجمه ترافیک خروجی را فراهم میکند. حالت host مستقیماً از stack شبکه میزبان استفاده میکند: بدون آدرس مجزا، بدون امکان resolve کردن نام سرویس، بدون نیاز به publish کردن پورت و بدون ایزولاسیون از سایر سرویسهای در حال گوش دادن روی میزبان. حالت bridge پیشفرض است و تا زمانی که پردازش شما به اینترفیسهای میزبان نیاز نداشته باشد، گزینه مناسب است.
چگونه کانتینرهای دو فایل Compose متفاوت را به هم متصل کنم؟
یک شبکه مشترک با docker network create edge ایجاد کنید، سپس آن را در هر دو فایل با external: true تعریف کرده و سرویسهایی که نیاز به ارتباط دارند را به آن متصل کنید. ابزار Compose نه آن را ایجاد میکند و نه حذف. اگر مرحله ایجاد را نادیده بگیرید، Compose از شروع کار خودداری کرده و گزارش میدهد که شبکه به عنوان external تعریف شده اما یافت نشده است.
چرا کانتینر من با وجود مسدود بودن پورت در UFW، از اینترنت قابل دسترسی است؟
زیرا پورت publish شده توسط قوانین forwarding که Docker به iptables اضافه میکند، مدیریت میشود. آن قوانین پیش از قوانین UFW بررسی میشوند و ترافیک forward شده به هر حال از زنجیرهای که UFW فیلتر میکند عبور نمیکند. سمت میزبان را با "127.0.0.1:8080:80" به loopback محدود کنید و هر سرویس عمومی را پشت یک reverse proxy روی پورتهای 80 و 443 قرار دهید.