تخيل أن رئيس قسم في الجامعة يتصل بك ويقول إن البريد الإلكتروني في قسمه يعمل، ولكنه لا يصل إلى أي مكان أبعد من 500 ميل تقريباً، وإن عنده خريطة تثبت ذلك. أول ما سوف تفكر فيه أن البريد لا يعرف شيئاً عن الأميال، وأن الرسالة تصل إلى الطرف الآخر من العالم كما تصل إلى المبنى المجاور، وبالتالي فالبلاغ إما مزحة وإما سوء فهم. ولكن هذا البلاغ وصل فعلاً إلى مدير أنظمة اسمه Trey Harris، وعندما جمع البيانات بنفسه وجد أنه صحيح، وكان السبب مهلة اتصال Connect Timeout صارت صفراً بعد تحديث بسيط، والمسافة التي يقطعها الضوء في تلك المهلة القصيرة تساوي تقريباً 500 ميل.
وهذه القصة من أشهر قصص مديري الأنظمة، وقد كتبها صاحبها بنفسه في 2002، ونحن هنا لن نعيد نصها وإنما سوف نرويها بكلماتنا كتحقيق تقني، كما فعلنا في تحقيق الباب الخلفي في XZ: لكل خطوة الملاحظة والفرضية والأمر الذي تستخدمه اليوم في الموقف نفسه، ولماذا كانت الخطوة تضيق مكان البحث، ثم نصنع على جهازك «مسافة» حقيقية بين Containers ونرى بأعيننا كيف تفشل المهلة القصيرة مع البعيد وتنجح مع القريب.
وسوف نناقش في هذا المقال ما يلي:
- مصدر القصة ومن كتبها، وما الذي نعرفه عنها بشكل مؤكد.
- البلاغ الغريب، ثم خطوات التحقيق الثماني بالترتيب: ما الذي تغير، وجمع البيانات، وعزل العامل الحقيقي، وملف الإعداد، ورسالة الترحيب Banner، والسبب، والحساب الفيزيائي، والإصلاح.
- الحساب بالأرقام: كم يقطع الضوء في الفراغ وفي الألياف خلال 3 مللي ثانية، وما الذي قاله الكاتب بنفسه عن الأرقام التقريبية في قصته.
- أين تجد مهلة الاتصال اليوم: Postfix وSendmail ونواة Linux وcurl ومكتبات HTTP في تطبيقك، وكم تكلف المسافة فعلاً.
- تجربة عملية Lab آمنة على Containers نضيف فيها تأخيراً بالمللي ثانية، ونقيس بالأوامر curl وswaks وPython وPostfix، ونحسب أقصى مسافة لكل مهلة.
- الدروس التي تستحق أن تنقلها إلى فريقك.
الصورة التالية تبين خطوات التحقيق التي سوف نمر عليها بالترتيب:

من كتب القصة، وما الذي نعرفه عنها؟
كتب Trey Harris القصة في 24 نوفمبر 2002 كرسالة إلى القائمة البريدية sage-members، وهي قائمة أعضاء SAGE أي نقابة مديري الأنظمة System Administrators Guild، وكانت الرسالة رداً على نقاش خفيف عنوانه «المهمة المستحيلة المفضلة لديك»، ثم انتشرت بعد ذلك انتشاراً كبيراً، وهو يحتفظ بها اليوم بنصها الأصلي في صفحته على ibiblio. ولاحظ أنه كتب في أول الرسالة أنه غير بعض التفاصيل وحذف ما كان مملاً ليجعل القصة أمتع، وهذه نقطة مهمة سوف نعود إليها.
وبعد أن كثرت الأسئلة والاعتراضات على التفاصيل التقنية، كتب صفحة أسئلة شائعة FAQ عن القصة أجاب فيها بصراحة، فذكر أن القصة حدثت فعلاً وأنه كان يدير نظام البريد المركزي في جامعة North Carolina at Chapel Hill، وأنه كتب ملفات إعداد Sendmail التي تستخدمها أغلب سيرفرات البريد في الأقسام، وأنه لا يعرف التاريخ بالضبط ولكنه يقدر أنها حدثت بين 1994 و1997. وذكر أيضاً أنه لم يحتفظ بالسجلات ولا بالملاحظات التي كتبها وقت التحقيق، لذلك فبعض الأرقام في القصة أعيد بناؤها من الذاكرة، وسوف نفصل في قسم الدقة ما هو مؤكد وما هو تقريبي.
البلاغ: بريد لا يصل إلى أبعد من 500 ميل
بدأت القصة بمكالمة من رئيس قسم الإحصاء، وجملته التي اشتهرت بها القصة كانت: "We can't send mail more than 500 miles"، أي أنهم لا يستطيعون إرسال البريد لأبعد من 500 ميل، ثم أضاف أن المسافة أكثر بقليل، نحو 520 ميلاً، ولكن ليس أبعد من ذلك.
وكان رد فعل Trey الطبيعي هو عدم التصديق، فالبريد لا يعمل بهذه الطريقة، والرسالة تمر بنفس البروتوكول SMTP مهما كان بعد المستلم، كما شرحنا في مقال كيف يعمل البريد الإلكتروني. والطريف أن رئيس القسم لم يتصل فور ظهور المشكلة، وإنما انتظر عدة أيام حتى يجمع بيانات كافية، فهو في النهاية رئيس قسم الإحصاء، وطلب من متخصصة في الإحصاء الجغرافي Geostatistics أن تدرس الأمر، فرسمت خريطة تبين أن البريد يصل داخل دائرة نصف قطرها أكثر بقليل من 500 ميل، وأن بعض الوجهات داخل الدائرة تفشل أيضاً أو تنجح أحياناً فقط، ولكن لا توجد أي وجهة خارج الدائرة نجح الإرسال إليها.
وقد يتساءل البعض: لماذا نعطي بلاغاً كهذا أي وقت؟ والإجابة أن البلاغ يصف عرضاً Symptom وليس سبباً، والمستخدم يصف ما يراه بلغته، وهو هنا يراه بلغة الخرائط لأنه إحصائي، والعرض نفسه حقيقي حتى لو كان التفسير الذي في رأسه غير صحيح. وهذه هي الخطوة الأولى في أي تحقيق: صف العرض بدقة كما هو، قبل أن تقرر أنه مستحيل.
التحقيق خطوة بخطوة
سوف نعرض الخطوات بالترتيب الذي رواه Trey، ونذكر لكل خطوة ما لاحظه، والفرضية التي كانت في ذهنه، ثم الأمر الذي يستخدمه أي مدير أنظمة اليوم في الموقف نفسه. ولاحظ أن الأوامر هنا من أدوات اليوم وليست من القصة، فالقصة حدثت في التسعينات على SunOS وSendmail، أما ما يهمنا فهو طريقة التفكير.
الخطوة الأولى: ما الذي تغير؟
قبل أن يفتح أي ملف سأل رئيس القسم سؤالاً واحداً: هل تغير شيء في أنظمتكم عندما بدأت المشكلة؟ والجواب كان أن مستشاراً خارجياً قام بتحديث السيرفر Patching وأعاد تشغيله قبل أيام، ولكنه أكد أنه لم يلمس نظام البريد. وهذه معلومة ذهبية حتى لو قال صاحبها إنها غير مهمة، والسبب أن أغلب الأعطال المفاجئة يسبقها تغيير، والجملة «لم ألمس نظام البريد» تعني أنه لم يعدل إعدادات البريد بنفسه، ولكنها لا تقول شيئاً عن الحزم التي غيرها التحديث.
وعلى سيرفر Linux اليوم تجيب عن هذا السؤال بأوامر تقرأ ما حدث فعلاً بدلاً من ذاكرة الأشخاص:
# متى أعيد تشغيل السيرفر آخر مرة؟
uptime -s
last -x reboot | head -5
# ما الحزم التي حدثت أو ثبتت مؤخراً؟ (Debian وUbuntu)
grep -E " (upgrade|install) " /var/log/dpkg.log | tail -20
# هل تغير إصدار برنامج البريد نفسه؟
postconf mail_versionوقد شرحنا هذه القاعدة في دليل استكشاف الأخطاء: ما الذي تغير؟، فالسؤال عن التغيير يسبق السؤال عن السبب، وفي هذه القصة كان الجواب الصحيح موجوداً من أول دقيقة، ولكن لم يعرف أحد بعد أنه الجواب.
الخطوة الثانية: اجمع البيانات بنفسك
الحل البسيط هنا هو أن تقرر أن البلاغ خطأ وتغلق التذكرة، أو أن تبدأ بتغيير الإعدادات عشوائياً. ولكن Trey دخل إلى سيرفر القسم وأرسل رسائل اختبار بنفسه، والسبب أن البيانات التي يجمعها المحقق بيده أوثق من أي وصف يسمعه. وكانت النتائج كما رواها: الرسالة إلى حسابه في الجامعة نفسها وصلت، وكذلك إلى Richmond وAtlanta وWashington، ثم Princeton على بعد 400 ميل وصلت، ونيويورك على بعد 420 ميلاً وصلت، أما Memphis على بعد 600 ميل فقد فشلت، وكذلك Boston وDetroit، وProvidence على بعد 580 ميلاً فشلت أيضاً.
والملاحظة المهمة هنا أن النتيجة قابلة للتكرار، وأن الحد الفاصل بين النجاح والفشل يقع فعلاً بين 420 و580 ميلاً، أي أن البلاغ الذي بدا مستحيلاً صار حقيقة يمكن قياسها. واليوم تجمع هذه البيانات بأداة swaks التي تفتح اتصال SMTP وتعرض كل خطوة فيه، والخيار --quit-after RCPT يوقف المحادثة قبل إرسال الرسالة فعلاً، فلا تزعج أحداً برسائل الاختبار:
for d in example.org example.net example.com; do
echo "== $d"
time swaks --to postmaster@$d --quit-after RCPT --hide-informational
done
# وفي سجل Postfix: هل تتكرر أخطاء الاتصال نفسها؟
grep -E "connect to .*(timed out|refused)" /var/log/mail.log | tail -20ولاحظ أن الأداة time مع كل وجهة تعطيك الرقم الذي سوف يكون مفتاح القصة كلها، وهو زمن فتح الاتصال، وسوف نرى في التجربة العملية كيف يظهر هذا الزمن عند كل أمر في محادثة SMTP.
الخطوة الثالثة: افصل بين المستلم والسيرفر
بعد أن تأكد العرض ظهرت فرضية مقلقة، فهل تعتمد المشكلة على مكان الإنسان الذي يستلم الرسالة؟ وهذا مستحيل فيزيائياً، لأن السيرفر المرسل لا يعرف أين يجلس المستلم، وإنما يعرف أين سيرفر البريد الذي يستقبل عنه. وللتأكد من ذلك أرسل Trey رسالة إلى صديق يسكن في North Carolina نفسها، ولكن مزود بريده في Seattle على الطرف الآخر من الولايات المتحدة، فإذا وصلت الرسالة فالمشكلة في مكان الإنسان، وإذا فشلت فالمشكلة في المسافة إلى السيرفر. وقد فشلت، وكتب أنه شعر بالارتياح لذلك.
وهذه تجربة ضابطة Control Experiment بالمعنى الحرفي، فهي تغير عاملاً واحداً فقط، المسافة إلى السيرفر، وتثبت الباقي، كما شرحنا في قاعدة فرضية واحدة وقياس واحد وتغيير واحد. واليوم تعرف أين يستقبل أي نطاق بريده من سجل MX كما شرحنا في مقال DNS وسجلاته، ثم تقيس الطريق إلى ذلك السيرفر نفسه:
# أين يستقبل النطاق بريده؟
dig +short MX example.org
# كم يستغرق الذهاب والعودة إلى ذلك السيرفر، وما الطريق إليه؟
ping -c 5 mx.example.org
mtr -rwc 10 mx.example.org
# كم يستغرق فتح اتصال TCP على المنفذ 25 فقط؟
time nc -z -w 5 mx.example.org 25والأمر الأخير يفتح اتصال TCP فقط ثم يغلقه، فالزمن الذي يطبعه time هو تقريباً زمن اكتمال الاتصال، وهو بالضبط الرقم الذي كانت تقطعه المهلة في هذه القصة، وسوف نقيسه بدقة أكبر في التجربة بالمتغير time_connect في curl -w الذي شرحناه في تمرين توقيتات curl.
الخطوة الرابعة: هل تغير ملف الإعداد؟
الآن صار السؤال: ما الذي يمنع الاتصال بالسيرفرات البعيدة؟ وأول مكان طبيعي للبحث هو ملف إعداد Sendmail واسمه sendmail.cf، ففتحه Trey ووجده طبيعياً بل ومألوفاً، ثم قارنه بالأمر diff مع النسخة التي يحتفظ بها في مجلده، فلم يجد أي فرق، لأنه هو من كتب هذا الملف أصلاً. وعلق ساخراً بأنه متأكد أنه لم يفعل خياراً اسمه «افشل في إرسال البريد لأبعد من 500 ميل».
وهذه الخطوة أغلقت فرضية كاملة: الإعداد لم يتغير. ولكن لاحظ أنها لم تجب عن السؤال الأهم، وهو هل البرنامج الذي يقرأ الإعداد هو نفسه؟ واليوم تقارن إعداد Postfix بطريقة أدق من diff، لأن الأمر postconf -n يعرض فقط القيم التي غيرتها عن الافتراضي، وpostconf -d يعرض القيم الافتراضية التي يعرفها البرنامج المثبت فعلاً:
# الإعداد الذي كتبته أنت، مقارنة مع نسخة محفوظة
postconf -n > /tmp/postconf-now.txt
diff -u ~/postconf-known-good.txt /tmp/postconf-now.txt
# القيمة الافتراضية لمهلة الاتصال في هذا الإصدار
postconf -d smtp_connect_timeoutوالمخرج للأمر الأخير على Postfix 3.10 في تجربتنا سوف يكون كما يلي:
smtp_connect_timeout = 30sالخطوة الخامسة: اسأل السيرفر من هو
بعد أن عجز عن إيجاد السبب في الإعداد، اتصل Trey بالمنفذ 25 مباشرة عبر telnet ليرى كيف يرد السيرفر، فرد عليه السيرفر برسالة الترحيب Banner الخاصة بنسخة Sendmail التي تأتي مع SunOS. وهنا توقف، لأن Sun كانت ما زالت توزع Sendmail 5 مع نظامها رغم أن Sendmail 8 كان ناضجاً، وهو كمدير أنظمة جيد كان قد وحد كل السيرفرات على Sendmail 8، وكتب ملفات الإعداد بأسماء الخيارات الطويلة الواضحة التي جاءت مع الإصدار 8، بدلاً من الرموز المختصرة التي كان يستخدمها الإصدار 5.
وهذه هي الملاحظة المفتاحية في القصة كلها: رسالة الترحيب تقول إن البرنامج الذي يعمل الآن ليس البرنامج الذي كتب له الإعداد. واليوم تحصل على نفس المعلومة بالأمر التالي، وهو من تجربتنا العملية:
swaks --server 10.50.2.10 --quit-after CONNECTوالمخرج سوف يكون كما يلي:
=== Trying 10.50.2.10:25...
=== Connected to 10.50.2.10.
<- 220 mx.far.lab ESMTP Postfix (Debian)
-> QUIT
<- 221 2.0.0 Bye
=== Connection closed with remote host.في المخرج أعلاه لاحظ التالي:
- السطر الذي يبدأ بالرقم
220هو رسالة الترحيب، وفيها اسم السيرفر واسم البرنامج، وأحياناً الإصدار، وكثير من المسؤولين يخفون الإصدار لأسباب أمنية، ولكن اسم البرنامج يكفي غالباً لتعرف هل هو ما تتوقعه أم لا. - على السيرفر نفسه لا تعتمد على رسالة الترحيب، فاسأل البرنامج مباشرة عن إصداره بالأمر
postconf mail_versionأوdpkg -l postfix، أو بالأمرsendmail -d0.1 -bv rootإذا كان Sendmail هو البرنامج.
الخطوة السادسة: السبب، إصدار قديم يقرأ إعداداً جديداً
بعدها اتضحت الصورة كاملة، والصورة التالية تبين السلسلة:

