Робота з сигналами в Laravel

Робота з сигналами в Laravel
Переклад 10 хв. читання
15 вересня 2023

Черги на Laravel зупиняються плавно. Що це означає?

Під час розгортання ви, ймовірно, перезапускаєте ваших обробників черги за допомогою чогось на кшталт artisan queue:restart або supervisorctl restart <ім'я обробника>.

Laravel завбачливо підмітив, що нам не подобається, коли завдання, яке знаходиться у процесі виконання, раптово вбивається. Laravel перевіряє, чи воркер повинен зупинитися. Якщо так, то він чекає, поки поточне завдання буде завершено, а потім завершує роботу.

Він робить це за допомогою сигналів.

Сигнали?

Так, сигнали. Сигнали - це події, які процес може слухати і вирішувати, як на них реагувати. Наприклад, натискання ctrl+c у вашому терміналі надсилає SIGINT (переривання) поточному процесу. Зазвичай це призводить до зупинки процесу.

Ви також можете скористатися командою kill для надсилання сигналу (будь-якого сигналу). Надсилання SIGINT за допомогою kill виглядає наступним чином:

# Ці команди еквівалентними
kill -2 <process-id>`
kill -s INT <process-id>

Команда SIGKILL (kill -9 <process-id> або kill -s KILL <process-id>) є спеціальною - вона негайно вбиває процес. У процесу не буде ніякого вибору.

Розглянемо, як Laravel реалізує перезапуски черги, і як ми можемо використати цю ідею в нашому власному коді.

Черги в Laravel

У бібліотеці Queue Laravel реалізовано сигнали для плавної зупинки воркерів. При отриманні сигналу SIGTERM або SIGQUIT воркер чекає на завершення поточного завдання перед тим, як зупинитися.

Таким чином, робота не переривається посеред обробки - вона має шанс завершитися.

Це робиться за допомогою простої булевої змінної. По суті, Worker - це просто цикл while() {}. На кожній ітерації він перевіряє цю змінну і зупиняється, якщо $shouldQuit == true.

Тут ми бачимо, що Laravel слухає сигнали SIGTERM і SIGQUIT. Прослуховування цих сигналів налаштовується безпосередньо перед початком вищезгаданого циклу while().

Нічого надзвичайного!

SIGINT

Очевидно, що команди terminate (SIGTERM) і quit (SIGQUIT) мають очевидні назви - вони хочуть завершити процес (різниця в тому, що SIGQUIT генерує core dump).

А як щодо SIGINT (переривання)?

Це сигнал, що надсилається через ctrl+c. Зазвичай він використовується лише в інтерактивному терміналі - коли ми знаходимося біля клавіатури (для локальної розробки або для тих одноразових завдань у продакшні, які вам дійсно варто автоматизувати).

Ви помітите, що SIGINT не слухається в обробнику черги Laravel! Замість цього цим займається PHP, і він просто завершує все, що виконується. Отже, це не є способом гарного завершення процесу!

Розглянемо на прикладі. Я створив завдання з назвою LongJob, яке просто спить 10 секунд:

<?php

namespace App\Jobs;

use ...

class LongJob implements ShouldQueue
{
    use ...

    public function handle(): void
    {
        Log::info("starting LongJob ".$this->job->getJobId());
        Sleep::for(10)->seconds();
        Log::info("finished LongJob ".$this->job->getJobId());
    }
}

У мене був відкритий термінал із запущеним php artisan queue:work. Потім я відправив це завдання і швидко натиснув ctrl+c. Журнали показали, що робота почалася, але так і не завершилася!

[2023-06-28 14:19:09] local.INFO: starting LongJob 1 

Якщо замість цього я надішлю сигнал SIGTERM, програма завершить завдання і припинить роботу:

# Start a worker
php artisan queue:work

# Find the process ID
ps aux | grep queue:work

# Dispatch a job, and then 
# kill the  worker with SIGTERM
# Process ID 69679 in my case
kill -s TERM 69679

Ми бачимо, що завдання буде виконано до того, як процес завершиться! Однак програма не спала вказані 10 секунд. Більше про це нижче!

[2023-06-28 14:19:09] local.INFO: starting LongJob 2  
[2023-06-28 14:19:11] local.INFO: finished LongJob 2 

Плавні перезапуски в продакшені

Обробники черги, як правило, контролюються процесом моніторингу, таким як Supervisor. Він перезапускає будь-який процес, який зупинився несподівано.

Воркери Laravel користується цим, припиняючи роботу у багатьох випадках (умови помилок, запуск artisan queue:restart тощо), оскільки припускає, що Воркер буде перезапущений, коли це буде потрібно.

Супервізор і йому подібні зазвичай зупиняють процес, посилаючи сигнал SIGTERM, а потім чекають, поки процес сам граціозно завершить свою роботу (що Laravel зробить сам, як описано вище).

Зазвичай, Supervisor (або інша програма) дає процесу певну кількість секунд на завершення роботи. Якщо цей час спливає, то надсилається SIGKILL, і процес примусово зупиняється (оскільки процеси не можуть ігнорувати SIGKILL).

У Supervisor таймаут встановлюється за допомогою параметрів stopwaitsecs. У прикладі з документації Laravel цей параметр встановлено на одну годину (у секундах). Ви можете зменшити це значення, якщо ваші завдання виконуються недовго і не потребують години для завершення.

Використання сигналів у нашому коді

Подивімось, як реалізувати сигнали самостійно!

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

Перше, що ми побачимо, це те, як сигнал може негайно зупинити процес (запущену команду).

Зверніть увагу, що я буду використовувати терміни "процес" і "команда" як взаємозамінні. Запуск такої команди, як php artisan, запускає процес PHP. Цей процес запускає artisan, який завантажує фреймворк, запускає нашу команду, і так далі, і тому подібне.

Я створив команду LongCommand і змусив її спати 10 секунд (так само як і LongJob).

public function handle()
{
    $this->info('starting long command: '.now()->format('H:i:s'));

    Sleep::for(10)->seconds();

    $this->info('finished long command: '.now()->format('H:i:s'));
}

Якщо я запускаю її через artisan на тривалий час і натискаю ctrl+c, вона негайно зупиняється:

$ php artisan longtime   
starting long command 14:27:59
^C%   

Не було виведено жодного повідомлення про завершення виконання команди!

Пастка

Ми можемо "перехопити" - прослухати - сигнал. Це дозволяє нам виконати код до завершення команди.

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

public function handle()
{
    $this->trap(
      [SIGINT, SIGTERM], 
      fn($s) => $this->info('signal received: ' . $s)
    );

    $this->info('starting long command: '.now()->format('H:i:s'));

    Sleep::for(30)->seconds();

    $this->info('finished long command: '.now()->format('H:i:s'));
}

Ми перехоплюємо SIGINT і SIGTERM і просто виводимо деяку інформацію. Це дає нам хук для запуску коду очищення перед виходом!

$ php artisan longtime   
starting long command: 14:29:50
^Csigint received  

Сигнал SIGINT був "перехоплений", але він все одно зупинив процес! Аналогічну поведінку ми отримуємо для SIGTERM:

$ php artisan longtime   
starting long command: 14:33:39
sigint received 

Таким чином, ми можемо відповісти на сигнал, але не можемо зупинити процес після виконання зворотного виклику.

Було б корисно, якби ми могли ігнорувати сигнал, поки не будемо готові вийти! На щастя, ми можемо це зробити.

Реалізація SignalableCommandInterface

Якщо наша команда реалізує інтерфейс Symfony SignalableCommandInterface, ми можемо змусити команду завершити виконання до того, як вона вийде.

На перший погляд здається, що це працює так само як і метод $this->trap(). Однак, якщо ми повернемо false в обробнику, то команда зможе завершити свою роботу.

Ось як це виглядає:

# Some stuff omitted
use Symfony\Component\Console\Command\SignalableCommandInterface;

class LongCommandTwo extends Command implements SignalableCommandInterface
{
    public function handle()
    {
        $this->info('starting long2 cmd: '.now()->format('H:i:s'));

        Sleep::for(30)->seconds();

        $this->info('finished long2 cmd: '.now()->format('H:i:s'));
    }

    public function getSubscribedSignals(): array
    {
        return [SIGINT, SIGTERM];
    }

    public function handleSignal(int $signal)
    {
        $this->info('signal received: ' . $signal);
        return false;
    }
}

Оскільки обробник сигналу повертає false, наша команда може завершитися. Як це працює - це просто деталь реалізації інтерфейсу SignalableCommandInterface у Symfony - він каже коду не виконувати exit($statusCode);.

Після цього ми побачимо, що наш рядок "finished..." буде виконано:

$ php artisan long2
starting long2 cmd: 15:14:01
^Csignal received: 2
finished long2 cmd: 15:14:02

Ви можете помітити, що насправді ми не спали 30 секунд! Це пов'язано з використанням sleep() для тестування. Використання сигналів фактично скорочує поточні виклики sleep(), тому якщо ваш код покладається на них, це може бути проблемою!

Навіщо так робити?

Корисною практикою для цього є виконання певної роботи з очищення перед виходом:

# Some stuff omitted
use Symfony\Component\Console\Command\SignalableCommandInterface;

class LongCommandTwo extends Command implements SignalableCommandInterface
{
    protected $shouldExit = false;

    public function handle()
    {
        $this->info('starting long2 cmd: '.now()->format('H:i:s'));

        while(! $this->shouldExit) {
            $this->info("We're doing stuff");
            Sleep::for(1)->seconds();
        }

        // Pretend we're working hard on
        // cleaning everything up
        // Oh, also, this sleep actually happens
        // since it was started after the signal was received
        Sleep::for(10)->seconds();

        $this->info('finished long2 cmd: '.now()->format('H:i:s'));
    }

    public function getSubscribedSignals(): array
    {
        return [SIGINT, SIGTERM];
    }

    public function handleSignal(int $signal,)
    {
        $this->shouldExit = true;
        $this->info('Cleaning up: signal received: '.$signal);
        return false;
    }
}

Тепер ви можете запустити код для очищення або в методі handleSignal(), або після циклу while(). Це чудово підходить для видалення тимчасових файлів, забезпечення цілісності даних при вимкненні команди, закриття мережевих з'єднань і багато чого іншого!

Джерело: Handling Signals in Laravel
Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід