در این مطلب، قدم‌به‌قدم توضیح می‌دهم چطور چند وب‌سایت پربازدید یک مشتری را از Drupal 8 به یک زیرساخت مبتنی بر Kubernetes منتقل کردیم. در ادامه، دقیق‌تر می‌گویم چطور یک monolith را به Kubernetes آوردیم، performance را بهتر کردیم، با autoscaling هزینه زیرساخت را پایین آوردیم و در برابر DDoS attack هم آمادگی ایجاد کردیم. 🚀

چرا اصلاً باید یک monolith را به Kubernetes منتقل کرد؟ هنوز هم سیستم‌های قدیمی و سنگین زیادی در سرویس‌های واقعی وجود دارند و این پروژه هم از همان جنس بود. وقتی مالک این وب‌سایت‌های Drupal برای پشتیبانی زیرساخت سراغ ما آمد، پیشنهاد اول ما migration به Kubernetes بود. طبیعی بود که با همان سؤال همیشگی روبه‌رو شویم: «اصلاً چه نیازی به این کار داریم؟ الان هم سیستم بد نیست.» سؤال کاملاً منطقی‌ای است؛ پس بیایید مهم‌ترین دلیل‌هایی را که در این پروژه دیدیم، مرور کنیم:

مدیریت deployment و server management خیلی به یک administrator وابسته بود.
عملاً هیچ automation واقعی وجود نداشت.
برگرداندن سرویس بعد از crash یا اختلال، دردسر زیادی ایجاد می‌کرد. 😓

درست است که برای حل مسئله‌هایی مثل autoscaling، self-healing و portability الزاماً مجبور نیستید سراغ Kubernetes بروید. می‌شود برایشان یک راهکار اختصاصی هم طراحی کرد. اما مشکل اینجاست که پیاده‌سازی و نگهداری چنین راهکاری از شما زمان و انرژی زیادی می‌گیرد. تازه در نهایت هم ممکن است باز به نوعی vendor lock-in برسید، در حالی که Kubernetes امروز به یک استاندارد de facto در زیرساخت مدرن تبدیل شده است. برای همین، از نگاه بلندمدت، K8s در نگهداری و scale کردن انتخاب منطقی‌تری است.

پس migration به Kubernetes می‌تواند یک راه‌حل واقعی برای دردسرهای مالکین monolith باشد. این کار فقط برای حذف کارهای دستی نیست؛ بلکه به پایداری بیشتر سیستم، automation بهتر فرایندها و البته صرفه‌جویی در منابع هم کمک می‌کند. سؤال بعدی هم کاملاً طبیعی است: «آیا واقعاً می‌شود یک monolith مشخص، مثل همین Drupal-based setup، را روی Kubernetes اجرا کرد؟» پاسخ کوتاه این است: بله، اما… فقط منتقل کردن monolith به Kubernetes بدون درنظر گرفتن جزئیات، معمولاً نتیجه خوبی ندارد و حتی می‌تواند هزینه cloud resources را به‌شدت بالا ببرد.

چرا؟ چون علاوه بر هزینه منابعی که باید برای هم‌سطح شدن با dedicated server قبلی تهیه کنید، باید overhead مربوط به اجزای Kubernetes، load balancerها و موارد مشابه را هم پرداخت کنید. واضح است که این سناریوی مطلوبی نیست. از طرف دیگر، برای زنده ماندن در زمان traffic spike هم مجبور می‌شوید زیرساخت را بیش از حد واقعی provision کنید. بنابراین، چالش‌های اصلی در این پروژه معمولاً به سه بخش برمی‌گردند: هزینه منابع، performance خود Drupal، و نحوه جای‌گذاری همه componentها.

وقتی همه چیز فقط روی یک سرور اجرا می‌شد، معماری خیلی ساده به نظر می‌رسید، درست است؟

معماری یک monolith معمولی Drupal 8 حدود یک دهه پیش منتشر شد و یک monolith کلاسیک مبتنی بر PHP است. content و configuration را در یک MySQL database نگه می‌دارد و file system هم شامل directoryهای مربوط به moduleها، static assetها و فایل‌های دیگر است. در نهایت یک web server مثل nginx وظیفه دارد همه این‌ها را به کاربر سرویس بدهد. 📦

فعلاً از این‌که چرا از چنین نسخه قدیمی‌ای از Drupal استفاده شده بود می‌گذریم؛ چون این یک تصمیم business بوده و ما هم نمی‌توانستیم آن را عوض کنیم. اما حتی اگر Drupal 8 را هم کنار بگذاریم، باید قبول کنیم این سناریو اصلاً عجیب نیست. خیلی وقت‌ها با setupهای monolith کلاسیک روبه‌رو می‌شویم که همه چیز روی یک سرور، به‌صورت دستی کانفیگ شده و database هم همان‌جا کنار application قرار گرفته است. این سیستم‌ها هنوز هم وجود دارند و گاهی نگهداری آن‌ها بر عهده ما می‌افتد.

در نسخه‌های کمی پیشرفته‌تر، وقتی آسیب reputational ناشی از downtime جدی می‌شود، این setup ساده را با یک backup server تکمیل می‌کنند. در این حالت، در زمان اضطراری می‌توان با استفاده از HAProxy یا راهکار مشابه، traffic را به آن سرور منتقل کرد:

اما داشتن یک cold backup server یعنی آن سرور بیشتر وقت‌ها بیکار می‌ماند و فقط هزینه اضافی برای مشتری تولید می‌کند. راستش این تنها مشکل هم نیست. اگر بخواهیم بدترین سناریوی یک setup قدیمی برای اجرای legacy software را فهرست کنیم، با این موارد روبه‌رو می‌شویم:

ناهمگونی زیرساخت: ممکن است سرورها نسخه‌های متفاوتی از OS یا software داشته باشند.
No health checks: عملاً نمی‌دانید برنامه واقعاً درست کار می‌کند یا نه.
آپدیت‌ها سخت و زمان‌بر هستند.
No CI/CD.
Scaling فقط vertical است؛ یعنی فقط می‌شود سرور را قوی‌تر کرد، نه این‌که سرور جدید اضافه کرد.
Serviceها از هم جدا نیستند.
Failover به‌صورت دستی انجام می‌شود.
No infrastructure automation.
محیط test شما کاملاً با production فرق دارد.
performance پایین است.
برای hardware بیش از حد پول می‌دهید.

البته لازم نیست همه این محدودیت‌ها دقیقاً در همه پروژه‌ها وجود داشته باشد، اما این فهرست خیالی نیست؛ چیزهایی است که واقعاً در عمل دیده‌ایم. حالا می‌خواهیم ببینیم migration به Kubernetes چطور می‌تواند این چالش‌ها را پوشش بدهد. 🌟

قبلاً درباره این‌که «چرا Kubernetes؟» صحبت کردم، اما بد نیست دوباره تأکید کنم که همه مشکلات بالا را می‌شود بدون Kubernetes هم حل کرد. مثلاً در AWS می‌توان برای virtual machineها autoscaling تنظیم کرد و با load balancer traffic را توزیع کرد. یا می‌شود application را داخل Docker بسته‌بندی کرد و با نوعی automation روی VMها deploy کرد. با این حال، من این مسیر را برای توضیح عمیق‌تر به بحث دیگری می‌سپارم و در اینجا سراغ K8s می‌روم.

Migrating to Kubernetes

Step 1. Building

اولین قدم در مسیر migration به یک environment مبتنی بر cloud Kubernetes، فرایند build است. در ابتدا باید یک Dockerfile بنویسیم. واکنش طبیعی این است که از image رسمی Drupal در Docker Hub استفاده کنیم. اما این انتخاب چندان خوب نیست: image خیلی بزرگ است و Apache web server را هم همراه خودش دارد، مثل CMD [“apache2-foreground”]. علاوه بر این، چیزهای زیادی داخل image هست که در خروجی نهایی لازم نداریم؛ مثل PHPIZE_DEPS که شامل gcc و composer است. همین موارد، سطح security image را هم پایین می‌آورند.

پس بهتر است از یک base container استفاده کنیم؛ توزیع‌های آشنای Linux مثل Ubuntu یا Debian کاملاً کافی‌اند و بعد هر چیزی را که لازم داریم، در مرحله build نصب کنیم. این کار هم size نهایی image را به‌طور محسوسی کم می‌کند و هم کنترل دقیق‌تری روی dependencyها به ما می‌دهد. نمونه Dockerfile می‌تواند چیزی شبیه این باشد:

FROM ubuntu:22.04 RUN apt-get update && apt-get install -y  php8.1-fpm  php8.1-cli  php8.1-mysql  php8.1-gd  php8.1-curl  php8.1-intl  php8.1-bcmath  php8.1-soap  php8.1-zip  php8.1-xml  curl  git  unzip  zip RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer COPY . /var/www/html/ RUN chown -R www-data:www-data /var/www/html/sites/default/files  && chmod -R 775 /var/www/html/sites/default/files

اگر چند وب‌سایت مختلف برای مشتری داشته باشیم، باید تعداد زیادی Dockerfile را نگهداری کنیم. حالا اگر لازم شود یک تغییر در Dockerfile اعمال شود، باید تک‌تک repositoryها را باز کنیم و همان update را همه‌جا انجام دهیم. این موضوع احتمال خطا را بالا می‌برد. برای حل این مشکل، می‌شود از werf به‌عنوان ابزار اصلی build استفاده کرد؛ چون اجازه می‌دهد در فرایند build از template و loop استفاده کنیم:

