کانال تلگرام فراسوعضو شوید
بازگشت به نمونه کارها
InfrastructureNext.jsDockerPostgreSQLCaddy

pouyakarimi.ir — از serverless به سروری که خودم اداره می‌کنم

میزبانی و پایگاه داده‌ی همین سایت از Vercel و نئون به سروری در فرانکفورت منتقل شد که خودم اداره‌اش می‌کنم. میانه‌ی زمان تا اولین بایت صفحه‌ی اصلی فارسی: از ۱۳۱۸ به ۵۶۸ میلی‌ثانیه، با ده دور اندازه‌گیری در هر طرف.

کارفرما
سایت خودم — pouyakarimi.ir
دامنه‌ی کار
مهاجرت میزبانی و پایگاه داده، از serverless به سرور اختصاصی
نقش
تنها — معماری، CI، جابه‌جایی و برگشت‌پذیری
سال
۲۰۲۶

این سایت قبلاً روی Vercel اجرا می‌شد و پایگاه داده‌اش در منطقه‌ی us-east-1 نئون بود. حالا داخل یک کانتینر روی سروری در فرانکفورت اجرا می‌شود که خودم اداره‌اش می‌کنم، و PostgreSQL روی همان ماشین است. میانه‌ی زمان تا اولین بایتِ صفحه‌ی اصلی فارسی از ۱۳۱۸ به ۵۶۸ میلی‌ثانیه رسید. همین صفحه‌ای که می‌خوانید، نتیجه‌ی همان کار است.

مسئله

بیشتر خواننده‌های این سایت در ایران هستند. مبدأ serverless بود و پایگاه داده آن‌سوی اقیانوس اطلس، پس هر صفحه برای ساخته‌شدن به ازای هر کوئری یک رفت‌وبرگشت فرااقیانوسی می‌کرد — و صفحه‌ای که فهرست کتاب‌ها را می‌سازد، چند کوئری دارد.

تا وقتی اندازه‌گیری نشود این فقط یک حدس است، برای همین اول اندازه گرفتم: ده دور روی همان مبدأِ زنده، پیش از آن‌که چیزی دست بخورد. صفحه‌ی اصلی فارسی با میانه‌ی ۱۳۱۸ میلی‌ثانیه تا اولین بایت برگشت و صدک ۹۵ آن ۳۱۳۳ میلی‌ثانیه بود. این اندازه‌گیری در خودِ مخزن کنار اندازه‌گیریِ بعدی ذخیره شده است، تا مقایسه یک diff باشد نه یک خاطره.

مسئله‌ی دوم عددی نبود، ساختاری بود. خط فرمان و داشبورد Vercel از ایران در دسترس نیست؛ استقرار فقط به این دلیل کار می‌کرد که GitHub وسط مسیر نشسته بود. هر کاری که مستقیماً به آن پلتفرم نیاز داشت — خواندن لاگ یک بیلد، تنظیم یک متغیر محیطی، یا حتی فهمیدن این‌که استقرار اصلاً انجام شده یا نه — به شبکه‌ی یک نفر دیگر نیاز داشت. سیستمی که نمی‌توانید از جایی که هستید بازرسی‌اش کنید، عملاً سیستمی نیست که اداره‌اش کنید.

محدودیت، خودِ مقصد بود. روی آن سرور از قبل سرویس‌های عملیاتی دیگری اجرا می‌شد: ماله‌تو، محصول خودم، با حساب‌های واقعی کاربران و سوابق مالی‌شان؛ یک استک اتوماسیون n8n؛ و یک VPN. دو هسته، و از قبل درگیر swap. هیچ بخشی از این مهاجرت اجازه نداشت هیچ‌کدام از این‌ها را به هم بریزد.

راهکار