وبالتفصيل كما رواها Trey:
- المستشار قام بتحديث نظام التشغيل SunOS إلى إصدار أحدث، وهذا التحديث استبدل برنامج Sendmail 8 الذي ثبته Trey بنسخة Sendmail 5 التي توزعها Sun، أي أن التحديث أرجع برنامج البريد إلى إصدار أقدم Downgrade.
- التحديث ترك ملف
sendmail.cfكما هو، رغم أنه مكتوب لإصدار آخر. - نسخة Sendmail 5 من Sun، وكانت فيها تعديلات خاصة، استطاعت أن تقرأ الملف، لأن أغلب القواعد Rules لم تتغير بين الإصدارين، أما خيارات الإعداد بأسمائها الطويلة فاعتبرتها كلاماً غير مفهوم وتجاوزتها بدون أي خطأ.
- البرنامج لم تكن فيه قيم افتراضية مكتوبة داخله Compiled Defaults لأغلب هذه الخيارات، لذلك عندما لم يجد قيمة صالحة في الملف جعلها صفراً.
- أحد هذه الخيارات كان مهلة الاتصال بسيرفر SMTP البعيد، وبالتجربة وجد Trey أن المهلة الصفرية على ذلك الجهاز وبحمله المعتاد كانت تقطع استدعاء
connect()بعد أكثر من 3 مللي ثانية بقليل.
والمفارقة أن المعنى الحديث للصفر في هذا الخيار عكس ما حدث تماماً، ففي دليل تشغيل Sendmail 8 الخيار اسمه Timeout.connect، والصفر فيه يعني «استخدم المهلة الافتراضية في النواة»، وهي مهلة طويلة، والدليل يذكر أن هذا الخيار يستطيع أن يقصر مهلة النواة ولكن لا يستطيع أن يطيلها. وكذلك في توثيق Postfix الصفر في smtp_connect_timeout يعني إلغاء المهلة وترك القرار لنظام التشغيل، أي أن نفس القيمة كانت تعني «انتظر طويلاً» في برنامج و«لا تنتظر تقريباً» في برنامج آخر.
وقد يتساءل البعض: هل يمكن أن يحدث هذا اليوم؟ والإجابة نعم بأشكال أخرى، فأي برنامج يتجاهل الإعداد الذي لا يفهمه بصمت معرض لنفس المشكلة. وPostfix مثال جيد على السلوك الصحيح، فهو يحذرك من أي إعداد لا يعرفه، والمثال التالي من تجربتنا كتبنا فيه اسم الخيار بخطأ إملائي عن قصد:
echo "smtp_connect_timout = 1s" >> /etc/postfix/main.cf
postconf -n > /dev/nullوالمخرج سوف يكون كما يلي:
postconf: warning: /etc/postfix/main.cf: unused parameter: smtp_connect_timout=1sلاحظ أن Postfix لم يتوقف ولم يرفض الإعداد، وإنما طبع تحذيراً، وهذا التحذير يظهر أيضاً عند postfix reload وفي السجل، لذلك اجعل فحص هذه التحذيرات جزءاً من أي تحديث، والسبب أن الخيار المكتوب خطأ أو غير المدعوم في إصدار أقدم يعمل بالقيمة الافتراضية بدون أن تعرف.
الخطوة السابعة: كم يقطع الضوء في 3 مللي ثانية؟
بقي سؤال واحد: لماذا 500 ميل بالذات؟ وهنا جاءت النقطة الثانية في القصة، فشبكة الجامعة في ذلك الوقت كانت كلها تعمل بأجهزة Switch، فالحزمة الخارجة لا تمر بأي Router حتى تصل إلى نقطة اتصال الجامعة بالإنترنت، وبالتالي فإن زمن الاتصال بسيرفر قليل الحمل في شبكة قريبة يحكمه أساساً بعد الوجهة بسرعة الضوء، وليس التأخير في أجهزة الطريق. فكتب Trey في الأداة units سؤالاً بسيطاً: كم ميلاً يقطع الضوء في 3 مللي ثانية؟ ونفس السؤال على Debian 13 في تجربتنا:
units "3 millilightseconds" milesوالمخرج سوف يكون كما يلي:
* 558.84719
/ 0.0017893979أي نحو 559 ميلاً، وهو الرقم الذي ذكره رئيس القسم تقريباً: «500 ميل أو أكثر بقليل». والحساب نفسه سهل باليد، فسرعة الضوء في الفراغ حسب قيمة NIST الرسمية هي 299,792,458 متراً في الثانية، أي نحو 300 كيلومتر في كل مللي ثانية، وفي 3 مللي ثانية يقطع نحو 899 كيلومتراً، وكل ميل يساوي 1.609344 كيلومتر، فيكون الناتج 559 ميلاً.
والصورة التالية تضع مدن القصة على هذا الحساب، وتبين أيضاً لماذا لا يكفي حساب الضوء في الفراغ وحده، كما سوف نشرح في القسم التالي:

