پورت و سوکت؛ تفاوت EADDRINUSE و ECONNREFUSED
در این درس ۸ بخش
در مسیر بکاند قبلی با خطای اشغال پورت آشنا شدید. ارزش تازهٔ این درس، تکرار نام همان خطا نیست؛ زنجیرهٔ ساخت سوکت، اختصاص نشانی، گوشدادن و تلاش اتصال را جدا بازتولید میکنیم. میخواهیم بفهمیم چرا یک برنامه پیش از شروع شنود شکست میخورد و چرا پورت رزروشده میتواند هنوز اتصال را رد کند. همهٔ آزمایشها TCP و IPv4، روی loopback همین محیط و پورت انتخابی سیستمعاملاند. هیچ مقصد اینترنتی یا پورتِ سرویس دیگری را آزمایش یا جابهجا نمیکنیم.
چهار فعل نزدیک، چهار مسئولیت متفاوت
socket یک نقطهٔ ارتباطی با خانواده و نوع مشخص میسازد. bind نشانی محلی را به آن اختصاص میدهد. در سمت سرور TCP، listen آن را برای دریافت درخواست اتصال آماده میکند و accept اتصال پذیرفتهشده را به سوکت تازهای میرساند. سمت کلاینت، connect تلاش اتصال به مقصد مشخص را آغاز میکند. این ترتیب ساده نشان میدهد «فرایند وجود دارد»، «نشانی گرفته شده»، «سوکت گوش میدهد» و «برنامه پاسخ درست داده» یک شاهد یکسان نیستند. #linux-socket و #linux-port این اصطلاحها را جدا مرور میکنند.
نشانی را فقط یک عدد پورت نبینید؛ خانوادهٔ آدرس، نشانی رابط و نوع ارتباط نیز در معنا دخیلاند. 127.0.0.1 با نشانی عمومی یا همهٔ رابطها فرق دارد. این آزمایش روی IPv4 صریح است و دربارهٔ رفتار دوپشتهٔ IPv6 حکم نمیدهد. هنگام bind، پورت صفر از سیستمعامل یک پورت موقت میخواهد؛ شمارهٔ واقعی را با getsockname میگیریم. کلاینت به همان شمارهٔ واقعی وصل میشود، نه به صفر. #linux-loopback محدودهٔ محلیبودن آزمایش را روشن میکند.
آزمایش اول؛ اشغال نشانی در لحظهٔ bind
از پوشهٔ project فرمان inuse را اجرا کنید. سوکت اولِ خود آزمایش به نشانی loopback و یک پورت آزاد bind میشود و تا پایان تلاش دوم باز میماند. سوکت دوم همان نشانی را بدون سازوکار اشتراک پورت میخواهد. در این قرارداد انتظار EADDRINUSE داریم. چون هر دو مالک و ترتیب ساختشان را میشناسیم، برای ایجاد شاهد به حدسزدن پورت یک برنامهٔ دیگر یا خاتمهدادن آن نیاز نداریم.
python3 lab.py inuseJSON موفق، case برابر inuse، errno برابر EADDRINUSE و address برابر 127.0.0.1 دارد. port عدد متغیر همین اجراست. صفرِ پایان فرمان یعنی این برخوردِ مورد انتظار مشاهده و منابع جمع شدهاند، نه اینکه برنامهٔ دوم توانسته شنونده شود. اگر نتیجه متفاوت بود، شیوهٔ اجرا و پیام واقعی را ثبت کنید. افزودن SO_REUSEPORT یا دستکاری تنظیمات کرنل، آزمایش دیگری میسازد و راهی برای گذراندن همین قرارداد نیست.
آزمایش دوم؛ نشانی هست ولی شنونده نیست
در refused، سوکت اول همان روش رزرو loopback را دارد، ولی listen روی آن انجام نمیشود. سوکت دوم به نشانی واقعیِ رزروشده connect میکند و در محیط هدف لینوکس باید ECONNREFUSED بگیرد. سوکت مالک را پیش از تلاش نمیبندیم؛ بنابراین فاصلهای نمیسازیم که برنامهٔ دیگری بتواند پورت را بگیرد. این طراحی، شاهد را از روش ضعیف «یک شمارهٔ آزاد پیدا کن، آن را ببند و بعد امیدوار باش هنوز آزاد باشد» مستقل میکند.
python3 lab.py refusedدر نتیجهٔ موفق، case برابر refused، errno برابر ECONNREFUSED و listening برابر false است. نشانی 127.0.0.1 و پورت ثبتشده نشان میدهند آزمایش کجا انجام شده است؛ هنوز این عددها را بهعنوان نشانی یک سرویس واقعی بازاستفاده نمیکنیم. اینجا علت ردشدن را خودمان کنترل کردهایم. در شبکهٔ واقعی، دریافت رد فعال بهتنهایی محل تولید آن را برای همیشه ثابت نمیکند؛ لایههای واسط و سیاستها هم باید در پروندهٔ تشخیص دیده شوند.
کد مستقل برای دیدن مرز دو شکست
فایل examples/04-socket-steps.py دو آزمایش کوتاه را با یک مالک انجام میدهد. ابتدا bind دوم را میسنجد و بعد، چون مالک هرگز listen نکرده، connect را امتحان میکند. context manager همهٔ سوکتها را میبندد و timeout کوتاه فقط همان تلاش اتصال محلی را محدود میکند. در خروجی نام عملیات هم چاپ میشود؛ وجود یک نام errno بدون دانستن اینکه bind شکست خورده یا connect، اطلاعات کافی برای انتخاب اصلاح نیست.
import errno
import socket
import sys
if not sys.platform.startswith("linux"):
raise SystemExit("This example targets Linux")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as owner:
owner.bind(("127.0.0.1", 0))
address = owner.getsockname()
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as second:
try:
second.bind(address)
except OSError as error:
assert error.errno == errno.EADDRINUSE
print("bind:", errno.errorcode[error.errno])
else:
raise AssertionError("Second bind unexpectedly succeeded")
# The owner reserves this address but never calls listen().
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client:
client.settimeout(1)
try:
client.connect(address)
except OSError as error:
assert error.errno == errno.ECONNREFUSED
print("connect:", errno.errorcode[error.errno])
else:
raise AssertionError("Connection unexpectedly succeeded")
دو خط انتظار داریم: bind: EADDRINUSE و connect: ECONNREFUSED. کد اگر شکست مورد انتظار رخ ندهد، AssertionError میسازد؛ هر OSError را موفق فرض نمیکند. در این مثال عملیات کمتعداد و دادهٔ کنترلشده است و نیازی به سنجش سرعت ندارد. خروجی، شاهد رفتار همین سناریوی لینوکسی است؛ آزمون ظرفیت backlog، پایداری سرویس در بار زیاد یا دسترسپذیری از گوشی موضوع دیگری است.
چرا نامِ ابزار و مرحلهٔ خطا باید در گزارش باشد؟
یک پیام رابط کاربری مانند «ارتباط برقرار نشد» ممکن است شکست حل نام، connect، TLS یا پاسخ نامعتبر برنامه را پنهان کند. در این درس نام دامنه را کنار گذاشتهایم و نشانی عددی داریم؛ پس ادعای تشخیص DNS نمیکنیم. اگر connect موفق شده و برنامه بعداً منتظر داده مانده باشد، به درس Timeout میرسیم. حتی پاسخ HTTP خطا مانند ۵۰۰ نیز شاهد دریافت پاسخ پروتکل است و با نرسیدن TCP یکسان نیست. گام تشخیص باید از شاهد همان مرحله بیاید.
در سرور واقعی، پیش از تغییر پورت باید قرارداد کلاینت، پراکسی و سرویس را هم دید. عوضکردن عدد در یک طرف ممکن است اشغال را دور بزند ولی ارتباط انتهابهانتها را بشکند. برای شناسایی شنونده، اطلاعات خواندنیِ ابزارهایی مانند ss میتواند کمک کند، اما نبود نام PID در خروجی محدود به مجوزهاست. در این آزمایش، مالک را از خود کد میشناسیم. مشاهدهٔ یک PID ناشناس مجوز کشتن آن نیست؛ ابتدا باید رابطهٔ آن با خدمت و تصمیم مدیریتی روشن باشد.
اصلاح کوچک متناسب با شاهد
برای برخورد عمدیِ آزمایش، بستن سوکت مالک در پایان محدوده، نشانی را آزاد میکند؛ اما در برنامهٔ واقعی، تصمیم ممکن است جلوگیری از اجرای تکراری یا اصلاح قرارداد پورت باشد. برای refused، آغاز شنود درست در نشانی مورد توافق یک فرضیهٔ مرتبط است؛ خاموشکردن فایروال پاسخ عمومی این پیام نیست. پس از تغییر، فقط نبود پیام را نسنجید؛ اتصال و یک پاسخ معنیدار برنامه را هم بررسی کنید. موفقیت connect، کیفیت یا درستی محتوای پاسخ را تضمین نمیکند.
پیش از بازتولید روی سامانهٔ مشترک، دامنهٔ آزمایش را محدود کنید. اتصال محلی به پورت تصادفیِ خودمان با اسکن شبکه یا آزمودن تمام پورتهای میزبان تفاوت دارد. این بسته هیچ شنوندهٔ عمومی باقی نمیگذارد و نیاز به تغییر قواعد شبکه ندارد. اگر محیط اجرای شما از ساخت سوکت جلوگیری میکند، محدودیت را گزارش کنید؛ آن را با تغییر حفاظتهای میزبان یا اجرای نامحدود با root پنهان نکنید. در درس بعد به انتظار پس از اتصال و بودجهٔ کل زمان میپردازیم.
تمرینها و پاسخ تشریحی
پرسش — دو اجرای برنامه یک نشانی محلی یکسان را میخواهند و دومی هنگام bind، EADDRINUSE میگیرد. چرا بازکردن فایروال به این شاهد پاسخ نمیدهد؟
پاسخ تشریحی — شکست در اختصاص نشانی محلی رخ داده، پیش از آنکه سرویس دوم شنوندهٔ مورد انتظار شود. در این آزمایش مالکِ نشانی همان سوکت اولِ خودمان است. باید هویت اجراها و قرارداد پورت را بررسی کرد. تغییر فایروال مالکیت نشانی را آزاد نمیکند و کشتن تصادفی یک PID هم اصلاح قابل دفاعی نیست.
پرسش — در refused، همان سوکت اول پورت را نگه داشته است. پس چرا connect موفق نیست؟
پاسخ تشریحی — bind فقط نشانی را به سوکت داده است. در آزمایش TCP، owner هرگز listen را فراخوانی نکرده و شنوندهای برای پذیرش اتصال وجود ندارد. فرایند و پورتِ رزروشده بهتنهایی سلامت سرویس را اثبات نمیکنند. نگهداشتن رزرو همچنین مانع مسابقهٔ معمولِ گرفتن شمارهٔ آزاد و بستن آن پیش از آزمایش میشود.
پرسش — اگر روی میزبان واقعی ECONNREFUSED دیدیم، آیا میتوانیم بگوییم برنامه حتماً خاموش است و timeout هم همین معنا را دارد؟
پاسخ تشریحی — خیر. آزمایش محلی ما علت را از پیش کنترل میکند، اما در مسیر واقعی رد فعال میتواند به مقصد یا قواعد مسیر مربوط باشد. باید نشانی، خانوادهٔ آدرس، شنونده و شاهد همان لحظه را بررسی کرد. timeout هم فقط پایان بودجهٔ انتظار را میگوید و با رد فعال یکسان نیست؛ محل انتظار و علت آن جدا بررسی میشوند.
ادامهٔ آموختههای قبلی و منابع
برای مدل لایهبهلایهٔ نام تا پاسخ، درس شبکهٔ لینوکس پیشرفته را مرور کنید:
https://www.darsavand.com/courses/linux-advanced-administration?lesson=0e24bd50-d875-4979-8a88-69fea1b60aa9
برای جایگاه نشانی شنونده و پورت در استقرار بکاند، درس دامنه و فایروال را ببینید:
https://www.darsavand.com/courses/nodejs-backend-ubuntu-deployment?lesson=e9c6a3bd-ba35-4a0e-85d0-3d64757c2caa
اکنون آن نقشه را به دو شکست واقعی و کوچک وصل کردیم؛ این آزمایش جای راهاندازی Nginx یا صدور گواهی را نمیگیرد.
منابع رسمی برای مطالعهٔ تکمیلی:
Linux man-pages — bind(2): https://man7.org/linux/man-pages/man2/bind.2.html
Linux man-pages — connect(2): https://man7.org/linux/man-pages/man2/connect.2.html
Python 3.12 — socket: https://docs.python.org/3.12/library/socket.html
Linux man-pages — listen(2): https://man7.org/linux/man-pages/man2/listen.2.html
تألیف، مثالها و پرسشهای اختصاصی: تحریریه درسآوند.
فایل آزمایشگاه، راهنمای اجرا، آزمونها و فرم گزارش رخداد:
https://www.darsavand.com/resources/linux-application-errors-practice-kit
برای دیدن ترتیب دورههای مقدماتی، پیشرفته و این مسیر، نقشهٔ راه لینوکس را بخوانید:
https://www.darsavand.com/resources/linux-learning-roadmap
تألیف و مثالهای اختصاصی: تحریریه درسآوند.
متن درسهای عمومی را بدون ورود بخوانید.