في مقال أنواع التخزين وواجهة S3 شرحنا نموذج تخزين الكائنات من الخارج: ال Bucket والمفتاح Key والكائن Object، والبيانات الوصفية Metadata، ولماذا لا توجد مجلدات حقيقية، ورأينا رابطاً موقعاً لتنزيل فاتورة ينتهي بعد ستين ثانية. أما هذا المقال فينظر إلى S3 من داخل تطبيقك، أي من الكود الذي يكتب الملفات ويقرؤها كل يوم، وهنا تظهر أسئلة مختلفة تماماً: أي صلاحيات يأخذ مفتاح التطبيق، وكيف يرفع المستخدم صورته مباشرة دون أن تمر عبر السيرفر، وكيف تمنع رفع ملف من 2 GB في نموذج صورة شخصية، وماذا يحدث عندما يكتب سكربت خاطئ فوق ملفات العملاء، ولماذا تأتي الفاتورة أضعاف سعر التخزين الذي قرأته في صفحة الأسعار.
وسوف نناقش في هذا المقال ما يلي:
- كيف يقرر التخزين قبول الطلب أو رفضه: مفتاح الوصول Access Key وصلاحياته، ثم سياسة ال Bucket Bucket Policy، ولماذا يغلب الرفض الصريح Deny أي سماح.
- الرفع المباشر من المتصفح برابط موقع Presigned URL أو بنموذج موقع Presigned POST، وما الذي يوقعه كل منهما وما الذي لا يوقعه.
- النسخ Versioning كحماية من الكتابة فوق الملفات، والاستعادة دون صلاحية حذف.
- الرفع المجزأ Multipart Upload والأجزاء المنسية التي تأخذ مساحة ولا تظهر، وقواعد دورة الحياة Lifecycle Rules التي تنظفها.
- فئات التخزين Storage Classes وشروطها، وتكلفة الطلبات وال Egress بأسعار حقيقية من AWS.
- تجربة عملية على SeaweedFS بأداة aws CLI ومكتبة boto3 في Python، نكسر فيها كل قاعدة ونقرأ الخطأ.
ما تحتاج أن تتذكره قبل أن نبدأ
واجهة S3 بدأت كخدمة من Amazon، ثم أصبحت المعيار الفعلي De facto Standard لتخزين الكائنات، فالمكتبة نفسها مثل boto3 أو AWS SDK في أي لغة تتعامل مع Amazon S3 ومع Garage وSeaweedFS وCeph وCloudflare R2 بتغيير عنوان ال Endpoint فقط، ولذلك كل ما في هذا المقال يعمل على السحابة وعلى سيرفرك، وسوف نجربه على SeaweedFS لأنه يدعم الصلاحيات والسياسات والنسخ كلها، كما سنرى في جدول الأدوات.
وتذكر أمرين من المقال السابق: الأول أن ال Bucket قائمة مسطحة من المفاتيح، والشرطة المائلة في invoices/invoice-1042.pdf حرف عادي، والثاني أن Amazon أعلنت في ديسمبر 2020 أن S3 أصبحت تعطي اتساقاً قوياً Strong Read-After-Write Consistency لكل الطلبات، فإذا كتبت ملفاً ثم قرأته أو عرضت القائمة مباشرة فسوف تجد النسخة الجديدة، وهذا وعد Amazon لخدمتها، أما الأدوات المتوافقة فلكل منها ضماناتها، وقد فصلنا ذلك في قسم الاتساق.
من يستطيع ماذا؟ المفتاح وصلاحياته وسياسة ال Bucket
كل طلب إلى S3 يحمل توقيعاً يحسبه الكلاينت بالمفتاح السري Secret Access Key، والتخزين يعيد حساب التوقيع، فإذا طابق عرف أن الطلب جاء من صاحب معرف المفتاح Access Key ID، وإذا لم يطابق رفضه بالخطأ 403 قبل أن ينظر في أي شيء آخر. بعد ذلك يسأل سؤالين: ماذا يسمح لهذا المفتاح أن يفعل أصلاً، وهي الصلاحيات المرتبطة بالمفتاح أو المستخدم، في AWS عبر سياسات IAM، وفي SeaweedFS عبر قائمة الأفعال Actions لكل هوية Identity في ملف الإعداد كما في توثيق الهويات، ثم ماذا تقول سياسة ال Bucket نفسه، وهي مستند JSON على ال Bucket يحدد من يستطيع ماذا على أي مسار. والقاعدة التي يجب أن تحفظها من منطق تقييم السياسات في AWS أن الرفض الصريح Explicit Deny في أي مكان يغلب أي سماح Allow في أي مكان آخر، والصورة التالية تبين هذه المراحل والردود التي حصلنا عليها في التجربة:

