عندما تسأل عن طريقة نشر تطبيقك على الإنترنت فسوف تسمع ثلاثة أسماء في نفس الجملة: ال Reverse Proxy، وموزع الحمل Load Balancer، وبوابة ال API Gateway، ثم تجد أن Nginx يوصف بالثلاثة، وأن HAProxy موزع حمل وReverse Proxy في نفس الوقت، وأن بعض المشاريع تضع الثلاثة واحداً خلف الآخر دون أن يعرف أحد في الفريق لماذا. والنتيجة المعتادة إما طبقة ناقصة تظهر الحاجة إليها يوم المشكلة، وإما ثلاث طبقات تعيد كل منها كتابة نفس الترويسات Headers، فيضيع عنوان الزائر الحقيقي ويظن التطبيق أن الطلب جاء عبر HTTP.
وقد شرحنا في دليل بوابة ال API الفرق بين البوابة وأقاربها في جدول واحد، وفي مقارنة أدوات ال Reverse Proxy أي أداة تناسب سيرفرك، أما هذا المقال فيجيب عن سؤال آخر: ما الطبقات التي يمر بها الطلب، وبماذا تقرر كل طبقة، ومتى تضيف الطبقة التالية؟ ثم نبني الطبقات كلها في تجربة واحدة ونقيس ما تضيفه كل منها، ونكسرها في المواضع التي تنكسر فيها عادة.
دليلما هي بوابة API Gateway ومتى تحتاجها؟ما الذي تفعله بوابة API ولا يفعله ال Reverse Proxy، وما الذي يبقى في خدماتك، ومتى لا تحتاج إلى بوابة أصلاً.
مقارنةأي Reverse Proxy يناسب سيرفرك وفريقك؟مقارنة بين أشهر أدوات ال Reverse Proxy في سهولة الإعداد وشهادات HTTPS وتوزيع الحمل، لتعرف أيها يناسب سيرفرك.
دليلHAProxy: توزيع الحمل Load Balancing أمام خدماتكعندما يعمل تطبيقك على أكثر من سيرفر، يوزع HAProxy الطلبات بينها ويستبعد المعطل منها تلقائياً مع شهادات HTTPS.
دليلكيف يرى تطبيقك عنوان الزائر الحقيقي خلف ال Reverse Proxyإذا كان تطبيقك يرى عنوان ال Proxy بدلاً من الزائر أو يولد روابط http، فهذا هو السبب والإعداد الصحيح لإطار عملك.وسوف نناقش في هذا المقال ما يلي:
- خريطة الطبقات من ال DNS إلى التطبيق، وبماذا تقرر كل طبقة: بالعنوان والمنفذ، أو بالنطاق والمسار، أو بهوية العميل.
- الفرق بين موزع الطبقة الرابعة L4 وموزع الطبقة السابعة L7 كما يظهر في السجلات الحقيقية.
- أين تختلط الأدوار: Nginx وHAProxy وTraefik وEnvoy وAPISIX، كل منها يستطيع أن يلعب أكثر من دور.
- جدول للقرار، ومراحل نمو المشروع من سيرفر واحد إلى Kubernetes.
- تجربة عملية: HAProxy في وضع TCP، ثم Nginx، ثم APISIX، أمام نسختين من التطبيق، مع قياس زمن كل طبقة وخمس خطوات «نكسرها».
- الأخطاء الشائعة: ثلاثة Proxy تعيد كتابة الترويسات، وبوابة تستخدم كموزع حمل فقط، وتشفير يفك مرتين دون سبب، وعنوان زائر ضائع.
ما الطبقات التي قد يمر بها الطلب؟
أبسط طريقة لفهم هذه الأسماء أن تنسى أسماء البرامج، وأن تسأل عن كل طبقة سؤالاً واحداً: ما المعلومة التي تقرر بها إلى أين يذهب الطلب؟ والسبب أن البرنامج الواحد قد يعمل في أكثر من طبقة كما سيأتي، أما المعلومة التي يقرر بها فهي التي تحدد الدور. والصورة التالية تبين الطبقات بالترتيب الذي يمر به الطلب:

وفيما يلي كل طبقة في سطرين، مع الرابط إلى المقال الذي يشرحها بالتفصيل:
- ال DNS: يحول اسم النطاق إلى عنوان IP قبل أن يبدأ الطلب أصلاً، فهو لا يرى الطلب، ولكنه أول مكان توزع فيه الحمل بأكثر من عنوان أو بعنوان قريب من الزائر، كما شرحنا في دليل ال DNS.
- شبكة توصيل المحتوى CDN: تقرر بالرابط وترويسات ال Cache هل ترد من نسختها المحفوظة أم ترسل الطلب إلى سيرفرك، وقد شرحناها في دليل ال CDN.
- جدار حماية تطبيقات الويب WAF: يقرأ محتوى الطلب ويقارنه بقواعد معروفة للهجمات، فيمنع أو يمرر، وغالباً يأتي مع ال CDN من نفس المزود، أو تضعه داخل ال Reverse Proxy كما في قسم ال WAF.
- موزع الحمل في الطبقة الرابعة L4 Load Balancer: يرى اتصال TCP أو UDP فقط، أي العنوان والمنفذ وعدد البايتات، ويختار السيرفر دون أن يقرأ الطلب، ومثاله موزع الشبكة Network Load Balancer في AWS الذي يعمل في الطبقة الرابعة من نموذج OSI كما يقول توثيقه.
- ال Reverse Proxy أو موزع الطبقة السابعة L7: ينهي التشفير TLS Termination ويقرأ طلب HTTP، فيوجهه بالنطاق Host والمسار Path والترويسات، ومثاله Nginx وCaddy وTraefik، وموزع التطبيقات Application Load Balancer في AWS الذي يعمل في الطبقة السابعة.
- بوابة ال API Gateway: Reverse Proxy يعرف العميل، فيقرأ مفتاح ال API أو التوكن ويطبق الحدود والحصص لكل عميل، وقد شرحنا مسؤولياتها في دليل البوابة.
- ال Ingress Controller: هو ال Reverse Proxy داخل Kubernetes، يقرأ قواعد Ingress أو Gateway API ويوجه الطلب إلى Service، وقد شرحنا الفرق بينهما في دليل مصطلحات Kubernetes.
- ال Sidecar في شبكة الخدمات Service Mesh: Proxy صغير بجانب كل خدمة يقرر بهوية الخدمة المتصلة عبر mTLS، ويعيد المحاولة ويجمع المقاييس، وفي Istio وضعان: Sidecar بجانب كل Pod، أو الوضع Ambient الذي يستخدم Proxy للطبقة الرابعة على كل Node وProxy اختياري للطبقة السابعة لكل Namespace.
- التطبيق: وحده يعرف المستخدم وقواعد العمل، فقرار «هل يحق لهذا المستخدم أن يرى هذا الطلب؟» مكانه هنا وليس في أي طبقة قبله.
وقد يتساءل البعض: هل أحتاج كل هذه الطبقات؟ والإجابة لا، فأغلب المشاريع تعمل بطبقة أو طبقتين، والصورة تبين أين تقع الطبقة إذا احتجتها، وليست قائمة بما يجب أن تشتريه أو تثبته.
الطبقة الرابعة L4 والطبقة السابعة L7: ماذا يرى كل منهما؟
الفرق بين الطبقتين هو الفرق بين ساعي البريد الذي يقرأ العنوان على الظرف، وموظف المكتب الذي يفتح الظرف ويقرأ الرسالة. فموزع الطبقة الرابعة يرى عنوان المرسل ومنفذه، وعنوان الوجهة ومنفذها، وعدد البايتات، وإذا كان الاتصال مشفراً ب TLS فهو لا يرى من الطلب شيئاً، أما ال Reverse Proxy فيفك التشفير ويقرأ الطلب كاملاً. والصورة التالية تبين ما رآه كل منهما لنفس الطلب في التجربة التي سوف نبنيها:

لاحظ في الصورة أن الطبقة الرابعة ترى شيئاً واحداً من داخل الاتصال المشفر، وهو اسم النطاق في رسالة بدء TLS، واسمها مؤشر اسم السيرفر Server Name Indication واختصاراً SNI، لأن المتصفح يرسله دون تشفير حتى يعرف السيرفر أي شهادة يقدم، لذلك يستطيع HAProxy أن يوجه بالنطاق في وضع TCP عبر req.ssl_sni كما في توثيق HAProxy، وكذلك Traefik بقاعدة HostSNI، ولكن لا أحد منهما يستطيع في هذا الوضع أن يرى المسار /api/orders/7، لأنه داخل الجزء المشفر.
والجدول التالي يلخص الفرق:
| الطبقة الرابعة L4 | الطبقة السابعة L7 | |
|---|---|---|
| ما الذي يراه | عناوين IP ومنافذ وبايتات، وال SNI إن وجد | طلب HTTP كاملاً: الطريقة والنطاق والمسار والترويسات وال Cookies |
| يقرر بماذا | العنوان والمنفذ وال SNI | النطاق والمسار والترويسات، وهوية العميل إذا كان بوابة |
| التشفير TLS | يمرره كما هو Passthrough، أو ينهيه في بعض الأدوات | ينهيه ليقرأ الطلب |
| البروتوكولات | أي شيء فوق TCP أو UDP: قواعد البيانات وSSH وSMTP | HTTP وWebSocket وgRPC |
| فحص الصحة | هل المنفذ مفتوح؟ | هل يرد مسار الصحة بالرمز 200؟ |
| عنوان الزائر | يضيع عند السيرفر إلا مع PROXY Protocol | ينقله في ترويسة X-Forwarded-For |
| التكلفة | قليلة لكل اتصال | أكثر، لأنه يفك التشفير ويحلل كل طلب |
وبالتالي فالقاعدة بسيطة: إذا كان قرارك بالعنوان والمنفذ فقط، أو كانت الخدمة ليست HTTP، فالطبقة الرابعة تكفي وهي أخف، وإذا احتجت أن توجه بالمسار أو تقرأ ترويسة أو مفتاحاً، فلا بد من طبقة تفك التشفير وتقرأ HTTP.
برنامج واحد بأكثر من دور
هنا يأتي الالتباس الذي بدأنا به، فأغلب البرامج المعروفة تعمل في أكثر من طبقة بحسب الإعداد، لذلك لا تسأل «هل Nginx موزع حمل أم Reverse Proxy؟» وإنما «ما الدور الذي أعطيته له في هذا الإعداد؟»:
- Nginx: وحدة
httpتجعله Reverse Proxy للطبقة السابعة، ووحدة stream تجعله موزعاً للطبقة الرابعة لاتصالات TCP وUDP، ومع قواعد للمفاتيح والحدود يصبح بوابة صغيرة كما رأينا في تجربة دليل البوابة. - HAProxy: الوضع
mode tcpللطبقة الرابعة وmode httpللطبقة السابعة، في نفس الملف ونفس العملية، كما في دليل HAProxy. - Traefik: موجهات HTTP وTCP وUDP، وفيه Middlewares للمصادقة والحدود تجعله بوابة خفيفة، ويعمل Ingress Controller في Kubernetes.
- Envoy: في أساسه Proxy للطبقتين الثالثة والرابعة تضاف إليه فلاتر HTTP للطبقة السابعة، وهو أساس Istio في وضع ال Sidecar، وأساس Envoy Gateway الذي يطبق Gateway API.
- Caddy: Reverse Proxy للطبقة السابعة في الأساس، ويضاف إليه دعم الطبقة الرابعة بإضافة خارجية هي caddy-l4.
- Apache APISIX: بوابة API مبنية على Nginx وOpenResty، وتستطيع أيضاً أن توجه اتصالات TCP وUDP.
والنتيجة العملية أنك تستطيع أن تبدأ بأداة واحدة تؤدي دورين أو ثلاثة، ولا تحتاج برنامجاً جديداً لكل دور، ولكن عندما تضع أداتين أو ثلاثاً في نفس المسار فعليك أن تعرف أي دور تؤديه كل منها، وإلا قامت اثنتان بنفس العمل، أو لم تقم به أي منهما، كما سوف نرى في التجربة.
جدول القرار: ما الذي تحتاجه فعلاً؟
ابدأ من الحاجة وليس من اسم الأداة، فكل سطر في الجدول التالي حاجة تظهر في مشروع حقيقي، والطبقة التي تحلها:
| الحاجة | الطبقة التي تحلها | أمثلة |
|---|---|---|
| عدة مواقع على سيرفر واحد، مع شهادات HTTPS | Reverse Proxy | Caddy، Nginx، Nginx Proxy Manager، Traefik |
توجيه بالمسار: /api إلى خدمة و/ إلى الموقع | Reverse Proxy (L7) | نفس الأدوات |
| نسختان أو أكثر من التطبيق، وإخراج المعطلة تلقائياً | موزع حمل (L4 أو L7) بفحص صحة | HAProxy، موزعات السحابة، أو ال Reverse Proxy نفسه |
| قاعدة بيانات أو SMTP أو أي خدمة ليست HTTP | موزع الطبقة الرابعة L4 | HAProxy في mode tcp، Nginx stream |
| عنوان ثابت واحد أمام عدة سيرفرات، ونجاة من تعطل الموزع نفسه | موزع حمل مع عنوان عائم أو موزع مدار | Keepalived، موزع السحابة |
| عملاء من خارج فريقك بمفاتيح وحصص وإصدارات للواجهة | بوابة API | APISIX وKong وKrakenD |
| حماية من الهجمات المعروفة على HTTP | WAF | ModSecurity مع CRS، أو مزود CDN |
| زوار بعيدون وملفات ثابتة كثيرة | CDN | مزود CDN أو Cache أمام السيرفر |
| خدمات كثيرة على Kubernetes | Ingress Controller أو Gateway API | Traefik، Envoy Gateway، APISIX |
| تشفير وهوية بين عشرات الخدمات الداخلية | Service Mesh | Istio، Linkerd |
لاحظ أن «موزع الحمل» في أغلب السطور لا يعني برنامجاً منفصلاً، فال Reverse Proxy يوزع الحمل على نسختين بسطر واحد، ويصبح الموزع المنفصل ضرورياً عندما يكون لديك أكثر من سيرفر، لأن ال Reverse Proxy على أحدها لا يحميك من تعطل ذلك السيرفر نفسه.
مراحل النمو: متى تضيف الطبقة التالية؟
الخطأ المعتاد في البنية ليس الطبقة الناقصة، وإنما الطبقة التي أضيفت قبل حاجتها، لأن كل طبقة تعني إعداداً تحفظه وتحدثه، وسجلات أخرى تبحث فيها عند المشكلة، ونقطة فشل إضافية. لذلك الأفضل أن تضيف الطبقة عندما تظهر حاجتها، كما تبين الصورة التالية:

- سيرفر واحد وReverse Proxy واحد: Caddy أو Nginx على المنفذين 80 و443 يكفي لموقع ولوحة إدارة وعدة تطبيقات، ويوزع الحمل على نسختين من التطبيق على نفس السيرفر إذا احتجت. وهذه المرحلة تكفي أغلب المشاريع الصغيرة والمتوسطة سنوات.
- موزع حمل منفصل: عندما يصبح لديك سيرفران أو أكثر، أو تريد أن تحدث سيرفراً دون أن تتوقف الخدمة، تضع أمامها موزعاً منفصلاً، مداراً من مزود السحابة أو HAProxy مع Keepalived، وقد شرحنا شكل المدخل الواحد بعنوان ثابت في دليل المدخل والمخرج.
- بوابة API: تظهر حاجتها عندما يستدعي ال API عملاء لا تملك كودهم، مثل تطبيق جوال منشور، أو شريك يربط نظامه بك، فتحتاج مفتاحاً لكل عميل، وحداً لكل عميل، وسجلاً تعرف منه من استهلك ماذا. وقبل ذلك فإن البوابة إعداد إضافي دون فائدة، كما ناقشنا في قسم متى لا تحتاج إلى بوابة.
- Kubernetes: موزع للطبقة الرابعة من السحابة أمام ال Ingress Controller أو Gateway API، ويوصي مشروع Kubernetes اليوم باستخدام Gateway بدلاً من Ingress الذي جمدت واجهته، أما شبكة الخدمات فلا تضفها إلا عندما تحتاج mTLS وسياسات بين خدمات كثيرة، لأنها أثقل طبقة في القائمة.
تجربة عملية: أربع طبقات أمام نسختين من التطبيق
سوف نبني الآن الطبقات في Docker على جهاز واحد، بحيث يكون لكل طبقة عنوان ثابت تعرفه في كل سطر سجل: عميل، ثم HAProxy في وضع TCP كموزع للطبقة الرابعة، ثم Nginx ينهي TLS ويوجه بالنطاق والمسار، ثم APISIX كبوابة تعرف العميل بمفتاحه وتحد طلباته، ثم نسختان من تطبيق صغير يرد بما وصله من ترويسات، حتى نرى بالضبط ما تغيره كل طبقة. والصورة التالية تبين التجربة ونتيجة القياس التي سوف نصل إليها في آخرها:

