حلقة Lambda اللانهائية مع S3: كيف تمنع التشغيل المتكرر؟

أحد أكثر الأخطاء شيوعاً عند بناء معالجة ملفات على AWS: تكتب دالة Lambda تُعالج الملفات المرفوعة إلى S3 ثم تحفظ النتيجة في نفس الحاوية — فتجد نفسك أمام حلقة لا تتوقف تستنزف الفاتورة وتملأ السجلات بآلاف الاستدعاءات في دقائق.

TL;DR — ملخص سريع

الحلمتى تستخدمهالتعقيد
تصفية البادئة/اللاحقةالملفات المعالَجة لها امتداد أو مجلد مختلفمنخفض
وسم الكائن (Object Tag)تريد تتبع حالة المعالجةمتوسط
حاويتان منفصلتانفصل واضح بين المدخلات والمخرجاتمنخفض

كيف تنشأ الحلقة — فهم آلية المشكلة

عندما تُهيّئ إشعار S3 لتشغيل Lambda على حدث s3:ObjectCreated:*، فإن S3 يُطلق الإشعار لأي كائن جديد بغض النظر عمّن أنشأه. Lambda نفسها تكتب ملفاً جديداً → S3 يُطلق إشعاراً جديداً → Lambda تُستدعى مجدداً. هذا ليس خطأً في Lambda أو S3، بل هو السلوك المتوقع تماماً — المشكلة في التصميم.

graph LR User["المستخدم"] -->|يرفع ملفاً| S3Input["S3: input/file.csv"] S3Input -->|ObjectCreated Event| Lambda["Lambda: MyProcessor"] Lambda -->|تكتب النتيجة| S3Output["S3: output/file.csv
(نفس الحاوية)"] S3Output -->|ObjectCreated Event جديد| Lambda Lambda -->|تكتب مجدداً| S3Output style Lambda fill:#ff6b6b,color:#fff style S3Output fill:#ffa07a,color:#fff
  1. المستخدم يرفع ملفاً إلى input/file.csv — يُطلق حدث ObjectCreated.
  2. Lambda تُعالج الملف وتكتب النتيجة إلى output/file_processed.csv في نفس الحاوية.
  3. S3 يُطلق إشعاراً جديداً على الكائن المكتوب — Lambda تُستدعى مرة أخرى.
  4. الحلقة تتكرر حتى يتدخل المشغّل يدوياً أو تنفد الحصة.

تشخيص حلقة نشطة وإيقافها — إجراء الطوارئ

إذا كانت الحلقة تعمل الآن، أول خطوة هي قطع المشغّل قبل أي شيء آخر. مشغلات S3 لا تظهر كـ Event Source Mappings في Lambda — هي إشعارات مُهيَّأة على مستوى الحاوية مباشرةً، لذا يجب التحقق منها عبر S3 API وليس Lambda API.

# الخطوة 1: تحقق من إشعارات S3 المُهيَّأة على الحاوية
aws s3api get-bucket-notification-configuration \
  --bucket my-bucket

# ملاحظة: مشغلات S3 لا تظهر في list-event-source-mappings
# لأن تلك القائمة خاصة بـ SQS/Kinesis/DynamoDB Streams فقط

# الخطوة 2: إذا أردت إيقاف المشغّل فوراً — احذف إعداد الإشعار مؤقتاً
aws s3api put-bucket-notification-configuration \
  --bucket my-bucket \
  --notification-configuration '{}'

# الخطوة 3: تحقق من عدد الاستدعاءات النشطة
aws cloudwatch get-metric-statistics \
  --namespace AWS/Lambda \
  --metric-name Invocations \
  --dimensions Name=FunctionName,Value=MyProcessor \
  --start-time $(date -u -d '30 minutes ago' +%Y-%m-%dT%H:%M:%SZ) \
  --end-time $(date -u +%Y-%m-%dT%H:%M:%SZ) \
  --period 60 \
  --statistics Sum

بعد قطع المشغّل، راجع CloudWatch Logs لتحديد أول استدعاء أطلق الحلقة — ابحث عن الكائن الذي كتبته Lambda وتحقق من مساره.

الحل الأول: تصفية البادئة واللاحقة في إشعار S3

أبسط حل وأسرعه: اجعل Lambda تستمع فقط على مسار محدد (مثل input/) وتكتب النتيجة في مسار مختلف (مثل processed/). S3 يدعم تصفية الإشعارات بالبادئة (prefix) واللاحقة (suffix) مباشرةً في إعداد الإشعار.

aws s3api put-bucket-notification-configuration \
  --bucket my-bucket \
  --notification-configuration '{
    "LambdaFunctionConfigurations": [
      {
        "LambdaFunctionArn": "arn:aws:lambda:us-east-1:123456789012:function:MyProcessor",
        "Events": ["s3:ObjectCreated:*"],
        "Filter": {
          "Key": {
            "FilterRules": [
              {
                "Name": "prefix",
                "Value": "input/"
              }
            ]
          }
        }
      }
    ]
  }'

