في مقال أنواع التخزين وواجهة S3 تعرفنا على أشهر أدوات تخزين الكائنات التي تعمل على سيرفرك، وشغلنا Garage على عقدة واحدة لنفهم فكرة ال Bucket والمفتاح، ثم رأينا في S3 من داخل تطبيقك كيف يستخدم الكود هذه الواجهة. وعقدة واحدة لا تحمي شيئاً، فإذا تعطل قرصها ضاعت الملفات، والسؤال الحقيقي عندما تقرر أن تستضيف التخزين بنفسك هو: كيف تبني كل أداة الحماية من الأعطال، وكم سيرفراً تحتاج، وما الذي يحدث فعلاً عندما يتوقف سيرفر أو يتلف قرص، وما الذي لا تدعمه من واجهة S3. وهذا ما سوف نجربه هنا على Garage وSeaweedFS وMinIO، كل واحد في إعداد صغير لكنه يشبه الإنتاج، ثم نكسره عمداً.
وسوف نناقش في هذا المقال ما يلي:
- Garage: ثلاث عقد في ثلاث مناطق، والنصاب Quorum، وماذا يحدث عندما تتوقف عقدة ثم عقدتان.
- SeaweedFS: البوابة وال Filer وال Master وخوادم ال Volumes كخدمات منفصلة، وقاعدة النسخ 001، وماذا يحدث عندما يتوقف خادم.
- MinIO: لماذا نبنيه من الكود اليوم، وترميز المحو Erasure Coding على أربعة أقراص، وإتلاف قرصين ثم الإصلاح Heal.
- سكربت واحد يسأل كل أداة عن ميزات S3، والفروق التي ظهرت فعلاً، والرخصة Licence لكل أداة.
- أساسيات الأمان والمراقبة والنسخ الاحتياطي المشتركة بين الثلاثة.
أما المقارنة بالأرقام، والتغييرات التي حدثت في MinIO بالتفصيل، ودليل الاختيار، فهي موضوع أي تخزين S3 مستضاف ذاتياً يناسبك؟.
Garage: ثلاث مناطق ونسخة كاملة في كل منطقة
Garage من تطوير مجموعة Deuxfleurs الفرنسية، ومكتوب بلغة Rust، ورخصته AGPL-3.0 كما في ملف الرخصة، وصفحة أهدافه تقول بوضوح إنه مصمم لعدة مواقع متصلة عبر الإنترنت العادي، مثل مراكز بيانات أو مكاتب أو بيوت، وإن الأداء الأقصى وكثرة الميزات وترميز المحو ليست من أهدافه، فهو يكتفي بنسخ البيانات كاملة. والفكرة الأساسية فيه هي المنطقة Zone: تعطي كل عقدة اسم منطقة، ومع replication_factor = 3 يضع Garage كل كائن على ثلاث عقد في مناطق مختلفة إن أمكن، وبحسب توثيق الإعداد يحتاج في الوضع الافتراضي consistent ردين من ثلاثة للكتابة وردين للقراءة، وبهذا يضمن أن ما كتبته تقرؤه مباشرة. والصورة التالية تبين ذلك مع نتائج التجربة:

تشغيل ثلاث عقد
كل عقدة تحتاج ملف إعداد، والسر المشترك rpc_secret يجب أن يكون نفسه في الثلاثة، لأنه ما يسمح للعقد بالتواصل، والعنوان rpc_public_addr هو ما تستخدمه العقد الأخرى للوصول إليها:
mkdir -p ~/lab-s3servers/garage && cd ~/lab-s3servers/garage
SECRET=$(openssl rand -hex 32); TOKEN=$(openssl rand -base64 32)
for i in 1 2 3; do cat > garage$i.toml <<EOF
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 3
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "lab-s3servers-garage$i:3901"
rpc_secret = "$SECRET"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage.localhost"
[admin]
api_bind_addr = "[::]:3903"
admin_token = "$TOKEN"
EOF
done
chmod 600 garage*.tomlثم ملف Docker Compose بثلاث خدمات، والعقدة الأولى وحدها تنشر منفذ S3 على 127.0.0.1:
name: lab-s3servers-garage
services:
garage1:
image: dxflrs/garage:v2.4.1
container_name: lab-s3servers-garage1
volumes: ["./garage1.toml:/etc/garage.toml:ro", "g1:/var/lib/garage"]
ports: ["127.0.0.1:39001:3900"]
garage2:
image: dxflrs/garage:v2.4.1
container_name: lab-s3servers-garage2
volumes: ["./garage2.toml:/etc/garage.toml:ro", "g2:/var/lib/garage"]
garage3:
image: dxflrs/garage:v2.4.1
container_name: lab-s3servers-garage3
volumes: ["./garage3.toml:/etc/garage.toml:ro", "g3:/var/lib/garage"]
volumes: { g1: {}, g2: {}, g3: {} }
networks:
default: { name: lab-s3servers-net }نشغلها، ثم نعرف كل عقدة بالأخرى بمعرفها، لأن العقد لا تكتشف بعضها وحدها:
docker compose up -d
g() { docker exec lab-s3servers-garage$1 /garage "${@:2}" 2>&1 | grep -v INFO; }
g 1 node connect "$(g 2 node id -q)"
g 1 node connect "$(g 3 node id -q)"
g 1 statusوالمخرج سوف يكون كما يلي:
Success.
Success.
==== HEALTHY NODES ====
ID Hostname Address Tags Zone Capacity DataAvail Version
979cc95a0f25848c ? 192.168.48.4:3901 NO ROLE ASSIGNED
dfdb128c1838027d 4aeb27944388 192.168.48.3:3901 NO ROLE ASSIGNED v2.4.1
e937c632dd949740 ? 192.168.48.2:3901 NO ROLE ASSIGNEDالعقد متصلة ولكنها دون دور NO ROLE ASSIGNED، فلا تخزن شيئاً بعد، ونعطيها الأدوار في تخطيط ال Cluster Layout: لكل عقدة منطقة وسعة ووسم، ثم نطبق التخطيط برقم إصداره:
g 1 layout assign dfdb128c1838027d -z riyadh -c 10G -t g1
g 1 layout assign 979cc95a0f25848c -z jeddah -c 10G -t g2
g 1 layout assign e937c632dd949740 -z dammam -c 10G -t g3
g 1 layout apply --version 1
g 1 statusوالمخرج سوف يكون كما يلي، بعد اختصار جدول الأقسام:
==== COMPUTATION OF A NEW PARTITION ASSIGNATION ====
Partitions are replicated 3 times on at least 3 distinct zones.
Optimal partition size: 37.3 MiB
Usable capacity / total cluster capacity: 27.9 GiB / 27.9 GiB (100.0 %)
Effective capacity (replication factor 3): 9.3 GiB
...
New cluster layout with updated role assignment has been applied in cluster.
Data will now be moved around between nodes accordingly.
==== HEALTHY NODES ====
ID Hostname Address Tags Zone Capacity DataAvail Version
979cc95a0f25848c 1ae43995db71 192.168.48.4:3901 [g2] jeddah 9.3 GiB 840.5 GiB (83.5%) v2.4.1
dfdb128c1838027d 4aeb27944388 192.168.48.3:3901 [g1] riyadh 9.3 GiB 840.5 GiB (83.5%) v2.4.1
e937c632dd949740 a998b4d1f936 192.168.48.2:3901 [g3] dammam 9.3 GiB 840.5 GiB (83.5%) v2.4.1في المخرج أعلاه لاحظ التالي:
- البيانات مقسمة إلى 256 قسماً Partition، وكل قسم منسوخ ثلاث مرات على ثلاث مناطق مختلفة، كما قال السطر Partitions are replicated 3 times on at least 3 distinct zones.
- السعة الفعلية 9.3 GiB من 27.9 GiB، لأن كل بايت يحفظ ثلاث مرات، فاحسب ذلك عندما تشتري الأقراص.
- كل تغيير في التخطيط يجهز أولاً ثم يطبق برقم الإصدار التالي، والتطبيق ينقل البيانات بين العقد، فلا تطبقه في وقت الذروة إذا كان ال Cluster ممتلئاً.
Bucket ومفتاح، ثم نوقف عقدة ثم عقدتين
g 1 bucket create media
g 1 key create media-app
g 1 bucket allow --read --write media --key media-appوالمخرج سوف يكون كما يلي، وقد اختصرنا المفتاح السري:
Key ID: GK881e53fc5126df0b15f57bdf
Secret key: 9bae69fb…(مختصر)
==== KEYS FOR THIS BUCKET ====
Permissions Access key Local aliases
RW GK881e53fc5126df0b15f57bdf media-appنضع المفتاح في ملف بيئة، ونشغل Container فيه aws CLI على الشبكة نفسها، وهو ال Image التي بنيناها في مقال S3 من داخل تطبيقك، ثم نرفع ملفاً من 20 MiB، ونوقف garage3، ونكتب ونقرأ:
cd ~/lab-s3servers
cat > garage.env <<'EOF'
AWS_ACCESS_KEY_ID=GK881e53fc5126df0b15f57bdf
AWS_SECRET_ACCESS_KEY=<the secret key>
AWS_DEFAULT_REGION=garage
AWS_ENDPOINT_URL=http://lab-s3servers-garage1:3900
EOF
docker run -d --name lab-s3servers-tools --network lab-s3servers-net -v "$PWD:/lab" lab/s3-toolbox:1
gs() { docker exec --env-file garage.env lab-s3servers-tools sh -c "$1"; }
gs 'head -c 20M /dev/urandom > /tmp/video.mp4; aws s3 cp /tmp/video.mp4 s3://media/videos/intro.mp4 --only-show-errors; aws s3 ls s3://media --recursive'
docker stop lab-s3servers-garage3
gs 'md5sum /tmp/video.mp4; echo hello > /tmp/n.txt; aws s3 cp /tmp/n.txt s3://media/notes/n1.txt --only-show-errors && aws s3 cp s3://media/videos/intro.mp4 /tmp/back.mp4 --only-show-errors && md5sum /tmp/back.mp4'والمخرج سوف يكون كما يلي:
2026-10-11 06:51:49 20971520 videos/intro.mp4
adc759211f03f6f4e4b638f021ee0fb0 /tmp/video.mp4
adc759211f03f6f4e4b638f021ee0fb0 /tmp/back.mp4الكتابة نجحت والملف رجع بالبصمة نفسها مع عقدة متوقفة. والآن نوقف garage2 أيضاً، ونجعل aws CLI يحاول مرة واحدة فقط حتى نرى الخطأ بدل أن يعيد المحاولة:
docker stop lab-s3servers-garage2
gs 'AWS_MAX_ATTEMPTS=1 aws s3 cp /tmp/n.txt s3://media/notes/n2.txt'
gs 'AWS_MAX_ATTEMPTS=1 aws s3 cp s3://media/videos/intro.mp4 /tmp/back2.mp4'upload failed: ../tmp/n.txt to s3://media/notes/n2.txt An error occurred (ServiceUnavailable) when calling the PutObject operation (reached max retries: 0): Internal error: Could not reach quorum of 2 (sets=None). 1 of 3 request succeeded, others returned errors: ["Network error: Not connected: 979cc95a0f25848c", "Network error: Not connected: e937c632dd949740"]
fatal error: An error occurred (503) when calling the HeadObject operation (reached max retries: 0): Service Unavailableالرسالة تشرح نفسها: العقدة الباقية كتبت عندها، ولكن النصاب اثنان، فرفضت الكتابة كلها بدل أن تقبل كتابة لا تضمن قراءتها لاحقاً، والقراءة رفضت للسبب نفسه. وقد يتساءل البعض: هل أستطيع أن أقرأ ملفاتي في هذه الحالة؟ والإجابة نعم إذا غيرت consistency_mode إلى degraded، فيصبح نصاب القراءة واحداً، أو dangerous فيصبح نصاب الكتابة واحداً أيضاً، وكلاهما حل طوارئ مؤقت كما يحذر التوثيق، والسبب أنك تتنازل عن ضمان أن القراءة ترى آخر كتابة. وعندما أعدنا تشغيل العقدتين بالأمر docker start عادتا إلى قائمة HEALTHY NODES دون أي تدخل.
وفي التجربة استخدمت العقد الثلاث بعد الاختبار 32.76 MiB و5.3 MiB و7.3 MiB من الذاكرة بحسب docker stats، وهذا ما يجعل Garage مناسباً لأجهزة صغيرة في مواقع متفرقة. ولاحظ أن منفذ الإدارة 3903 ومنفذ التواصل 3901 لم ننشرهما، فالأول يحمي بتوكن الإدارة، والثاني بالسر المشترك، وكلاهما لا يجب أن يصل إليه أحد من الإنترنت، وبين المواقع الحقيقية ضعهما داخل VPN مثل WireGuard.
SeaweedFS: البوابة والفهرس والخريطة والأقراص
SeaweedFS مكتوب بلغة Go، ورخصته Apache-2.0 كما في مستودعه، وله نسخة تجارية Enterprise فيها ميزات إضافية مثل الإصلاح الذاتي وترميز المحو القابل للتخصيص بحسب الملف نفسه. وبحسب وصف البنية فهو أربعة أدوار: خادم ال Master يعرف أي Volume على أي خادم ويعطي معرفات الملفات، ولا يقع في مسار القراءة، وخوادم ال Volumes تحفظ الكتل في ملفات كبيرة تضيف إليها فقط، وال Filer يضيف الأسماء والمجلدات وقوائم الأجزاء ويحفظها في قاعدة بيانات تختارها، وبوابة S3 تتكلم S3 وتتحقق من المفاتيح ولا تحفظ حالة، والصورة التالية تبين ذلك مع ما رأيناه في التجربة:

تشغيل كل دور في Container
نبدأ بملف الهويات، فبدونه تقبل البوابة أي مفتاح كما رأينا في تجربة المقال الأول:
mkdir -p ~/lab-s3servers/seaweed && cd ~/lab-s3servers/seaweed
cat > s3.json <<'EOF'
{
"identities": [
{
"name": "admin",
"credentials": [{ "accessKey": "SWADMIN00001", "secretKey": "sw-admin-secret-change-me" }],
"actions": ["Admin", "Read", "List", "Tagging", "Write"]
},
{
"name": "media-app",
"credentials": [{ "accessKey": "SWMEDIA00001", "secretKey": "sw-media-secret-change-me" }],
"actions": ["Read:media", "Write:media", "List:media"]
}
]
}
EOF
chmod 600 s3.jsonثم Compose بخمس خدمات: Master، وخادما Volumes في الرف نفسه، وFiler، وبوابة S3، وهي النسخة المبسطة من ملف Compose الرسمي مع إصدار مثبت وقاعدة نسخ:
name: lab-s3servers-seaweed
x-weed: &weed
image: chrislusf/seaweedfs:4.48
restart: unless-stopped
services:
master:
<<: *weed
container_name: lab-s3servers-sw-master
command: master -ip=lab-s3servers-sw-master -ip.bind=0.0.0.0 -mdir=/data -defaultReplication=001 -volumeSizeLimitMB=1024
volumes: ["master:/data"]
volume1:
<<: *weed
container_name: lab-s3servers-sw-volume1
command: volume -ip=lab-s3servers-sw-volume1 -ip.bind=0.0.0.0 -port=8080 -master=lab-s3servers-sw-master:9333 -dir=/data -max=20 -dataCenter=dc1 -rack=rack1
volumes: ["vol1:/data"]
depends_on: [master]
volume2:
<<: *weed
container_name: lab-s3servers-sw-volume2
command: volume -ip=lab-s3servers-sw-volume2 -ip.bind=0.0.0.0 -port=8080 -master=lab-s3servers-sw-master:9333 -dir=/data -max=20 -dataCenter=dc1 -rack=rack1
volumes: ["vol2:/data"]
depends_on: [master]
filer:
<<: *weed
container_name: lab-s3servers-sw-filer
command: filer -ip=lab-s3servers-sw-filer -ip.bind=0.0.0.0 -master=lab-s3servers-sw-master:9333
volumes: ["filer:/data"]
depends_on: [volume1, volume2]
s3:
<<: *weed
container_name: lab-s3servers-sw-s3
command: s3 -filer=lab-s3servers-sw-filer:8888 -ip.bind=0.0.0.0 -config=/etc/seaweedfs/s3.json
volumes: ["./s3.json:/etc/seaweedfs/s3.json:ro"]
ports: ["127.0.0.1:38333:8333"]
depends_on: [filer]
volumes: { master: {}, vol1: {}, vol2: {}, filer: {} }
networks:
default: { name: lab-s3servers-net, external: true }في الإعداد أعلاه لاحظ التالي:
- القاعدة
-defaultReplication=001مكونة من ثلاثة أرقام: نسخ في مراكز بيانات أخرى، ثم في رفوف أخرى، ثم في خوادم أخرى في الرف نفسه، فالقيمة 001 تعني نسخة إضافية واحدة على خادم آخر في الرف نفسه، كما في توثيق النسخ. - الخيار
-volumeSizeLimitMB=1024يصغر حجم ال Volume الواحد من القيمة الافتراضية حتى يكفي القرص الصغير في التجربة. - البوابة وحدها منشورة على 127.0.0.1، أما المنافذ 9333 و8080 و8888 فداخل الشبكة فقط، لأنها لا تتحقق من مفاتيح S3.
نشغلها، وننشئ ال Bucket بالمدير، ونرفع بمفتاح التطبيق، ثم نسأل ال Master أين ذهبت البيانات بأداة weed shell:
docker compose up -d
cd ~/lab-s3servers
printf 'AWS_ACCESS_KEY_ID=SWADMIN00001\nAWS_SECRET_ACCESS_KEY=sw-admin-secret-change-me\nAWS_DEFAULT_REGION=us-east-1\nAWS_ENDPOINT_URL=http://lab-s3servers-sw-s3:8333\n' > sw-admin.env
printf 'AWS_ACCESS_KEY_ID=SWMEDIA00001\nAWS_SECRET_ACCESS_KEY=sw-media-secret-change-me\nAWS_DEFAULT_REGION=us-east-1\nAWS_ENDPOINT_URL=http://lab-s3servers-sw-s3:8333\n' > sw-media.env
docker exec --env-file sw-admin.env lab-s3servers-tools aws s3 mb s3://media
docker exec --env-file sw-media.env lab-s3servers-tools sh -c 'head -c 20M /dev/urandom > /tmp/video.mp4; md5sum /tmp/video.mp4; aws s3 cp /tmp/video.mp4 s3://media/videos/intro.mp4 --only-show-errors; aws s3 ls s3://media --recursive'
echo "volume.list" | docker exec -i lab-s3servers-sw-master weed shell -master=lab-s3servers-sw-master:9333والمخرج سوف يكون كما يلي، بعد حذف سطور السجل وبعض ال Volumes الفارغة:
make_bucket: media
ee8af303819f92d7f0c55f76fff52cf0 /tmp/video.mp4
2026-10-11 06:59:18 20971520 videos/intro.mp4
Topology volumeSizeLimit:1024 MB hdd(volume:12/40 active:12 free:28 remote:0)
DataCenter dc1 hdd(volume:12/40 active:12 free:28 remote:0)
Rack rack1 hdd(volume:12/40 active:12 free:28 remote:0)
DataNode lab-s3servers-sw-volume1:8080 hdd(volume:6/20 active:6 free:14 remote:0)
Disk hdd(volume:6/20 active:6 free:14 remote:0) id:0
volume Id:1, Size:12583064, ReplicaPlacement:001, Collection:media, Version:3, Ttl:, FileCount:3, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1791701958
volume Id:2, Size:4194360, ReplicaPlacement:001, Collection:media, Version:3, Ttl:, FileCount:1, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1791701958
volume Id:3, Size:4194360, ReplicaPlacement:001, Collection:media, Version:3, Ttl:, FileCount:1, DeleteCount:0, DeletedByteCount:0, ReadOnly:false, ModifiedAtSecond:1791701958
...
DataNode lab-s3servers-sw-volume1:8080 {Size:20971808 FileCount:5 DeletedFileCount:0 DeletedBytes:0}
DataNode lab-s3servers-sw-volume2:8080 hdd(volume:6/20 active:6 free:14 remote:0)
volume Id:1, Size:12583064, ReplicaPlacement:001, Collection:media, Version:3, Ttl:, FileCount:3, ...
volume Id:2, Size:4194360, ReplicaPlacement:001, Collection:media, Version:3, Ttl:, FileCount:1, ...
volume Id:3, Size:4194360, ReplicaPlacement:001, Collection:media, Version:3, Ttl:, FileCount:1, ...
...
DataNode lab-s3servers-sw-volume2:8080 {Size:20971808 FileCount:5 DeletedFileCount:0 DeletedBytes:0}
total size:41943616 file_count:10في المخرج أعلاه لاحظ التالي:
- الملف من 20 MiB أصبح خمسة أجزاء FileCount:5 بحجم 4 MiB تقريباً، موزعة على ثلاثة Volumes، والبوابة هي التي قسمته.
- ال Volumes نفسها برقمها نفسه موجودة على الخادمين، فالنسخ في SeaweedFS على مستوى ال Volume كله وليس على مستوى الملف، والمجموع 41,943,616 بايت لملف واحد من 20 MiB.
- ال Bucket أصبح مجموعة Collection باسم media، فكل Bucket له Volumes خاصة به، ولذلك أنشأ ستة Volumes دفعة واحدة.
نوقف خادماً
docker stop lab-s3servers-sw-volume2
docker exec --env-file sw-media.env lab-s3servers-tools sh -c 'aws s3 cp s3://media/videos/intro.mp4 /tmp/back.mp4 --only-show-errors && md5sum /tmp/back.mp4; echo hi > /tmp/n.txt; AWS_MAX_ATTEMPTS=1 aws s3 cp /tmp/n.txt s3://media/notes/n1.txt'
docker logs lab-s3servers-sw-s3 2>&1 | grep "chunked upload failed" | tail -1
docker logs lab-s3servers-sw-master 2>&1 | grep "not enough" | tail -1والمخرج سوف يكون كما يلي:
ee8af303819f92d7f0c55f76fff52cf0 /tmp/back.mp4
upload failed: ../tmp/n.txt to s3://media/notes/n1.txt An error occurred (InternalError) when calling the PutObject operation (reached max retries: 0): We encountered an internal error, please try again.
E1011 07:00:27.942810 upload_chunked.go:281 chunked upload failed: assign volume: assign volume: assign volume: rpc error: code = ResourceExhausted desc = no writable volumes for {"collection":"media","replication":{"node":1},"ttl":{"Count":0,"Unit":0},"version":3}, volume growth in progress (returning 0 partial chunks for cleanup)
dc1:Only has 0 racks with more than 2 free data nodes, not enough for 1.القراءة نجحت من النسخة الباقية بالبصمة نفسها، أما الكتابة فرفضت، والعميل لم ير إلا InternalError، والسبب الحقيقي في سجل البوابة وال Master: القاعدة 001 تحتاج خادمين، ولا يوجد إلا خادم واحد يقبل الكتابة، وهذا بالضبط ما يقوله توثيق النسخ: القراءة ممكنة ما دامت نسخة واحدة متاحة، والكتابة لا تسمح إلا إذا أمكن تحقيق عدد النسخ المطلوب. وهذا فرق مهم عن Garage، فالكتابة هنا تتوقف عند تعطل خادم واحد من اثنين، لذلك في الإنتاج اجعل عدد الخوادم أكبر من عدد النسخ، فمع 001 تحتاج ثلاثة خوادم على الأقل حتى تبقى الكتابة ممكنة إذا تعطل واحد. وعندما أعدنا تشغيل volume2 قبلت الكتابة مباشرة.
وبعد الاختبار كانت الذاكرة بحسب docker stats للبوابة 162.8 MiB، وال Filer 66.9 MiB، وال Master 66.7 MiB، وخادمي ال Volumes 126.9 MiB و84 MiB، أي حوالي نصف GiB للإعداد كله.
MinIO: من الكود، وعلى أربعة أقراص
كان MinIO لسنوات أسهل طريق إلى S3 على سيرفرك، ورخصته AGPL-3.0، ولكن مستودعه اليوم مؤرشف للقراءة فقط، وملف README يقول إنه لم يعد يصان THIS REPOSITORY IS NO LONGER MAINTAINED، وإن النسخة المجتمعية توزع ككود مصدري فقط، وتاريخ هذه التغييرات وما يعنيه لك فصلناه في مقال المقارنة. والنتيجة العملية للتثبيت أنك لن تجد Docker Image رسمية تسحبها، فعندما حاولنا في 11 أكتوبر 2026 سحب minio/minio من Docker Hub وquay.io/minio/minio رفض السجلان الطلب، لذلك نبني آخر إصدار مجتمعي RELEASE.2025-10-15T17-29-55Z من الكود كما يطلب README، وهو إصدار لإصلاح ثغرة كما في ملاحظات الإصدار:
mkdir -p ~/lab-s3servers/minio && cd ~/lab-s3servers/minio
cat > Dockerfile <<'EOF'
# Build the last community release of MinIO from source, as its README asks (no official image any more)
FROM golang:1.24-bookworm AS build
ARG MINIO_TAG=RELEASE.2025-10-15T17-29-55Z
RUN CGO_ENABLED=0 go install -v github.com/minio/minio@${MINIO_TAG} && go install github.com/minio/mc@latest
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/*
COPY --from=build /go/bin/minio /go/bin/mc /usr/local/bin/
EXPOSE 9000 9001
ENTRYPOINT ["minio"]
EOF
docker build -t lab/minio:RELEASE.2025-10-15T17-29-55Z .البناء استغرق حوالي 15 دقيقة على جهاز التجربة، والسبب أن Go يبني MinIO ومكتباته كلها. ثم نشغله على أربعة أقراص، وكل قرص هنا Volume منفصل:
name: lab-s3servers-minio
services:
minio:
image: lab/minio:RELEASE.2025-10-15T17-29-55Z
container_name: lab-s3servers-minio
command: server /data1 /data2 /data3 /data4 --console-address :9001
environment:
MINIO_ROOT_USER: minioadmin-lab
MINIO_ROOT_PASSWORD: change-me-long-password-01
volumes: ["d1:/data1", "d2:/data2", "d3:/data3", "d4:/data4"]
ports: ["127.0.0.1:39000:9000", "127.0.0.1:39090:9001"]
volumes: { d1: {}, d2: {}, d3: {}, d4: {} }
networks:
default: { name: lab-s3servers-net, external: true }docker compose up -d
docker logs lab-s3servers-minio 2>&1 | head -7INFO: Formatting 1st pool, 1 set(s), 4 drives per set.
INFO: WARNING: Host local has more than 2 drives of set. A host failure will result in data becoming unavailable.
MinIO Object Storage Server
Copyright: 2015-2026 MinIO, Inc.
License: GNU AGPLv3 - https://www.gnu.org/licenses/agpl-3.0.html
Version: DEVELOPMENT.GOGET (go1.24.13 linux/amd64)لاحظ التحذير في السطر الثاني: الأقراص الأربعة في جهاز واحد، فإذا تعطل الجهاز توقف كل شيء، وهذا صحيح، فما سوف نراه حماية من تلف الأقراص وليس من تعطل السيرفر. ولاحظ أيضاً أن الإصدار يظهر DEVELOPMENT.GOGET، لأن البناء بالأمر go install لا يضع رقم الإصدار في الملف التنفيذي، فاحفظ رقم الإصدار الذي بنيته في اسم ال Image كما فعلنا.
ترميز المحو: نتلف قرصين ثم نصلحهما
MinIO لا ينسخ الملف كاملاً، وإنما يقسمه إلى أجزاء بيانات وأجزاء تماثل Parity موزعة على أقراص مجموعة المحو Erasure Set، فإذا ضاع قرص أعاد حساب ما عليه من الباقي، والصورة التالية تبين ما حدث في التجربة:

m() { docker exec lab-s3servers-minio mc "$@"; }
m alias set lab http://127.0.0.1:9000 minioadmin-lab change-me-long-password-01
m admin info lab
m mb lab/media
docker exec lab-s3servers-minio sh -c 'head -c 20M /dev/urandom > /tmp/video.mp4; md5sum /tmp/video.mp4'
m cp -q /tmp/video.mp4 lab/media/videos/intro.mp4
docker exec lab-s3servers-minio sh -c 'ls /data3/media/videos/intro.mp4/*/; du -sh /data1/media /data2/media /data3/media /data4/media'والمخرج سوف يكون كما يلي، بعد حذف سطر التسجيل:
● 127.0.0.1:9000
Uptime: 43 seconds
Version: <development>
Network: 1/1 OK
Drives: 4/4 OK
Pool: 1
┌──────┬────────────────────────┬─────────────────────┬──────────────┐
│ Pool │ Drives Usage │ Erasure stripe size │ Erasure sets │
│ 1st │ 12.3% (total: 1.9 TiB) │ 4 │ 1 │
└──────┴────────────────────────┴─────────────────────┴──────────────┘
4 drives online, 0 drives offline, EC:2
Bucket created successfully `lab/media`.
5e12f72eaa2338a93f843524b06b1671 /tmp/video.mp4
part.1
part.2
11M /data1/media
11M /data2/media
11M /data3/media
11M /data4/mediaفي المخرج أعلاه لاحظ التالي:
- EC:2 تعني جزأي تماثل في كل مجموعة من أربعة أقراص، فالمساحة المستخدمة ضعف حجم البيانات، 44M لملف من 20 MiB، وهذا مثل نسختين، ولكن الحماية من ضياع أي قرصين، وليس قرصاً واحداً كما في نسختين عاديتين.
- كل قرص فيه مجلد باسم الكائن، وفيه ملف وصف xl.meta وأجزاء part.1 وpart.2، فلا تقرأ الملفات من القرص مباشرة ولا تعدلها، لأنها ليست الملف الأصلي.
والآن نحذف بيانات قرصين كاملين، ونقرأ الملف، ثم نطلب الإصلاح:
docker exec lab-s3servers-minio rm -rf /data3/media /data4/media
m cp -q lab/media/videos/intro.mp4 /tmp/back.mp4; docker exec lab-s3servers-minio md5sum /tmp/back.mp4
m admin heal -r lab/media
docker exec lab-s3servers-minio sh -c 'du -sh /data*/media'5e12f72eaa2338a93f843524b06b1671 /tmp/back.mp4
[Yellow -> Green] media/
[Red -> Green] media/videos/intro.mp4
Healed: 1/1 objects; 20 MiB in 1s
11M /data1/media
11M /data2/media
11M /data3/media
11M /data4/mediaالملف رجع بالبصمة نفسها من نصف الأقراص، والإصلاح أعاد بناء الجزأين المفقودين في ثانية. وفي الإنتاج يكون ال Erasure Set على عدة سيرفرات، فيصبح ضياع سيرفر مثل ضياع قرص هنا، ولذلك يطلب MinIO عدداً من السيرفرات والأقراص من البداية، ولا يحب أن تضيف قرصاً واحداً لاحقاً، وإنما مجموعة كاملة Pool.
ماذا بقي في الواجهة؟
الواجهة على المنفذ 9001 ما زالت موجودة في هذا الإصدار، ولكنها بعد إصدار مايو 2025 أصبحت متصفحاً للملفات فقط، كما في الصورة التالية من تجربتنا، فلا توجد فيها إدارة المستخدمين ولا السياسات ولا الإعدادات، وكل ذلك يتم بأداة mc:

وإذا أردت MinIO بإصدارات مبنية جاهزة وواجهة كاملة، فهناك نسخ مشتقة Forks من مجتمعات أخرى، أشهرها Silo من فريق Pigsty، وقد ذكرناها مع تقييمها في مقال المقارنة. وكانت ذاكرة MinIO بعد الاختبار 152.1 MiB بحسب docker stats.
ما الذي تدعمه كل أداة من واجهة S3؟
صفحات التوافق مفيدة، ولكن الأفضل أن تسأل الأداة نفسها، فكتبنا سكربتاً ينشئ Bucket ويجرب ثماني ميزات، ويطبع رد الأداة كما هو، وفي ميزة Object Lock لا يكتفي بقبول الإعداد، وإنما يحاول حذف نسخة مقفلة ويتأكد أنها رفضت، ونحفظه باسم compat.py في المجلد ~/lab-s3servers:
# compat.py: ask the server for each S3 feature and print what it answers.
import os, io, json, uuid, boto3, requests
from botocore.config import Config
from botocore.exceptions import ClientError
s3 = boto3.client("s3", endpoint_url=os.environ["AWS_ENDPOINT_URL"],
config=Config(signature_version="s3v4", retries={"max_attempts": 1}))
B = "compat-" + uuid.uuid4().hex[:6]
s3.create_bucket(Bucket=B)
def check(name, fn):
try:
fn(); print(f"{name:<24} OK")
except ClientError as e:
err = e.response["Error"]; print(f"{name:<24} {err.get('Code')}: {err.get('Message', '')[:60]}")
def presigned():
s3.put_object(Bucket=B, Key="a.txt", Body=b"hi")
r = requests.get(s3.generate_presigned_url("get_object", Params={"Bucket": B, "Key": "a.txt"}, ExpiresIn=60))
assert r.status_code == 200 and r.content == b"hi"
def multipart():
up = s3.create_multipart_upload(Bucket=B, Key="big.bin")
parts = []
for n in (1, 2):
p = s3.upload_part(Bucket=B, Key="big.bin", UploadId=up["UploadId"], PartNumber=n, Body=b"x" * (5 * 1024 * 1024))
parts.append({"PartNumber": n, "ETag": p["ETag"]})
s3.complete_multipart_upload(Bucket=B, Key="big.bin", UploadId=up["UploadId"], MultipartUpload={"Parts": parts})
def versioning():
s3.put_bucket_versioning(Bucket=B, VersioningConfiguration={"Status": "Enabled"})
s3.put_object(Bucket=B, Key="v.txt", Body=b"1"); s3.put_object(Bucket=B, Key="v.txt", Body=b"2")
assert len(s3.list_object_versions(Bucket=B, Prefix="v.txt")["Versions"]) == 2
def object_lock():
lb = B + "-lock"
s3.create_bucket(Bucket=lb, ObjectLockEnabledForBucket=True)
s3.put_object_lock_configuration(Bucket=lb, ObjectLockConfiguration={
"ObjectLockEnabled": "Enabled", "Rule": {"DefaultRetention": {"Mode": "GOVERNANCE", "Days": 1}}})
v = s3.put_object(Bucket=lb, Key="l.txt", Body=b"locked")["VersionId"]
try:
s3.delete_object(Bucket=lb, Key="l.txt", VersionId=v)
except ClientError:
return # refused: the lock works
raise ClientError({"Error": {"Code": "NotEnforced", "Message": "locked version was deleted"}}, "DeleteObject")
def lifecycle_expire():
s3.put_bucket_lifecycle_configuration(Bucket=B, LifecycleConfiguration={"Rules": [
{"ID": "tmp", "Status": "Enabled", "Filter": {"Prefix": "tmp/"}, "Expiration": {"Days": 1}}]})
def lifecycle_noncurrent():
s3.put_bucket_lifecycle_configuration(Bucket=B, LifecycleConfiguration={"Rules": [
{"ID": "old", "Status": "Enabled", "Filter": {"Prefix": ""}, "NoncurrentVersionExpiration": {"NoncurrentDays": 30}}]})
def bucket_policy():
s3.put_bucket_policy(Bucket=B, Policy=json.dumps({"Version": "2012-10-17", "Statement": [{
"Effect": "Deny", "Principal": "*", "Action": "s3:DeleteObject", "Resource": f"arn:aws:s3:::{B}/keep/*"}]}))
def cors():
s3.put_bucket_cors(Bucket=B, CORSConfiguration={"CORSRules": [
{"AllowedOrigins": ["https://app.example"], "AllowedMethods": ["GET", "PUT"], "AllowedHeaders": ["*"]}]})
for name, fn in [("presigned URL", presigned), ("multipart upload", multipart), ("versioning", versioning),
("object lock", object_lock), ("lifecycle: expiration", lifecycle_expire),
("lifecycle: old versions", lifecycle_noncurrent), ("bucket policy", bucket_policy), ("CORS", cors)]:
check(name, fn)شغلناه على كل أداة بمفتاح يستطيع إنشاء Buckets، وفي Garage أعطينا المفتاح هذه الصلاحية أولاً بالأمر garage key allow --create-bucket media-app:
cd ~/lab-s3servers
printf 'AWS_ACCESS_KEY_ID=minioadmin-lab\nAWS_SECRET_ACCESS_KEY=change-me-long-password-01\nAWS_DEFAULT_REGION=us-east-1\nAWS_ENDPOINT_URL=http://lab-s3servers-minio:9000\n' > minio.env
docker exec --env-file garage.env lab-s3servers-tools python3.11 /lab/compat.py
docker exec --env-file sw-admin.env lab-s3servers-tools python3.11 /lab/compat.py
docker exec --env-file minio.env lab-s3servers-tools python3.11 /lab/compat.pyوالمخرج لكل أداة كما يلي، Garage أولاً ثم SeaweedFS ثم MinIO:
presigned URL OK
multipart upload OK
versioning NotImplemented: Unimplemented action: PutBucketVersioning
object lock NotImplemented: Unimplemented action: PutObjectLockConfiguration
lifecycle: expiration OK
lifecycle: old versions OK
bucket policy NotImplemented: Unimplemented action: PutBucketPolicy
CORS OK
presigned URL OK
multipart upload OK
versioning OK
object lock OK
lifecycle: expiration OK
lifecycle: old versions OK
bucket policy OK
CORS OK
presigned URL OK
multipart upload OK
versioning OK
object lock OK
lifecycle: expiration OK
lifecycle: old versions OK
bucket policy OK
CORS NotImplemented: A header you provided implies functionality that is not implفي المخرج أعلاه لاحظ التالي:
- Garage رفض النسخ وقفل الكائنات وسياسات ال Bucket، وهذا يطابق صفحة التوافق، فالصلاحيات فيه لكل مفتاح على كل Bucket بنظامه الخاص، وقبوله قاعدة «النسخ القديمة» لا يعني شيئاً لأنه لا يحفظ نسخاً أصلاً.
- SeaweedFS قبل الميزات الثماني، ورفض حذف النسخة المقفلة، وهذا يطابق صفحة واجهة S3 فيه، مع أن قواعد النقل بين الفئات Transition غير مدعومة.
- MinIO قبل كل شيء ما عدا إعداد CORS على ال Bucket، فهذه الواجهة غير موجودة في النسخة المجتمعية، فإذا كان المتصفح يرفع مباشرة إليه فاضبط CORS في ال Reverse Proxy أمامه.
والخلاصة أن التطبيق الذي يرفع ويقرأ ويستخدم روابط موقعة يعمل على الثلاثة، أما النسخ الاحتياطي الذي يعتمد على Versioning أو Object Lock فلا يعمل على Garage، وهذا موضوع النسخ الاحتياطي إلى S3 مع Object Lock.
الأمان والمراقبة والنسخ الاحتياطي للثلاثة
- المنافذ: انشر منفذ S3 وحده، وعلى 127.0.0.1 خلف Reverse Proxy بشهادة TLS مثل Caddy، أما منافذ الإدارة والتواصل بين العقد فداخل شبكة خاصة أو VPN فقط.
- المفاتيح: مفتاح لكل تطبيق على ال Bucket الخاص به، والمفتاح الرئيسي Root أو Admin لا يغادر السيرفر، كما شرحنا في قسم الصلاحيات.
- المراقبة: الثلاثة تعطي مقاييس بصيغة Prometheus، Garage على منفذ الإدارة بحسب توثيق المراقبة، وSeaweedFS بالخيار
-metricsPortلكل خدمة كما في ملف Compose الرسمي، وراقب أولاً المساحة الحرة وعدد العقد أو الأقراص المتصلة. - النسخ ليس نسخة احتياطية: النسخ بين العقد والتماثل يحميان من تعطل قرص أو سيرفر، ولكنهما ينسخان الحذف أيضاً، فإذا حذف سكربت كل الملفات حذفها من كل النسخ، لذلك تحتاج نسخة احتياطية خارجية كما في قاعدة 3-2-1.
- البيانات الوصفية: في Garage قاعدة ال Metadata في
metadata_dir، وفي SeaweedFS قاعدة ال Filer، وفقدانها أسوأ من فقدان قرص بيانات، لأن الأجزاء تبقى دون أسماء، فضعها على قرص سريع ومحمي.
التنظيف
cd ~/lab-s3servers/garage && docker compose down -v
cd ~/lab-s3servers/seaweed && docker compose down -v
cd ~/lab-s3servers/minio && docker compose down -v
docker rm -f lab-s3servers-tools
docker network rm lab-s3servers-net
docker image rm lab/minio:RELEASE.2025-10-15T17-29-55Zأخطاء شائعة
- عقدة واحدة في الإنتاج: replication_factor 1 أو قاعدة 000 تعني نسخة واحدة، والقرص الذي يتعطل يأخذ ملفاته معه.
- عدد خوادم يساوي عدد النسخ: في SeaweedFS مع 001 وخادمين توقفت الكتابة عند تعطل أحدهما، فاجعل الخوادم أكثر من النسخ.
- نسيان layout apply في Garage: العقد المتصلة دون دور لا تخزن شيئاً.
- بوابة SeaweedFS دون ملف هويات: تقبل أي مفتاح.
- أقراص MinIO كلها في سيرفر واحد مع ظن أنها توفر عالي: هي حماية من تلف الأقراص فقط.
- الاعتماد على Image قديمة لم تعد تصلها إصلاحات: احفظ الإصدار الذي بنيته، وتابع ثغراته، وخطط للانتقال.
- اعتبار النسخ بين العقد نسخة احتياطية: الحذف ينسخ أيضاً.
الخلاصة
- Garage خفيف ومصمم لمواقع متفرقة، بنسخة كاملة في كل منطقة ونصاب 2 من 3، تحمل توقف عقدة ورفض الكتابة والقراءة عند توقف عقدتين، واستخدم أقل من 50 MiB من الذاكرة للعقد الثلاث.
- SeaweedFS أربعة أدوار منفصلة، ينسخ على مستوى ال Volume، وقرأ الملف مع خادم متوقف ولكنه رفض الكتابة لأن القاعدة 001 احتاجت خادمين، ويدعم أوسع مجموعة من ميزات S3 في تجربتنا.
- MinIO يبنى اليوم من الكود، وترميز المحو EC:2 على أربعة أقراص قرأ الملف بعد إتلاف قرصين وأصلحهما في ثانية، ولكن مستودعه لم يعد يصان.
- Garage لا يدعم Versioning ولا Object Lock ولا سياسات ال Bucket، وMinIO المجتمعي لا يدعم إعداد CORS على ال Bucket.
- الرخص: Garage وMinIO برخصة AGPL-3.0، وSeaweedFS برخصة Apache-2.0.
دليلأنواع التخزين وواجهة S3: أين تضع بيانات تطبيقك؟أين تضع قاعدة البيانات وملفات المستخدمين والنسخ الاحتياطية، ولماذا يناسب كلاً منها نوع مختلف من التخزين.
دليلS3 من داخل تطبيقك: الصلاحيات والروابط الموقعة والتكلفةكيف يرفع تطبيقك الملفات إلى S3 بمفتاح محدود وروابط موقعة، ويحمي الملفات من الكتابة فوقها، ويتجنب البنود التي تضخم الفاتورة.
دليلأي تخزين S3 مستضاف ذاتياً يناسبك؟ وما الذي تغير في MinIOمقارنة عملية بين أشهر أدوات تخزين S3 المستضافة ذاتياً بقياسات على جهاز واحد، لتعرف أيها يناسب سيرفرك وماذا حدث فعلاً ل MinIO.
دليلالنسخ الاحتياطي إلى S3 مع Object Lock ضد الفديةكيف تقفل نسخك الاحتياطية في S3 حتى لا يمحوها من يسرق مفاتيحها، وكيف تستعيدها إلى ما قبل الهجوم.سجل التحديثات
- أكتوبر 2026: كتابة المقال، والتجربة على Garage v2.4.1 بثلاث عقد، وSeaweedFS 4.48 بخمس خدمات، وMinIO RELEASE.2025-10-15T17-29-55Z مبني من الكود بأداة Go 1.24.13، مع aws-cli 2.37.12 وboto3 1.43.111 في Docker Engine 29.8.0، وحالة مستودع MinIO والسجلات كما رأيناها في 11 أكتوبر 2026.