{{- define "site_build" }} {{- $project := index . 1 }} {{- $branchtag := index . 2 }} {{- $arg_arr := . }} {{- with index . 0 }} --- # Backend image. Drupal image: drupal-{{ $project }} fromImage: drupal-base-compiled

وقتی container مربوط به Drupal آماده شد، باید Helm chartها را هم اضافه کنیم. Pod ما سه container خواهد داشت: nginx، php و php-fpm-exporter. حتماً دومی را هم داخل Pod بگذارید، چون بعداً به آن نیاز خواهید داشت. nginx هم static fileها را سرویس می‌دهد، با مدیریت requestهای در حال اجرا، shutdown نرم backend را ممکن می‌کند و درخواست‌ها را با fastcgi_pass به container مربوط به PHP proxy می‌کند. 🛠️

نکته مهم: اگر nginx را به یک Pod جدا منتقل کنید و آن Pod چندین Pod PHP را سرویس بدهد، ممکن است هنگام deployment برنامه خطای 500 بگیرید. این اتفاق زمانی رخ می‌دهد که یک request در حال پردازش باشد و backend هم‌زمان در حال update شدن باشد.

حالا که Docker imageها و Helm chartها آماده شده‌اند، چند مشکل را از فهرست‌مان حذف کردیم؛ 4 مورد از 11 مورد:

ناهمگونی‌ها حذف شدند؛ چون dependencyها حالا داخل Dockerfile تعریف شده‌اند.
Health checkها اضافه شده‌اند.
Updateها خودکار شده‌اند.
CI راه‌اندازی شده است.

در بخش CI هم werf کار را خیلی ساده‌تر می‌کند، چون هم برای build و هم برای deploy به‌کار می‌رود. به‌جای یک pipeline پیچیده شامل docker build، helm install و کارهای مشابه، فقط به این سه دستور نیاز دارید:

werf build
werf deploy
werf cleanup

ضمناً در فرایند werf cleanup، این ابزار به registry وصل می‌شود و هر Docker imageای را که دیگر در cluster استفاده نمی‌شود، حذف می‌کند.

Step 2. Handling static assets

فایل‌های static قبلاً روی همان سروری نگهداری می‌شدند که instance مربوط به Drupal را میزبانی می‌کرد. حالا باید این assetها را بین چند replica توزیع کنیم. برای این کار سه راه اصلی وجود دارد:

ساده‌ترین راه این است که یک NFS راه‌اندازی کنیم و آن را مستقیم داخل Podها mount کنیم.
اگر بخواهید setup پیچیده‌تری داشته باشید، می‌توانید Ceph را deploy کنید و از CephFS استفاده کنید.
یا این‌که توسعه‌دهنده‌ها یک module بسازند که با S3 کار کند.

ما ساده‌ترین گزینه، یعنی NFS را انتخاب کردیم و فرض گرفتیم که بعداً می‌توانیم همه چیز را به S3 منتقل کنیم. 💡

نکته: اگر می‌خواهید دید بهتری درباره مدیریت فایل‌ها هنگام migration نرم‌افزار به Kubernetes داشته باشید، بهتر است best practiceهای مربوط به What to do with files را هم مرور کنید.

Step 3. Getting things running in Kubernetes

الان زیرساخت ما از این بخش‌ها تشکیل شده است:

یک Kubernetes cluster؛
MySQL/MariaDB databaseها روی virtual machineها یا به‌صورت managed solution؛
NFS که روی Podها mount شده است.

نکته: php-admin Podها دقیقاً شبیه php Podهایی هستند که درخواست‌ها را سرویس می‌دهند، با این تفاوت که اجازه write روی database دارند. این Podها توسط editorها استفاده می‌شوند تا content را به وب‌سایت اضافه کنند.

وقتی همه componentها deploy شدند، برنامه ما عملاً بالا آمده است. اما بزرگ‌ترین مشکل این معماری فعلی این است که زیرساخت خیلی پرهزینه می‌شود. دلیلش هم این است که Drupal به‌صورت پیش‌فرض performance خوبی ندارد و برای تحمل load ممکن است مجبور شوید تعداد زیادی backend Pod scale کنید؛ چیزی که مستقیم روی هزینه نهایی اثر می‌گذارد. البته کمی بعد دوباره سراغ performance می‌رویم. 📈

نکته: قبلاً best practiceهایی هم برای اجرای applicationها در Kubernetes نوشته‌ایم که همه mechanismهای مهم K8s برای اجرای روان نرم‌افزارهای containerized را پوشش می‌دهد. حالا هنوز هم…