الخطوة الثامنة: الإصلاح أولاً، ثم الفهم
في صفحة الأسئلة الشائعة ذكر Trey تفصيلاً لم يكن في القصة، وهو أنه بمجرد أن عرف أن استبدال برنامج Sendmail 5 بنسخة Sendmail 8 سوف يحل المشكلة قام بذلك فوراً، لأن تشغيل Sendmail 5 مع ملف إعداد مكتوب للإصدار 8 إعداد غير صحيح في كل الأحوال، ثم احتفظ بالبرنامج القديم ليجرب عليه في وقته ويفهم قصة المهلة كاملة. وكتب أيضاً أن التحقيق لم يأخذ عشر دقائق كما توحي القصة، وإنما عدة ساعات من العمل.
وهذا ترتيب صحيح في أي حادثة: أوقف الضرر عن المستخدمين بأسرع إصلاح آمن، ثم ارجع إلى فهم السبب بهدوء، كما شرحنا في متى توقف الضرر أولاً ثم تبحث عن السبب، فقسم الإحصاء يحتاج بريده اليوم، أما الحساب الفيزيائي فيمكن أن ينتظر.
ما الدقيق وما التقريبي في القصة؟
القصة مكتوبة كحكاية طريفة لزملاء في قائمة بريدية وليست تقرير حادثة، وقد قال Trey ذلك بنفسه في صفحة الأسئلة الشائعة، وأجاب عن أغلب الاعتراضات التقنية بصراحة، ومن المفيد أن تعرفها لأنها تعلمك كيف تقرأ أي رقم في أي تقرير:
| النقطة | ما قاله الكاتب في صفحة الأسئلة الشائعة |
|---|---|
| المهلة الصفرية تقطع الاتصال بعد 3 مللي ثانية تقريباً | قال إن الرقم 3 مللي ثانية لم يكن المهلة نفسها، وإنما رقم وصل إليه في النهاية بعد ساعات من التحقيق |
| الضوء يقطع 559 ميلاً في 3 مللي ثانية | الاتصال يحتاج أن تذهب الحزمة ويعود الرد، فالزمن الحقيقي ذهاباً وإياباً يجب أن يكون نحو 6 مللي ثانية، وقال إن هذا تفصيل حذفه لأنه ممل |
| هل يحتاج الاتصال المصافحة الثلاثية Three-way Handshake كاملة؟ | يظن أن connect() ينتهي عند وصول SYN-ACK، فلا يحتاج المصافحة كاملة، وهذا صحيح من جهة الكلاينت |
| الإشارة تسير بسرعة الضوء | اعترف أنها في النحاس نحو 3c/4، وفي الألياف بين 2c/3 وأقل قليلاً من c، وأنه قاس ping إلى عدة وجهات وكتب المسافة مقابل الزمن ووصل إلى «زمن فعلي» Effective Time تجريبي |
| شبكة الجامعة Switched بالكامل | قال إنه غير متأكد لماذا أضاف هذه الفقرة، وإن تأخير كل Router إضافي في حدود بضع مئات من الميكروثانية، وإنه كان يستطيع قياسه وطرحه |
| الحزم تسير في خط مستقيم | الدائرة كانت حداً أقصى، وبعض الوجهات داخلها فشلت لأن طريقها أطول من الخط المستقيم أو بسبب تأخير آخر مثل جدار ناري Firewall |
| Sendmail 5 قرأ ملف Sendmail 8 | قيل له إن Sendmail 5 الأصلي لا يقبل هذا الملف، فاستنتج أن نسخة Sun كانت معدلة، وهذا يوافق ما ذكره في القصة، ولاحظ أنه كتب في هذه الإجابة Solaris بينما كتب في القصة SunOS، وهذا مثال آخر على التفاصيل التي تروى من الذاكرة |
| البرنامج جعل الخيارات الناقصة صفراً | أكد أن Sendmail في ذلك الوقت لم تكن فيه قيم افتراضية لتلك الخيارات، وأن Eric Allman مؤلف Sendmail قال ذلك في درس حضره، وعندما سمع منه القصة سقط على الأرض متألماً كما كتب مازحاً |
والآن لنحسب بأنفسنا لماذا كان الكاتب محقاً عندما قال إن الأرقام لا تتطابق حرفياً، فالجدول التالي حسبناه بالأداة units في تجربتنا، والألياف بسرعة 2c/3 أي نحو 199,862 كيلومتراً في الثانية:
| الافتراض | الأمر | النتيجة |
|---|---|---|
| 3 مللي ثانية في اتجاه واحد بسرعة الضوء في الفراغ (حساب القصة) | units "3 millilightseconds" miles | 558.8 ميل |
| 3 مللي ثانية في اتجاه واحد داخل الألياف | units -t "3 ms * c * 2 / 3" miles | 372.6 ميل |
| 3 مللي ثانية ذهاباً وإياباً بسرعة الضوء في الفراغ | units -t "1.5 ms * c" miles | 279.4 ميل |
| كم يحتاج الذهاب والعودة إلى 520 ميلاً داخل الألياف؟ | units -t "2 * 520 miles / (c * 2 / 3)" ms | 8.37 مللي ثانية |
والخلاصة من الجدول أن المهلة الحقيقية التي تفسر حداً قريباً من 520 ميلاً تقع بين 6 و9 مللي ثانية تقريباً حسب الوسط وطول الطريق، وأن الرقم 3 مللي ثانية في القصة هو «الزمن الفعلي» في اتجاه واحد الذي قاسه الكاتب على شبكته، وليس المهلة كما تقيسها ساعة الجهاز. ولكن الفكرة الأساسية صحيحة تماماً: مهلة اتصال بضع مللي ثوان تكفي لتقسم العالم إلى قريب يرد في الوقت وبعيد لا يرد، وهذا بالضبط ما سوف نعيد إنتاجه في التجربة. وكما قال الكاتب في نهاية صفحته، السؤال الحقيقي هو: هل يمكن لإعداد خاطئ أن يجعل البريد القريب يصل والبعيد لا يصل؟ والإجابة نعم.
أين تجد مهلة الاتصال اليوم؟
مهلة الاتصال Connect Timeout هي أقصى وقت ينتظره البرنامج حتى يكتمل اتصال TCP، أي حتى يصل رد SYN-ACK من السيرفر، وهي غير مهلة القراءة Read Timeout التي تبدأ بعد الاتصال وتنتظر البيانات. وكل برنامج يتصل بالشبكة عنده هذه المهلة، سواءً كتبتها أنت أو تركتها لقيمتها الافتراضية، والفرق كبير بين البرامج، لذلك لنمر على أهمها.
في Postfix
الخيار هو smtp_connect_timeout، وقيمته الافتراضية 30 ثانية كما رأينا في مخرج postconf -d، وعندما تنتهي المهلة يجرب Postfix العنوان التالي في قائمة سجلات MX، فإذا فشلت كلها بقيت الرسالة في الطابور Queue وأعاد المحاولة لاحقاً كما شرحنا في قسم الطابور وإعادة المحاولة. والقيمة صفر تعني ترك المهلة لنظام التشغيل، والوحدات المسموحة هي الثواني والدقائق والساعات والأيام والأسابيع، فلا توجد أجزاء من الثانية، وهذا وحده يجعل قصة 500 ميل مستحيلة في Postfix. وإذا رأيت في السجل السطر connect to …:25: Connection timed out فقد شرحنا أسبابه الشائعة في دليل Postfix وDovecot، وأغلبها أن المنفذ 25 محجوب عند مزود السيرفر وليس المسافة.
في نواة Linux نفسها
عندما لا يضع البرنامج مهلة، تقرر النواة متى تتوقف، والقاعدة في صفحة tcp في دليل Linux أن النواة تعيد إرسال حزمة SYN حتى عدد مرات يحدده الإعداد tcp_syn_retries، وقيمته الافتراضية 6، وهذا يعني محاولة تستمر نحو 127 ثانية قبل أن يفشل الاتصال. وأول إعادة إرسال تكون بعد ثانية واحدة، وهي القيمة الأولية لمهلة إعادة الإرسال RTO التي يوصي بها المعيار RFC 6298 قبل أن يعرف الطرفان زمن الذهاب والعودة الحقيقي. وهذه الثانية مهمة جداً عندما تختار أي مهلة قصيرة، كما سوف نرى في التجربة.
cat /proc/sys/net/ipv4/tcp_syn_retriesوالمخرج داخل Container من debian:13.1 في تجربتنا سوف يكون كما يلي:
6في curl
الخيار --connect-timeout يقبل أجزاء الثانية مثل 0.010، ويغطي مرحلة الاتصال كاملة: البحث في DNS واتصال TCP والمصافحة TLS أو QUIC إذا كانت موجودة. وإذا لم تضعه فإن مكتبة libcurl تستخدم مهلة افتراضية للاتصال قدرها 300 ثانية كما يذكر توثيق CURLOPT_CONNECTTIMEOUT، وهذه المهلة مستقلة عن --max-time التي تحدد زمن العملية كلها.
curl --connect-timeout 3 --max-time 20 -s -o /dev/null \
-w "connect=%{time_connect}s tls=%{time_appconnect}s total=%{time_total}s\n" https://example.com/في مكتبات HTTP داخل تطبيقك
هنا يقع أغلب المطورين في الخطأ المعاكس لقصتنا، فبدلاً من مهلة قصيرة جداً لا توجد مهلة أصلاً. فمكتبة requests في Python مثلاً لا تضع أي مهلة إلا إذا طلبتها، وتوثيقها في قسم المهلات Timeouts يقول إن الكود بدون مهلة قد يتوقف دقائق أو أكثر، وينصح بأن تكون مهلة الاتصال أكبر بقليل من مضاعفات 3 ثوان، وهي نافذة إعادة الإرسال التقليدية في TCP، ويسمح بمهلتين منفصلتين للاتصال والقراءة:
import requests
# 3.05 ثانية للاتصال، و27 ثانية لانتظار الرد بعد الاتصال
r = requests.get("https://api.example.com/orders", timeout=(3.05, 27))والقاعدة العملية التي نخرج بها من القصة ومن هذه التوثيقات: ضع دائماً مهلة اتصال ومهلة قراءة منفصلتين، واجعل مهلة الاتصال أكبر من أسوأ زمن ذهاب وعودة متوقع إلى أبعد سيرفر تتصل به، مضافاً إليها على الأقل إعادة إرسال SYN واحدة، أي أكثر من ثانية، والسبب أن فقد حزمة واحدة في الطريق أمر طبيعي، ومهلة أقل من ثانية تحول كل فقد عابر إلى خطأ يراه المستخدم. وقد شرحنا تكلفة الاتصال الجديد وإعادة استخدامه في مقال HTTP وHTTPS.
كم تكلف المسافة فعلاً؟
كما شرحنا في مقال كيف يصل الإنترنت إلى بيتك، الضوء داخل الألياف يسير بنحو 200,000 كيلومتر في الثانية، فكل 100 كيلومتر تضيف قرابة مللي ثانية واحدة إلى زمن الذهاب والعودة RTT، وهذا حد أدنى فيزيائي، والطريق الحقيقي أطول من الخط المستقيم ويضيف كل Router وكل طابور وقته. والجدول التالي يبين هذا الحد الأدنى لمسافات مختلفة، أما القياسات الحقيقية إلى مراكز بيانات في أوروبا والولايات المتحدة فتجدها في قسم ping في المقال نفسه:
| المسافة في خط مستقيم | أقل RTT ممكن داخل الألياف | هل تكفي مهلة 10ms؟ |
|---|---|---|
| 50 كم، داخل المدينة | 0.5 مللي ثانية | نعم |
| 837 كم، أي 520 ميلاً مثل القصة | 8.4 مللي ثانية | بالكاد، بدون أي تأخير آخر |
| 4,000 كم، بين قارتين متجاورتين | 40 مللي ثانية | لا |
| 12,000 كم، إلى الطرف الآخر من العالم تقريباً | 120 مللي ثانية | لا |
| قمر صناعي في المدار الثابت GEO | 480 مللي ثانية على الأقل | لا |
تجربة عملية: نصنع «المسافة» على جهازك
في هذه التجربة سوف نبني على جهازك سيرفرين بينهما Router يؤخر كل حزمة عدداً من المللي ثواني نختاره، فيصبح السيرفر «بعيداً» بالقدر الذي نريد، ثم نتصل به بمهلات مختلفة ونقيس. وكل شيء يعمل داخل شبكات Docker خاصة، ولا يوجد أي منفذ مفتوح على جهازك ولا أي اتصال بسيرفر حقيقي خارجي، وكل المخرجات أدناه حقيقية من تجربتنا على Docker Desktop.
لماذا لا نستخدم tc أو Proxy بسيطاً؟
الطريقة المعتادة لإضافة تأخير على Linux هي tc qdisc add dev eth0 root netem delay 20ms، ولكن نواة WSL2 التي يعمل عليها Docker Desktop لا تحتوي على وحدة netem، فيفشل الأمر بالخطأ التالي:
Error: Specified qdisc kind is unknown.والفكرة الثانية التي قد تخطر لك هي Proxy صغير بلغة Python يستقبل الاتصال وينتظر ثم يمرره، ولكن هذا لا يؤخر الاتصال نفسه، والسبب أن النواة عند الـ Proxy ترد على SYN بـ SYN-ACK فوراً وتكمل المصافحة قبل أن يستدعي البرنامج accept() أصلاً، وبالتالي سوف يرى الكلاينت اتصالاً سريعاً دائماً، وهو بالضبط الرقم الذي نريد أن نؤخره.
لذلك سوف نستخدم جهاز TUN، وهو جهاز شبكة افتراضي تستقبل منه الحزم الكاملة في برنامج عادي وتعيدها إليه، فالـ Router يوجه كل حزمة عابرة إلى الجهاز wan-in، وبرنامج Python صغير يقرؤها وينتظر N مللي ثانية ثم يكتبها في الجهاز wan-out، فتكمل النواة توجيهها إلى وجهتها. والصورة التالية تبين المختبر:

