التقييد الديناميكي للمعدل في OpenResty

API7.ai

January 6, 2023

OpenResty (NGINX + Lua)

في المقال السابق، قدمت لكم خوارزميات leaky bucket وtoken bucket، والتي تُستخدم بشكل شائع للتعامل مع حركة المرور المفاجئة. كما تعلمنا كيفية تنفيذ تحديد معدل الطلبات باستخدام تكوين NGINX. ومع ذلك، فإن استخدام تكوين NGINX يقتصر فقط على مستوى الإمكانية ولا يزال هناك طريق طويل ليكون مفيدًا.

المشكلة الأولى هي أن مفتاح تحديد المعدل يقتصر على نطاق من متغيرات NGINX ولا يمكن تعيينه بشكل مرن. على سبيل المثال، لا توجد طريقة لتعيين حدود سرعة مختلفة للمقاطعات المختلفة وقنوات العملاء المختلفة، وهو أمر مطلوب بشكل شائع مع NGINX.

مشكلة أخرى أكبر هي أن المعدل لا يمكن تعديله ديناميكيًا، ويتطلب كل تغيير إعادة تحميل خدمة NGINX. نتيجة لذلك، يمكن تنفيذ تحديد السرعة بناءً على فترات مختلفة فقط بشكل غير فعال من خلال البرامج النصية الخارجية.

من المهم فهم أن التكنولوجيا تخدم الأعمال، وفي نفس الوقت، تقود الأعمال التكنولوجيا. في وقت ولادة NGINX، كانت هناك حاجة قليلة لتعديل التكوين ديناميكيًا؛ كانت أكثر حول الوكيل العكسي، موازنة الحمل، استخدام الذاكرة المنخفضة، وغيرها من الاحتياجات المشابهة التي قادت نمو NGINX. من حيث هندسة التكنولوجيا والتنفيذ، لم يكن بإمكان أي شخص توقع الانفجار الهائل في الطلب على التحكم الديناميكي والدقيق في سيناريوهات مثل الإنترنت المحمول، إنترنت الأشياء، والخدمات المصغرة.

استخدام OpenResty للنصوص البرمجية Lua يعوض النقص في NGINX في هذا المجال، مما يجعله مكملاً فعالاً. هذا هو السبب في أن OpenResty يستخدم على نطاق واسع كبديل لـ NGINX. في المقالات القليلة القادمة، سأستمر في تقديم المزيد من السيناريوهات والأمثلة الديناميكية في OpenResty. لنبدأ بالنظر في كيفية استخدام OpenResty لتنفيذ تحديد المعدل الديناميكي.

في OpenResty، نوصي باستخدام lua-resty-limit-traffic لتحديد حركة المرور. يتضمن limit-req (تحديد معدل الطلبات)، limit-count (تحديد عدد الطلبات)، وlimit-conn (تحديد الاتصالات المتزامنة)؛ ويوفر limit.traffic لتجميع هذه الطرق الثلاث.

تحديد معدل الطلبات

لنبدأ بالنظر إلى limit-req، الذي يستخدم خوارزمية leaky bucket لتحديد معدل الطلبات.

في القسم السابق، قدمنا بإيجاز الكود الرئيسي لتنفيذ خوارزمية leaky bucket في مكتبة resty هذه، والآن سنتعلم كيفية استخدام هذه المكتبة. أولاً، لنلقي نظرة على الكود النموذجي التالي.

resty --shdict='my_limit_req_store 100m' -e 'local limit_req = require "resty.limit.req"
local lim, err = limit_req.new("my_limit_req_store", 200, 100)
local delay, err = lim:incoming("key", true)
if not delay then
    if err == "rejected" then
        return ngx.exit(503)
    end
    return ngx.exit(500)
end

 if delay >= 0.001 then
    ngx.sleep(delay)
end'

نحن نعلم أن lua-resty-limit-traffic يستخدم shared dict لتخزين وإحصاء المفاتيح، لذلك نحتاج إلى الإعلان عن مساحة 100m لـ my_limit_req_store قبل أن نتمكن من استخدام limit-req. هذا مشابه لـ limit-conn وlimit-count، حيث يحتاج كل منهما إلى مساحة shared dict منفصلة للتمييز.

limit_req.new("my_limit_req_store", 200, 100)

السطر السابق من الكود هو أحد أكثر الأسطر أهمية. يعني ذلك أن shared dict يسمى my_limit_req_store يستخدم لتخزين الإحصائيات، ويتم تعيين المعدل في الثانية إلى 200، بحيث إذا تجاوز 200 ولكن كان أقل من 300 (يتم حساب هذه القيمة من 200 + 100)، سيتم وضعها في قائمة الانتظار، وإذا تجاوزت 300، سيتم رفضها.

بعد الانتهاء من الإعداد، علينا معالجة الطلب من العميل. lim: incoming("key", true) موجود هنا للقيام بذلك. incoming له معاملان، نحتاج إلى قراءتهما بالتفصيل.

المعامل الأول، المفتاح المحدد من قبل المستخدم لتحديد المعدل، هو ثابت نصي في المثال أعلاه، مما يعني أن تحديد المعدل يجب أن يكون موحدًا لجميع العملاء. إذا كنت تريد تحديد المعدل وفقًا للمقاطعات والقنوات المختلفة، فمن السهل جدًا استخدام كلتا المعلومات كالمفتاح، وإليك الكود الزائف لتحقيق هذا المطلب.

local  province = get_ province(ngx.var.binary_remote_addr)
local channel = ngx.req.get_headers()["channel"]
local key = province .. channel
lim:incoming(key, true)

بالطبع، يمكنك أيضًا تخصيص معنى المفتاح وشروط استدعاء incoming، بحيث تحصل على تأثير مرن جدًا لتحديد المعدل.

لننظر إلى المعامل الثاني لدالة incoming، وهو قيمة منطقية. القيمة الافتراضية هي false، مما يعني أن الطلب لن يتم تسجيله في shared dict للإحصاء؛ إنه مجرد تمرين. إذا تم تعيينه إلى true، فسيكون له تأثير حقيقي. لذلك، في معظم الحالات، ستحتاج إلى تعيينه إلى true بشكل صريح.

قد تتساءل عن سبب وجود هذا المعامل. فكر في سيناريو حيث تقوم بإعداد مثيلين مختلفين من limit-req بمفاتيح مختلفة، أحد المفاتيح هو اسم المضيف والآخر هو عنوان IP العميل. ثم، عند معالجة طلب العميل، يتم استدعاء دوال incoming لهذه المثيلات بالترتيب، كما هو موضح في الكود الزائف التالي.

local limiter_one, err = limit_req.new("my_limit_req_store", 200, 100)
local limiter_two, err = limit_req.new("my_limit_req_store", 20, 10)

limiter_one :incoming(ngx.var.host, true)
limiter_two:incoming(ngx.var.binary_remote_addr, true)

إذا تجاوز طلب المستخدم عتبة الكشف لـ limiter_one ولكن تم رفضه بواسطة الكشف لـ limiter_two، فيجب اعتبار استدعاء دالة limiter_one:incoming تمرينًا ولا نحتاج إلى إحصائه.

في هذه الحالة، منطق الكود أعلاه ليس صارمًا بما فيه الكفاية. نحتاج إلى إجراء تمرين مسبق لجميع المحددات بحيث إذا تم تشغيل عتبة محددة يمكن أن ترفض طلب العميل، يمكنها العودة مباشرة.

for i = 1, n do
    local lim = limiters[i]
    local delay, err = lim:incoming(keys[i], i == n)
    if not delay then
        return nil, err
    end
end

هذا هو كل ما يتعلق بالمعامل الثاني لدالة incoming. هذا الكود هو الكود الأساسي لوحدة limit.traffic، والتي تُستخدم لدمج عدة محددات معدل.

تحديد عدد الطلبات

لنلق نظرة على limit.count، مكتبة لتحديد عدد الطلبات. تعمل مثل GitHub API Rate Limiting، التي تحد من عدد طلبات المستخدم في نافذة زمنية ثابتة. كالعادة، لنبدأ بكود نموذجي.

local limit_count = require "resty.limit.count"

local lim, err = limit_count.new("my_limit_count_store", 5000, 3600)

local key = ngx.req.get_headers()["Authorization"]
local delay, remaining = lim:incoming(key, true)

يمكنك أن ترى أن limit.count وlimit.req يستخدمان بشكل مشابه. نبدأ بتعريف shared dict في nginx.conf.

lua_shared_dict my_limit_count_store 100m;

ثم نقوم بـ new كائن محدد، وأخيرًا نستخدم دالة incoming لتحديد ومعالجة الطلب.

ومع ذلك، الفرق هو أن القيمة الثانية التي تُرجعها دالة incoming في limit-count تمثل الطلبات المتبقية، ويمكننا إضافة حقول إلى رأس الاستجابة وفقًا لذلك لإعطاء العميل إشارة أفضل.

ngx.header["X-RateLimit-Limit"] = "5000"
ngx.header["X-RateLimit-Remaining"] = remaining

تحديد عدد الاتصالات المتزامنة

limit.conn هي مكتبة لتحديد عدد الاتصالات المتزامنة. تختلف عن المكتبتين المذكورتين سابقًا في أنها تحتوي على leaving API خاص، والذي سأصفه بإيجاز هنا.

تحديد معدل الطلبات وعدد الطلبات، كما ذكرنا أعلاه، يمكن القيام به مباشرة في مرحلة access. على عكس تحديد عدد الاتصالات المتزامنة، الذي يتطلب ليس فقط تحديد ما إذا تم تجاوز العتبة في مرحلة access ولكن أيضًا استدعاء leaving API في مرحلة log.

log_by_lua_block {
    local latency = tonumber(ngx.var.request_time) - ctx.limit_conn_delay
    local key = ctx.limit_conn_key

    local conn, err = lim:leaving(key, latency)
}

ومع ذلك، فإن الكود الأساسي لهذا API بسيط جدًا، وهو السطر التالي من الكود الذي يقلل عدد الاتصالات بواحد. إذا لم تقم بالتنظيف في مرحلة log، سيستمر عدد الاتصالات في الزيادة وسيصل قريبًا إلى عتبة التزامن.

local conn, err = dict:incr(key, -1)

دمج محددات المعدل

هذا هو نهاية مقدمة كل من هذه الطرق الثلاث. أخيرًا، لنرى كيفية دمج limit.rate، limit.conn وlimit.count. هنا نحتاج إلى استخدام دالة combine في limit.traffic.

local lim1, err = limit_req.new("my_req_store", 300, 200)
local lim2, err = limit_req.new("my_req_store", 200, 100)
local lim3, err = limit_conn.new("my_conn_store", 1000, 1000, 0.5)

local limiters = {lim1, lim2, lim3}
local host = ngx.var.host
local client = ngx.var.binary_remote_addr
local keys = {host, client, client}

local delay, err = limit_traffic.combine(limiters, keys, states)

يجب أن يكون هذا الكود سهل الفهم مع المعرفة التي حصلت عليها للتو. الكود الأساسي لدالة combine، الذي ذكرناه في تحليل limit.rate أعلاه، يتم تنفيذه بشكل أساسي بمساعدة دالة drill ودالة uncommit. هذا الدمج يسمح لك بتعيين عتبات ومفاتيح مختلفة لمحددات متعددة لتحقيق متطلبات أعمال أكثر تعقيدًا.

الخلاصة

لا يدعم limit.traffic فقط محددات المعدل الثلاثة المذكورة اليوم، ولكن طالما أن محدد المعدل يحتوي على incoming وuncommit API، يمكن إدارته بواسطة دالة combine لـ limit.traffic.

أخيرًا، سأترك لكم سؤالاً للواجب المنزلي. هل يمكنك كتابة مثال يدمج محددات معدل token وbucket التي قدمناها من قبل؟ لا تتردد في كتابة إجابتك في قسم التعليقات لمناقشتها معي، وأنت أيضًا مرحب بك لمشاركة هذه المقالة مع زملائك وأصدقائك للتعلم والتبادل معًا.