جابه‌جایی به دو گام مستقلاً برگشت‌پذیر تقسیم شد، نه یک گام: اول انتقال میزبانی در حالی که پایگاه داده هنوز دوردست بود، و یک روز بعد انتقال خودِ پایگاه داده. هر گام مسیر برگشت خودش را داشت و روزِ میان آن دو یک پنجره‌ی مشاهده‌ی واقعی بود، نه یک تشریفات.

  • ایمیج‌ها در CI ساخته می‌شوند و از رجیستری کشیده می‌شوند، هرگز روی خود سرور. بیلد Next.js در اوج ۲ تا ۳ گیگابایت حافظه می‌گیرد در برابر حدود ۲٫۴ گیگابایت آزاد، و هر بار هر دو هسته را برای مدت استقرار از همسایه‌ها می‌گرفت.
  • استقرار یک اتصال SSH است، نه بیشتر. نسخه‌ی اول از یک اکشن انتقال فایل و بعد یک اکشن اجرای دستور استفاده می‌کرد و با timeout شبکه می‌مرد — فایروال هر آدرسی را که در سی ثانیه شش اتصال باز کند drop می‌کند، و خودِ انتقال چند اتصال باز می‌کند. حالا فایل compose به‌صورت base64 داخل همان یک اتصال سفر می‌کند.
  • PostgreSQL در همان پروژه‌ی compose و بدون هیچ پورت منتشرشده‌ای اجرا می‌شود، تنظیم‌شده برای ماشینی دوهسته‌ای که از قبل swap می‌کند: ۱۹۲ مگابایت shared buffers و سقف ۲۰ اتصال در برابر استخر ۱۰ تایی اپلیکیشن.
  • Caddy مسئول TLS است و گواهی‌ها را با tls-alpn-01 می‌گیرد — شش ثانیه برای دامنه‌ی آزمایشی، و دوازده ثانیه برای دامنه‌ی اصلی و www بعد از جابه‌جایی.
  • پشتیبان‌گیری شبانه حالا این پایگاه داده را هم می‌گیرد، داخل همان آرشیو رمزنگاری‌شده‌ی خارج از سرور که از قبل برای سرویس‌های دیگر ساخته می‌شد.
  • کارهای زمان‌بندی‌شده به تایمرهای systemd تبدیل شدند، با پسوند صریح UTC روی هر زمان‌بندی.

تصمیم‌های طراحی

اعتبارسنجی روی یک نام واقعی، نه با فلگ --resolve. نقشه می‌گفت پیش از دست‌زدن به DNS، curl را مستقیم به IP جدید بزن. معلوم شد این کار نه «کمی دردسر»، بلکه ناممکن است: این نسخه از Caddy ماژول DNS ندارد، پس DNS-01 در دسترس نیست و برای نامی که DNS آن هنوز به سرور اشاره نمی‌کند هیچ گواهی‌ای نمی‌تواند وجود داشته باشد — هر درخواست روی عدم‌تطابق نام شکست می‌خورد. به‌جایش از یک زیردامنه‌ی موقت واقعی استفاده شد، که آزمون بهتری هم بود: صدور واقعی ACME را تمرین کرد، یعنی همان مرحله‌ای که نقشه‌ی اولیه آن را تا لحظه‌ی حساس آزمون‌نشده باقی می‌گذاشت.

بازیابی از dump، نه از تاریخچه‌ی migration. مخزن چهار migration ثبت‌شده‌ی Prisma دارد و نوزده فایل SQL دستی، چون پورت پایگاه داده از این شبکه فیلتر است و تغییرات schema از راه کنسول انجام می‌شده. پس تاریخچه‌ی ثبت‌شده‌ی Prisma نمی‌تواند schema واقعی را بازسازی کند — بازسازی مبتنی بر migrate پایگاه داده‌ای می‌سازد که به‌شکل ظریفی غلط است، نه پایگاه داده‌ای که با صدای بلند خطا بدهد. بازیابی با diff گرفتن از schema تأیید شد، نه با شمارش ردیف‌ها: شمارش، ایندکس یا constraint یا default یا sequenceِ گم‌شده را نمی‌بیند.