ملفات التجربة
أنشئ مجلداً فارغاً وضع فيه الملفات التالية، وأولها ملف Dockerfile لـ Docker Image واحدة تستخدمها ال Containers الثلاث، مبنية على debian:13.1 وفيها Postfix وswaks وPython وcurl والأداة units:
FROM debian:13.1
RUN echo "postfix postfix/main_mailer_type select No configuration" | debconf-set-selections \
&& apt-get update \
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
postfix swaks python3 iproute2 iputils-ping curl units procps \
&& cp /usr/share/postfix/main.cf.debian /etc/postfix/main.cf \
&& echo "postmaster: root" > /etc/aliases \
&& rm -rf /var/lib/apt/lists/*ثم برنامج الـ Router الذي يصنع «المسافة»، واسمه wan-delay.py، وهو يقرأ التأخير من الملف wan.conf ويعيد قراءته كلما تغير، فلا نحتاج إعادة تشغيل أي شيء عند تغيير المسافة:
# Userspace "distance": every packet that crosses this router waits DELAY ms.
# Reads /lab/wan.conf (delay_ms=N, drop_first_syn=0|1) and reloads it when it changes.
import fcntl, os, struct, subprocess, threading, time, collections
TUNSETIFF, IFF_TUN, IFF_NO_PI = 0x400454CA, 0x0001, 0x1000
CONF = "/lab/wan.conf"
def tun(name):
fd = os.open("/dev/net/tun", os.O_RDWR)
fcntl.ioctl(fd, TUNSETIFF, struct.pack("16sH", name.encode(), IFF_TUN | IFF_NO_PI))
return fd
def sh(cmd):
subprocess.run(cmd, shell=True, check=True)
fd_in, fd_out = tun("wan-in"), tun("wan-out")
sh("ip link set wan-in up && ip link set wan-out up")
sh("ip route add default dev wan-in table 100")
sh("ip rule add iif lo lookup main pref 50") # the router's own packets
sh("ip rule add iif wan-out lookup main pref 100") # packets that already waited
sh("ip rule add from 10.50.0.0/16 lookup 100 pref 200") # everything else waits first
state = {"delay": 0.0, "drop": False, "mtime": None}
seen = set()
def reload():
while True:
try:
m = os.stat(CONF).st_mtime
if m != state["mtime"]:
kv = dict(l.strip().split("=", 1) for l in open(CONF) if "=" in l)
state["delay"] = float(kv.get("delay_ms", 0)) / 1000
state["drop"] = kv.get("drop_first_syn", "0") == "1"
state["mtime"] = m
seen.clear()
print(f"wan: delay={state['delay']*1000:g}ms each way, drop_first_syn={int(state['drop'])}", flush=True)
except FileNotFoundError:
pass
time.sleep(0.2)
queue, cv = collections.deque(), threading.Condition()
def writer():
while True:
with cv:
while not queue:
cv.wait()
due, pkt = queue[0]
wait = due - time.monotonic()
if wait > 0:
cv.wait(wait)
continue
queue.popleft()
os.write(fd_out, pkt)
def is_first_syn(p):
if len(p) < 40 or p[0] >> 4 != 4 or p[9] != 6:
return False
ihl = (p[0] & 0x0F) * 4
flags = p[ihl + 13]
if not (flags & 0x02) or (flags & 0x10): # SYN without ACK
return False
key = (p[12:16], p[ihl:ihl + 2], p[16:20], p[ihl + 2:ihl + 4])
if key in seen:
return False
seen.add(key)
return True
threading.Thread(target=reload, daemon=True).start()
threading.Thread(target=writer, daemon=True).start()
while True:
pkt = os.read(fd_in, 65535)
if state["drop"] and is_first_syn(pkt):
continue
with cv:
queue.append((time.monotonic() + state["delay"], pkt))
cv.notify()في البرنامج أعلاه لاحظ التالي:
- القواعد
ip ruleهي قلب الفكرة: كل حزمة من الشبكتين تذهب أولاً إلى الجدول 100 الذي يرسلها إلىwan-in، أما الحزمة التي تخرج منwan-outفقد انتظرت بالفعل، لذلك توجه بالجدول الرئيسي إلى وجهتها الحقيقية، وبدون هذه القاعدة سوف تدور الحزمة في حلقة لا تنتهي. - التأخير يطبق في كل اتجاه، فإذا كان N يساوي 5 فإن SYN ينتظر 5 مللي ثوان، وSYN-ACK ينتظر 5 أخرى، فيصبح الحد الأدنى لزمن الاتصال 10 مللي ثوان، تماماً كما يحدث على مسافة حقيقية.
- الخيار
drop_first_synيحذف أول حزمة SYN في كل اتصال جديد، وسوف نستخدمه في الخطوة الأخيرة لنرى ما يحدث عندما تفقد حزمة واحدة.
ثم سكربت السيرفر البعيد server.sh، وهو يضيف الطريق إلى الشبكة القريبة عبر الـ Router، ويجهز Postfix ليستقبل بريد النطاق far.lab في صندوق المستخدم trey، ويشغل صفحة HTTP صغيرة على المنفذ 8080:
set -e
ip route add 10.50.1.0/24 via 10.50.2.254
useradd -m trey
postconf -e myhostname=mx.far.lab mydestination='far.lab, localhost' \
inet_interfaces=all inet_protocols=ipv4 mynetworks=127.0.0.0/8 \
maillog_file=/var/log/postfix.log home_mailbox=Maildir/
newaliases
cd /tmp && python3 -m http.server 8080 >/dev/null 2>&1 &
exec postfix start-fgثم سكربت الكلاينت client.sh، وهو سيرفر بريد القسم الذي يرسل، ويوجه كل بريد far.lab إلى السيرفر البعيد مباشرة بدون DNS عبر ملف transport:
set -e
ip route add 10.50.2.0/24 via 10.50.1.254
echo "far.lab smtp:[10.50.2.10]:25" > /etc/postfix/transport
postmap /etc/postfix/transport
postconf -e myhostname=mail.near.lab mydestination= inet_interfaces=loopback-only \
inet_protocols=ipv4 transport_maps=hash:/etc/postfix/transport \
maillog_file=/var/log/postfix.log
newaliases
exec postfix start-fgثم ملف Docker Compose الذي يربط كل ذلك:
name: lab-mail500
x-common: &common
image: lab-mail500-img:1
cap_add: [NET_ADMIN]
volumes: ["./:/lab"]
services:
wan:
<<: *common
build: .
container_name: lab-mail500-wan
devices: ["/dev/net/tun:/dev/net/tun"]
sysctls:
net.ipv4.ip_forward: "1"
net.ipv4.conf.all.rp_filter: "0"
net.ipv4.conf.default.rp_filter: "0"
net.ipv4.conf.all.send_redirects: "0"
net.ipv4.conf.default.send_redirects: "0"
command: python3 -u /lab/wan-delay.py
networks:
near: {ipv4_address: 10.50.1.254}
far: {ipv4_address: 10.50.2.254}
server:
<<: *common
container_name: lab-mail500-server
hostname: mx.far.lab
command: sh /lab/server.sh
networks:
far: {ipv4_address: 10.50.2.10}
client:
<<: *common
container_name: lab-mail500-client
hostname: mail.near.lab
command: sh /lab/client.sh
networks:
near: {ipv4_address: 10.50.1.10}
networks:
near:
ipam: {config: [{subnet: 10.50.1.0/24}]}
far:
ipam: {config: [{subnet: 10.50.2.0/24}]}في الملف أعلاه لاحظ التالي:
- الـ Router وحده يحصل على الجهاز
/dev/net/tunوعلى إعداداتsysctlsالتي تفعل التوجيه IP Forwarding وتوقف فحص المصدرrp_filter، والسبب أن الحزمة تخرج منwan-outبعنوان مصدر لا يتوقعه هذا الجهاز، فيرفضها فحص المصدر لو بقي فعالاً. - الصلاحية
NET_ADMINفي كل Container لأن السكربتات تضيف طرقاً Routes، وهي صلاحية داخل شبكة الـ Container فقط ولا تمس شبكة جهازك. - لا يوجد أي
portsفي الملف، فلا يوجد منفذ مفتوح على جهازك، وكل الاختبارات تتم من داخل الـ Container الكلاينت.
وأخيراً برنامج صغير بلغة Python يرسل رسالة بمهلة اتصال بالمللي ثانية، مثل خيار Timeout.connect في Sendmail، ثم يرجع إلى مهلة عادية بعد الاتصال، واسمه smtp-try.py:
# Sends one message, with a separate (very short) connect timeout like Sendmail's Timeout.connect
import smtplib, socket, sys, time
from email.message import EmailMessage
HOST, CONNECT_TIMEOUT = sys.argv[1], float(sys.argv[2]) / 1000 # milliseconds
class SMTP(smtplib.SMTP):
def _get_socket(self, host, port, timeout):
t = time.perf_counter()
try:
sock = socket.create_connection((host, port), CONNECT_TIMEOUT)
finally:
self.connect_ms = (time.perf_counter() - t) * 1000
sock.settimeout(30) # after connect, normal timeouts for the conversation
return sock
msg = EmailMessage()
msg["From"], msg["To"], msg["Subject"] = "[email protected]", "[email protected]", "distance test"
msg.set_content("hello from the statistics department\n")
s = SMTP()
try:
s.connect(HOST, 25)
s.send_message(msg)
s.quit()
print(f"sent connect={s.connect_ms:.1f}ms")
except TimeoutError as e:
print(f"FAILED connect gave up after {s.connect_ms:.1f}ms: {e}")ولاحظ أن الدالة _get_socket جزء داخلي في مكتبة smtplib وليست واجهة رسمية، واستخدمناها هنا فقط لنفصل مهلة الاتصال عن مهلة المحادثة في تجربة، أما في كود الإنتاج فاستخدم المعامل timeout الرسمي.
التشغيل
من داخل المجلد نجهز ملف التأخير بقيمة صفر ثم نشغل المختبر:
printf 'delay_ms=0\ndrop_first_syn=0\n' > wan.conf
docker compose up -d --build
docker compose ps --format 'table {{.Name}}\t{{.Status}}'
docker logs lab-mail500-wanوالمخرج سوف يكون كما يلي:
NAME STATUS
lab-mail500-client Up 7 seconds
lab-mail500-server Up 7 seconds
lab-mail500-wan Up 6 seconds
wan: delay=0ms each way, drop_first_syn=0ولتغيير «المسافة» بسهولة نعرف في الـ Shell دالة صغيرة تكتب الملف من داخل الـ Router ثم تنتظر ثانية حتى يقرأه البرنامج:
setwan() { docker exec lab-mail500-wan sh -c "printf 'delay_ms=$1\ndrop_first_syn=${2:-0}\n' > /lab/wan.conf"; sleep 1; }وإذا كنت تستخدم Git Bash على Windows فاكتب قبل الأوامر export MSYS_NO_PATHCONV=1، حتى لا يحول Git Bash مسارات مثل /lab/wan.conf إلى مسارات Windows.
خط الأساس، ثم 10 مللي ثوان في كل اتجاه
أولاً نقيس بدون أي تأخير، والأمر curl -w يطبع توقيت كل مرحلة في الطلب:
docker exec lab-mail500-client ping -c 5 -q 10.50.2.10
docker exec lab-mail500-client curl -s -o /dev/null \
-w "dns=%{time_namelookup}s connect=%{time_connect}s first_byte=%{time_starttransfer}s total=%{time_total}s\n" \
http://10.50.2.10:8080/والمخرج سوف يكون كما يلي:
PING 10.50.2.10 (10.50.2.10) 56(84) bytes of data.
--- 10.50.2.10 ping statistics ---
5 packets transmitted, 5 received, 0% packet loss, time 4150ms
rtt min/avg/max/mdev = 0.385/0.475/0.607/0.072 ms
dns=0.000016s connect=0.000643s first_byte=0.002226s total=0.002279sثم نجعل التأخير 10 مللي ثوان في كل اتجاه ونكرر الأمرين نفسيهما:
setwan 10
docker logs --tail 1 lab-mail500-wanوالمخرج سوف يكون كما يلي:
wan: delay=10ms each way, drop_first_syn=0
PING 10.50.2.10 (10.50.2.10) 56(84) bytes of data.
--- 10.50.2.10 ping statistics ---
5 packets transmitted, 5 received, 0% packet loss, time 4007ms
rtt min/avg/max/mdev = 20.628/20.713/20.810/0.058 ms
dns=0.000012s connect=0.020909s first_byte=0.042463s total=0.042532sفي المخرج أعلاه لاحظ التالي:
- زمن
pingصار 20.7 مللي ثانية، أي 10 ذهاباً و10 إياباً، وهذا ما يحدث مع سيرفر على بعد نحو 1,000 كيلومتر داخل الألياف بدون أي تأخير آخر. - القيمة
connectصارت 0.0209 ثانية، أي ذهاباً وعودة مرة واحدة، لأن الاتصال يكتمل عند وصول SYN-ACK. - القيمة
first_byteصارت 0.042، أي ضعف زمن الاتصال، لأن الطلب يحتاج ذهاباً وعودة أخرى بعد الاتصال، وهذه هي تكلفة المسافة التي تتكرر مع كل اتصال جديد.
مهلة 10 مللي ثوان في curl: أين ينقطع البريد؟
الآن نعيد قصة الـ 500 ميل بأرقامنا، فنضع مهلة اتصال 10 مللي ثوان، ونزيد «المسافة» تدريجياً:
for d in 1 2 3 4 5 6 8; do
setwan $d
docker exec lab-mail500-client curl -sS -o /dev/null --connect-timeout 0.010 \
-w "one-way=${d}ms connect=%{time_connect}s code=%{http_code}\n" http://10.50.2.10:8080/
doneوالمخرج سوف يكون كما يلي:
one-way=1ms connect=0.002981s code=200
one-way=2ms connect=0.005159s code=200
one-way=3ms connect=0.006888s code=200
one-way=4ms connect=0.008938s code=200
curl: (28) Connection timed out after 10 milliseconds
one-way=5ms connect=0.000000s code=000
curl: (28) Connection timed out after 10 milliseconds
one-way=6ms connect=0.000000s code=000
curl: (28) Connection timed out after 10 milliseconds
one-way=8ms connect=0.000000s code=000في المخرج أعلاه لاحظ التالي:
- زمن الاتصال يساوي تقريباً ضعف التأخير في اتجاه واحد مع نحو مللي ثانية زائدة من البرنامج والـ Router نفسه، فـ 4 مللي ثوان أعطت 8.9.
- عند 5 مللي ثوان في كل اتجاه يحتاج الاتصال 10 مللي ثوان على الأقل، فتنتهي المهلة قبل وصول الرد، ويخرج curl بالرمز 28 وهو رمز انتهاء المهلة.
- الحد هنا حاد تماماً مثل الدائرة في خريطة قسم الإحصاء: كل ما هو أقرب من الحد ينجح دائماً، وكل ما هو أبعد يفشل دائماً، ولا توجد حالة بينهما.
وبحساب الألياف، أي 200 كيلومتر لكل مللي ثانية في اتجاه واحد، فإن مهلة 10 مللي ثوان تعني أن أقصى مسافة ممكنة نظرياً نحو 1,000 كيلومتر، وفي تجربتنا كان آخر نجاح على 4 مللي ثوان في كل اتجاه، أي نحو 800 كيلومتر، لأن البرنامج والـ Router يضيفان نحو مللي ثانية إلى كل اتصال.
مهلة 6 مللي ثوان في SMTP، مثل القصة
الآن نرسل بريداً حقيقياً من الكلاينت إلى Postfix البعيد بالبرنامج smtp-try.py ومهلة اتصال 6 مللي ثوان، وهي المهلة ذهاباً وإياباً التي تقابل 3 مللي ثوان في اتجاه واحد كما في القصة:
for d in 1 2 3 4; do
setwan $d
printf "one-way=%sms " $d
docker exec lab-mail500-client python3 /lab/smtp-try.py 10.50.2.10 6
doneوالمخرج سوف يكون كما يلي:
one-way=1ms sent connect=2.7ms
one-way=2ms sent connect=4.7ms
one-way=3ms FAILED connect gave up after 6.3ms: timed out
one-way=4ms FAILED connect gave up after 6.3ms: timed outأي أن هذا «القسم» يستطيع أن يرسل البريد إلى سيرفر على بعد 2 مللي ثانية، نحو 400 كيلومتر في الألياف، ولا يستطيع الإرسال إلى سيرفر على بعد 3 مللي ثوان، نحو 600 كيلومتر، وهذا هو الحد الذي حسبناه في جدول الدقة: 373 ميلاً تقريباً داخل الألياف. ولاحظ أن الرسالة التي تفشل لا تصل إلى السيرفر أصلاً، فلا يوجد أي أثر لها في سجله، وهذا ما يجعل هذا النوع من الأعطال صعباً، فالطرف الآخر لا يرى شيئاً.
وقد يتساءل البعض: وماذا بعد الاتصال؟ هل يكلف كل أمر في SMTP ذهاباً وعودة أخرى؟ والإجابة نعم، والأداة swaks تعرض زمن كل رد إذا أضفت الخيار --show-time-lapse، والمثال التالي مع تأخير 4 مللي ثوان في كل اتجاه:
setwan 4
docker exec lab-mail500-client swaks --to [email protected] --from [email protected] \
--server 10.50.2.10 --helo mail.near.lab --show-time-lapse --suppress-dataوالمخرج سوف يكون كما يلي:
=== Trying 10.50.2.10:25...
=== Connected to 10.50.2.10.
=== response in 0.927s
<- 220 mx.far.lab ESMTP Postfix (Debian)
-> EHLO mail.near.lab
=== response in 0.009s
<- 250-mx.far.lab
<- 250-PIPELINING
<- 250-SIZE 10240000
<- 250-VRFY
<- 250-ETRN
<- 250-ENHANCEDSTATUSCODES
<- 250-8BITMIME
<- 250-DSN
<- 250-SMTPUTF8
<- 250 CHUNKING
-> MAIL FROM:<[email protected]>
=== response in 0.009s
<- 250 2.1.0 Ok
-> RCPT TO:<[email protected]>
=== response in 0.010s
<- 250 2.1.5 Ok
-> DATA
=== response in 0.009s
<- 354 End data with <CR><LF>.<CR><LF>
-> 11 lines sent
=== response in 0.012s
<- 250 2.0.0 Ok: queued as 66E7B21291
-> QUIT
=== response in 0.009s
<- 221 2.0.0 Bye
=== Connection closed with remote host.في المخرج أعلاه لاحظ التالي:
- كل أمر بعد الاتصال أخذ نحو 9 مللي ثوان، أي زمن ذهاب وعودة كاملاً، فالرسالة الواحدة تكلف ستة أضعاف هذا الزمن على الأقل، ولهذا يدعم Postfix الإرسال المتتابع PIPELINING وإعادة استخدام الاتصال كما شرحنا في قسم إعادة استخدام اتصال SMTP.
- رسالة الترحيب
220وحدها أخذت 0.927 ثانية، وهذا ليس من المسافة، والسبب أن Postfix في السيرفر يبحث عن اسم الكلاينت في DNS قبل أن يرحب به، وهذا سلوك الخيارsmtpd_peername_lookup، وفي تشغيل آخر لنفس الأمر كان الزمن 0.098 ثانية، وهذا مثال حي على ما قاله Trey عن الوجهات التي فشلت داخل الدائرة: تأخير آخر غير المسافة يكفي لتتجاوز المهلة.
Postfix بمهلة ثانية واحدة
الآن نجرب سيرفر بريد حقيقياً يرسل من الطابور، فنضع في Postfix الكلاينت المهلة smtp_connect_timeout = 1s، وهي أقل قيمة ممكنة فيه، ثم نرسل رسالة إلى سيرفر على بعد 300 مللي ثانية في كل اتجاه، وأخرى إلى سيرفر على بعد 600:
docker exec lab-mail500-client sh -c 'postconf -e smtp_connect_timeout=1s && postfix reload'
setwan 300
docker exec lab-mail500-client sh -c 'printf "Subject: 300ms away\n\nnear enough\n" | sendmail -f [email protected] [email protected]'
sleep 5
setwan 600
docker exec lab-mail500-client sh -c 'printf "Subject: 600ms away\n\ntoo far\n" | sendmail -f [email protected] [email protected]'
sleep 5
docker exec lab-mail500-client sh -c 'grep "postfix/smtp\[" /var/log/postfix.log | tail -3'
docker exec lab-mail500-client postqueue -pوالمخرج سوف يكون كما يلي:
Oct 08 15:44:52 mail postfix/smtp[297]: 2A8732129B: to=<[email protected]>, relay=10.50.2.10[10.50.2.10]:25, delay=3.3, delays=0.07/0.01/2/1.3, dsn=2.0.0, status=sent (250 2.0.0 Ok: queued as 7EEB62129C)
Oct 08 15:44:56 mail postfix/smtp[297]: connect to 10.50.2.10[10.50.2.10]:25: Connection timed out
Oct 08 15:44:56 mail postfix/smtp[297]: E94D22129C: to=<[email protected]>, relay=none, delay=1, delays=0.01/0/1/0, dsn=4.4.1, status=deferred (connect to 10.50.2.10[10.50.2.10]:25: Connection timed out)
-Queue ID- --Size-- ----Arrival Time---- -Sender/Recipient-------
E94D22129C 269 Thu Oct 8 15:44:55 [email protected]
(connect to 10.50.2.10[10.50.2.10]:25: Connection timed out)
[email protected]
-- 0 Kbytes in 1 Request.في المخرج أعلاه لاحظ التالي:
- الرسالة الأولى وصلت
status=sent، والحقلdelaysفيه أربعة أرقام: الانتظار قبل مدير الطابور، ثم داخل الطابور، ثم زمن إعداد الاتصال مع الترحيب وEHLO، ثم زمن إرسال الرسالة، والرقمان الأخيران 2 و1.3 ثانية كلها من المسافة التي صنعناها. - الرسالة الثانية لم تفشل نهائياً، وإنما تأجلت
status=deferredبالرمز4.4.1، وهو رمز خطأ مؤقت، وبقيت في الطابور ليعيد Postfix المحاولة لاحقاً، وهذا أفضل بكثير مما حدث في القصة. - الحقل
delays=0.01/0/1/0يبين أن Postfix انتظر ثانية واحدة بالضبط في مرحلة الاتصال ثم توقف، لأن الرد يحتاج 1.2 ثانية.
والآن نقرب السيرفر إلى 300 مللي ثانية ونطلب من Postfix تفريغ الطابور فوراً بدلاً من انتظار المحاولة التالية:
setwan 300
docker exec lab-mail500-client postqueue -f
sleep 5
docker exec lab-mail500-client sh -c 'grep "postfix/smtp\[" /var/log/postfix.log | tail -1; postqueue -p'والمخرج سوف يكون كما يلي:
Oct 08 15:45:06 mail postfix/smtp[297]: E94D22129C: to=<[email protected]>, relay=10.50.2.10[10.50.2.10]:25, delay=11, delays=7.5/0/1.9/1.2, dsn=2.0.0, status=sent (250 2.0.0 Ok: queued as A53B72129F)
Mail queue is emptyوأخيراً نعيد المسافة إلى 600 مللي ثانية ونضع المهلة صفراً، وهي في Postfix تعني ترك القرار للنواة، أي عكس معناها في قصة Sendmail 5:
setwan 600
docker exec lab-mail500-client sh -c 'postconf -e smtp_connect_timeout=0 && postfix reload'
docker exec lab-mail500-client sh -c 'printf "Subject: zero\n\nfar but fine\n" | sendmail -f [email protected] [email protected]'
sleep 8
docker exec lab-mail500-client sh -c 'grep "postfix/smtp\[" /var/log/postfix.log | tail -1'والمخرج سوف يكون كما يلي:
Oct 08 15:45:19 mail postfix/smtp[370]: D9B2B2129F: to=<[email protected]>, relay=10.50.2.10[10.50.2.10]:25, delay=6.3, delays=0.07/0.01/3.7/2.5, dsn=2.0.0, status=sent (250 2.0.0 Ok: queued as 3D1D421291)والرسالة وصلت رغم أن الرد يحتاج 1.2 ثانية، وتستطيع التأكد من الطرف الآخر بالأمر docker exec lab-mail500-server grep "postfix/local\[" /var/log/postfix.log، وستجد سطراً لكل رسالة وصلت ينتهي بالعبارة delivered to maildir.
حزمة SYN واحدة مفقودة تساوي ثانية كاملة
الخطوة الأخيرة هي الأهم لمن يكتب مهلات في كوده، فنعيد المسافة إلى صفر، ولكن نجعل الـ Router يحذف أول حزمة SYN في كل اتصال، كأنها فقدت في الطريق، ثم نتصل ثلاث مرات بدون مهلة، ومرة رابعة بمهلة نصف ثانية:
setwan 0 1
docker logs --tail 1 lab-mail500-wan
for i in 1 2 3; do
docker exec lab-mail500-client curl -sS -o /dev/null -w "connect=%{time_connect}s code=%{http_code}\n" http://10.50.2.10:8080/
done
docker exec lab-mail500-client curl -sS -o /dev/null --connect-timeout 0.5 \
-w "connect=%{time_connect}s code=%{http_code}\n" http://10.50.2.10:8080/والمخرج سوف يكون كما يلي:
wan: delay=0ms each way, drop_first_syn=1
connect=1.080385s code=200
connect=1.034258s code=200
connect=1.078998s code=200
curl: (28) Connection timed out after 502 milliseconds
connect=0.000000s code=000في المخرج أعلاه لاحظ التالي:
- السيرفر على بعد أقل من مللي ثانية، ومع ذلك أخذ الاتصال أكثر من ثانية، والسبب أن النواة انتظرت مهلة إعادة الإرسال الأولى، وهي ثانية واحدة حسب RFC 6298، ثم أرسلت SYN مرة أخرى فنجح الاتصال.
- بمهلة نصف ثانية فشل الاتصال بسيرفر قريب جداً، فالمهلة التي تقل عن ثانية لا تقسم العالم حسب المسافة فقط، وإنما تحول كل حزمة SYN مفقودة إلى خطأ، حتى في الشبكة نفسها.
ثم نعيد الـ Router إلى وضعه الطبيعي:
setwan 0 0أقصى مسافة لكل مهلة
بجمع أرقام التجربة مع الحساب، نحصل على الجدول التالي، والمسافة فيه هي الحد الأقصى النظري داخل الألياف بدون أي تأخير آخر، أي نصف المهلة مضروباً في 200 كيلومتر لكل مللي ثانية:
| المهلة | أين رأيناها | أقصى مسافة نظرياً | ما حدث في التجربة |
|---|---|---|---|
| 6 مللي ثوان | smtp-try.py | 600 كم، نحو 373 ميلاً | نجح على 2ms وفشل على 3ms في كل اتجاه |
| 10 مللي ثوان | curl --connect-timeout 0.010 | 1,000 كم | نجح على 4ms وفشل على 5ms في كل اتجاه |
| نصف ثانية | curl --connect-timeout 0.5 | 50,000 كم | فشل على مسافة صفر بسبب حزمة SYN مفقودة واحدة |
| ثانية واحدة | smtp_connect_timeout = 1s | 100,000 كم، أكثر من محيط الأرض | نجح على 300ms وتأجل على 600ms في كل اتجاه |
| 30 ثانية | القيمة الافتراضية في Postfix | لا تحدها المسافة على الأرض | تتحمل عدة إعادات إرسال للحزمة SYN |
والدرس من الجدول أن أي مهلة أقل من ثانية لا تعتمد على المسافة فقط وإنما على حظك مع الحزم المفقودة، وأي مهلة أكبر من بضع ثوان لا تحدها المسافة على الأرض أصلاً، وإنما تحميك من سيرفر لا يرد أو منفذ محجوب.
حذف التجربة
نحذف ال Containers والشبكتين بالأمر down للمشروع نفسه، ثم نحذف الـ Docker Image التي بنيناها باسمها:
docker compose down
docker image rm lab-mail500-img:1والمخرج سوف يكون كما يلي، مختصراً:
Container lab-mail500-client Removed
Container lab-mail500-server Removed
Container lab-mail500-wan Removed
Network lab-mail500_near Removed
Network lab-mail500_far Removed
Untagged: lab-mail500-img:1ماذا نتعلم كمهندسين؟
- خذ البلاغ الغريب بجدية: المستخدم يصف العرض بلغته، وقد يكون تفسيره خطأ، ولكن العرض نفسه غالباً حقيقي، وفي هذه القصة كان البلاغ دقيقاً لدرجة أنه جاء مع خريطة، لذلك لا تقم بإغلاق بلاغ لأنه يبدو مستحيلاً، والسبب أن «مستحيل» تعني فقط أن النموذج الذي في رأسك عن النظام ناقص.
- البيانات قبل الفرضيات: لم يبدأ Trey بتعديل أي إعداد، وإنما أرسل رسائل إلى مدن معروفة المسافة، ثم صمم تجربة ضابطة بصديق بريده في Seattle، وكل نظرية جاءت بعد ذلك كانت تشرح أرقاماً حقيقية، كما شرحنا في دليل استكشاف الأخطاء.
- ابحث عما تغير، وليس فقط عمن غيره: «لم ألمس نظام البريد» كانت صحيحة حرفياً، ولكن تحديث نظام التشغيل غير البرنامج الذي يقرأ الإعداد، لذلك اقرأ سجل الحزم بعد كل تحديث، وتأكد من إصدار كل خدمة مهمة، ولا تكتف بمقارنة ملفات الإعداد.
- الإعداد والبرنامج زوج واحد: ملف مكتوب لإصدار 8 ويقرؤه إصدار 5 هو إعداد غير صحيح حتى لو لم يتغير فيه حرف، لذلك اجعل تحذيرات مثل
unused parameterفي Postfix جزءاً من فحص ما بعد التحديث، وفضل البرامج التي ترفض الإعداد الذي لا تفهمه أو تحذر منه على البرامج التي تتجاهله بصمت. - أصلح أولاً ثم افهم: أعاد Trey البرنامج الصحيح فوراً، واحتفظ بالقديم ليفهم المشكلة على مهل، والمستخدم يحتاج الخدمة الآن، والفهم يمكن أن ينتظر ساعة.
- المهلات فيزياء: كل 100 كيلومتر في الألياف تكلف مللي ثانية تقريباً ذهاباً وإياباً، وكل حزمة SYN مفقودة تكلف ثانية، لذلك احسب مهلة الاتصال من أبعد وجهة ومن ثانية إعادة الإرسال، وضع دائماً مهلة اتصال ومهلة قراءة منفصلتين، ولا تترك أي اتصال بدون مهلة.
- اقرأ الأرقام في القصص بحذر: الكاتب نفسه قال إن رقم 3 مللي ثوان تقريبي وإن قصته حكاية وليست تقرير حادثة، وهذا لا ينقص من قيمتها، ولكنه يذكرك أن تفرق دائماً بين ما قيس وما يروى من الذاكرة، وأن تكتب ملاحظاتك وقت التحقيق، فالكاتب تمنى لاحقاً لو احتفظ بها.
الخلاصة
- في 24 نوفمبر 2002 روى Trey Harris في قائمة sage-members كيف وصله بلاغ من قسم الإحصاء في جامعة North Carolina at Chapel Hill بأن البريد لا يصل لأبعد من 500 ميل تقريباً، ويقدر أن القصة حدثت بين 1994 و1997.
- التحقيق: سؤال ما الذي تغير، ثم رسائل اختبار إلى مدن معروفة المسافة، ثم تجربة ضابطة تثبت أن المسافة إلى السيرفر هي العامل، ثم
diffلملف الإعداد، ثم رسالة الترحيب على المنفذ 25 التي كشفت Sendmail 5. - السبب: تحديث SunOS أرجع Sendmail إلى الإصدار 5 وترك ملف إعداد الإصدار 8، فتجاهل البرنامج الخيارات الطويلة وجعلها صفراً لعدم وجود قيم افتراضية، ومهلة الاتصال الصفرية كانت تقطع
connect()بعد نحو 3 مللي ثوان. - الحساب: الضوء يقطع 558.8 ميلاً في 3 مللي ثوان في الفراغ، والكاتب نفسه أوضح أن الرقم «زمن فعلي» قاسه، وأن الحساب الدقيق يحتاج الذهاب والعودة وسرعة الإشارة في الألياف والطريق الحقيقي.
- اليوم: الصفر في Postfix وSendmail 8 يعني ترك المهلة للنواة، وأقل وحدة في Postfix ثانية، والنواة تعيد إرسال SYN بعد ثانية وتستمر نحو 127 ثانية، وcurl يقبل أجزاء الثانية، وrequests في Python بدون مهلة إلا إذا طلبتها.
- التجربة: بتأخير نصنعه بجهاز TUN رأينا مهلة 10 مللي ثوان تنجح حتى 4 مللي ثوان في كل اتجاه وتفشل بعدها، ومهلة 6 مللي ثوان تعيد القصة بحد نحو 600 كيلومتر، وحزمة SYN مفقودة واحدة تجعل الاتصال القريب يأخذ أكثر من ثانية.
وإذا أردت أن تكمل من هنا، فهذه المقالات تشرح البريد والشبكات وطريقة التحقيق التي مر ذكرها:
دليلكيف يعمل البريد الإلكتروني؟ SMTP وIMAP للمبتدئينماذا يحدث بين ضغطك على «إرسال» ووصول الرسالة، ولماذا ترتد بعض الرسائل، وأي منفذ تفتح لكل غرض.
دليلبناء خادم بريد بيدك مع Postfix وDovecot على Ubuntuنبني خادم بريد كاملاً من حزم Ubuntu دون Docker، فتعرف دور كل مكون وأين تبحث عندما تتوقف رسالة.
دليلشرح DNS وسجلاته للمبتدئين بمثال حقيقيكيف يتحول اسم نطاقك إلى عنوان سيرفرك، وما وظيفة كل سجل تضيفه في لوحة النطاق، والأخطاء التي تضيع فيها الساعات عادة.
دليلكيف يصل الإنترنت إلى بيتك؟ من ال Router إلى الكابلات البحريةنتتبع طلباً واحداً من حاسوبك إلى سيرفر في بلد آخر ثم عودته، فتعرف من يحمله في كل محطة ولماذا يتأخر الرد.
دليلHTTP وHTTPS ببساطة: ماذا يحدث بين الرابط والصفحةماذا يحدث بين كتابة الرابط وظهور الصفحة، وكيف تقرأ الطلب والرد ورموز مثل 502 و504، ولماذا يكلف الاتصال الجديد أكثر.
دليلاستكشاف الأخطاء وتحليل الأداء للمبتدئين: من العرض إلى السببعندما يبطئ الموقع أو يعطي 502، هذه طريقة منظمة توصلك من العرض إلى السبب قبل أن تخمن أو تشتري سيرفراً أكبر.
دليلتمارين Kata في الشبكات بثلاثة مستويات مع الحلولتمارين في الشبكات تحلها بيدك دون ذكاء اصطناعي، من حساب الشبكة الفرعية إلى الفرق بين 502 و504، وكل حل مجرب في مختبر Docker.
دليلكيف اكتشف Andres Freund الباب الخلفي في XZخطوات التحقيق والأوامر التي قادت مطوراً لاحظ نصف ثانية زائدة في دخول SSH إلى باب خلفي في مكتبة XZ، وكيف تكررها على سيرفرك.سجل التحديثات (Changelog)
- أكتوبر 2026: كتابة المقال اعتماداً على منشور Trey Harris الأصلي في 2002 وصفحة الأسئلة الشائعة التي كتبها عن القصة، ودليل تشغيل Sendmail 8 وتوثيق Postfix وcurl وrequests وصفحة tcp في دليل Linux وRFC 6298، مع تجربة عملية على Debian 13.1 وPostfix 3.10.13 وcurl 8.14.1 وPython 3.13 ورسوم توضيحية من عملنا.