راهنمای کامل شبکهسازی در 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 داخلی اجرا میکند که هر container آن را در 127.0.0.11 میبیند. این سرور، نام سرویسها را به آدرسهای فعلی containerها ترجمه میکند. بنابراین، web میتواند به پایگاه داده با نام میزبان db و در پورت 5432 متصل شود، بدون اینکه نیاز به هیچگونه پیکربندی باشد.
docker compose exec web getent hosts dbاین دستور خطی مشابه 172.18.0.2 db چاپ میکند. اگر خروجی خالی باشد، یعنی دو سرویس در یک شبکه قرار ندارند.
اشتباهی که تقریباً همه یکبار مرتکب میشوند، استفاده از localhost در پیکربندی برنامه است. در داخل یک container، localhost به همان container اشاره دارد، نه به میزبان (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?رشته اتصال (connection string) باید postgresql://postgres:example@db:5432/postgres باشد. بخش میزبان، همان نام سرویس است.
دو نکته که بعداً در زمان شما صرفهجویی میکند: نامها به هر چیزی که در حال اجرا باشد ترجمه میشوند، بنابراین docker compose up -d --scale web=3 یک نام با سه آدرس ارائه میدهد و کلاینتی که DNS را برای همیشه کش (cache) کند، خود را به یک container ازکارافتاده متصل نگه میدارد. همچنین، شبکه قدیمی bridge که توسط یک docker run ساده و بدون --network استفاده میشود، هیچ قابلیت ترجمه نامی ندارد؛ به همین دلیل است که توصیههای مربوط به container links که مربوط به سال 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 فضای نام شبکه (network namespace) کانتینر را حذف میکند و به پردازش اجازه میدهد مستقیماً از رابطهای (interfaces) میزبان استفاده کند.
services:
probe:
image: alpine:3.20
network_mode: host
command: sleep infinityدلایل موجهی برای استفاده از این حالت وجود دارد. پردازشی که نیاز به مشاهده ترافیک broadcast یا multicast در شبکه محلی دارد، مانند کشف دستگاه برای یک مدیا سرور یا هاب اتوماسیون خانگی، نمیتواند این ترافیک را از پشت یک bridge ببیند، زیرا bridge این ترافیک را به کانتینر هدایت نمیکند. یک عامل مانیتورینگ که شمارندههای رابط میزبان را میخواند نیز به رابطهای میزبان نیاز دارد. همچنین، شما از مرحله ترجمه آدرس (NAT) عبور میکنید که در نرخهای بالای بسته (packet rates) اهمیت دارد.
هزینههای این کار مشخص است.
ports: از کار میافتد. Docker هشدار میدهد که هنگام استفاده از حالت شبکه host، پورتهای منتشر شده (published ports) نادیده گرفته میشوند و کانتینر به هر پورتی که پردازش آن متصل شود، bind میشود. دو کانتینر در حالت host که هر دو بخواهند از پورت 8080 استفاده کنند، دچار تداخل میشوند و دومی با خطای bind: address already in use متوقف میشود.
وضوح نام (Name resolution) بر اساس نام سرویس در هر دو جهت از بین میرود. کانتینر در شبکه پروژه قرار ندارد، بنابراین نمیتواند db را resolve کند و سایر سرویسها نیز نمیتوانند آن را پیدا کنند. کانتینر تنها از طریق پورتهای منتشر شده روی میزبان، معمولاً در 127.0.0.1، به آنها دسترسی پیدا میکند.
ایزولاسیون از بین میرود. پردازشی که داخل یک کانتینر با حالت host به 0.0.0.0 متصل میشود، روی تمام رابطهای سرور شما، از جمله رابط عمومی، گوش میدهد؛ دقیقاً مانند بستهای که با 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 اجرا شده و یا اصلاً در حال اجرا نیست.
وضوح نام (name resolution) را از طریق یک کانتینر موقت که به همان شبکه متصل است تست کنید؛ با این روش نیازی نیست هیچ ابزاری را درون imageهای خود نصب کنید:
docker run --rm --network shop_default busybox nslookup db
docker run --rm --network shop_default busybox nc -zv db 5432nslookup در صورت شکست، نشاندهنده مشکل در وضوح نام یا عضویت در شبکه است. اگر nslookup موفقیتآمیز باشد اما nc با خطا مواجه شود، به این معناست که سرویس در حال اجراست اما روی آن پورت گوش نمیدهد، یا به جای 0.0.0.0، روی 127.0.0.1 در داخل کانتینر خود گوش میدهد. مورد آخر در سرورهای توسعه رایج است و راهحل آن در تنظیمات bind address برنامه نهفته است، نه در Docker.
یک خطای دیگر که ممکن است شبیه به باگ Docker به نظر برسد: اگر کانتینرها میتوانند با یکدیگر ارتباط برقرار کنند اما به ماشینی در شبکه دفتر یا VPN شما دسترسی ندارند، احتمالاً subnet شبکه Docker با آن شبکه همپوشانی دارد. 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 را اجرا کرده و شبکههای تحت تأثیر را دوباره ایجاد کنید، زیرا شبکههای موجود، subnet اولیه خود را حفظ میکنند.
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 قرار دهید.