لنفرض أن لديك تطبيقاً يرفع صور المستخدمين وفواتيرهم، فما الصلاحية التي تعطيها لمفتاحه؟ الحل البسيط الذي تراه في أغلب الدروس هو مفتاح المدير نفسه، لأنه يعمل من أول مرة، وهذا الحل غير مناسب إطلاقاً، والسبب أن مفتاح المدير يستطيع أن يحذف كل ال Buckets وينشئ غيرها، فإذا تسرب من ملف .env أو من سجل أخطاء فقد خسرت كل شيء. والحل الأنسب ثلاث طبقات:
- مفتاح لكل تطبيق، بصلاحية على ال Bucket الخاص به فقط، ودون صلاحية إنشاء Buckets أو تعديل سياساتها، وفي التجربة رفض SeaweedFS أن يعرض مفتاح التطبيق Bucket اسمه
reportsأو ينشئ Bucket جديداً. - سياسة على ال Bucket للقواعد التي لا تتغير، مثل «لا يحذف أحد الفواتير»، وهذه تبقى حتى لو أخطأ أحدهم لاحقاً وأعطى مفتاحاً صلاحيات أوسع، لأن الرفض الصريح يغلب.
- النسخ Versioning للحالة التي لا تمنعها الصلاحيات، وهي الكتابة فوق ملف موجود، فالتطبيق يحتاج صلاحية الكتابة ليعمل، والكتابة فوق مفتاح موجود هي في الحقيقة استبدال كامل للملف، كما سنرى بعد قليل.
الرفع المباشر من المتصفح: ما الذي يوقعه التطبيق بالضبط؟
رأينا في المقال السابق أن الرابط الموقع للتنزيل يوفر على التطبيق مرور الملف عبره، والرفع أولى بذلك، فملف فيديو من 2 GB يمر عبر التطبيق يعني أن تضبط حدود الحجم والمهلة في كل طبقة، كما شرحنا في حدود رفع الملفات، ورأينا في منصة الفيديو كيف يرفع الكلاينت الملفات الكبيرة أجزاء بروابط موقعة. والفكرة أن المتصفح يطلب من التطبيق إذناً بالرفع، فيتحقق التطبيق من المستخدم ويوقع إذناً محدداً بمفتاحه السري دون أي اتصال بالتخزين، ثم يرفع المتصفح البايتات مباشرة إلى التخزين، والتخزين هو الذي يفرض ما وقع عليه التطبيق. وهناك طريقتان، والفرق بينهما مهم:
- رابط PUT موقع: يوقع اسم المفتاح، ونوع المحتوى Content-Type إذا مررته، ومدة الصلاحية، ولكنه لا يوقع الحجم، فمن يحمل الرابط يستطيع أن يرفع أي حجم بالمفتاح نفسه، كما يذكر توثيق الرفع بالروابط الموقعة.
- نموذج POST موقع: يوقع سياسة Policy فيها شروط، منها بادئة المفتاح ونوع المحتوى ونطاق الحجم
content-length-range، والتخزين يرفض الملف الذي يخالف أي شرط، كما في توثيق سياسة POST ودالة generate_presigned_post في boto3.
والصورة التالية تبين الطريقتين والردود التي أعادها SeaweedFS في التجربة:

وقد يتساءل البعض: إذا كان التخزين يتحقق من كل شيء، فهل أثق بالملف الذي رفعه المستخدم؟ والإجابة لا، فالتوقيع يضمن أن الترويسة Content-Type هي image/png، ولكنه لا يفحص البايتات نفسها، فملف HTML يرسل بنوع image/png سوف يقبل، لذلك افحص المحتوى بعد الرفع في Worker منفصل قبل أن تعرضه لمستخدمين آخرين، ولا تقدم الملفات المرفوعة من النطاق الرئيسي لموقعك. وتذكر أيضاً أن المتصفح لن يرفع إلى نطاق آخر إلا إذا سمح التخزين بذلك في إعداد CORS على ال Bucket، وقد شرحنا ذلك في شرح CORS.
عندما يكتب أحدهم فوق ملفك
الكتابة فوق مفتاح موجود في S3 لا تعدل الملف، وإنما تستبدله كاملاً، وبالتالي سكربت ترحيل فيه خطأ، أو مخترق حصل على مفتاح التطبيق، يستطيع أن يكتب فوق كل الفواتير دون أن يحتاج صلاحية حذف واحدة. والحل هو النسخ Versioning الذي شرحنا فكرته في المقال السابق، فكل كتابة تضيف نسخة برقم VersionId والقديمة تبقى. وما نضيفه هنا هو الاستعادة دون صلاحية حذف: بدل أن تحذف النسخة الخاطئة، وهذا يحتاج s3:DeleteObjectVersion التي منعناها في السياسة، تنسخ النسخة القديمة فوق المفتاح نفسه بعملية CopyObject، فتصبح هي النسخة الحالية، كما في توثيق استعادة النسخ السابقة، والصورة التالية تبين ما حدث للفاتورة في التجربة:

ولاحظ أن النسخ تجعل ال Bucket يكبر مع كل كتابة، لذلك يأتي معه دائماً قاعدة Lifecycle تحذف النسخ غير الحالية بعد مدة، ولاحظ أيضاً أن النسخ وحدها لا تحمي النسخ الاحتياطية من مخترق يملك صلاحيات المدير، لأنه يستطيع أن يحذف النسخ السابقة نفسها أو يغير السياسة، وهذا ما يحله قفل الكائنات Object Lock في مقال النسخ الاحتياطي إلى S3 مع Object Lock.
الرفع المجزأ والأجزاء التي لا تراها
الرفع المجزأ Multipart Upload يقسم الملف الكبير إلى أجزاء ترفع بالتوازي، ويعاد الجزء الفاشل وحده، وأدوات مثل aws CLI وboto3 تستخدمه تلقائياً للملفات الكبيرة، وبحسب حدود الرفع المجزأ في توثيق S3 فالحد الأقصى 10,000 جزء، وكل جزء من 5 MiB إلى 5 GiB ما عدا الأخير، وتفاصيل العملية في توثيق الرفع المجزأ. والمشكلة التي لا ينتبه لها كثيرون أن الرفع الذي يبدأ ولا يكتمل، لأن المستخدم أغلق الصفحة أو انقطع ال Worker، تبقى أجزاؤه محفوظة وتحسب في التخزين، ولا تظهر في aws s3 ls أبداً، فتجد فاتورة تخزين لا تطابق مجموع ملفاتك. والحل قاعدة Lifecycle تلغي الرفع غير المكتمل بعد أيام، كما يوصي التوثيق.
وقواعد دورة الحياة Lifecycle Rules هي الأداة التي تنظف ال Bucket دون كود، فتحذف ما تحت بادئة معينة بعد مدة، أو النسخ القديمة، أو الأجزاء المنسية، أو تنقل الملفات إلى فئة أرخص. ولاحظ أنها تعمل من داخل التخزين، فلا تمر عبر سياسة ال Bucket، أي أن قاعدة تحذف النسخ القديمة بعد 30 يوماً تعمل حتى لو منعت السياسة أي شخص من حذفها، وهذا ما تريده غالباً، ولكن تذكره عندما تحسب كم يوماً تستطيع أن تستعيد.
فئات التخزين Storage Classes: السعر الأرخص له شروط
كل كائن في Amazon S3 له فئة تخزين، والافتراضية Standard، وهناك فئات أرخص في سعر التخزين، ولكن كل واحدة منها تأخذ المال من مكان آخر، والصورة التالية تبين أشهرها بأسعار منطقة فرانكفورت من قائمة أسعار S3 كما قرأناها عبر AWS Price List API في 11 أكتوبر 2026، والشروط من صفحة فئات التخزين:

في الجدول أعلاه لاحظ التالي:
- الحد الأدنى للحجم 128 KB في Standard-IA وGlacier Instant Retrieval، فالصورة المصغرة Thumbnail من 8 KB تحسب كأنها 128 KB، أي ست عشرة مرة حجمها، كما سنحسب في التجربة.
- الحد الأدنى للمدة 30 يوماً في Standard-IA و90 في Glacier Instant وFlexible و180 في Deep Archive، فإذا حذفت الملف أو كتبت فوقه قبلها تدفع باقي المدة.
- رسوم القراءة لكل GB تضاف إلى سعر الطلب، وطلبات الكتابة نفسها أغلى في Standard-IA (0.01 دولار لكل ألف مقابل 0.0054 في Standard).
- Glacier Flexible Retrieval وDeep Archive لا تقرأ مباشرة، وإنما تطلب استعادة Restore تستغرق دقائق إلى ساعات، ثم تقرأ النسخة المؤقتة.
ولأن نقل الملفات الصغيرة إلى فئة أرخص يكلف أكثر مما يوفر، غيرت Amazon في سبتمبر 2024 السلوك الافتراضي لقواعد Lifecycle، فأصبحت لا تنقل أي كائن أصغر من 128 KB إلى أي فئة إلا إذا طلبت ذلك صراحة بفلتر الحجم، كما في توثيق النقل بين الفئات، ولكن هذه الحماية في قواعد Lifecycle فقط، أما الكود الذي يرفع مباشرة بفئة STANDARD_IA فلا يحميه شيء. وتذكر أن هذه الفئات خاصة ب Amazon، والأدوات المستضافة ذاتياً لها فئات أبسط أو لا تدعم النقل بين الفئات أصلاً، فمثلاً صفحة واجهة S3 في SeaweedFS تذكر أن قواعد النقل Transition غير مدعومة.
أين تذهب الفاتورة فعلاً؟
فاتورة S3 فيها أربعة بنود: التخزين بال GB في الشهر، والطلبات بالآلاف، ورسوم القراءة في الفئات الأرخص، والبيانات الخارجة Egress إلى الإنترنت بال GB. وأغلب الناس يقرؤون البند الأول فقط لأنه في أعلى صفحة الأسعار، ثم تأتي المفاجأة من البند الأخير، والسبب أن الملف يخزن مرة ويقرأ آلاف المرات، وكل قراءة من الإنترنت تحسب. وفي فرانكفورت سعر البيانات الخارجة 0.09 دولار لكل GB لأول 10 TB بعد الحصة المجانية بحسب AWS Price List API، وقد شرحنا هذا البند وطرق تخفيضه بالتفصيل في التكاليف الخفية في السحابة، والطريقة الأولى لتخفيضه أن تضع أمام ال Bucket شبكة توصيل محتوى كما في CDN للمبتدئين. وفي التجربة سوف نحسب ثلاث حالات بسكربت صغير، ونرى أن ال Egress كان 94% من فاتورة الصور.
تجربة عملية: صلاحيات وروابط موقعة ونسخ على SeaweedFS
الآن سوف نقوم بتشغيل SeaweedFS ببوابة S3 على سيرفر واحد داخل Docker، مع هويتين: مدير، وتطبيق لا يملك إلا القراءة والكتابة والعرض على Bucket واحد، ثم نجرب كل ما سبق بأداة aws CLI ومكتبة boto3. وإذا لم يكن Docker مثبتاً لديك فابدأ من دليل تثبيت Docker.
تشغيل SeaweedFS بهويتين
نبدأ بملف الهويات، وفيه لكل هوية مفاتيحها وأفعالها، والصيغة Read:uploads تعني القراءة على ال Bucket المسمى uploads فقط، كما في توثيق الهويات:
mkdir -p ~/lab-s3basics && cd ~/lab-s3basics
cat > s3.json <<'EOF'
{
"identities": [
{
"name": "admin",
"credentials": [{ "accessKey": "ADMINKEY0001", "secretKey": "admin-secret-change-me-0001" }],
"actions": ["Admin", "Read", "List", "Tagging", "Write"]
},
{
"name": "app",
"credentials": [{ "accessKey": "APPKEY000001", "secretKey": "app-secret-change-me-000001" }],
"actions": ["Read:uploads", "Write:uploads", "List:uploads"]
}
]
}
EOF
printf 'AWS_ACCESS_KEY_ID=ADMINKEY0001\nAWS_SECRET_ACCESS_KEY=admin-secret-change-me-0001\n' > admin.env
printf 'AWS_ACCESS_KEY_ID=APPKEY000001\nAWS_SECRET_ACCESS_KEY=app-secret-change-me-000001\n' > app.env
chmod 600 s3.json admin.env app.envثم نبني Image صغيرة فيها aws CLI وboto3، حتى لا نثبت شيئاً على السيرفر نفسه، ونشغل SeaweedFS وال Container الذي فيه الأدوات على شبكة واحدة:
cat > Dockerfile <<'EOF'
FROM amazon/aws-cli:2.37.12
RUN dnf install -y python3.11 python3.11-pip jq && dnf clean all && python3.11 -m pip install --no-cache-dir boto3==1.43.111 requests==2.32.5
WORKDIR /lab
ENTRYPOINT []
CMD ["sleep", "infinity"]
EOF
docker build -t lab/s3-toolbox:1 .
docker network create lab-s3basics-net
docker run -d --name lab-s3basics-seaweed --network lab-s3basics-net \
-p 127.0.0.1:38333:8333 \
-v "$PWD/s3.json:/etc/seaweedfs/s3.json:ro" -v lab-s3basics-data:/data \
chrislusf/seaweedfs:4.48 server -s3 -s3.config=/etc/seaweedfs/s3.json -dir=/data
docker run -d --name lab-s3basics-tools --network lab-s3basics-net -v "$PWD:/lab" \
-e AWS_DEFAULT_REGION=us-east-1 -e AWS_ENDPOINT_URL=http://lab-s3basics-seaweed:8333 \
lab/s3-toolbox:1
admin() { docker exec --env-file admin.env lab-s3basics-tools aws "$@"; }
app() { docker exec --env-file app.env lab-s3basics-tools aws "$@"; }في الإعداد أعلاه لاحظ التالي:
- الأمر
server -s3يشغل كل مكونات SeaweedFS في عملية واحدة، وهذا يكفي للتجربة، أما المكونات المنفصلة فشرحناها في تخزين الكائنات S3 على سيرفرك. - منفذ S3 منشور على 127.0.0.1 فقط، والأدوات تصل إليه بالاسم داخل الشبكة، وأسباب ذلك في شبكات Docker.
- الدالتان
adminوappتشغلان الأداة نفسها بمفتاحين مختلفين، فكل أمر بعدها نعرف بأي صلاحية نفذ.
المدير ينشئ، والتطبيق يكتب فقط
المدير ينشئ Bucket للتطبيق وآخر للتقارير، ويفعل النسخ على الأول، ويضع عليه سياسة تمنع حذف الفواتير:
cat > policy.json <<'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "InvoicesAreNeverDeleted",
"Effect": "Deny",
"Principal": "*",
"Action": ["s3:DeleteObject", "s3:DeleteObjectVersion"],
"Resource": "arn:aws:s3:::uploads/invoices/*"
}
]
}
EOF
admin s3 mb s3://uploads
admin s3 mb s3://reports
admin s3api put-bucket-versioning --bucket uploads --versioning-configuration Status=Enabled
admin s3api put-bucket-policy --bucket uploads --policy file:///lab/policy.json
admin s3api get-bucket-versioning --bucket uploadsوالمخرج سوف يكون كما يلي:
make_bucket: uploads
make_bucket: reports
{
"Status": "Enabled"
}الآن نجرب مفتاح التطبيق: نعرض ال Buckets، ثم نحاول أن نقرأ التقارير، ثم أن ننشئ Bucket خاصاً بنا:
app s3 ls
app s3 ls s3://reports
app s3 mb s3://my-own-bucketوالمخرج سوف يكون كما يلي:
2026-10-11 06:44:16 uploads
aws: [ERROR]: An error occurred (AccessDenied) when calling the ListObjectsV2 operation: Access Denied.
make_bucket failed: s3://my-own-bucket An error occurred (AccessDenied) when calling the CreateBucket operation: Access Denied.لاحظ أن مفتاح التطبيق لم ير إلا ال Bucket الخاص به، ورفض في الأمرين الآخرين بالخطأ AccessDenied، وهذا ما يجب أن يحدث لمفتاح تسرب من سيرفر التطبيق.
رابط موقع للرفع والتنزيل بمكتبة boto3
السكربت التالي يمثل الطرفين: التطبيق يوقع بمكتبة boto3، والمتصفح نمثله بمكتبة requests التي لا تملك أي مفتاح، فيرفع صورة برابط PUT موقع، ثم يحاول أن يرفع بالرابط نفسه ملفاً بنوع text/html، ثم ينزل الصورة برابط صالح خمس ثوان فقط، وينتظر سبع ثوان ويحاول مرة أخرى، ونحفظه في المجلد ~/lab-s3basics باسم presign.py حتى يراه ال Container في المسار /lab:
# presign.py: the app signs, the "browser" (requests) talks to the storage directly.
import os, time, boto3, requests
from botocore.config import Config
s3 = boto3.client("s3", endpoint_url=os.environ["AWS_ENDPOINT_URL"],
config=Config(signature_version="s3v4"))
BUCKET, KEY = "uploads", "avatars/user-17.png"
# 1) Upload: the app signs a PUT for one key, one content type, 60 seconds
put_url = s3.generate_presigned_url(
"put_object",
Params={"Bucket": BUCKET, "Key": KEY, "ContentType": "image/png"},
ExpiresIn=60)
print("PUT URL :", put_url[:120], "...")
r = requests.put(put_url, data=b"\x89PNG fake avatar bytes", headers={"Content-Type": "image/png"})
print("upload :", r.status_code)
# the browser lies about the type: the signature covered Content-Type
r = requests.put(put_url, data=b"<script>alert(1)</script>", headers={"Content-Type": "text/html"})
print("as html :", r.status_code, r.text[:160])
# 2) Download: a GET that is valid for 5 seconds only
get_url = s3.generate_presigned_url("get_object", Params={"Bucket": BUCKET, "Key": KEY}, ExpiresIn=5)
r = requests.get(get_url)
print("download:", r.status_code, r.headers.get("Content-Type"), r.content)
time.sleep(7)
r = requests.get(get_url)
print("after 7s:", r.status_code, r.text[:200])نقوم بتشغيله بمفتاح التطبيق بالأمر التالي:
docker exec --env-file app.env lab-s3basics-tools python3.11 /lab/presign.pyوالمخرج سوف يكون كما يلي:
PUT URL : http://lab-s3basics-seaweed:8333/uploads/avatars/user-17.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=APPKEY000 ...
upload : 200
as html : 403 <?xml version="1.0" encoding="UTF-8"?>
<Error><Code>SignatureDoesNotMatch</Code><Message>The request signature we calculated does not match the signature you pr
download: 200 image/png b'\x89PNG fake avatar bytes'
after 7s: 403 <?xml version="1.0" encoding="UTF-8"?>
<Error><Code>AccessDenied</Code><Message>Request has expired</Message><Resource>/uploads/avatars/user-17.png</Resource><RequestId>18DD66B7181A4D4CF0994CB4</Requeفي المخرج أعلاه لاحظ التالي:
- التوقيع حسب داخل السكربت دون أي اتصال بالتخزين، والمتصفح رفع بالرابط دون أن يعرف المفتاح السري.
- تغيير نوع المحتوى إلى text/html أعطى SignatureDoesNotMatch، لأن الترويسة كانت جزءاً مما وقع، وبالتالي من سرق الرابط لا يستطيع أن يرفع به صفحة HTML.
- الرابط نفسه رفض بعد انتهاء المدة بالرسالة Request has expired، فإذا رأيتها مع رابط جديد فافحص ساعة السيرفر الذي وقعه.
نموذج POST يحدد حجم الملف
الرابط السابق لا يمنع رفع ملف من 2 GB بنوع image/png، لذلك نستخدم للرفع من نماذج المتصفح سياسة POST، وفيها بادئة المفتاح والنوع ونطاق الحجم من بايت واحد إلى 1 MB، ثم نرفع ملفاً صغيراً وآخر من 2 MB، والسكربت في الملف presign_post.py:
# presign_post.py: a browser form upload with limits the storage enforces itself.
import os, json, boto3, requests
from botocore.config import Config
s3 = boto3.client("s3", endpoint_url=os.environ["AWS_ENDPOINT_URL"],
config=Config(signature_version="s3v4"))
post = s3.generate_presigned_post(
Bucket="uploads",
Key="avatars/user-17-${filename}",
Fields={"Content-Type": "image/png"},
Conditions=[
{"Content-Type": "image/png"},
["starts-with", "$key", "avatars/user-17-"],
["content-length-range", 1, 1024 * 1024], # 1 byte .. 1 MB
],
ExpiresIn=300)
print(json.dumps({k: (v[:40] + "...") if len(v) > 40 else v for k, v in post["fields"].items()}, indent=2))
small = ("small.png", b"\x89PNG" + b"0" * 1000, "image/png")
big = ("big.png", b"\x89PNG" + b"0" * (2 * 1024 * 1024), "image/png")
for f in (small, big):
r = requests.post(post["url"], data=post["fields"], files={"file": f})
print(f[0], len(f[1]), "->", r.status_code, r.text[:170].replace("\n", " "))docker exec --env-file app.env lab-s3basics-tools python3.11 /lab/presign_post.py
app s3 ls s3://uploads/avatars/والمخرج سوف يكون كما يلي:
{
"Content-Type": "image/png",
"key": "avatars/user-17-${filename}",
"x-amz-algorithm": "AWS4-HMAC-SHA256",
"x-amz-credential": "APPKEY000001/20261011/us-east-1/s3/aws4_...",
"x-amz-date": "20261011T064607Z",
"policy": "eyJleHBpcmF0aW9uIjogIjIwMjYtMTAtMTFUMDY6...",
"x-amz-signature": "2927510544491d32781865ef7fb7982e91f3a8e3..."
}
small.png 1004 -> 204
big.png 2097156 -> 400 <?xml version="1.0" encoding="UTF-8"?> <Error><Code>EntityTooLarge</Code><Message>Your proposed upload exceeds the maximum allowed object size.</Message><Resource>/upload
2026-10-11 06:46:07 1004 user-17-small.png
2026-10-11 06:45:56 22 user-17.pngفي المخرج أعلاه لاحظ التالي:
- الحقول التي يعيدها التطبيق للمتصفح تضعها في نموذج HTML كما هي، والحقل
policyهو الشروط مرمزة بصيغة Base64، والتوقيع عليها، فأي تعديل في الشروط يبطل التوقيع. - الملف الصغير قبل بالرد 204، واسمه صار
user-17-small.pngلأن${filename}يستبدل باسم الملف المرفوع، وهو داخل البادئة المسموح بها. - الملف من 2 MB رفضه التخزين نفسه بالخطأ EntityTooLarge، ولم يصل إلى التطبيق أي بايت منه.
نكسرها: حذف الفاتورة ثم الكتابة فوقها
نرفع فاتورة بمفتاح التطبيق، ثم نحاول حذفها، ثم نكتب فوقها نسخة معدلة كما يفعل سكربت فيه خطأ أو مخترق، ثم نعرض النسخ:
docker exec --env-file app.env lab-s3basics-tools sh -c \
'echo "invoice 1042" > /tmp/invoice.pdf && aws s3 cp /tmp/invoice.pdf s3://uploads/invoices/invoice-1042.pdf --only-show-errors'
app s3 rm s3://uploads/invoices/invoice-1042.pdf
docker exec --env-file app.env lab-s3basics-tools sh -c \
'echo "invoice 1042 EDITED" > /tmp/invoice.pdf && aws s3 cp /tmp/invoice.pdf s3://uploads/invoices/invoice-1042.pdf --only-show-errors'
app s3api list-object-versions --bucket uploads --prefix invoices/ \
--query "Versions[].{VersionId:VersionId,IsLatest:IsLatest,Size:Size}"والمخرج سوف يكون كما يلي:
delete failed: s3://uploads/invoices/invoice-1042.pdf An error occurred (AccessDenied) when calling the DeleteObject operation: Access Denied.
[
{
"VersionId": "67229942139b97eed2059b126f1dd0f6",
"IsLatest": true,
"Size": 20
},
{
"VersionId": "67229944e230a8d1ee440e2d24a3ff5f",
"IsLatest": false,
"Size": 13
}
]الحذف رفض لأن السياسة تمنعه حتى لو كان المفتاح يملك صلاحية الكتابة، أما الكتابة فوق الملف فنجحت لأنها كتابة، ولكن النسخة الأصلية بحجم 13 بايت ما زالت موجودة. ونستعيدها بنسخها فوق المفتاح نفسه، بمفتاح التطبيق نفسه:
OLD=$(app s3api list-object-versions --bucket uploads --prefix invoices/ \
--query 'Versions[?IsLatest==`false`].VersionId' --output text)
app s3api copy-object --bucket uploads --key invoices/invoice-1042.pdf \
--copy-source "uploads/invoices/invoice-1042.pdf?versionId=$OLD" --query VersionId
app s3 cp s3://uploads/invoices/invoice-1042.pdf -والمخرج سوف يكون كما يلي:
"6722993d6a95db219024bb310e0cd8dc"
invoice 1042الاستعادة أضافت نسخة ثالثة حالية محتواها هو الأصلي، والنسخة المعدلة بقيت في التاريخ، فتستطيع أن تفحصها لاحقاً لتعرف ماذا كتب فوق الفاتورة.
قواعد Lifecycle والرفع المجزأ المنسي
نضع على ال Bucket ثلاث قواعد: حذف ما تحت tmp/ بعد يوم، وحذف النسخ القديمة بعد 30 يوماً من توقفها عن أن تكون حالية، وإلغاء الرفع المجزأ غير المكتمل بعد يومين:
cat > lifecycle.json <<'EOF'
{
"Rules": [
{ "ID": "tmp-files-1-day", "Status": "Enabled",
"Filter": { "Prefix": "tmp/" },
"Expiration": { "Days": 1 } },
{ "ID": "old-versions-30-days", "Status": "Enabled",
"Filter": { "Prefix": "" },
"NoncurrentVersionExpiration": { "NoncurrentDays": 30 } },
{ "ID": "unfinished-uploads-2-days", "Status": "Enabled",
"Filter": { "Prefix": "" },
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 2 } }
]
}
EOF
admin s3api put-bucket-lifecycle-configuration --bucket uploads --lifecycle-configuration file:///lab/lifecycle.json
admin s3api get-bucket-lifecycle-configuration --bucket uploads --query "Rules[].ID"والمخرج سوف يكون كما يلي:
[
"tmp-files-1-day",
"old-versions-30-days",
"unfinished-uploads-2-days"
]ولنرى لماذا نحتاج القاعدة الثالثة، نبدأ رفعاً مجزأً لفيديو ونرفع جزءاً واحداً من 5 MiB ثم نتركه، كما يحدث عندما يغلق المستخدم الصفحة:
docker exec --env-file app.env lab-s3basics-tools sh -c '
dd if=/dev/urandom of=/tmp/part1 bs=1M count=5 2>/dev/null
ID=$(aws s3api create-multipart-upload --bucket uploads --key videos/big.mp4 --query UploadId --output text)
aws s3api upload-part --bucket uploads --key videos/big.mp4 --part-number 1 --upload-id "$ID" --body /tmp/part1 >/dev/null
echo "--- ls:"; aws s3 ls s3://uploads/videos/
echo "--- unfinished uploads:"; aws s3api list-multipart-uploads --bucket uploads --query "Uploads[].{Key:Key,Initiated:Initiated}"
aws s3api list-parts --bucket uploads --key videos/big.mp4 --upload-id "$ID" --query "Parts[].Size"
aws s3api abort-multipart-upload --bucket uploads --key videos/big.mp4 --upload-id "$ID" && echo aborted'والمخرج سوف يكون كما يلي:
--- ls:
--- unfinished uploads:
[
{
"Key": "videos/big.mp4",
"Initiated": "2026-10-11T06:47:54+00:00"
}
]
[
5242880
]
abortedالأمر aws s3 ls لم يعرض شيئاً، ولكن الجزء بحجم 5,242,880 بايت محفوظ وينتظر، ولا يظهر إلا بالأمر list-multipart-uploads. ألغيناه هنا يدوياً، وفي الإنتاج تتولى ذلك القاعدة الثالثة، فإذا كان لديك Bucket قديم دون هذه القاعدة فشغل هذا الأمر عليه أولاً، وقد تتفاجأ بما تجده.
حساب الفاتورة بسكربت
السكربت التالي يحسب ثلاث حالات بأسعار فرانكفورت من AWS Price List API، والكميات افتراضات لتطبيق متوسط وليست قياسات، فغيرها بأرقامك، ونحفظه باسم cost.py:
# cost.py: what one month of S3 costs in eu-central-1 (Frankfurt), in USD.
# Prices from the AWS Price List API (AmazonS3 published 2026-09-28, AWSDataTransfer 2026-09-16), read 2026-10-11.
STD, IA = 0.0245, 0.0135 # GB-month, first 50 TB
PUT_STD, GET_STD = 0.0054 / 1000, 0.0043 / 10000
PUT_IA, GET_IA, RETR_IA = 0.01 / 1000, 0.01 / 10000, 0.01 # IA requests, and a per-GB retrieval fee
EGRESS = 0.09 # GB to the internet, after the 100 GB free tier
KB, GB = 1024, 1024 ** 3
def line(name, usd): print(f" {name:<44} {usd:>9.2f}")
print("A) user photos: 200 GB, 300k uploads, 3M views, 1.5 TB served")
a = [("storage 200 GB Standard", 200 * STD), ("300,000 PUT", 300_000 * PUT_STD),
("3,000,000 GET", 3_000_000 * GET_STD), ("egress 1,536 GB (100 GB free)", (1536 - 100) * EGRESS)]
for n, u in a: line(n, u)
line("total", sum(u for _, u in a))
print("B) 10 million thumbnails of 8 KB, uploaded straight into Standard-IA to save money")
n, size = 10_000_000, 8 * KB
line(f"Standard: {n * size / GB:.0f} GB billed per month", n * size / GB * STD)
line(f"Standard-IA: {n * 128 * KB / GB:.0f} GB billed (128 KB minimum)", n * 128 * KB / GB * IA)
line("10M uploads (PUT) into Standard, once", n * PUT_STD)
line("10M uploads (PUT) into Standard-IA, once", n * PUT_IA)
print("C) restoring a 500 GB backup once (32,000 files of 16 MB)")
line("from Standard: 32,000 GET", 32_000 * GET_STD)
line("from Standard-IA: GET + retrieval fee per GB", 32_000 * GET_IA + 500 * RETR_IA)
line("to your office over the internet (egress)", (500 - 100) * EGRESS)docker exec lab-s3basics-tools python3.11 /lab/cost.pyوالمخرج سوف يكون كما يلي:
A) user photos: 200 GB, 300k uploads, 3M views, 1.5 TB served
storage 200 GB Standard 4.90
300,000 PUT 1.62
3,000,000 GET 1.29
egress 1,536 GB (100 GB free) 129.24
total 137.05
B) 10 million thumbnails of 8 KB, uploaded straight into Standard-IA to save money
Standard: 76 GB billed per month 1.87
Standard-IA: 1221 GB billed (128 KB minimum) 16.48
10M uploads (PUT) into Standard, once 54.00
10M uploads (PUT) into Standard-IA, once 100.00
C) restoring a 500 GB backup once (32,000 files of 16 MB)
from Standard: 32,000 GET 0.01
from Standard-IA: GET + retrieval fee per GB 5.03
to your office over the internet (egress) 36.00في المخرج أعلاه لاحظ التالي:
- في الحالة A كان سعر التخزين 4.90 دولار من 137.05، والباقي تقريباً كله Egress، فإذا أردت أن تخفض الفاتورة فابدأ من ال CDN وليس من فئة التخزين.
- في الحالة B «التوفير» بفئة Standard-IA ضاعف تكلفة التخزين الشهرية تسع مرات تقريباً، من 1.87 إلى 16.48 دولار، وضاعف تكلفة الرفع تقريباً، والسبب الحد الأدنى 128 KB وسعر الطلب الأعلى.
- في الحالة C رسوم القراءة من Standard-IA صغيرة مقارنة بال Egress، فإذا كنت تخطط لاستعادة النسخ الاحتياطية من السحابة إلى مكتبك فاحسب هذا البند قبل أن تحتاجه في يوم سيء.
التنظيف
نحذف ما أنشأناه فقط بأسمائه:
docker rm -f lab-s3basics-tools lab-s3basics-seaweed
docker volume rm lab-s3basics-data
docker network rm lab-s3basics-net
rm -f admin.env app.envواحتفظ بال Image lab/s3-toolbox:1 إذا كنت سوف تكمل مقالات التخزين التالية لأنها تستخدمها، وإلا فاحذفها بالأمر docker image rm lab/s3-toolbox:1.
أخطاء شائعة
- مفتاح المدير داخل التطبيق: مفتاح لكل تطبيق على ال Bucket الخاص به فقط، دون إنشاء Buckets أو تعديل سياسات.
- رابط PUT موقع لنموذج رفع عام: لا يحدد الحجم، فاستخدم POST موقعاً مع
content-length-range. - الثقة بنوع المحتوى: التوقيع يحمي الترويسة وليس البايتات، فافحص الملف بعد الرفع.
- روابط بمدة طويلة: دقائق للرفع والتنزيل تكفي، والرابط في سجل عام أو رسالة يصبح بيد أي شخص حتى ينتهي.
- Versioning دون قاعدة للنسخ القديمة: ال Bucket يكبر مع كل كتابة، فاجعل معه NoncurrentVersionExpiration.
- Bucket دون قاعدة للرفع غير المكتمل: أجزاء لا تراها في القائمة وتدفع ثمنها.
- Standard-IA للملفات الصغيرة: الحد الأدنى 128 KB يجعلها أغلى من Standard.
- حساب التخزين وحده: ال Egress والطلبات هما البندان اللذان يكبران مع النجاح.
تدريب عملي
- أضف إلى ملف الهويات هوية ثالثة لها
Read:uploadsفقط، وأعد تشغيل SeaweedFS، ثم جرب بها الرفع والحذف واقرأ الخطأ. - غير السكربت cost.py بأرقام تطبيقك، ثم أضف سطراً لل CDN يخرج 90% من الطلبات من ال Cache، وقارن الفاتورة.
الخلاصة
- الطلب يمر بالتوقيع، ثم صلاحيات المفتاح، ثم سياسة ال Bucket، والرفض الصريح يغلب أي سماح، وفي التجربة رفض مفتاح التطبيق في كل ما يخرج عن Bucket واحد.
- رابط PUT الموقع يحدد المفتاح والنوع والمدة، ونموذج POST الموقع يضيف الحجم والبادئة، والتخزين رفض ملفاً من 2 MB بالخطأ EntityTooLarge.
- السياسة تمنع الحذف ولكنها لا تمنع الكتابة فوق الملف، والنسخ Versioning يحفظ القديم، والاستعادة نسخه فوق المفتاح دون صلاحية حذف.
- الرفع المجزأ غير المكتمل لا يظهر في القائمة ويأخذ مساحة، وقاعدة Lifecycle تلغيه.
- الفئات الأرخص لها حد أدنى للمدة والحجم ورسوم قراءة، وفي فاتورة الصور كان ال Egress 94% من المبلغ.
دليلأنواع التخزين وواجهة S3: أين تضع بيانات تطبيقك؟أين تضع قاعدة البيانات وملفات المستخدمين والنسخ الاحتياطية، ولماذا يناسب كلاً منها نوع مختلف من التخزين.
دليلتخزين الكائنات S3 على سيرفرك: التثبيت والبنيةكيف تثبت ثلاث أدوات تخزين S3 مفتوحة المصدر في إعداد صغير يتحمل الأعطال، وماذا يحدث فعلاً عندما يتوقف سيرفر أو يتلف قرص.
دليلالنسخ الاحتياطي إلى S3 مع Object Lock ضد الفديةكيف تقفل نسخك الاحتياطية في S3 حتى لا يمحوها من يسرق مفاتيحها، وكيف تستعيدها إلى ما قبل الهجوم.
دليلالتكاليف الخفية في السحابة وكيف تخفضهاالبنود التي لا تظهر في حساب يوم التصميم ثم تملأ فاتورة السحابة بعد أشهر، وكيف تحسب كل بند وتخفضه.
دليلكيف تعمل شبكة توصيل المحتوى CDN وما الذي تحفظهلماذا يبطؤ موقعك على الزائر البعيد، وكيف تقرب شبكة CDN ملفاتك منه، وكيف تمنعها من حفظ الصفحات الخاصة.سجل التحديثات
- أكتوبر 2026: كتابة المقال، والتجربة على SeaweedFS 4.48 مع aws-cli 2.37.12 وboto3 1.43.111 في Docker Engine 29.8.0، والأسعار من AWS Price List API لمنطقة eu-central-1 (S3 بتاريخ نشر 28 سبتمبر 2026، ونقل البيانات بتاريخ 16 سبتمبر 2026) كما قرأناها في 11 أكتوبر 2026.