Laravel Multi Service: як масштабувати Laravel-застосунок
Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті
Мене звати Ярослав Снісар, я — Backend Tech Lead у продуктовій IT-компанії Futurra Group. Ми працюємо у сфері EdTech та наразі фокусуємося на розробці та просуванні освітнього математичного застосунку MathMaster.
Ми використовуємо Laravel-фреймворк як api-сервіс для доступу наших web та мобайл iOS & Android-застосунків, а також для продукту MathMinds — вебінтерфейсу для спілкування з користувачами в real-time чаті та вирішення математичних завдань.
Документація по Laravel-фреймворку хоч і надає багато теоретичної інформації для реалізації схеми масштабування, проте практичні кроки створення базової архітектури для цього — не такі вже й очевидні.
У цій статті я розповім, як нам вдалося підготуватися та витримати високе навантаження шляхом масштабування laravel-застосунку та переходу до відмовостійкої архітектури взаємодії сервісів.
Чому саме Laravel
Про те, що Laravel є одним з найпопулярніших PHP-фреймворків, вже багато сказано і написано. Та все ж я вважаю за потрібне акцентувати на його перевагах, які допомагають проєкту швидко зростати та реалізовувати велику кількість тестів. Ось найважливіші з них:
- відмінна документація;
- швидкість розробки;
- різноманітність інструментів;
- низький поріг входження для розробників різних рівнів;
- простота архітектури та структури проєкту MVC;
- підтримка сучасних сервісів та бібліотек;
- продуктивність, стабільність та стійкість до відмов при великому навантаженні тощо.
Перед нами стояв виклик — ми досить швидко зростали як за кількістю запитів на застосунок, так і за кількістю функціоналу на проєкті. Оскільки все було реалізовано в рамках одного виділеного сервера, за нашими підрахунками в найближчому майбутньому ми досягнули б максимальних показників за ресурсами.
Обмеживши архітектуру проєкту лише одним застосунком, ми зіткнулися б з рядом таких ризиків:
- Всі сервіси на одному сервері. Перевантаження хоча б одного сервісу впливає на функціонування інших та загалом на продуктивність всього застосунку.
- Відсутня можливість розподіляти навантаження. Як по трафіку між застосунками, так і за ролями. Наприклад, запити може обробляти один фронтенд-застосунок, а черги й таски — інший.
- Відсутня диверсифікація ризиків. При відмові хоча б одного з сервісів весь застосунок буде недоступним.
- Файлове сховище на тому ж диску. Відсутність CDN, резервного копіювання тощо.
- База даних — найслабкіше місце. Внаслідок відсутності реплікації, slave-серверу та розподіленого доступу.
- Недоступність сервісу під час деплою.
- Неможливість горизонтального скейлу проєкту.
Як ми впоралися з ризиками
Розуміючи, що паралельне розгортання другого застосунку з такою ж конфігурацією спричинило б конфлікти (наприклад, cron-завдання виконувались би паралельно), ми сконцентрувалися на таких діях:
- використання зовнішнього сховища файлових даних замість локального;
- перенесення сесії, кеша і черги у зовнішню швидку БД;
- міграція даних в кластер БД з розподіленим доступом;
- запуск планувальника задач в один момент лише на одному сервері;
Оскільки ми активно використовуємо DigitalOcean, будемо описувати інтеграцію сервісів цього провайдера у наш проєкт.
Тож перейдімо до практичних кроків.
Використання зовнішнього сховища файлів
Слід перейти від запису та читання файлів з локального storage-каталогу до віддаленого. Це можуть бути файли користувачів, такі як зображення з соціальних мереж при авторизації через OAuth.
Краще зберігати їх у хмарному сховищі S3 з можливістю CDN. Це забезпечує резервне копіювання даних та швидкий доступ до них шляхом регіонального кешування.
Для цього нам знадобиться цей пакет:
composer require league/flysystem-aws-s3-v3
При використанні DigitalOcean Spaces слід враховувати його відмінності від AWS S3 в плані конфігурації.
Вкажемо конфігурацію:
'spaces_disk' => env('SPACES_DISK', 'spaces'),
app/config/filesystems.php
'disks' => [ 'local' => [ 'driver' => 'local', 'root' => storage_path('app'), ], 'spaces' => [ 'driver' => 's3', 'key' => env('SPACES_ACCESS_KEY_ID'), 'secret' => env('SPACES_SECRET_ACCESS_KEY'), 'region' => env('SPACES_DEFAULT_REGION'), 'bucket' => env('SPACES_BUCKET'), 'url' => env('SPACES_URL'), 'endpoint' => env('SPACES_ENDPOINT'), 'bucket_endpoint' => true, 'visibility' => 'public', ], ];
app/config/filesystems.php
Також в оточення додамо:
SPACES_DISK=spaces SPACES_ACCESS_KEY_ID=<KEY_ID> SPACES_SECRET_ACCESS_KEY=<SECRET_KEY_ID> SPACES_DEFAULT_REGION=fra1 SPACES_BUCKET=<bucket_name> SPACES_ENDPOINT=https://<bucket_name>.fra1.digitaloceanspaces.com SPACES_URL=https://<bucket_name>.fra1.cdn.digitaloceanspaces.com
Нижче поділюся прикладом коду використання завантаження файлу до S3, а також отримання URL.
SPACES_DISK=spaces SPACES_ACCESS_KEY_ID=<KEY_ID> SPACES_SECRET_ACCESS_KEY=<SECRET_KEY_ID> SPACES_DEFAULT_REGION=fra1 SPACES_BUCKET=<bucket_name> SPACES_ENDPOINT=https://<bucket_name>.fra1.digitaloceanspaces.com SPACES_URL=https://<bucket_name>.fra1.cdn.digitaloceanspaces.com
Сесії, кеш та черги
Аби максимально диверсифікувати ризики, потрібно винести сесії, кеш та черги до зовнішньої БД. Який саме сервіс використовувати? Це залежить від ваших вподобань та вимог.
Ми розглянемо REDIS, який буде розміщений окремо на сервісі (дроплеті) з доступом лише через внутрішню мережу та з паролем.
Для цього необхідно виділити окремо конфігурацію під кожен з елементів (див. схему вище). Така архітектура вже дозволяє нам масштабуватися. А в перспективі надасть можливість виділити додаткові REDIS-сервери під кожен окремий елемент.
Конфіг бази даних REDIS
'redis' => [ 'client' => env('REDIS_CLIENT', 'predis'), 'options' => [ 'cluster' => env('REDIS_CLUSTER', 'redis'), 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_') . '_database_'), ], 'default' => [ 'url' => env('REDIS_URL'), 'host' => env('REDIS_HOST', '127.0.0.1'), 'password' => env('REDIS_PASSWORD', null), 'port' => env('REDIS_PORT', '6379'), 'database' => env('REDIS_DB', '0'), ], 'cache' => [ 'url' => env('REDIS_URL'), 'host' => env('REDIS_HOST', '127.0.0.1'), 'password' => env('REDIS_PASSWORD', null), 'port' => env('REDIS_PORT', '6379'), 'database' => env('REDIS_DB', '1'), ], 'session' => [ 'url' => env('REDIS_URL'), 'host' => env('REDIS_HOST', '127.0.0.1'), 'password' => env('REDIS_PASSWORD', null), 'port' => env('REDIS_PORT', '6379'), 'database' => env('REDIS_DB', '2'), ], ];
Конфіг черг матимемо наступний:
'connections' => [ 'redis' => [ 'driver' => 'redis', 'connection' => env('REDIS_QUEUE_CONNECTION', 'default'), 'queue' => env('REDIS_QUEUE', 'default'), 'retry_after' => 90, 'block_for' => null, ], ];
Конфіг кеша буде таким:
'stores' => [ 'redis' => [ 'driver' => 'redis', 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), ], ];
Конфіг сесій виглядатиме так:
'connection' => env('SESSION_CONNECTION', null),
Також додамо в локальне оточення:
REDIS_HOST=<REMOTE_HOST_IP> REDIS_PASSWORD=<REMOTE_HOST_PASSWORD> REDIS_PORT=6379 REDIS_CLIENT=predis CACHE_DRIVER=redis QUEUE_CONNECTION=redis SESSION_DRIVER=redis SESSION_CONNECTION=session REDIS_QUEUE_CONNECTION=default REDIS_CACHE_CONNECTION=cache
Таким чином, ми отримаємо роботу черг, кешу та сесій в різних базах даних з використанням одного зовнішнього REDIS.
Міграція у кластер баз даних
Дані — це найцінніше, що може бути у проєкту. Важливо мати легку доступність до них та можливість їх швидкого відновлення.
Міграція у кластер БД дає низку переваг. Наприклад, у сервісі DigitalOcean Managed Databases є можливість використовувати MySQL 8 у такій комбінації:
- Main Read/Write Node + StandBy Node
- Read-only node 1
- Read-only node 2
У цьому стані ми можемо балансувати навантаження за операціями на запис до основної ноди та читання на інші ноди. Наявність read-only ноди надає можливість роботи з даними, використовуючи важкі запити (наприклад, для аналітики), не впливаючи на основні ноди.
Конфігурація для роботи з БД виглядатиме так:
'connections' => [ 'mysql' => [ 'read' => [ 'host' => [ env('DB_HOST_RO', '127.0.0.1'), env('DB_HOST_RO_2', '127.0.0.1') ], ], 'write' => [ 'host' => [ env('DB_HOST', '127.0.0.1'), ], ], 'sticky' => env('DATABASE_STICKY_MODE', true), 'driver' => 'mysql', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '3306'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'unix_socket' => env('DB_SOCKET', ''), 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', 'prefix' => '', 'prefix_indexes' => true, 'strict' => true, 'engine' => null, 'modes' => [ 'STRICT_TRANS_TABLES', 'NO_ZERO_IN_DATE', 'NO_ZERO_DATE', 'ERROR_FOR_DIVISION_BY_ZERO', 'NO_ENGINE_SUBSTITUTION', ], 'options' => extension_loaded('pdo_mysql') ? array_filter([ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], 'mysql_node_rw' => [ 'driver' => 'mysql', 'url' => env('DATABASE_URL'), 'host' => env('DB_HOST', '127.0.0.1'), 'port' => env('DB_PORT', '3306'), 'database' => env('DB_DATABASE', 'forge'), 'username' => env('DB_USERNAME', 'forge'), 'password' => env('DB_PASSWORD', ''), 'unix_socket' => env('DB_SOCKET', ''), 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', 'prefix' => '', 'prefix_indexes' => true, 'strict' => true, 'engine' => null, 'modes' => [ 'STRICT_TRANS_TABLES', 'NO_ZERO_IN_DATE', 'NO_ZERO_DATE', 'ERROR_FOR_DIVISION_BY_ZERO', 'NO_ENGINE_SUBSTITUTION', ], 'options' => extension_loaded('pdo_mysql') ? array_filter([ PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), ]) : [], ], ];app/config/database.php
Параметри локального оточення:
DB_HOST=<HOST_RW>.b.db.ondigitalocean.com DB_PORT=25060 DB_DATABASE=<DB_NAME> DB_USERNAME=<DB_USER_NAME> DB_PASSWORD=<DB_USER_PASSWORD> DB_HOST_RO=<HOST_READ_ONLY_1>.b.db.ondigitalocean.com DB_HOST_RO2=<HOST_READ_ONLY_2>.b.db.ondigitalocean.com
Важливо також використовувати параметр
DATABASE_STICKY_MODE
, який дозволяє в межах сесії запиту підтримувати те ж саме з’єднання з основною нодою як для читання, так і для запису.
Це допоможе уникнути помилок відсутності перед записом даних та знизити кількість з’єднань.
Планувальник задач
Якщо ви використовуєте консольні команди як окремі заплановані процеси (наприклад, для щоденної генерації звітів), вам потрібно врахувати, що команда буде запускатися стільки разів, скільки у вас є застосунків.
Щоб уникнути цього, необхідно використовувати метод onOneServer()
, який реалізує логіку lock-ключа в загальному кеш-сховищі (у нашому випадку це REDIS). Параметр ключа не дозволяє повторно запускати процес на іншому сервері.
protected function schedule(Schedule $schedule) { $schedule->command('your:task') ->withoutOverlapping() ->runInBackground() ->hourly() ->onOneServer(); }
Виконавши всі вищезазначені пункти, ви готові до різкого збільшення навантаження, легкого та швидкого горизонтального масштабування вашого застосунку, звісно за умови, якщо ваш код не містить неоптимізованих логік 😏
Zero Downtime Deployment
Цей пункт заслуговує на окрему статтю, але я не можу його оминути зараз, адже він необхідний для безперебійного конекту користувачів з мультизастосунками під час оновлення або деплою.
Базово для цього можна використовувати DNS + Failover, а саме, створивши декілька A-записів з однаковим hostname на різні IP-адреси. Таким чином, за умови недоступності одного з серверів під час деплою запити будуть йти на інший.
Проте існують і інші варіанти, наприклад, використання CloudFlare, DigitalOcean Load Balancer, або ж заміна nginx-конфігурації через CI.
З усім тим, ці варіанти на початковому етапі можуть over-спендити та займуть більше часу на конфігурацію.
Баланс розробки та маркетингу
Позаяк ми використовували цей фреймворк як інструмент для реалізації маркетингових MVP-проєктів, то дотримувались стратегії, що дозволила не витрачати багато часу розробників на старті.
Вона передбачала наступні заходи:
- відмова від використання складних абстрактних патернів;
- імплементація швидких технічних рішень з гарантованим результатом;
- доцільність використання нових сервісів без певної експертизи (звісно, якщо їх використання не є ціллю вашого проєкту);
- відокремлення маркетинг-логік від базового функціоналу;
- денормалізація даних тощо.
Це дозволило нам втілити ряд успішних маркетингових тестів, на основі яких команда змогла отримати цінні інстайти для подальшого розвитку продукту та побудувати вдалу економіку.
Замість висновків
Звісно, Laravel — не панацея для масштабування усіх проєктів і може не спрацювати для вас. Проте для нас це був найефективніший шлях, що дозволив вирішити проблему з масштабуванням швидко та легко.
У наступних матеріалах я детальніше розповім про безперервне впровадження на Laravel без відключення (Zero Downtime Deployment), про проблеми, з якими ми зіткнулися та які рішення ми впровадили.
Дякую за увагу! 🏄
9 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів