اللاخوادم والعمليات المدفوعة بالأحداث

البدء البارد والأداء

18 دقيقة الدرس 3 من 28

البدء البارد والأداء

في الخدمة التقليدية التي تعمل باستمرار، يُسدَّد عبء تشغيل العملية — تحميل JVM، وتهيئة سياق Spring، وجلب الأسرار — مرةً واحدةً عند النشر ثم يتوزّع على الملايين من الطلبات. في الدالة بلا خادم (serverless)، قد يُسدَّد هذا العبء في كل استدعاء يصطدم ببيئة تنفيذ باردة. فهم المصدر الدقيق لهذا التأخر، ومعرفة متى وكيف تعالجه، يُعدّ من أكثر المهارات قيمةً عملياً لتشغيل Lambda في بيئات الإنتاج واسعة النطاق.

البدء البارد على نطاق الإنتاج: على نطاق تجزئة Amazon نفسه، حتى معدّل بدء بارد بنسبة 1 % على دالة عالية الحركة بتأخر بدء بارد 10 مللي ثانية يكاد يكون غير ملحوظ. عند النسبة المئوية الخمسين (p50) لا يظهر أبداً. الضرر يكمن في الذيل: دالة Java بوقت بدء بارد 3 ثوانٍ يمكنها خرق مهلة API Gateway البالغة ثانية واحدة وإعادة خطأ 504 لمستخدمين حقيقيين. السؤال الهندسي الصحيح ليس "هل يوجد بدء بارد؟" بل "ما هو تأخر البدء البارد عند p99، وهل يخرق هدف مستوى الخدمة (SLO)؟"

تشريح البدء البارد: ما الذي يحدث فعلاً

عندما تقرر Lambda أنها بحاجة إلى بيئة تنفيذ جديدة للدالة، تُنفّذ تسلسلاً ثابتاً من العمليات. لكل منها ميزانية تأخر خاصة:

  1. تخصيص فتحة Hypervisor (~1–10 مللي ثانية): تعمل Lambda على micro-VMs من نوع Firecracker. تُخصّص مستوى التحكم فتحة MicroVM. هذا داخلي في AWS ولا تملك أي تأثير عليه.
  2. تمهيد بيئة التشغيل (~50–500 مللي ثانية للبيئات المُدارة): تُهيَّأ عملية بيئة التشغيل (python3.12، node20، JVM لـjava21) داخل MicroVM. البيئات المعتمدة على JVM تدفع أعلى تكلفة هنا — تحميل الكلاسات (classloading) وإحماء JIT والتحقق من البايت كود مكلفة بطبيعتها. Node.js وPython أرخص بكثير (عشرات المللي ثوانٍ).
  3. كود التهيئة للدالة (كودك خارج الـ handler): كل سطر في كودك على مستوى الوحدة — بناء عملاء SDK، وإعداد اتصال قاعدة البيانات، وقراءة متغيرات البيئة، وتحليل الإعدادات — يُنفَّذ بالتسلسل قبل أن يصبح handler قابلاً للاستدعاء. هذا هو الجزء الذي تتحكم فيه تماماً وأين تقع أكبر المكاسب.
  4. استدعاء الـ handler: يعمل الـ handler الفعلي للمرة الأولى. لأغراض قياس البدء البارد، هذا هو خط النهاية. مجموع الخطوات 1–3 هو تأخر البدء البارد الملحوظ من منظور المستدعي الخارجي.
Lambda Cold Start vs Warm Start Anatomy Cold Start MicroVM Alloc ~5 ms Runtime Bootstrap 50–500 ms (runtime-dependent) Init Code your code — variable Handler business logic إجمالي تأخر البدء البارد (ما يرصده المستدعي) Warm Start Existing execution env (reused) MicroVM + runtime already running Handler ~0.1–2 ms overhead تأخر الإحماء فقط Provisioned Concurrency بيئات تنفيذ مُهيَّأة مسبقاً وجاهزة — الخطوات 1–3 مدفوعة بالفعل؛ كل استدعاء يبدأ مباشرةً من Handler
تشريح البدء البارد مقابل الإحماء، وكيف يُزيل Provisioned Concurrency مرحلة البدء البارد الملحوظة.

قياس البدء البارد: المقاييس الصحيحة

تنشر AWS Lambda حقل Init Duration في سجلات CloudWatch عند حدوث بدء بارد. هذا الحقل غائب في استدعاءات الإحماء، مما يُسهّل فلترته وقياسه. المقاييس التي يجب تتبّعها في الإنتاج:

  • Init Duration p50/p95/p99: استخرجها عبر CloudWatch Logs Insights. النسبة المئوية 99 هي أسوأ تجربة محتملة للمستدعي وهي الرقم المهم للامتثال لـ SLO.
  • معدل البدء البارد: cold_starts / total_invocations على نافذة متحركة. عند حركة مرور مرتفعة ومستدامة يقترب من 0 %; عند الأنماط المتقطعة قد يتجاوز 10 %.
  • مقاييس التزامن: ConcurrentExecutions وUnreservedConcurrentExecutions من CloudWatch Metrics. ارتفاع مفاجئ في التنفيذات المتزامنة يتنبأ مباشرةً بانفجار البدء البارد.
# استعلام CloudWatch Logs Insights — تحليل البدء البارد # شغّله على مجموعة سجلات الدالة: /aws/lambda/<اسم-الدالة> fields @timestamp, @requestId, @initDuration, @duration, @billedDuration | filter @initDuration > 0 | stats count() as cold_starts, avg(@initDuration) as avg_init_ms, pct(@initDuration, 95) as p95_init_ms, pct(@initDuration, 99) as p99_init_ms, max(@initDuration) as max_init_ms by bin(5m) | sort by bin(5m) desc | limit 60

تحسين كود التهيئة: أعلى العمل مردوداً

كود التهيئة (الكود على مستوى الوحدة خارج الـ handler) يعمل مرةً واحدةً في كل بدء بارد. أي تأخر تُزيله من كود التهيئة يُزال من كل بدء بارد بصفة دائمة. هنا يُركّز المهندسون المتمرسون جهودهم قبل اللجوء إلى Provisioned Concurrency. الأنماط الشائعة:

  • التهيئة الكسولة للعملاء الاختيارية: إذا كان عميل Secrets Manager أو مرجع جدول DynamoDB أو رابط SQS مطلوبًا فقط في مسارات كود معينة، انقله داخل الـ handler أو خلف singleton على مستوى الوحدة يتهيأ عند الاستدعاء الأول. لا تدفع ثمنه في كل بدء بارد إذا كان 5 % فقط من الاستدعاءات تستخدمه.
  • حل الأسرار مرةً واحدةً وتخزينها في الكاش على مستوى الوحدة: استدعاء Secrets Manager GetSecretValue يستغرق ~50 مللي ثانية. إذا استدعيته داخل جسم الـ handler، تدفع 50 مللي ثانية في كل استدعاء. إذا استدعيته في كود التهيئة، تدفع 50 مللي ثانية مرةً واحدةً في كل بدء بارد. لكن انتبه: خزّن القيمة المحلولة في متغير على مستوى الوحدة؛ بيئة التنفيذ تبقى بين استدعاءات الإحماء (هذا هو النمط المقصود).
  • استيراد ما تحتاجه فقط: في Python وNode.js، استيراد حزمي SDK بأكملها عندما تحتاج عميلاً واحداً فقط يحمّل كميات كبيرة من الكود. استخدم استيرادات المسار: from boto3 import client as boto_client بدلاً من import boto3، أو import { DynamoDBClient } from "@aws-sdk/client-dynamodb" بدلاً من حزمة V2 SDK الكاملة. في Node.js Lambda مع AWS SDK V3، هذا مؤثر بشكل خاص — V3 وحدوي تحديداً لتقليل البدء البارد.
  • تجنب إدخال/إخراج الملفات التزامني عند التهيئة: قراءة ملفات إعداد كبيرة، وتحليل مخططات JSON، وتصريف أنماط regex على مستوى الوحدة أمر شائع ومكلف. قيّس باستخدام AWS_LAMBDA_LOG_LEVEL=TRACE أو فارق بسيط لـDate.now() حول كل كتلة تهيئة لرؤية أين يُستهلك الوقت.
# Python Lambda — نمط تحسين كود التهيئة import os import json from functools import lru_cache from boto3 import client as boto_client # ---- Singletons على مستوى الوحدة (تُهيَّأ مرةً واحدةً، تُعاد عبر استدعاءات الإحماء) ---- _ssm = boto_client("ssm", region_name=os.environ["AWS_REGION"]) _dyndb = boto_client("dynamodb", region_name=os.environ["AWS_REGION"]) # حل الأسرار بشكل كسول مع كاش على مستوى الوحدة _DB_PASSWORD: str | None = None def _get_db_password() -> str: global _DB_PASSWORD if _DB_PASSWORD is None: resp = _ssm.get_parameter( Name=os.environ["DB_PASS_PARAM"], WithDecryption=True, ) _DB_PASSWORD = resp["Parameter"]["Value"] return _DB_PASSWORD # ---- Handler — لا بناء SDK، لا جلب للأسرار ---- def handler(event, context): db_pass = _get_db_password() # مجاني في استدعاءات الإحماء item = _dyndb.get_item( TableName=os.environ["TABLE_NAME"], Key={"pk": {"S": event["id"]}}, ) return {"statusCode": 200, "body": json.dumps(item.get("Item", {}))}

Provisioned Concurrency: إزالة البدء البارد من المسارات الحرجة

Provisioned Concurrency (PC) تُهيّئ مسبقاً عدداً محدداً من بيئات التنفيذ، وتُشغّل جميع كود التهيئة، وتُبقي تلك البيئات جاهزةً لقبول الطلبات. من منظور المستدعي، استدعاء PC لا يحمل أي تأخر للتهيئة — إنه غير قابل للتمييز عن استدعاء الإحماء. أنت تدفع مقابل حساب خامل؛ معادلة التكلفة إذن هي: تكلفة PC × العدد المحجوز × الوقت مقابل تكلفة البدء البارد × معدل البدء البارد × تأخر التهيئة عند p99 × أثر SLO.

أين يكون PC مجدياً اقتصادياً وتشغيلياً:

  • واجهات برمجية بـ SLOs صارمة لتأخر p99 (تدفقات الدفع، نقاط نهاية المصادقة، الميزات الفورية)
  • Lambda المعتمدة على JVM (Java، Kotlin، Scala) حيث تتجاوز البدايات الباردة عادةً 1–3 ثوانٍ
  • الدوال التي تُستدعى بمعدلات متقلبة جداً — بعد فترة من حركة المرور الصفرية، موجة الطلبات الأولى كلها تبدأ بارداً في وقت واحد دون PC
  • المهام المجدولة بموعد نهائي ضيق — مهمة Step Function بمهلة 10 ثوانٍ وبدء بارد 5 ثوانٍ لا تترك هامشاً للعمل الفعلي
# Terraform — إعدادات Provisioned Concurrency resource "aws_lambda_function" "payment_api" { function_name = "payment-api-${var.env}" runtime = "java21" handler = "com.acme.PaymentHandler::handleRequest" memory_size = 1024 timeout = 30 filename = "payment-api.zip" environment { variables = { REGION = var.aws_region TABLE_NAME = var.dynamodb_table } } } # نشر إصدار — PC يجب أن يُرفق بإصدار محدد، ليس $LATEST resource "aws_lambda_alias" "live" { name = "live" function_name = aws_lambda_function.payment_api.function_name function_version = aws_lambda_function.payment_api.version } resource "aws_lambda_provisioned_concurrency_config" "payment_api_pc" { function_name = aws_lambda_function.payment_api.function_name qualifier = aws_lambda_alias.live.name provisioned_concurrent_executions = 10 } # Application Auto Scaling — ضبط PC تلقائياً بين 5 و50 بناءً على الاستخدام resource "aws_appautoscaling_target" "pc_target" { max_capacity = 50 min_capacity = 5 resource_id = "function:${aws_lambda_function.payment_api.function_name}:live" scalable_dimension = "lambda:function:ProvisionedConcurrency" service_namespace = "lambda" } resource "aws_appautoscaling_policy" "pc_policy" { name = "payment-api-pc-tracking" policy_type = "TargetTrackingScaling" resource_id = aws_appautoscaling_target.pc_target.resource_id scalable_dimension = aws_appautoscaling_target.pc_target.scalable_dimension service_namespace = aws_appautoscaling_target.pc_target.service_namespace target_tracking_scaling_policy_configuration { target_value = 0.7 # ارفع عند تجاوز 70% من استخدام PC predefined_metric_specification { predefined_metric_type = "LambdaProvisionedConcurrencyUtilization" } scale_in_cooldown = 300 scale_out_cooldown = 60 } }
استهدف 70 % من الاستخدام، لا 100 %: إذا كان PC عند 100 % من الاستخدام، فأي استدعاء متزامن جديد يتجاوز سيسقط في بدء بارد عند الطلب. هامش 70 % يعطيك مساحة لامتصاص ارتفاعات مفاجئة في الحركة قبل أن يستجيب Auto Scaling (عادةً 60–90 ثانية). الفجوة بين نقطة ضبطك 70 % و100 % هي مخزن انفجاري — ضع حجمه وفقاً لتقلب حركة مرورك.

SnapStart: تخفيف البدء البارد بالقطعة الأثرية لـ JVM

AWS Lambda SnapStart (متاح لبيئة تشغيل Java 21+ المُدارة) يلتقط لقطة من بيئة التنفيذ المُهيَّأة بالكامل بعد مرحلة التهيئة ويخزّنها كلقطة ذاكرة Firecracker. عند بدء بارد، تستعيد Lambda من هذه اللقطة بدلاً من إعادة التهيئة من الصفر. عملياً، يضغط هذا بدايات JVM الباردة من 3–8 ثوانٍ إلى 200–600 مللي ثانية — دون أي تغييرات في الكود ودون تكلفة Provisioned Concurrency لكل مثيل.

تفعيل SnapStart إعداد Terraform واحد أو إعداد في وحدة التحكم:

# Terraform — Lambda SnapStart لدالة Java 21 resource "aws_lambda_function" "order_processor" { function_name = "order-processor-${var.env}" runtime = "java21" handler = "com.acme.OrderHandler::handleRequest" memory_size = 1024 timeout = 60 filename = "order-processor.zip" publish = true # SnapStart يتطلب نشر إصدارات snap_start { apply_on = "PublishedVersions" } } # بعد تفعيل SnapStart يجب استدعاء الدالة مرةً واحدةً في بيئة اختبار # حتى تتمكن Lambda من التقاط اللقطة — تُلتقط عند النشر. # اللقطة مرتبطة بالإصدار المنشور؛ نشر جديد ينتج لقطة جديدة # من مرحلة تهيئة الإصدار الجديد.

لدى SnapStart محذوران للصحة يجب على كل مهندس Java فهمهما قبل تفعيله في الإنتاج:

  • خطافات التفرّد: أي حالة يجب أن تكون فريدة لكل بيئة تنفيذ — بذور عشوائية، UUID مُولَّدة عند التهيئة، مفاتيح جلسات TLS — ستكون متطابقة عبر جميع المثيلات المُستعادة إذا وُلّدت قبل اللقطة. توفر Lambda واجهة برمجة CRaC (Coordinated Restore at Checkpoint): نفّذ org.crac.Resource وسجّل مع Core.getGlobalContext(). في خطاف beforeCheckpoint، أغلق اتصالات الشبكة وأطلق أي حالة فريدة. في خطاف afterRestore، أعد إنشاء الاتصالات وأعد توليد الحالة الفريدة.
  • اتصالات الشبكة في كود التهيئة: اتصال TCP بـ RDS أو ElastiCache أو واجهة برمجة خارجية يُفتح عند التهيئة سيكون قديماً بعد استعادة اللقطة. إما افتح الاتصالات بشكل كسول في جسم الـ handler، أو استخدم خطاف CRaC afterRestore لإعادة الاتصال.
SnapStart لا يُعوّض Provisioned Concurrency في تحمّل الانفجارات: SnapStart يُقلّص تأخر البدء البارد لكل استدعاء من ثوانٍ إلى مئات المللي ثوانٍ. لكن إذا وصل 500 طلب متزامن في وقت واحد وكان على Lambda تهيئة 500 بيئة تنفيذ جديدة من اللقطات، فكل واحدة منها ستتحمل تأخر الاستعادة (الأقصر الآن). بالنسبة للواجهات البرمجية الحرجة التي تشترط صفر بدايات باردة، ادمج SnapStart (للحالة الاحتياطية) مع Provisioned Concurrency المُوسَّع تلقائياً (للمسار الطبيعي). SnapStart يعالج الفائض؛ PC يعالج المسار الساخن.

الذاكرة والمهلة والمعمارية: عجلات التحكم الأخرى

يرتبط البدء البارد بإعدادات ذاكرة الدالة. تخصيص CPU في Lambda يتناسب مع الذاكرة: دالة 128 ميغابايت تحصل على جزء من vCPU؛ دالة 1769 ميغابايت تحصل على vCPU واحد تماماً؛ دالة 3008 ميغابايت تحصل على ما يقارب vCPU اثنين. بالنسبة لدوال JVM تحديداً، المزيد من الذاكرة يعني تحميل كلاسات أسرع، وتصريف JIT أسرع، وبالتالي بدايات باردة أقصر. النقطة المثلى لتقليل بداية Java الباردة دون هدر ميزانية هي عادةً 1024–2048 ميغابايت.

بالنسبة لدوال ARM64 (Graviton2)، البدايات الباردة أقصر بشكل ملحوظ من x86_64 لنفس إعدادات الذاكرة، وتكلفة الحساب لكل استدعاء أقل بنسبة 20 %. ما لم يكن لديك سبب محدد للبقاء على x86_64 (ملحقات native، مكتبات خاصة بالمعمارية)، يجب أن تعتمد الدوال الجديدة افتراضياً architectures = ["arm64"] في Terraform.

أخيراً، حجم حزمة الدالة يؤثر مباشرةً على وقت التنزيل أثناء البدء البارد. ملف ZIP بحجم 50 ميغابايت له نافذة تنزيل أقصر من ملف 250 ميغابايت. أبقِ الاعتمادات في حدودها الدنيا؛ استخدم Lambda Layers للمكتبات المشتركة حتى تُخزَّن الطبقة مؤقتاً على مستوى منطقة التوفر بدلاً من تنزيلها لكل إصدار دالة.