هم‌سان‌کردن collation پیش از حرکت هر داده‌ای. PostgreSQL جدید را روی en_US.utf8 تنظیم کرده بودم، با این فرض که مبدأ از یک locale مبتنی بر glibc استفاده می‌کند. وقتی مستقیم از خودش پرسیدم، جواب C.UTF-8 بود. collation داخل هر ایندکس متنی کامپایل می‌شود و ترتیب نتیجه‌ی هر ORDER BY روی ستون متنی را تعیین می‌کند، پس ناهم‌سانی‌اش بی‌سروصدا ترتیب همه‌ی فهرست‌های کتاب و مقاله‌ی فارسی را عوض می‌کند و هیچ چیزی گزارش نمی‌دهد — نه خطایی، نه هشداری، و نه تستی که بگیردش. volume پیش از ورود حتی یک ردیف دوباره ساخته شد.

خطایی که هیچ چیز گزارش نمی‌کند، خطرناک‌ترین است. چهار اشکال جداگانه در این مهاجرت هرکدام خودشان را چیز دیگری نشان دادند: یک محدودیت نرخ در فایروال شبیه سرورِ مرده، یک باگ دستکاری مسیر شبیه خطای پیکربندی compose، یک حفره‌ی MTU در IPv6 شبیه از دسترس خارج‌شدن رجیستری، و ناهم‌سانی collation شبیه هیچ. هرکدام با اندازه‌گیری خودِ مکانیزم پیدا شدند، نه با باور کردنِ روایتی که پیام خطا از خودش ارائه می‌داد.

رفع شکاف استقرار در پروکسی، نه در ارکستراسیون. فقط یک کانتینر اپلیکیشن وجود دارد، پس هر استقرار آن را پیش از آن‌که جایگزینش اتصال بپذیرد متوقف می‌کند — یک تا دو ثانیه که هیچ‌چیز روی آن پورت گوش نمی‌دهد. استقرارهای Vercel اتمیک‌اند؛ تعویض یک کانتینر تک‌نمونه‌ای نیست، و هیچ‌جای نقشه این تفاوت را پیش‌بینی نکرده بود. خودش را این‌طور نشان داد: چهارده خطای ۵۰۲ در یک بازه‌ی دوثانیه‌ای، همه از یک بازدیدکننده‌ی واقعی، ساعت‌ها بعد از این‌که یک پنجره‌ی پایش گزارش داده بود همه‌چیز سالم است. راه‌حل این بود که Caddy تا ده ثانیه‌ی محدود تلاش را تکرار کند، تا استقرار به‌جای یک صفحه‌ی خراب، فقط یک درخواست کُند هزینه داشته باشد — محدود، به‌عمد، چون قطعی واقعی باید خطا را به تأخیر بیندازد نه این‌که هر مرورگری را معلق نگه دارد. با عبور دادن سی درخواست هم‌زمان از میان یک بازسازی اجباری کانتینر تأیید شد: سی پاسخ ۲۰۰، بدون هیچ شکستی.

نوشتن مسیر برگشت، پیش از دَرِ یک‌طرفه. انتقال پایگاه داده همان لحظه‌ای است که نوشتن‌ها دیگر در دو جا وجود ندارند. اسکریپتی که همه‌چیز را به مسیر برعکس برمی‌گرداند پیش از انتقال نوشته شد، نه وسط یک حادثه، و از خواندن مقصدش از همان متغیر محیطی‌ای که بعد از انتقال نامِ مبدأ را دارد امتناع می‌کند — چون خواندنش یعنی بازگرداندن پایگاه داده روی خودش. به DNS هم دست نمی‌زند: رکوردها فقط وقتی برگردانده می‌شوند که شمارش ردیف‌های دو طرف بخواند.

هندلر خطایی که خطا را پنهان کند، خودش باگ است. یک مسیر تحلیل داده به یک catch {} خالی ختم می‌شد. این درباره‌ی پاسخ تصمیم درستی است — قطعیِ یک سیستم لاگ هرگز نباید چیزی را که بازدیدکننده می‌گیرد عوض کند — اما باعث می‌شد قطعیِ کامل از «هیچ اتفاقی نیفتاده» قابل تشخیص نباشد، و دو روز فرضیه‌های محتمل و غلط هزینه داشت. افزودن یک خط که علت زیرین را چاپ می‌کرد، مسئله را در همان درخواست بعدی نام برد: صحبت‌کردن TLS با یک پورت غیررمزنگاری‌شده، چون پروکسی هدر X-Forwarded-Proto: https را می‌گذارد و اپلیکیشن آدرس داخلی‌اش را از روی همان درخواست ورودی می‌ساخت. بی‌صدا شکست‌خوردن برای کاربر و بی‌صدا شکست‌خوردن برای اپراتور دو تصمیم جدا هستند، و این‌جا یکی گرفته شده بود.

نتیجه

مسیرقبل (میانه)بعد (میانه)تغییر
/fa۱۳۱۸ ms۵۶۸ ms۵۷٪ کمتر
/fa/blog۶۸۱ ms۱۷۶ ms۷۴٪ کمتر
/fa/books۵۲۰ ms۲۱۳ ms۵۹٪ کمتر

ده دور برای هر مسیر، در هر دو طرف، بدون هیچ خطایی. صدک ۹۵ صفحه‌ی اصلی فارسی از ۳۱۳۳ به ۹۷۲ میلی‌ثانیه رسید، و این از میانه مهم‌تر است: کندترین درخواست‌ها همان‌هایی هستند که بازدیدکننده رهایشان می‌کند.

  • هیچ حساب پلتفرمی روی مسیر بحرانی نیست. استقرار، لاگ‌ها، متغیرهای محیطی و پایگاه داده همه از همان‌جایی که کار انجام می‌شود در دسترس‌اند.
  • پشتیبان‌ها «قابل بازیابی» تأیید شده‌اند، نه صرفاً «موجود» — با رمزگشایی آرشیو و مقایسه‌ی جدول‌به‌جدولش با پایگاه داده‌ی زنده، نه با دیدن این‌که فایلی وجود دارد.
  • همسایه‌ها دست‌نخورده ماندند. نه راه‌اندازی مجددی، نه رقابتی بر سر منابع، و نه اثری روی پشتیبان‌گیری خودشان.
  • مسیر برگشت در تمام مدت واقعی بود: دو گام برگشت‌پذیر، اسکریپت بازگردانی که از قبل نوشته شده بود، و منطقه‌ی DNS قبلی که سر جایش نگه داشته شد.

پشته‌ی فناوری

  • اپلیکیشن: Next.js 16 (App Router) با خروجی standalone، Node 22 روی Alpine، کاربر غیرروت، بیلد چندمرحله‌ای
  • ارکستراسیون: Docker Compose با نام پروژه‌ی صریح، منتشرشده فقط روی loopback
  • پایگاه داده: PostgreSQL 17 روی شبکه‌ی داخلی compose، بدون هیچ پورت منتشرشده، با C.UTF-8
  • لبه: Caddy — TLS با tls-alpn-01، تلاش مجدد محدود در شکاف استقرار، و انتقال IP واقعی کاربر
  • CI/CD: GitHub Actions ایمیج را می‌سازد و به GHCR می‌فرستد؛ کار استقرار یک اتصال SSH است
  • زمان‌بندی: تایمرهای systemd با تقویم صریح UTC
  • پشتیبان‌گیری: pg_dump شبانه داخل آرشیو رمزنگاری‌شده‌ی خارج از سرور، با تأیید بایت‌به‌بایت پس از آپلود
  • اندازه‌گیری: ابزار سنجش TTFB داخل مخزن، ده دور برای هر مسیر، قبل و بعد
pouyakarimi.ir — از serverless به سروری که خودم اداره می‌کنم | پویا کریمی