الآن Lambda تُستدعى فقط عند رفع ملف تحت input/. أي ملف تكتبه Lambda تحت processed/ لن يُطلق إشعاراً. هذا يعمل بشرط واحد: يجب أن تكتب Lambda دائماً خارج input/ — إذا كتبت أي شيء تحت input/ ولو بالخطأ، تعود الحلقة.

فكّر في الأمر كبوابة مطار: الإشعار هو بوابة الوصول فقط — المغادرة من طرف آخر تماماً. ما يخرج من Lambda يذهب إلى صالة مختلفة.

الحل الثاني: فحص وسم الكائن داخل Lambda

عندما لا يمكنك فصل المسارات — مثلاً تحتاج أن تكتب في نفس المجلد — يمكنك تمييز الكائنات المعالَجة بوسم (tag) وتفحص Lambda الوسم قبل المعالجة. إذا وجدت الوسم، تتوقف فوراً.

graph TD Start["Lambda تُستدعى"] --> GetTag["s3:GetObjectTagging"] GetTag --> Check{"الوسم processed=true?"} Check -->|نعم| Skip["تتوقف — لا معالجة"] Check -->|لا| Process["تُعالج الملف"] Process --> Write["تكتب المخرجات"] Write --> Tag["s3:PutObjectTagging
processed=true"] Tag --> Done["انتهت"] style Skip fill:#90EE90,color:#333 style Done fill:#90EE90,color:#333

داخل كود Lambda (Python):

🔽 كود Python — فحص الوسم قبل المعالجة
import boto3

s3 = boto3.client('s3')

def lambda_handler(event, context):
    bucket = event['Records'][0]['s3']['bucket']['name']
    key = event['Records'][0]['s3']['object']['key']

    # فحص الوسم قبل أي معالجة
    try:
        tags_response = s3.get_object_tagging(Bucket=bucket, Key=key)
        tags = {t['Key']: t['Value'] for t in tags_response['TagSet']}
        if tags.get('processed') == 'true':
            print(f'Skipping already processed object: {key}')
            return {'status': 'skipped'}
    except s3.exceptions.NoSuchKey:
        return {'status': 'object_not_found'}

    # المعالجة الفعلية
    # ... منطق معالجة الملف ...

    # كتابة النتيجة
    output_key = key.replace('raw/', 'output/')
    s3.put_object(
        Bucket=bucket,
        Key=output_key,
        Body=b'processed_content'
    )

    # وسم الكائن الأصلي كـ 'معالَج'
    s3.put_object_tagging(
        Bucket=bucket,
        Key=key,
        Tagging={'TagSet': [{'Key': 'processed', 'Value': 'true'}]}
    )

    return {'status': 'success', 'output': output_key}

هذا النهج يحتاج صلاحيات إضافية في دور Lambda:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject",
        "s3:GetObjectTagging",
        "s3:PutObjectTagging"
      ],
      "Resource": "arn:aws:s3:::my-bucket/*"
    }
  ]
}

نقطة دقيقة هنا: put_object_tagging يُطلق حدث s3:ObjectTagging:PutObjectTagging وليس s3:ObjectCreated:* — لذا لن يُعيد تشغيل Lambda طالما إعداد الإشعار مقيّد بـ ObjectCreated فقط.

الحل الثالث: حاويتان منفصلتان

