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-серверу та розподіленого доступу.
  • Недоступність сервісу під час деплою.
  • Неможливість горизонтального скейлу проєкту.

Single App

Як ми впоралися з ризиками

Розуміючи, що паралельне розгортання другого застосунку з такою ж конфігурацією спричинило б конфлікти (наприклад, cron-завдання виконувались би паралельно), ми сконцентрувалися на таких діях:

  • використання зовнішнього сховища файлових даних замість локального;
  • перенесення сесії, кеша і черги у зовнішню швидку БД;
  • міграція даних в кластер БД з розподіленим доступом;
  • запуск планувальника задач в один момент лише на одному сервері;

Multi App

Оскільки ми активно використовуємо DigitalOcean, будемо описувати інтеграцію сервісів цього провайдера у наш проєкт.

Тож перейдімо до практичних кроків.

Використання зовнішнього сховища файлів

Слід перейти від запису та читання файлів з локального storage-каталогу до віддаленого. Це можуть бути файли користувачів, такі як зображення з соціальних мереж при авторизації через OAuth.

Краще зберігати їх у хмарному сховищі S3 з можливістю CDN. Це забезпечує резервне копіювання даних та швидкий доступ до них шляхом регіонального кешування.

Для цього нам знадобиться цей пакет:

composer require league/flysystem-aws-s3-v3

CDN

При використанні 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
.env

Нижче поділюся прикладом коду використання завантаження файлу до 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 server

Для цього необхідно виділити окремо конфігурацію під кожен з елементів (див. схему вище). Така архітектура вже дозволяє нам масштабуватися. А в перспективі надасть можливість виділити додаткові 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'),
        ],
];
app/config/database.php

Конфіг черг матимемо наступний:

'connections' => [
        'redis' => [
            'driver' => 'redis',
            'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
            'queue' => env('REDIS_QUEUE', 'default'),
            'retry_after' => 90,
            'block_for' => null,
        ],
    ];
app/config/queue.php

Конфіг кеша буде таким:

'stores' => [
        'redis' => [
            'driver' => 'redis',
            'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
        ],
    ];
app/config/cache.php


Конфіг сесій виглядатиме так:
'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
.env


Таким чином, ми отримаємо роботу черг, кешу та сесій в різних базах даних з використанням одного зовнішнього REDIS.

Міграція у кластер баз даних

Дані — це найцінніше, що може бути у проєкту. Важливо мати легку доступність до них та можливість їх швидкого відновлення.

Міграція у кластер БД дає низку переваг. Наприклад, у сервісі DigitalOcean Managed Databases є можливість використовувати MySQL 8 у такій комбінації:

  • Main Read/Write Node + StandBy Node
  • Read-only node 1
  • Read-only node 2

Database

У цьому стані ми можемо балансувати навантаження за операціями на запис до основної ноди та читання на інші ноди. Наявність 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
.env


Важливо також використовувати параметр DATABASE_STICKY_MODE, який дозволяє в межах сесії запиту підтримувати те ж саме з’єднання з основною нодою як для читання, так і для запису.

Це допоможе уникнути помилок відсутності перед записом даних та знизити кількість з’єднань.

Планувальник задач

Якщо ви використовуєте консольні команди як окремі заплановані процеси (наприклад, для щоденної генерації звітів), вам потрібно врахувати, що команда буде запускатися стільки разів, скільки у вас є застосунків.

Щоб уникнути цього, необхідно використовувати метод onOneServer(), який реалізує логіку lock-ключа в загальному кеш-сховищі (у нашому випадку це REDIS). Параметр ключа не дозволяє повторно запускати процес на іншому сервері.

protected function schedule(Schedule $schedule)
    {
        $schedule->command('your:task')
            ->withoutOverlapping()
            ->runInBackground()
            ->hourly()
            ->onOneServer();
    }
app/Console/Kernel.php


Виконавши всі вищезазначені пункти, ви готові до різкого збільшення навантаження, легкого та швидкого горизонтального масштабування вашого застосунку, звісно за умови, якщо ваш код не містить неоптимізованих логік 😏

Zero Downtime Deployment

Цей пункт заслуговує на окрему статтю, але я не можу його оминути зараз, адже він необхідний для безперебійного конекту користувачів з мультизастосунками під час оновлення або деплою.

Базово для цього можна використовувати DNS + Failover, а саме, створивши декілька A-записів з однаковим hostname на різні IP-адреси. Таким чином, за умови недоступності одного з серверів під час деплою запити будуть йти на інший.

Проте існують і інші варіанти, наприклад, використання CloudFlare, DigitalOcean Load Balancer, або ж заміна nginx-конфігурації через CI.

З усім тим, ці варіанти на початковому етапі можуть over-спендити та займуть більше часу на конфігурацію.

Баланс розробки та маркетингу

Позаяк ми використовували цей фреймворк як інструмент для реалізації маркетингових MVP-проєктів, то дотримувались стратегії, що дозволила не витрачати багато часу розробників на старті.

Вона передбачала наступні заходи:

  • відмова від використання складних абстрактних патернів;
  • імплементація швидких технічних рішень з гарантованим результатом;
  • доцільність використання нових сервісів без певної експертизи (звісно, якщо їх використання не є ціллю вашого проєкту);
  • відокремлення маркетинг-логік від базового функціоналу;
  • денормалізація даних тощо.

Це дозволило нам втілити ряд успішних маркетингових тестів, на основі яких команда змогла отримати цінні інстайти для подальшого розвитку продукту та побудувати вдалу економіку.

Замість висновків

Звісно, Laravel — не панацея для масштабування усіх проєктів і може не спрацювати для вас. Проте для нас це був найефективніший шлях, що дозволив вирішити проблему з масштабуванням швидко та легко.

У наступних матеріалах я детальніше розповім про безперервне впровадження на Laravel без відключення (Zero Downtime Deployment), про проблеми, з якими ми зіткнулися та які рішення ми впровадили.

Дякую за увагу! 🏄

👍ПодобаєтьсяСподобалось17
До обраногоВ обраному6
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Розуміючи, що паралельне розгортання другого застосунку з такою ж конфігурацією спричинило б конфлікти (наприклад, cron-завдання виконувались би паралельно)

Можно кроны только на одном сервере из кластера запускать

Звісно можна, але тоді всі крон-таски працювали лише б на одному сервері та якщо він буде не доступним, то таски не відпрацюють. Саме тому краще виконувати крон на всіх, але з методом  ->onOneServer()

Молодці!
Покращувати потрібно завжди і добре що у продукту є такий попит

слабое место в ларавеле это пхп

Да, если бы из него убрать дибильный немй конвешн переменных типа $name добавить строгую типизацию и чтобы компилился был бы нормальный язык

Слабое место в %FRAMEWORK% это %LANGUAGE%

синтаксис шарпа нормальный, если не брать в счет сахар новых свитчей и других

Коментар порушує правила спільноти і видалений модераторами.

Коментар порушує правила спільноти і видалений модераторами.

Коментар порушує правила спільноти і видалений модераторами.

Коментар порушує правила спільноти і видалений модераторами.

Коментар порушує правила спільноти і видалений модераторами.

Чому?) Мені навпаки подобається

Підписатись на коментарі