هذا دليل استعادة بعد الحوادث (post-mortem) / دليل تشغيل (runbook) لهجرة حقيقية. لقد تجاوزت التحضيرات الشائعة لـ Discourse (تغطيها الوثائق الرسمية). سأركز على مفاتيح التبديل الدقيقة، ومشاكل Cloudflare R2 الشائعة، وأوامر rails/rake المكونة من سطر واحد التي كانت مهمة، وما الذي فشل، وكيفية تكرار هذه الخطوة بنفس المخاطر المنخفضة في المرة القادمة.
الحالة النهائية المستهدفة
- يعمل Discourse على المضيف الجديد (Docker، حاوية
appواحدة). - تستضيف التحميلات + أصول الواجهة الأمامية على Cloudflare R2:
- دلو
discourse-uploads(عام) - دلو
discourse-backups(خاص)
- دلو
- نطاق مخصص لـ R2:
https://files.example.com(تم إنشاؤه في R2 → Custom domains، وليس عبر CNAME يدوي عبر حسابات مختلفة).
0) نسخ احتياطية لقاعدة البيانات تعمل فعليًا (كل ليلة و أثناء الانتقال)
النسخ الاحتياطي الليلي هو لـ استعادة الكوارث. أما النسخ الاحتياطي في اللحظات الأخيرة فهو لـ انتقال الهجرة. احتفظ بكليهما.
0.1 السياسة
- ليلي: نسخة احتياطية لقاعدة البيانات فقط (
.sql.gz، بدون تحميلات) → تحقق محليًا → ارفعها إلى R2. احتفظ بـ ≥7 نسخ (أو استخدم دورة حياة R2). - أثناء الانتقال: مباشرة قبل تبديل DNS، قم بإنشاء نسخة احتياطية أخرى لقاعدة البيانات فقط واستردها إلى المضيف الجديد لتقليل الفجوة في المحتوى.
0.2 إنشاء نسخة احتياطية لقاعدة البيانات فقط والتحقق منها
داخل الحاوية:
# اختياري لكنه جيد: تقليل الكتابة أثناء أخذ لقطة
discourse enable_readonly
# تشغيل نسخة احتياطية لقاعدة البيانات فقط من واجهة الإدارة (إلغاء تحديد "مع التحميلات")
# أو عبر سطر الأوامر:
discourse backup
# التحقق من الملف الناتج
ls -lh /var/discourse/shared/standalone/backups/default/
zcat -t /var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz
تحقق عميق (الأفضل): الاستعادة إلى قاعدة بيانات مؤقتة وعد الصفوف:
cd /var/discourse && ./launcher enter app
sudo -E -u postgres psql -tc "DROP DATABASE IF EXISTS verifydb;"
sudo -E -u postgres createdb verifydb
zcat /shared/backups/default/<DB_ONLY>.sql.gz | sudo -E -u postgres psql verifydb
sudo -E -u postgres psql -d verifydb -c "select count(*) from topics where deleted_at is null;"
sudo -E -u postgres psql -d verifydb -c "select count(*) from posts where post_type=1 and deleted_at is null;"
sudo -E -u postgres dropdb verifydb
exit
إذا فشل اختبار gzip أو الاستعادة المؤقتة، لا ترفع ذلك الملف إلى R2—قم بإصلاحه وقم بنسخ احتياطي مجددًا.
0.3 الرفع إلى R2 فقط بعد نجاح الاختبار
aws s3 cp /var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz \
s3://discourse-backups/
0.4 لماذا تختلف الأحجام (1–4 جيجابايت أمر طبيعي)
كل من النسخ الليلي من لوحة الإدارة و pg_dump اليدوي ينتجان ملف .sql.gz لقاعدة البيانات فقط. عادةً ما تعود فروق الحجم إلى الجداول المدرجة والضغط، وليس “المنشورات المفقودة”. إذا أردت رؤية ما بداخله:
# أي الجداول تحتوي على بيانات في الملف المصدّر؟
zcat <DB_ONLY>.sql.gz | grep -E '^COPY public\.' | awk '{print $2}' | sort -u | head
# تقدير سريع لعدد الأسطر للجداول الرئيسية
zcat <DB_ONLY>.sql.gz | awk '/^COPY public.posts /{c=1;next}/^\\\./{c=0} c' | wc -l
zcat <DB_ONLY>.sql.gz | awk '/^COPY public.topics /{c=1;next}/^\\\./{c=0} c' | wc -l
إذا تطابقت هذه الأرقام مع التوقعات، فإن النسخة الاحتياطية تحتوي على جميع المنشورات/المواضيع بغض النظر عن حجم الملف.
1) المضيف القديم: التحضير ونسخ النسخة الاحتياطية (المتحقق منها) لقاعدة البيانات فقط
أعلن عن الصيانة → فعّل وضع القراءة فقط:
cd /var/discourse && ./launcher enter app
discourse enable_readonly
exit
انسخ ملف .sql.gz المتحقق منه إلى المضيف الجديد:
rsync -avP -e "ssh -o StrictHostKeyChecking=no" \
root@OLD:/var/discourse/shared/standalone/backups/default/<DB_ONLY>.sql.gz \
/var/discourse/shared/standalone/backups/default/
إذا أردت فجوة محتوى شبه معدومة، كرر هذه الخطوة مباشرة قبل تبديل DNS.
2) تهيئة المضيف الجديد
تثبيت Docker + discourse_docker:
apt-get update && apt-get install -y git curl tzdata
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker
git clone https://github.com/discourse/discourse_docker /var/discourse
أنشئ containers/app.yml بقيم الإنتاج. ابق قوالب SSL معلقة حتى يشير DNS إلى هنا. مجموعة env الدنيا:
env:
DISCOURSE_HOSTNAME: forum.example.com
# R2 / S3
DISCOURSE_USE_S3: "true"
DISCOURSE_S3_REGION: "auto"
DISCOURSE_S3_ENDPOINT: "https://<ACCOUNT_ID>.r2.cloudflarestorage.com"
DISCOURSE_S3_FORCE_PATH_STYLE: "true"
DISCOURSE_S3_BUCKET: "discourse-uploads"
DISCOURSE_S3_BACKUP_BUCKET: "discourse-backups"
DISCOURSE_S3_ACCESS_KEY_ID: "<R2_KEY>"
DISCOURSE_S3_SECRET_ACCESS_KEY: "<R2_SECRET>"
DISCOURSE_S3_CDN_URL: "https://files.example.com"
DISCOURSE_BACKUP_LOCATION: "s3"
# مقابض التحقق من التجزئة في R2 (لمنع التعارضات)
AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"
# SMTP / بريد Let's Encrypt
DISCOURSE_SMTP_ADDRESS: smtp.gmail.com
DISCOURSE_SMTP_PORT: 587
DISCOURSE_SMTP_USER_NAME: you@example.com
DISCOURSE_SMTP_PASSWORD: "<app-password>"
DISCOURSE_SMTP_DOMAIN: example.com
DISCOURSE_NOTIFICATION_EMAIL: you@example.com
LETSENCRYPT_ACCOUNT_EMAIL: you@example.com
انشر الأصول إلى R2 أثناء إعادة البناء:
hooks:
after_assets_precompile:
- exec:
cd: $home
cmd:
- sudo -E -u discourse bundle exec rake s3:upload_assets
- sudo -E -u discourse bundle exec rake s3:expire_missing_assets
أطلق الحاوية (HTTP فقط حتى الآن):
cd /var/discourse && ./launcher rebuild app
3) استعادة ملف التصدير لقاعدة البيانات فقط (.sql.gz عبر psql)
cd /var/discourse && ./launcher enter app
sv stop unicorn || true; sv stop sidekiq || true
# التأكد من قاعدة بيانات نظيفة
sudo -E -u postgres psql -c "REVOKE CONNECT ON DATABASE discourse FROM public;"
sudo -E -u postgres psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='discourse';"
sudo -E -u postgres psql -c "DROP DATABASE IF EXISTS discourse;"
sudo -E -u postgres psql -c "CREATE DATABASE discourse WITH OWNER discourse TEMPLATE template0 ENCODING 'UTF8';"
sudo -E -u postgres psql -d discourse -c "CREATE EXTENSION IF NOT EXISTS citext;"
sudo -E -u postgres psql -d discourse -c "CREATE EXTENSION IF NOT EXISTS hstore;"
# استيراد الملف المصدّر
zcat /shared/backups/default/<DB_ONLY>.sql.gz | sudo -E -u postgres psql discourse
sv start unicorn
[ -d /etc/service/sidekiq ] && sv start sidekiq || true
exit
إذا كنت لا تزال تحمل تحميلات محلية قبل R2، يمكنك نسخها عبر rsync مرة واحدة كشبكة أمان؛ سنقوم بنقلها إلى R2 بعد ذلك.
4) مقابض R2 التي كانت مهمة
الدلاء والرمز: أنشئ discourse-uploads (عام) و discourse-backups (خاص). ابدأ بتوكن API للحساب مخصص لهذين الدلين مع صلاحيات قراءة وكتابة المسؤول (لكي تعمل PutBucketCors)، ثم قم بتدويره إلى قراءة وكتابة للكائنات بعد النجاح.
النطاق المخصص: أضف files.example.com في R2 → Custom domains تحت نفس حساب Cloudflare مثل منطقة DNS الخاصة بك (يتجنب أخطاء CNAME عبر الحسابات 1014).
CORS على discourse-uploads:
[
{
"AllowedOrigins": ["https://forum.example.com","https://files.example.com"],
"AllowedMethods": ["GET","HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["*"],
"MaxAgeSeconds": 86400
}
]
أعد البناء لنشر CSS/JS/الخطوط إلى R2:
cd /var/discourse && ./launcher rebuild app
5) هجرة لمرة واحدة للتحميلات التاريخية إلى R2
cd /var/discourse && ./launcher enter app
yes "" | AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_REQUIRED \
sudo -E -u discourse RAILS_ENV=production bundle exec rake uploads:migrate_to_s3
إذا حصلت على “لم يتم إعادة تعيين X من المنشورات…”، راجع القسم 7.2 للحصول على إصلاحات مستهدفة.
6) تبديل نطاق الإنتاج
قم بتعيينه في app.yml:
DISCOURSE_HOSTNAME: forum.example.com
LETSENCRYPT_ACCOUNT_EMAIL: you@example.com
DNS: وجه forum.example.com إلى الواجهة الأمامية الجديدة (أو الأصل)، فعّل قوالب SSL، ثم:
cd /var/discourse && ./launcher rebuild app
التحقق من الصحة:
curl -I https://forum.example.com
./launcher logs app | tail -n 200
ظهور HTTP/2 403 للمستخدمين المجهولين عادةً ما يعني login_required—وليس انقطاعًا في الخدمة.
7) الأشياء التي تعطلت فعليًا (والإصلاحات)
7.1 تعارض تجزئة R2
Aws::S3::Errors::InvalidRequest: You can only specify one non-default checksum at a time.
الإصلاح (ابقه دائمًا):
AWS_REQUEST_CHECKSUM_CALCULATION: "WHEN_REQUIRED"
AWS_RESPONSE_CHECKSUM_VALIDATION: "WHEN_REQUIRED"
7.2 “لم يتم إعادة تعيين X من المنشورات إلى عنوان URL الجديد لتحميل S3”
السبب: بعض HTML cooked لا يزال يشير إلى /uploads/<db>/original/....
إعادة تشكيل مستهدفة:
sudo -E -u discourse RAILS_ENV=production bundle exec rails r '
db = RailsMultisite::ConnectionManagement.current_db
ids = Post.where("cooked LIKE ?", "%/uploads/#{db}/original%").pluck(:id)
ids.each { |pid| Post.find(pid).rebake! }
puts "rebaked=#{ids.size}"
'
أو أعد تعيين بادئة ثابتة ثم أعد تشكيل المنشورات الملموسة:
sudo -E -u discourse RAILS_ENV=production bundle exec \
rake "posts:remap[/uploads/default/original,https://files.example.com/original]"
أعد تشغيل الهجرة للتأكد من النظافة:
yes "" | AWS_REQUEST_CHECKSUM_CALCULATION=WHEN_REQUIRED AWS_RESPONSE_CHECKSUM_VALIDATION=WHEN_REQUIRED \
sudo -E -u discourse RAILS_ENV=production bundle exec rake uploads:migrate_to_s3
7.3 مهام “مفقودة”
دائمًا شغّل مع bundler + env:
sudo -E -u discourse RAILS_ENV=production bundle exec rake -T s3
sudo -E -u discourse RAILS_ENV=production bundle exec rake -T uploads
اطبع إعدادات S3 الفعالة:
sudo -E -u discourse RAILS_ENV=production bundle exec rails r \
'puts({ use_s3: ENV["DISCOURSE_USE_S3"], bucket: ENV["DISCOURSE_S3_BUCKET"], endpoint: ENV["DISCOURSE_S3_ENDPOINT"], cdn: ENV["DISCOURSE_S3_CDN_URL"] })'
7.4 s3:upload_assets AccessDenied
استخدم توكن Admin RW للتهيئة (عمليات CORS على مستوى الدلو)، ثم قم بتدويره إلى Object RW.
8) التحقق من الصحة
داخل الحاوية
# الروابط تستخدم الآن CDN
sudo -E -u discourse RAILS_ENV=production bundle exec rails r \
'puts Upload.where("url LIKE ?", "%files.example.com%").limit(5).pluck(:url)'
# المراجع المتبقية في cooked للتحميلات المحلية (يجب أن تميل إلى 0)
sudo -E -u discourse RAILS_ENV=production bundle exec rails r \
'db=RailsMultisite::ConnectionManagement.current_db; puts Post.where("cooked LIKE ?", "%/uploads/#{db}/original%").count'
المتصفح
- تبويب الشبكة يعرض الأصول من
files.example.com. - المواضيع القديمة تعرض الصور تحت
https://files.example.com/original/....
النسخ الاحتياطية
- الإدارة → Backups → إنشاء نسخة واحدة؛ تأكد من ظهور كائن جديد في
discourse-backupsعلى R2.
9) التنظيف
عندما تصبح مراجع cooked شبه معدومة:
mv /var/discourse/shared/standalone/uploads /var/discourse/shared/standalone/uploads.bak
mkdir -p /var/discourse/shared/standalone/uploads
chown -R 1000:1000 /var/discourse/shared/standalone/uploads
# بعد بضعة أيام مستقرة
rm -rf /var/discourse/shared/standalone/uploads.bak
قم بتدوير الأسرار (توكن R2 → Object RW؛ كلمة مرور تطبيق SMTP إذا وصلت إلى السجلات في أي وقت).
10) المرة القادمة (دليل اللعب) — مسار R2 أولاً
- قديم → جديد (قاعدة بيانات فقط): وضع القراءة فقط → نسخة احتياطية → استعادة
.sql.gzعبرpsql. - ربط R2 قبل DNS: الدلاء، التوكن (Admin RW → لاحقًا Object RW)، النطاق المخصص، CORS.
env+hooks: مقابض التجزئة +s3:upload_assets؛ إعادة البناء.- تبديل DNS إلى المضيف الجديد.
- نقل التحميلات إلى R2.
- إصلاح المتخلفين (إعادة تشكيل/إعادة تعيين مستهدفة) → إعادة تشغيل سريعة للهجرة.
- Sidekiq يكمل إعادة التشكيل في الخلفية (أو
posts:rebake_uncooked_posts). - النسخ الاحتياطية إلى R2 تم التحقق منها.
- تقوية الأذونات وتدوير الأسرار.
- تنظيف التحميلات المحلية بعد فترة تبريد.
المرفق أ — “التحقق قبل الرفع” ليلي (cron وهمي)
LATEST=$(ls -1t /var/discourse/shared/standalone/backups/default/*.sql.gz | head -n1)
# 1) سلامة gzip
gzip -t "$LATEST" || exit 1
# 2) أرقام صفوف قاعدة البيانات المؤقتة
cd /var/discourse && ./launcher enter app <<'EOS'
sudo -E -u postgres psql -tc "DROP DATABASE IF EXISTS verifydb;"
sudo -E -u postgres createdb verifydb
zcat /shared/backups/default/$(basename '"$LATEST"') | sudo -E -u postgres psql verifydb
sudo -E -u postgres psql -d verifydb -c "select count(*) as topics from topics where deleted_at is null;"
sudo -E -u postgres psql -d verifydb -c "select count(*) as posts from posts where post_type=1 and deleted_at is null;"
sudo -E -u postgres dropdb verifydb
exit
EOS
# 3) فقط بعد ذلك ارفع إلى R2
aws s3 cp "$LATEST" s3://discourse-backups/
المرفق ب — وكيل أمامي بسيط (اختياري)
يمكن لـ VM وكيل عكسي صغير في المقدمة إنهاء TLS وتحويل الطلبات إلى الأصل عبر HTTPS. استبدل عناوين IP بعناوينك الخاصة.
المصدر العلوي: /etc/nginx/conf.d/upstream.conf
upstream origin_forum {
server <ORIGIN_IP>:443;
keepalive 64;
}
الموقع: /etc/nginx/sites-available/forum.conf
server {
listen 80;
listen [::]:80;
server_name forum.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name forum.example.com;
ssl_certificate /etc/letsencrypt/live/forum.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/forum.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_timeout 1d;
client_max_body_size 100m;
add_header Strict-Transport-Security "max-age=31536000" always;
location / {
proxy_pass https://origin_forum;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host forum.example.com;
proxy_ssl_server_name on;
proxy_ssl_name forum.example.com;
# تحقق اختياري:
# proxy_ssl_verify on;
# proxy_ssl_trusted_certificate /etc/ssl/certs/ca-certificates.crt;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_read_timeout 360s;
proxy_send_timeout 360s;
proxy_connect_timeout 60s;
add_header X-Relay relay-min always;
}
location /message-bus/ {
proxy_pass https://origin_forum;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host forum.example.com;
proxy_ssl_server_name on;
proxy_ssl_name forum.example.com;
proxy_buffering off;
proxy_read_timeout 3600s;
}
}
فعّل وأعد التحميل:
ln -sf /etc/nginx/sites-available/forum.conf /etc/nginx/sites-enabled/forum.conf
rm -f /etc/nginx/sites-enabled/default
nginx -t && systemctl reload nginx
فحص سريع:
curl -I https://forum.example.com # توقع HTTP/2 200/302 ورأس X-Relay