الحل الأنظف معمارياً: حاوية للمدخلات وحاوية للمخرجات. Lambda تستمع على الأولى وتكتب في الثانية. لا تصفية، لا وسوم، لا منطق إضافي داخل الكود.

# إنشاء الحاويتين
aws s3api create-bucket \
  --bucket my-input-bucket \
  --region us-east-1

aws s3api create-bucket \
  --bucket my-output-bucket \
  --region us-east-1

# إعداد الإشعار على حاوية المدخلات فقط
aws s3api put-bucket-notification-configuration \
  --bucket my-input-bucket \
  --notification-configuration '{
    "LambdaFunctionConfigurations": [
      {
        "LambdaFunctionArn": "arn:aws:lambda:us-east-1:123456789012:function:MyProcessor",
        "Events": ["s3:ObjectCreated:*"]
      }
    ]
  }'

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

تجربة من الميدان: الخطأ الذي لا يُنسى

في إحدى البيئات الإنتاجية، كانت Lambda تُعالج صور JPEG وتحفظ نسخة مصغّرة بنفس الاسم مع لاحقة _thumb.jpg — في نفس المجلد. المطوّر أضاف تصفية suffix: .jpg ظناً أن ذلك يكفي. المشكلة؟ الصورة المصغّرة نفسها تنتهي بـ .jpg أيضاً.

الأعراض كانت: ارتفاع مفاجئ في CloudWatch Invocations، فاتورة S3 GET/PUT تتضاعف كل دقيقة، وسجلات Lambda تُظهر نفس الملف يُعالَج مرات لا تُحصى. التشخيص الأولي أشار إلى خطأ في الكود — لكن الكود كان سليماً تماماً. المشكلة كانت في إعداد الإشعار.

الإصلاح: تغيير اللاحقة من .jpg إلى تصفية البادئة uploads/ مع كتابة المصغّرات تحت thumbnails/. الدرس: التصفية بالامتداد وحده غير كافية إذا كانت المخرجات تشترك في نفس الامتداد.

graph LR subgraph خاطئ["❌ التصميم الخاطئ"] U1["رفع .jpg"] --> S1["uploads/photo.jpg"] S1 -->|suffix: .jpg| L1["Lambda"] L1 --> S2["uploads/photo_thumb.jpg"] S2 -->|suffix: .jpg — يُطلق مجدداً!| L1 end subgraph صحيح["✅ التصميم الصحيح"] U2["رفع .jpg"] --> S3["uploads/photo.jpg"] S3 -->|prefix: uploads/| L2["Lambda"] L2 --> S4["thumbnails/photo_thumb.jpg"] end style خاطئ fill:#fff0f0 style صحيح fill:#f0fff0

مقارنة الحلول واختيار الأنسب

المعيارتصفية المساروسم الكائنحاويتان
الحماية من خطأ المطوّرمتوسطةمتوسطةعالية
التعقيد التشغيليمنخفضمتوسطمنخفض
صلاحيات IAM إضافيةلانعم (Tagging)لا
يعمل مع نفس الامتدادنعم (بالمجلد)نعمنعم
مناسب للإنتاجنعمنعمالأفضل

منع حلقة Lambda مع S3 — الخلاصة والخطوات التالية

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

للمزيد، راجع توثيق AWS الرسمي:

مسرد المصطلحات

المصطلحالتعريف
S3 Event Notificationإشعار يُرسله S3 إلى Lambda أو SQS أو SNS عند حدوث عمليات على الكائنات
Recursive Triggerحالة تُطلق فيها Lambda نفسها بشكل متكرر عبر حدث ناتج عن مخرجاتها
Object Tagبيانات وصفية مرتبطة بكائن S3 على شكل مفتاح/قيمة
Prefix Filterتصفية إشعارات S3 بناءً على بداية مسار الكائن
Event Source Mappingإعداد في Lambda يربطها بمصادر أحداث من نوع Stream أو Queue — لا ينطبق على S3

تعليقات

المشاركات الشائعة من هذه المدونة

استرجاع معرّف نسخة EC2 عبر خدمة البيانات الوصفية IMDSv2

أوضاع سعة DynamoDB: متى تختار On-Demand ومتى تختار Provisioned؟

فهم مهلة الرؤية في SQS: لماذا تُعالَج رسائلك مرتين؟