ملفات التجربة
نبدأ بالتطبيق، وهو سيرفر HTTP صغير بلغة Python يرد بالنسخة التي أجابت، وعنوان الاتصال الذي وصله، والترويسات التي تهمنا، ويمكن أن نجعله «يفقد قاعدة بياناته» فيرد بالرمز 503 على كل شيء مع بقاء منفذه مفتوحاً، وهذا ما سوف نحتاجه في خطوة فحص الصحة. واحفظه باسم app.py:
# A tiny backend that answers with what it received, so we can see what each layer changed.
# GET /break and /fix (from inside the container only) make every answer, /health included, a 503,
# as an app does when it lost its database: the port stays open, the app is useless.
import json, os
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
NAME = os.environ.get("NAME", "app")
state = {"broken": False}
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
disable_nagle_algorithm = True # headers and body leave at once (no 40 ms delayed-ACK wait on keep-alive)
def reply(self, code, body):
data = (json.dumps(body, indent=1) + "\n").encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
local = self.client_address[0] == "127.0.0.1"
if local and self.path in ("/break", "/fix"):
state["broken"] = self.path == "/break"
return self.reply(200, {"instance": NAME, "broken": state["broken"]})
if state["broken"]:
return self.reply(503, {"instance": NAME, "error": "database unreachable"})
if self.path == "/health":
return self.reply(200, {"instance": NAME, "status": "ok"})
seen = {k: v for k, v in self.headers.items()
if k.lower() in ("host", "x-forwarded-for", "x-real-ip", "x-forwarded-proto", "x-forwarded-host", "apikey", "x-consumer-username")}
self.reply(200, {"instance": NAME, "peer": self.client_address[0], "path": self.path, "headers": seen})
def log_message(self, *args):
pass
ThreadingHTTPServer(("0.0.0.0", 8080), Handler).serve_forever()ولاحظ السطر disable_nagle_algorithm، فقد أضفناه بعد أن أظهر أول قياس وسيطاً قدره 50 ms للتطبيق وحده، والسبب أن Python يرسل الترويسات والمحتوى في دفعتين، فيتأخر الثاني في انتظار تأكيد الأول على الاتصال المفتوح Keep-Alive، وبعد السطر نزل الزمن إلى أقل من ميلي ثانية. وهذا تذكير بأن تقيس التطبيق وحده أولاً قبل أن تلوم أي طبقة أمامه.
بعد ذلك ملف HAProxy باسم haproxy.cfg، وفيه ثلاث واجهات: المنفذ 443 في وضع TCP يمرر التشفير كما هو إلى Nginx، والمنفذان 8081 و8082 لخطوة فحص الصحة:
# Layer 4: HAProxy sees IP addresses, ports and bytes. TLS passes through untouched to Nginx.
global
log stdout format raw local0
defaults
mode tcp
log global
option tcplog
timeout connect 5s
timeout client 30s
timeout server 30s
frontend fe_tls
bind :443
default_backend be_l7
backend be_l7
server l7 10.89.0.20:443 check send-proxy-v2
# Health checks: the same two app instances, once behind a TCP check and once behind an HTTP check
frontend fe_tcpcheck
bind :8081
default_backend be_apps_tcp
backend be_apps_tcp
balance roundrobin
server app1 10.89.0.41:8080 check inter 2s
server app2 10.89.0.42:8080 check inter 2s
frontend fe_httpcheck
mode http
option httplog
bind :8082
default_backend be_apps_http
backend be_apps_http
mode http
balance roundrobin
option httpchk GET /health
http-check expect status 200
server app1 10.89.0.41:8080 check inter 2s
server app2 10.89.0.42:8080 check inter 2sفي الإعداد أعلاه لاحظ التالي:
- الكلمة
send-proxy-v2تجعل HAProxy يرسل في أول الاتصال سطراً ثنائياً فيه عنوان العميل الحقيقي، حسب مواصفة PROXY Protocol، والسبب أن موزع الطبقة الرابعة لا يستطيع أن يضيف ترويسة HTTP إلى اتصال مشفر لا يقرؤه. - في
be_apps_tcpالكلمةcheckوحدها تعني فحص TCP، أي هل يقبل المنفذ الاتصال، أما فيbe_apps_httpفالسطرoption httpchk GET /healthيجعل الفحص طلب HTTP، ويعد السيرفر معطلاً بعد ثلاثة فحوص فاشلة متتالية، ويعود بعد فحصين ناجحين، وهي القيم الافتراضية للخيارينfallوriseفي توثيق HAProxy.
ثم ملف Nginx باسم nginx.conf، وهو الطبقة السابعة:
# Layer 7: Nginx ends TLS, reads Host and path, and decides where the request goes.
worker_processes 1;
events { worker_connections 1024; }
http {
log_format edge '$remote_addr via $realip_remote_addr "$host" "$request" $status -> $upstream_addr';
access_log /dev/stdout edge;
# The client address comes from HAProxy in the PROXY protocol header, and only HAProxy is trusted to send it
set_real_ip_from 10.89.0.10;
real_ip_header proxy_protocol;
upstream site { server 10.89.0.41:8080; server 10.89.0.42:8080; keepalive 16; }
upstream gateway { server 10.89.0.30:9080; keepalive 16; }
ssl_certificate /etc/nginx/certs/lab.crt;
ssl_certificate_key /etc/nginx/certs/lab.key;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
# The edge overwrites what the client sent: the list starts here
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
server {
listen 443 ssl proxy_protocol;
listen 8443 ssl; # direct entry without HAProxy, used only to measure each hop
server_name www.lab.test;
location / { proxy_pass http://site; }
location /api/ { proxy_pass http://gateway; }
}
server {
listen 443 ssl proxy_protocol;
listen 8443 ssl;
server_name api.lab.test;
location / { proxy_pass http://gateway; }
}
}في الإعداد أعلاه لاحظ التالي:
- السطران
set_real_ip_fromوreal_ip_header proxy_protocolمن وحدة realip يجعلان$remote_addrعنوان العميل الذي أرسله HAProxy، ولكن فقط إذا جاء الاتصال من10.89.0.10. - السطر
X-Forwarded-For $remote_addrيكتب الترويسة من جديد ولا يضيف إلى ما أرسله العميل، لأن Nginx هنا أول طبقة تقرأ HTTP، أي أنه الحافة، وما يصله في هذه الترويسة كتبه الزائر بنفسه، وسوف نكسر هذا السطر في خطوة لاحقة. - النطاق
www.lab.testيذهب إلى التطبيقين مباشرة، ومساره/api/يذهب إلى البوابة، والنطاقapi.lab.testكله يذهب إلى البوابة، وهذا هو التوجيه بالنطاق والمسار الذي لا يستطيعه HAProxy في وضع TCP.
أما APISIX فيعمل في الوضع المستقل دون etcd، كما في تجربة أدوات البوابات، ويحتاج ملفين في المجلد apisix، الأول config.yaml:
# APISIX in standalone mode: routes and consumers come from apisix.yaml, no etcd.
deployment:
role: data_plane
role_data_plane:
config_provider: yaml
apisix:
node_listen: 9080
# Only Nginx may tell us the original scheme and host (X-Forwarded-Proto/Host/Port)
trusted_addresses:
- 10.89.0.20
nginx_config:
worker_processes: 1
error_log_level: warn
plugins:
- real-ip
- key-auth
- limit-count
- proxy-rewrite
stream_plugins: []والثاني apisix.yaml فيه نسختا التطبيق، وعميلان بحدين مختلفين، ومساران:
upstreams:
- id: apps
type: roundrobin
nodes:
"10.89.0.41:8080": 1
"10.89.0.42:8080": 1
keepalive_pool: { size: 64 }
consumers:
- username: mobile_app
plugins:
key-auth: { key: mobile-key-123 }
limit-count: { count: 5, time_window: 60, rejected_code: 429 }
- username: partner_erp
plugins:
key-auth: { key: partner-key-456 }
limit-count: { count: 1000000, time_window: 60, rejected_code: 429 }
routes:
# Keyed API: the gateway knows WHO is calling and counts per consumer
- id: orders
uris: ["/api/orders", "/api/orders/*"]
upstream_id: apps
plugins:
key-auth: {}
# Public API without a key: the only thing to count by is the client address
- id: public
uris: ["/api/public", "/api/public/*"]
upstream_id: apps
plugins:
real-ip:
source: http_x_forwarded_for
recursive: true
trusted_addresses: ["10.89.0.20"]
limit-count: { count: 3, time_window: 60, key_type: var, key: remote_addr, rejected_code: 429 }
#ENDفي الإعداد أعلاه لاحظ التالي:
- الحد في
mobile_appخمسة طلبات في الدقيقة، وفيpartner_erpمليون، أي أن الحد مربوط بالعميل وليس بالمسار أو بالعنوان، وهذا هو الشيء الذي لا تعرفه الطبقتان قبل البوابة. - المسار
publicبلا مفتاح، فلا يبقى للحد إلا عنوان العميل، والإضافة real-ip تأخذه من X-Forwarded-For، وتقرأ القائمة من اليمين وتتخطى العناوين الموثوقة، والعنوان الموثوق الوحيد هو Nginx. - القيمة
trusted_addressesفيconfig.yamlشيء آخر غير ما في real-ip، وسوف نرى أثرها في خطوة مستقلة.
بعد ذلك نجمع كل شيء في compose.yaml، ولكل طبقة عنوان ثابت حتى تقرأ السجلات بسهولة، والمنفذ الوحيد المنشور على الجهاز مربوط ب 127.0.0.1:
# One request, four layers: client -> HAProxy (L4, TCP) -> Nginx (L7, TLS, host/path) -> APISIX (API gateway) -> app1/app2
# Fixed addresses so every log line says who is who. Ports are bound to 127.0.0.1 only.
name: lab-plg
networks:
lab-plg-net:
name: lab-plg-net
ipam:
config:
- subnet: 10.89.0.0/24
services:
app1:
image: python:3.13.16-alpine
container_name: lab-plg-app1
command: ["python", "/app.py"]
environment: { NAME: app1 }
volumes: ["./app.py:/app.py:ro"]
networks: { lab-plg-net: { ipv4_address: 10.89.0.41 } }
app2:
image: python:3.13.16-alpine
container_name: lab-plg-app2
command: ["python", "/app.py"]
environment: { NAME: app2 }
volumes: ["./app.py:/app.py:ro"]
networks: { lab-plg-net: { ipv4_address: 10.89.0.42 } }
gw:
image: apache/apisix:3.19.0-debian
container_name: lab-plg-gw
volumes:
- ./apisix/config.yaml:/usr/local/apisix/conf/config.yaml:ro
- ./apisix/apisix.yaml:/usr/local/apisix/conf/apisix.yaml:ro
depends_on: [app1, app2]
networks: { lab-plg-net: { ipv4_address: 10.89.0.30 } }
l7:
image: nginx:1.31.6-alpine
container_name: lab-plg-l7
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- ./certs:/etc/nginx/certs:ro
depends_on: [gw]
networks: { lab-plg-net: { ipv4_address: 10.89.0.20 } }
l4:
image: haproxy:3.4.6-alpine
container_name: lab-plg-l4
volumes: ["./haproxy.cfg:/usr/local/etc/haproxy/haproxy.cfg:ro"]
ports: ["127.0.0.1:8443:443"]
depends_on: [l7]
networks: { lab-plg-net: { ipv4_address: 10.89.0.10 } }
client:
image: curlimages/curl:8.11.1
container_name: lab-plg-client
entrypoint: ["sleep", "infinity"]
volumes: ["./certs/ca.crt:/ca.crt:ro"]
networks: { lab-plg-net: { ipv4_address: 10.89.0.50 } }وأخيراً سكربت make-certs.sh يصنع جهة إصدار خاصة بالتجربة وشهادة للنطاق *.lab.test، حتى يتحقق curl من الشهادة دون الخيار -k، وقد شرحنا الشهادات وصيغها في دليل الشهادات:
#!/bin/sh
# A private CA and one certificate for *.lab.test, so curl can verify TLS without -k.
set -e
mkdir -p certs
docker run --rm --name lab-plg-certs -v "$PWD/certs:/c" -w /c alpine:3.24.2 sh -c '
apk add -q --no-cache openssl >/dev/null
openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 30 \
-subj "/CN=lab-plg CA" -keyout ca.key -out ca.crt 2>/dev/null
openssl req -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \
-subj "/CN=lab.test" -keyout lab.key -out lab.csr 2>/dev/null
printf "subjectAltName=DNS:lab.test,DNS:*.lab.test\n" > san.ext
openssl x509 -req -in lab.csr -CA ca.crt -CAkey ca.key -CAcreateserial -days 30 -extfile san.ext -out lab.crt 2>/dev/null
rm -f lab.csr san.ext ca.srl; chmod 644 *.key
openssl x509 -in lab.crt -noout -subject -ext subjectAltName'التشغيل وأول طلب عبر الطبقات كلها
إذا كنت تعمل على Windows في Git Bash فاكتب أولاً export MSYS_NO_PATHCONV=1، لأن Git Bash يحول المسارات التي تبدأ ب / إلى مسارات Windows، ثم شغل التجربة:
sh make-certs.sh
docker compose up -dوالمخرج الأول من السكربت سوف يكون كما يلي:
subject=CN=lab.test
X509v3 Subject Alternative Name:
DNS:lab.test, DNS:*.lab.testالآن نرسل من العميل طلباً إلى الموقع، ثم طلباً إلى ال API بمفتاح، ثم طلباً بلا مفتاح، وكلها إلى عنوان HAProxy، والخيار --resolve يجعل curl يتصل بالعنوان 10.89.0.10 دون الحاجة إلى DNS:
C="docker exec lab-plg-client curl -s --cacert /ca.crt --resolve www.lab.test:443:10.89.0.10 --resolve api.lab.test:443:10.89.0.10"
$C https://www.lab.test/
$C -H 'apikey: mobile-key-123' https://api.lab.test/api/orders/7
$C -i https://api.lab.test/api/orders/7والمخرج سوف يكون كما يلي:
{
"instance": "app1",
"peer": "10.89.0.20",
"path": "/",
"headers": {
"Host": "www.lab.test",
"X-Forwarded-For": "10.89.0.50",
"X-Forwarded-Proto": "https"
}
}
{
"instance": "app1",
"peer": "10.89.0.30",
"path": "/api/orders/7",
"headers": {
"Host": "api.lab.test",
"X-Real-IP": "10.89.0.20",
"X-Forwarded-For": "10.89.0.50, 10.89.0.20",
"X-Forwarded-Proto": "https",
"apikey": "mobile-key-123",
"X-Forwarded-Host": "api.lab.test",
"X-Consumer-Username": "mobile_app"
}
}
HTTP/1.1 401 Unauthorized
Server: nginx/1.31.6
Date: Sun, 11 Oct 2026 23:04:37 GMT
Content-Type: text/plain; charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
WWW-Authenticate: apikey realm="key"
{"message":"Missing API key in request"}في المخرج أعلاه لاحظ التالي:
- في الطلب الأول
peerهو Nginx لأنه آخر من اتصل بالتطبيق، ولكن X-Forwarded-For فيه عنوان العميل10.89.0.50، أي أن العنوان عبر HAProxy دون أن يضيع، بفضل PROXY Protocol. - في الطلب الثاني أضاف APISIX عنوان Nginx إلى القائمة، وأضاف
X-Consumer-Username: mobile_app، فالتطبيق يعرف الآن من العميل دون أن يتحقق من المفتاح بنفسه. - المفتاح نفسه
apikey: mobile-key-123وصل إلى التطبيق، وهذا هو السلوك الافتراضي في key-auth حيثhide_credentialsقيمتهfalse، أي أن كل سجل في التطبيق قد يحفظ مفاتيح العملاء، وسوف نصلح ذلك في خطوة الترويسات. - الطلب بلا مفتاح رفضته البوابة بالرمز 401 ولم يصل إلى التطبيق أصلاً.
ماذا تسجل كل طبقة عن نفس الطلب؟
السجلات هي أوضح طريقة لترى الفرق بين الطبقات، فاقرأ آخر الأسطر من كل منها:
docker logs --tail 3 lab-plg-l4
docker logs --tail 3 lab-plg-l7
docker logs --tail 5 lab-plg-gw 2>&1 | grep -v warnوالمخرج سوف يكون كما يلي:
10.89.0.50:54844 [11/Oct/2026:23:04:34.064] fe_tls be_l7/l7 1/0/72 1766 -- 1/1/0/0/0 0/0
10.89.0.50:54848 [11/Oct/2026:23:04:35.334] fe_tls be_l7/l7 1/0/41 1997 -- 1/1/0/0/0 0/0
10.89.0.50:54850 [11/Oct/2026:23:04:37.166] fe_tls be_l7/l7 1/0/100 1713 -- 1/1/0/0/0 0/0
10.89.0.50 via 10.89.0.10 "www.lab.test" "GET / HTTP/1.1" 200 -> 10.89.0.41:8080
10.89.0.50 via 10.89.0.10 "api.lab.test" "GET /api/orders/7 HTTP/1.1" 200 -> 10.89.0.30:9080
10.89.0.50 via 10.89.0.10 "api.lab.test" "GET /api/orders/7 HTTP/1.1" 401 -> 10.89.0.30:9080
10.89.0.20 - - [11/Oct/2026:23:04:35 +0000] api.lab.test "GET /api/orders/7 HTTP/1.1" 200 331 0.023 "-" "curl/8.11.1" 10.89.0.41:8080 200 0.022 "http://api.lab.test" "b0e3ce3b1594a3ab6102c557410726d4"
10.89.0.20 - - [11/Oct/2026:23:04:37 +0000] api.lab.test "GET /api/orders/7 HTTP/1.1" 401 52 0.000 "-" "curl/8.11.1" - - - "http://api.lab.test" "18f3cf4b753f5b5dc5d789d4f3d60621"سجل HAProxy فيه ثلاثة اتصالات متشابهة، لا يظهر فيها نطاق ولا مسار ولا رمز رد، فلو سألك أحد «لماذا رفض طلب ال API؟» فلن تجد الجواب هنا، وإنما في سجل Nginx الذي يعرف النطاق والمسار والرمز 401، وفي سجل APISIX الذي يعرف أن الرفض منه هو وأن مدته صفر لأنه لم يتصل بالتطبيق. ولهذا عندما تكون الطبقات متعددة فاجعل لكل طلب معرفاً واحداً يمر بها كلها، كما شرحنا في قسم معرف الطلب.
نكسرها: موزع الطبقة الرابعة لا يوجه بالمسار
لنفرض أن أحداً في الفريق أراد أن يرسل /api/ إلى نسختي التطبيق مباشرة من HAProxy، فأضاف هذا السطر إلى fe_tls قبل default_backend:
use_backend be_apps_tcp if { path_beg /api/ }ثم فحص الإعداد وأعاد تشغيل HAProxy وأرسل طلباً:
docker exec lab-plg-l4 haproxy -c -V -f /usr/local/etc/haproxy/haproxy.cfg
docker compose restart l4
docker exec lab-plg-client curl -s --cacert /ca.crt --resolve api.lab.test:443:10.89.0.10 -H "apikey: partner-key-456" https://api.lab.test/api/orders/7 | head -3
docker logs --tail 1 lab-plg-l4والمخرج سوف يكون كما يلي:
Configuration file is valid
{
"instance": "app2",
"peer": "10.89.0.30",
10.89.0.50:49406 [12/Oct/2026:05:09:09.498] fe_tls be_l7/l7 1/1/36 2014 -- 1/1/0/0/0 0/0وهذا أسوأ من رسالة خطأ، فالإعداد صحيح في نظر HAProxy ولم يظهر أي تحذير، ولكن القاعدة لا تعمل أبداً، فالطلب وصل إلى التطبيق من البوابة 10.89.0.30، والسجل يقول إنه ذهب إلى be_l7، والسبب أن المسار داخل بايتات TLS المشفرة التي لا يفكها HAProxy في وضع TCP. لذلك إذا احتجت التوجيه بالمسار فإما أن ينهي HAProxy التشفير ويعمل في mode http، وإما أن تترك القرار للطبقة السابعة خلفه، أما في وضع TCP فما يقرأه هو ال SNI فقط. احذف السطر وأعد تشغيل HAProxy قبل الخطوة التالية.
نكسرها: عنوان الزائر يضيع عند موزع الطبقة الرابعة
احذف الآن send-proxy-v2 من سطر server l7 في haproxy.cfg، واحذف من nginx.conf السطرين set_real_ip_from وreal_ip_header، والكلمة proxy_protocol من سطري listen 443، ثم أعد تشغيل الطبقتين وأرسل طلباً إلى الموقع:
docker compose restart l7 l4
docker exec lab-plg-client curl -s --cacert /ca.crt --resolve www.lab.test:443:10.89.0.10 https://www.lab.test/
docker logs --tail 1 lab-plg-l7والمخرج سوف يكون كما يلي:
{
"instance": "app1",
"peer": "10.89.0.20",
"path": "/",
"headers": {
"Host": "www.lab.test",
"X-Forwarded-For": "10.89.0.10",
"X-Forwarded-Proto": "https"
}
}
10.89.0.10 via 10.89.0.10 "www.lab.test" "GET / HTTP/1.1" 200 -> 10.89.0.41:8080كل الطلبات الآن «من» HAProxy 10.89.0.10، في سجل Nginx وفي التطبيق، وبالتالي فالحد من معدل الطلبات بالعنوان سوف يعامل كل زوارك كزائر واحد، وسجلات الأمان لا تعرف من هاجم. وهذه المشكلة لا تحلها ترويسة X-Forwarded-For، لأن موزع الطبقة الرابعة لا يكتب ترويسات، والحل هو PROXY Protocol على الجانبين، أو أن يعمل الموزع في الطبقة السابعة فيضيف الترويسة بنفسه.
والخطأ الثاني الشائع أن تفعل الجانب الذي يستقبل وتنسى الجانب الذي يرسل، فأرجع إعداد Nginx كما كان، واترك HAProxy دون send-proxy-v2، ثم أعد تشغيل Nginx وأرسل نفس الطلب:
docker compose restart l7
docker exec lab-plg-client curl -sS --cacert /ca.crt --resolve www.lab.test:443:10.89.0.10 https://www.lab.test/
docker logs --tail 1 lab-plg-l7والمخرج سوف يكون كما يلي، وقد اختصرنا البايتات الثنائية في سطر Nginx:
curl: (35) TLS connect error: error:00000000:lib(0)::reason(0)
2026/10/12 05:07:23 [error] 22#22: *1 broken header: "…" while reading PROXY protocol, client: 10.89.0.10, server: 0.0.0.0:443Nginx ينتظر سطر PROXY Protocol في أول الاتصال، فيصله بدلاً منه أول رسالة TLS من المتصفح، فيعدها ترويسة مكسورة ويغلق الاتصال، ويرى الزائر خطأ TLS لا يدل على السبب. لذلك عندما ترى broken header مع PROXY protocol فالجانبان غير متفقين، والعكس أيضاً يكسر الاتصال: HAProxy يرسل والطرف الآخر لا ينتظر. أرجع send-proxy-v2 وأعد تشغيل HAProxy قبل المتابعة.
البوابة تعرف العميل: حد لكل مفتاح
هذه هي الخطوة التي لا تستطيعها أي طبقة قبل البوابة، فنرسل سبعة طلبات بمفتاح تطبيق الجوال، ثم طلباً بمفتاح الشريك، ثم طلباً بمفتاح خاطئ:
docker exec lab-plg-client sh -c 'for i in 1 2 3 4 5 6 7; do printf "mobile #%s " $i; curl -s -o /dev/null -D - --cacert /ca.crt --resolve api.lab.test:443:10.89.0.10 -H "apikey: mobile-key-123" https://api.lab.test/api/orders/7 | grep -iE "^HTTP|x-ratelimit-remaining" | tr -d "\r" | tr "\n" " "; echo; done; printf "partner #1 "; curl -s -o /dev/null -w "%{http_code}\n" --cacert /ca.crt --resolve api.lab.test:443:10.89.0.10 -H "apikey: partner-key-456" https://api.lab.test/api/orders/7; printf "wrong key "; curl -s -w " %{http_code}\n" --cacert /ca.crt --resolve api.lab.test:443:10.89.0.10 -H "apikey: guess" https://api.lab.test/api/orders/7'والمخرج سوف يكون كما يلي:
mobile #1 HTTP/1.1 200 OK X-RateLimit-Remaining: 4
mobile #2 HTTP/1.1 200 OK X-RateLimit-Remaining: 3
mobile #3 HTTP/1.1 200 OK X-RateLimit-Remaining: 2
mobile #4 HTTP/1.1 200 OK X-RateLimit-Remaining: 1
mobile #5 HTTP/1.1 200 OK X-RateLimit-Remaining: 0
mobile #6 HTTP/1.1 429 Too Many Requests X-RateLimit-Remaining: 0
mobile #7 HTTP/1.1 429 Too Many Requests X-RateLimit-Remaining: 0
partner #1 200
wrong key {"message":"Invalid API key in request"}
401كل الطلبات جاءت من نفس العنوان ومرت بنفس HAProxy ونفس Nginx، ومع ذلك توقف تطبيق الجوال عند الطلب السادس واستمر الشريك، لأن البوابة تعد بالعميل لا بالعنوان. ولاحظ أن الشريك والجوال لو كانا خلف نفس الشبكة أو نفس NAT لكان الحد بالعنوان يخلط بينهما، وقد شرحنا اختيار مفتاح الحد في دليل تحديد معدل الطلبات.
نكسرها: البوابة تعيد كتابة الترويسات
هنا أول مثال على «ثلاث طبقات تعيد كتابة الترويسات». احذف من apisix/config.yaml السطرين trusted_addresses و- 10.89.0.20، ثم أعد تشغيل البوابة واطلب ال API:
docker compose restart gw
docker exec lab-plg-client curl -s --cacert /ca.crt --resolve api.lab.test:443:10.89.0.10 -H "apikey: partner-key-456" https://api.lab.test/api/orders/7 | grep Protoوالمخرج سوف يكون كما يلي:
"X-Forwarded-Proto": "http",الزائر اتصل ب HTTPS، وNginx كتب X-Forwarded-Proto: https، ثم وصل التطبيق http، والسبب أن APISIX 3.19 يستبدل X-Forwarded-Proto وX-Forwarded-Host وX-Forwarded-Port بقيمه هو، أي بالبروتوكول الذي وصله من Nginx وهو HTTP، إلا إذا جاء الطلب من عنوان في apisix.trusted_addresses، كما يشرح التعليق في ملف الإعداد النموذجي. والنتيجة في تطبيق حقيقي هي ما شرحناه في دليل التطبيق خلف ال Reverse Proxy: روابط تبدأ ب http://، وCookies بلا Secure، وربما حلقة تحويل لا تنتهي. أرجع السطرين وأعد تشغيل البوابة، فيعود المخرج "X-Forwarded-Proto": "https".
وفي نفس الخطوة نصلح تسرب المفتاح الذي رأيناه في أول طلب، فغير في apisix.yaml السطر key-auth: {} في المسار orders إلى key-auth: { hide_credentials: true }، وأعد تشغيل البوابة وأرسل طلب الشريك:
{
"instance": "app1",
"peer": "10.89.0.30",
"path": "/api/orders/7",
"headers": {
"Host": "api.lab.test",
"X-Real-IP": "10.89.0.20",
"X-Forwarded-For": "10.89.0.50, 10.89.0.20",
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "api.lab.test",
"X-Consumer-Username": "partner_erp"
}
}اختفى apikey وبقي X-Consumer-Username، وهذا ما يحتاجه التطبيق فعلاً: من العميل، وليس سره. ولاحظ أيضاً أن X-Real-IP هنا هو عنوان Nginx، لأن هذا المسار ليس فيه real-ip، فإذا كانت خدماتك تقرأ X-Real-IP فاعرف أي طبقة كتبته وبأي قيمة.
نكسرها: انتحال العنوان عندما تكون الثقة خاطئة
المسار /api/public بلا مفتاح، وحده ثلاثة طلبات في الدقيقة لكل عنوان. والسكربت التالي spoof.sh يرسل خمسة طلبات، كل منها بعنوان مزيف مختلف في X-Forwarded-For، كما يفعل من يحاول تخمين الباسورد دون أن يوقفه الحد:
#!/bin/sh
# Five calls to the public API, each with a different made-up X-Forwarded-For. The limit is 3 per minute per client address.
for i in 1 2 3 4 5; do
printf "fake 203.0.113.%s -> " $i
curl -s --cacert /ca.crt --resolve api.lab.test:443:10.89.0.10 \
-H "X-Forwarded-For: 203.0.113.$i" -o /dev/null -w "%{http_code}\n" https://api.lab.test/api/public/news
doneانسخه إلى العميل وشغله مع الإعداد الصحيح:
docker cp spoof.sh lab-plg-client:/tmp/spoof.sh
docker exec lab-plg-client sh /tmp/spoof.shوالمخرج سوف يكون كما يلي:
fake 203.0.113.1 -> 200
fake 203.0.113.2 -> 200
fake 203.0.113.3 -> 200
fake 203.0.113.4 -> 429
fake 203.0.113.5 -> 429الحد يعمل لأن Nginx، وهو الحافة، رمى ما كتبه الزائر وكتب العنوان الحقيقي. ولو غيرنا Nginx وحده إلى $proxy_add_x_forwarded_for الذي يضيف إلى القائمة بدل أن يكتبها، فالحد يبقى يعمل في التجربة، لأن real-ip يقرأ من اليمين ولا يثق إلا ب Nginx فيصل إلى العنوان الحقيقي قبل المزيف. أما إذا اجتمع الخطآن، فأضاف Nginx إلى القائمة، ووسع أحدهم الثقة في real-ip إلى trusted_addresses: ["0.0.0.0/0"] لأنه «لم يعرف عنوان ال Proxy بالضبط»، فأعد تشغيل l7 وgw وشغل السكربت مرة أخرى، ثم أرسل طلباً واحداً لترى ما يصل إلى التطبيق:
docker compose restart l7 gw
docker exec lab-plg-client sh /tmp/spoof.sh
docker exec lab-plg-client curl -s --cacert /ca.crt --resolve api.lab.test:443:10.89.0.10 -H "X-Forwarded-For: 203.0.113.77" https://api.lab.test/api/public/newsوالمخرج سوف يكون كما يلي:
fake 203.0.113.1 -> 200
fake 203.0.113.2 -> 200
fake 203.0.113.3 -> 200
fake 203.0.113.4 -> 200
fake 203.0.113.5 -> 200
{
"instance": "app2",
"peer": "10.89.0.30",
"path": "/api/public/news",
"headers": {
"Host": "api.lab.test",
"X-Real-IP": "203.0.113.77",
"X-Forwarded-For": "203.0.113.77, 10.89.0.50, 203.0.113.77",
"X-Forwarded-Proto": "https",
"X-Forwarded-Host": "api.lab.test"
}
}تجاوز المهاجم الحد بتغيير الترويسة في كل طلب، وصارت البوابة تعد عنوانه هو 203.0.113.77 الذي كتبه بنفسه، بل وأضافته إلى آخر القائمة كأنه عنوان حقيقي. وقد شرحنا قاعدة قراءة X-Forwarded-For من اليمين في دليل التطبيق خلف ال Reverse Proxy، والجديد هنا في البنية متعددة الطبقات قاعدتان: الطبقة الأولى التي تقرأ HTTP تكتب الترويسة من جديد ولا تضيف إليها، وكل طبقة بعدها تثق بعنوان الطبقة التي قبلها فقط، لا بشبكة كاملة ولا بكل العناوين. وفي التجربة كانت قاعدة واحدة صحيحة كافية لإيقاف الانتحال، ولكنك لا تعرف أي قاعدة سوف يكسرها التعديل القادم، لذلك طبق الاثنتين. ثم أرجع الملفين كما كانا وأعد تشغيل l7 وgw.
نكسرها: فحص الصحة بالمنفذ لا يرى التطبيق المعطل
الآن نجعل app2 يفقد «قاعدة بياناته»، فيبقى منفذه مفتوحاً ويرد على كل شيء بالرمز 503، ثم ننتظر بضع ثوان ونرسل عشرة طلبات إلى المنفذ 8081 بفحص TCP، وعشرة إلى المنفذ 8082 بفحص HTTP:
docker exec lab-plg-app2 wget -qO- http://127.0.0.1:8080/break
docker exec lab-plg-client sh -c 'for p in 8081 8082; do printf "port %s: " $p; for i in 1 2 3 4 5 6 7 8 9 10; do curl -s -o /dev/null -w "%{http_code} " http://10.89.0.10:$p/; done; echo; done'
docker logs lab-plg-l4 2>&1 | grep -iE "DOWN|UP" | tail -1والمخرج سوف يكون كما يلي:
{
"instance": "app2",
"broken": true
}
port 8081: 200 503 200 503 200 503 200 503 200 503
port 8082: 200 200 200 200 200 200 200 200 200 200
Server be_apps_http/app2 is DOWN, reason: Layer7 wrong status, code: 503, info: "Service Unavailable", check duration: 1ms. 1 active and 0 backup servers left. 0 sessions active, 0 requeued, 0 remaining in queue.والصورة التالية تبين ما حدث في الجهتين:

فحص TCP يسأل «هل يقبل المنفذ الاتصال؟» والجواب نعم، فيبقى app2 في المجموعة ويرى نصف المستخدمين خطأ، أما فحص HTTP فيسأل «هل يرد مسار الصحة بالرمز 200؟» فيخرج app2 خلال ست ثوان تقريباً. والأخطر أن هذه ليست مشكلة HAProxy وحده، فأرسل نفس الطلبات عبر Nginx وAPISIX اللذين يوزعان على نفس النسختين دون فحص صحة:
docker exec lab-plg-client sh -c 'R="--cacert /ca.crt --resolve www.lab.test:443:10.89.0.10 --resolve api.lab.test:443:10.89.0.10"; printf "nginx site: "; for i in 1 2 3 4 5 6 7 8 9 10; do curl -s $R -o /dev/null -w "%{http_code} " https://www.lab.test/; done; echo; printf "apisix api: "; for i in 1 2 3 4 5 6 7 8 9 10; do curl -s $R -H "apikey: partner-key-456" -o /dev/null -w "%{http_code} " https://api.lab.test/api/orders/7; done; echo'
docker exec lab-plg-app2 wget -qO- http://127.0.0.1:8080/fixوالمخرج سوف يكون كما يلي:
nginx site: 200 503 200 503 200 503 200 503 200 503
apisix api: 200 503 200 503 200 503 200 503 200 503
{
"instance": "app2",
"broken": false
}Nginx المفتوح المصدر يملك فحصاً سلبياً Passive فقط، أي أنه يلاحظ الفشل أثناء الطلبات الحقيقية، والفشل عنده افتراضياً هو خطأ الاتصال أو انتهاء المهلة كما في قيمة proxy_next_upstream الافتراضية error timeout، فالرد 503 ليس فشلاً في نظره، أما الفحص النشط Active فهو في NGINX Plus فقط. وفي APISIX الفحص النشط موجود ولكنه لا يعمل حتى تضيفه إلى ال Upstream كما في دليل فحوص الصحة. والقاعدة: كل طبقة توزع على أكثر من نسخة تحتاج فحص صحتها بنفسها، بطلب HTTP إلى مسار يعكس قدرة التطبيق على الخدمة، وقد ناقشنا عمق هذا الفحص في دليل التوفر العالي.
كم تضيف كل طبقة؟
بقي السؤال الذي يقلق الكثيرين: هل كل طبقة تبطئ الطلب بشكل ملحوظ؟ ولكي نجيب بأرقام نرسل نفس الطلب عبر مداخل مختلفة، من التطبيق وحده إلى الطبقات كلها، بمستخدم واحد يرسل 3000 طلب متتالية، ثلاث مرات لكل مدخل، بأداة k6 التي شرحناها في دليل اختبار الأحمال. ولهذا أضفنا في Nginx المنفذ 8443 دون PROXY Protocol، حتى نقيس Nginx دون HAProxy. وهذا ملف latency.js:
// One virtual user, requests one after another: the difference between entry points is what each hop adds.
// docker run --rm --name lab-plg-k6 --network lab-plg-net -v "$PWD/latency.js:/latency.js:ro" -e TARGET=... grafana/k6:2.3.0 run -q /latency.js
import http from 'k6/http';
import { check } from 'k6';
export const options = {
vus: 1, iterations: 3000, insecureSkipTLSVerify: true,
hosts: { 'api.lab.test': __ENV.IP || '10.89.0.10', 'www.lab.test': __ENV.IP || '10.89.0.10' },
summaryTrendStats: ['med', 'p(95)', 'p(99)'],
};
const headers = __ENV.KEY ? { apikey: __ENV.KEY } : {};
if (__ENV.HOST) headers.Host = __ENV.HOST;
export default function () {
const r = http.get(__ENV.TARGET, { headers });
check(r, { '200': (x) => x.status === 200 });
}وسكربت bench.sh يشغله على المداخل الستة:
#!/bin/sh
# Latency added by each hop: the same request through more and more layers, 3 rounds of 3000 sequential requests each.
run() {
name=$1; shift
for round in 1 2 3; do
printf "%-31s r%s " "$name" $round
docker run --rm --name lab-plg-k6 --network lab-plg-net -v "$PWD/latency.js:/latency.js:ro" "$@" \
grafana/k6:2.3.0 run -q /latency.js 2>&1 | grep -E "http_req_duration\.|checks_failed" \
| sed -E 's/.*checks_failed[. ]*: *([0-9.]+%).*/failed=\1/; s/.*http_req_duration[. ]*: *//' | tr '\n' ' '
echo
done
}
K="-e KEY=partner-key-456"
run "app" -e TARGET=http://10.89.0.41:8080/api/orders/7
run "apisix > app" -e TARGET=http://10.89.0.30:9080/api/orders/7 -e HOST=api.lab.test $K
run "nginx(TLS) > app" -e TARGET=https://www.lab.test:8443/ -e IP=10.89.0.20
run "nginx(TLS) > apisix > app" -e TARGET=https://api.lab.test:8443/api/orders/7 -e IP=10.89.0.20 $K
run "haproxy > nginx > app" -e TARGET=https://www.lab.test/
run "haproxy > nginx > apisix > app" -e TARGET=https://api.lab.test/api/orders/7 $Kوالمخرج سوف يكون كما يلي:
app r1 failed=0.00% med=354µs p(95)=2.29ms p(99)=10.17ms
app r2 failed=0.00% med=371.2µs p(95)=1.7ms p(99)=5.17ms
app r3 failed=0.00% med=358.95µs p(95)=1.78ms p(99)=6.92ms
apisix > app r1 failed=0.00% med=1.48ms p(95)=10.12ms p(99)=15.3ms
apisix > app r2 failed=0.00% med=1.38ms p(95)=11.79ms p(99)=17.33ms
apisix > app r3 failed=0.00% med=1.21ms p(95)=6.41ms p(99)=12.69ms
nginx(TLS) > app r1 failed=0.00% med=1ms p(95)=8.3ms p(99)=14.19ms
nginx(TLS) > app r2 failed=0.00% med=926.38µs p(95)=2.34ms p(99)=4.38ms
nginx(TLS) > app r3 failed=0.00% med=771.55µs p(95)=1.87ms p(99)=3.94ms
nginx(TLS) > apisix > app r1 failed=0.00% med=1.85ms p(95)=4.5ms p(99)=8.74ms
nginx(TLS) > apisix > app r2 failed=0.00% med=1.87ms p(95)=5.96ms p(99)=12.1ms
nginx(TLS) > apisix > app r3 failed=0.00% med=1.92ms p(95)=4.56ms p(99)=8.56ms
haproxy > nginx > app r1 failed=0.00% med=1.35ms p(95)=3.08ms p(99)=5.66ms
haproxy > nginx > app r2 failed=0.00% med=1.43ms p(95)=3.18ms p(99)=6.58ms
haproxy > nginx > app r3 failed=0.00% med=1.08ms p(95)=2.24ms p(99)=4.15ms
haproxy > nginx > apisix > app r1 failed=0.00% med=2.28ms p(95)=5.57ms p(99)=10.85ms
haproxy > nginx > apisix > app r2 failed=0.00% med=1.72ms p(95)=3.33ms p(99)=6.31ms
haproxy > nginx > apisix > app r3 failed=0.00% med=2ms p(95)=3.76ms p(99)=6.13msفي المخرج أعلاه لاحظ التالي:
- بأخذ الوسيط من الجولات الثلاث: التطبيق وحده 0.36 ms، وNginx مع TLS يضيف حوالي 0.6 ms، وHAProxy في وضع TCP يضيف بين 0.1 و0.4 ms، وAPISIX مع key-auth يضيف حوالي 1 ms، أي أن الطبقات كلها أضافت أقل من ميلي ثانيتين في الوسيط.
- البوابة أغلى طبقة، والسبب أنها تشغل إضافات بلغة Lua لكل طلب، وهذا ثمن معرفة العميل، وهو ثمن صغير إذا كنت تحتاجها، وخسارة إذا كنت تستخدمها كموزع حمل فقط.
- الاتصال هنا مفتوح طوال القياس، أي أن مصافحة TLS حدثت مرة واحدة، أما الزائر الجديد فيدفع ثمن المصافحة في أول طلب.
- قيم p95 وp99 تتحرك بين الجولات لنفس المدخل أكثر مما تختلف بين المداخل، فقد تحرك p99 بين 4 و17 ms في كل الصفوف، لذلك لا تستنتج منها شيئاً في هذه التجربة، فهي على جهاز واحد عليه Docker Desktop مع معالج Intel i7-7700K، والأرقام للمقارنة بين الصفوف وليست تقديراً لأداء سيرفرك.
وبالتالي فالطبقة الزائدة لا تكلفك زمناً يذكر في أغلب التطبيقات، فالاستعلام الواحد من قاعدة البيانات أبطأ منها كلها، وإنما تكلفك تعقيداً: إعداداً إضافياً، وموضعاً آخر تكسر فيه الترويسات، وسجلاً آخر تبحث فيه، كما رأينا في الخطوات السابقة.
إزالة التجربة
الأمر التالي يحذف ما أنشأه ملف Compose فقط، بالاسم lab-plg، ثم نحذف الشهادات التي صنعناها:
docker compose down
rm -rf certsأخطاء شائعة في البنية متعددة الطبقات
- ثلاثة Proxy في صف واحد، وكل منها يعيد كتابة الترويسات: رأينا في التجربة بوابة تستبدل X-Forwarded-Proto بقيمتها، وطبقة تضيف إلى X-Forwarded-For ما كتبه الزائر، وX-Real-IP يحمل عنوان طبقة داخلية. والحل أن تكتب لكل ترويسة قاعدة واحدة: من يكتبها أولاً، ومن يضيف إليها، ومن يثق بمن، والترويسة القياسية Forwarded في RFC 7239 تحمل نفس المعلومات إذا أردت ترويسة واحدة بدل عدة ترويسات.
- بوابة API تستخدم كموزع حمل فقط: إذا لم يكن فيها مفاتيح ولا حدود لكل عميل ولا إصدارات، فهي Reverse Proxy أغلى بحوالي 1 ms في التجربة، ومعها مخزن إعدادات وإضافات يجب أن تحدثها، فاستخدم Reverse Proxy عادياً حتى تظهر الحاجة.
- فك التشفير مرتين دون سبب: أن ينهي موزع السحابة TLS، ثم يعيد التشفير إلى Reverse Proxy ينهيه مرة أخرى على نفس الشبكة الخاصة، دون متطلب واضح. إعادة التشفير داخل الشبكة لها أسباب حقيقية، مثل شبكة لا تثق بها أو متطلب امتثال أو mTLS بين الخدمات، فإذا وجد السبب فاكتبه، وإلا فكل نقطة فك تشفير مكان آخر للشهادات والمفاتيح، وطبقة أخرى ترى كلمات المرور كنص واضح كما ذكرنا في مقارنة أدوات ال Reverse Proxy.
- عنوان الزائر الضائع: موزع الطبقة الرابعة دون PROXY Protocol يجعل كل الزوار عنواناً واحداً، والحد من معدل الطلبات والحظر التلقائي والسجلات كلها تفقد قيمتها، وتفعيل جانب واحد من البروتوكول يكسر الاتصال كما رأينا.
- الثقة بشبكة كاملة أو بكل العناوين:
0.0.0.0/0في قائمة الثقة يعني أن أي زائر يستطيع أن يقول «أنا Proxy»، فاكتب عنوان الطبقة التي قبلك بالضبط. - فحص الصحة بالمنفذ فقط: التطبيق الذي فقد قاعدة بياناته يقبل الاتصال ويرد بخطأ، وفي التجربة رأى نصف الطلبات 503 بينما الموزع يقول إن كل شيء سليم.
- قرار التوجيه في الطبقة الخطأ: قاعدة
path_begفي موزع للطبقة الرابعة قبلها HAProxy دون تحذير ولم تعمل أبداً، فإذا احتجت المسار فضع القرار في الطبقة التي تقرأ HTTP. - إضافة طبقة لأن الشركات الكبيرة تستخدمها: شبكة الخدمات وبوابة ال API وموزع الحمل المنفصل كل منها يحل مشكلة محددة، فإذا لم تكن المشكلة عندك فالطبقة عبء بلا فائدة.
تدريب عملي
- غير
be_l7في HAProxy ليوجه بال SNI: أضفtcp-request inspect-delay 5sوtcp-request content accept if { req.ssl_hello_type 1 }، ثمuse_backendلنطاق جديد مثلlegacy.lab.testإلى backend آخر، وتأكد من السجل أن القرار بالنطاق يعمل في وضع TCP بينما القرار بالمسار لم يعمل. - أضف إلى ال Upstream في
apisix.yamlفحصاً نشطاً على/healthكما في توثيق APISIX، ثم كرر خطوة/breakوتأكد أن مخرجapisix apiأصبح كله 200.
الخلاصة
- فرق بين الطبقات بالمعلومة التي تقرر بها: الطبقة الرابعة بالعنوان والمنفذ وال SNI، وال Reverse Proxy بالنطاق والمسار والترويسات، والبوابة بهوية العميل، والتطبيق بالمستخدم وقواعد العمل.
- البرنامج الواحد يلعب أكثر من دور، فNginx وHAProxy وTraefik وEnvoy وAPISIX تعمل كل منها في الطبقتين، فاسأل عن الدور في إعدادك لا عن اسم البرنامج.
- ابدأ بسيرفر وReverse Proxy واحد، وأضف موزعاً منفصلاً عند السيرفر الثاني، والبوابة عندما يأتي عملاء من خارج فريقك بمفاتيح وحصص، وIngress أو Gateway API ثم Service Mesh على Kubernetes فقط عند حاجتها.
- في التجربة أضافت كل طبقة بين 0.1 و1 ms في الوسيط، والطبقات الأربع معاً أقل من ميلي ثانيتين، فالثمن الحقيقي للطبقة هو التعقيد وليس الزمن.
- كسرنا التجربة في خمسة مواضع: قاعدة مسار في الطبقة الرابعة قبلت ولم تعمل، وعنوان زائر ضاع دون PROXY Protocol، وبوابة أعادت كتابة X-Forwarded-Proto إلى http، وثقة واسعة سمحت بتجاوز الحد بعنوان مزيف، وفحص بالمنفذ أبقى نسخة معطلة تخدم نصف الطلبات.
- القواعد التي تمنع ذلك: الطبقة الأولى التي تقرأ HTTP تكتب X-Forwarded-For من جديد، وكل طبقة تثق بعنوان التي قبلها فقط، وكل طبقة توزع على نسخ تفحص صحتها بطلب HTTP، والبوابة تخفي المفتاح عن التطبيق.
سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على Docker Engine 29.8.0 مع HAProxy 3.4.6 وnginx 1.31.6 وApache APISIX 3.19.0 وPython 3.13.16 وcurl 8.11.1 وk6 2.3.0.