Так уж получилось, что появилась необходимость досрочно останавливать уже запущенный сайдкик-воркер. И, как уже всем причастным известно, запущенную задачу невозможно остановить штатными средствами — этого просто не предусмотрено архитектурой. И когда сайдкик-задача начала уже выполняться, то ее уже ничто не остановит. Конечно же, в интернетах тут же нашлось решение с убиением руби-процесса и с отменой перезапуска оного, но это решение по очевидным причинам не может устраивать ни разработчиков приложения, ни разработчиков сайдкика.


image


В итоге решение появилось откуда не ждали и оказалось крайне простым и очевидным. В общем и целом идею можно сформулировать очень коротко: сайдкик-процесс должен сам себя убивать, а мы лишь должны сказать ему когда это сделать. Весь код, что раньше просто запускался в сайдкике, будем запускать в отдельном трэде внутри процесса сайдкика. И, параллельно ему запустим трэд, который будет следить за указаниями извне чтобы сайдкик процесс убил себя сам. Получив указания убить себя, следящий тред убивает соседа и убивается сам.


В общем и целом такой подход не ограничивается только лишь при использовании сайдкика и долгоработающая фигня может быть где угодно. Поэтому давайте абстрагируемся от сайдкика и попробуем обобщить наш подход.


Во-первых нужно решить каким образом мы будем сообщать треду-убийце новость, что нужно умереть. И в случае с руби лучше всего воспользоваться внешним механизмом передачи сообщений и редис в данном случае подойдет идеально. И лучше встроенный в редис механизм передачи сообщений не использовать по нескольким причинам главная из которых — отсутсвие отложенного чтения. Если по каким-либо причинам трэд-киллер пропустит сообщение, то наш процесс убийства может вообще не состоятся. Допустим, мы попросим сайдкик-процесс умереть очень быстро — сразу после того, как его запустим. Есть шанс, что трэд-киллер еще вообще не будет существовать.


Итак, процесс проверки сообщения достаточно простой и прямолинейный:


  until $redis.del("sidekiq:killer:#{self.identificator}") == 1 do
    sleep 0.1
  end
  main_thread.kill

А метод identificator будет отвечать за уникальную составляющую ключа в редисе.


Основной же трэд с кодовым именем "жертва" после своей естественной смерти должен оставить убедится, что киллер не будет его ждать вечно:


  begin
    self.perform_without_thread(*args)
  rescue
    $redis.set("sidekiq:killer:#{self.identificator}", 1)
  end

Опять же, как настоящий киллер, наш трэд-убийца должен быть уверен, что жертва мертва:


  until !!main_thread.status == false do
    sleep 0.1
  end

Ну, и в конце концов наш общий сайдкик-процесс дожидается окончания работы всех двух процессов:


  [watcher_thread, main_thread].each(&:join)

А теперь давайте соединим вышеописанный код вместе, чтобы получить целостный модуль для прямого добавления в существующие долгоиграющие процессы:


module SidekiqKiller

  def perform_with_thread(*args)
    main_thread = Thread.new do
      begin
        self.perform_without_thread(*args)
      rescue
        $redis.set("sidekiq:killer:#{self.identificator}", 1)
      end
    end

    watcher_thread = Thread.new do
      until $redis.del("sidekiq:killer:#{self.identificator}") == 1 do
        sleep 0.1
      end
      main_thread.kill
      until !!main_thread.status == false do
        sleep 0.1
      end
    end

    [watcher_thread, main_thread].each(&:join)
  end
  alias_method_chain :perform, :thread
end

В нашей системе механизм приказного самоубийства воркеров использовался для принудительной остановки воркера отдельно запущенного экземпляра стейджинг-сервера. Механизм, конечно, безотказный, но слишком дорогой по памяти. В итоге эту часть системы переписали на чистом руби, оптимизировав и избавившись от сайдкика и сопутствующих библиотек.


Какой из этого можно сделать вывод? Да никакого, кроме того, что все мы смертны. Еще, наверное то, что, не стоит так делать, если нет уж очень острой необходимости.


image

Поделиться с друзьями
-->

Комментарии (16)


  1. Fikys
    26.07.2016 02:06
    +2

    Привет, не очень понимаю смысла статьи, если у сайдкика есть API для работы с процессами. Был бы рад, если бы ты объяснил в чем преимущество твоего подхода :)


    1. aratak
      26.07.2016 11:03

      Я немного сбил вас с толку, называя запущенный воркер "сайдкик-процессом". Воркер работает внутри настоящего сайдкик-процесса и всю статью я пытаюсь остановить запущенный воркер, а процесс не трогать, так как ему еще предстоит обработать новые воркеры. API показывает как работать с запущенными процессами сайдкика, и команда #stop! останавливает весь сайдкик процесс, а не отдельно запущенный воркер.


      Вот разница при работе с процессами и в моем случае.


      Сначала запустим процесс и вызовем метод stop!, как это предлагают сайдкикеры.


      INFO: Starting processing, hit Ctrl-C to stop
      TestProcessWorker JID-e0d48711e21cd37c90e98186 INFO: start
      
      # в этом месте я вызвал метод `stop!` на процессе из соседней вкладки
      
      INFO: Shutting down
      INFO: Terminating 49 quiet workers
      INFO: Pausing up to 30 seconds to allow workers to finish...
      WARN: Terminating 1 busy worker threads
      WARN: Work still in progress [#<struct Sidekiq::BasicFetch::UnitOfWork queue="queue:default", message="{\"class\":\"TestProcessWorker\",\"args\":[1],\"retry\":false,\"queue\":\"default\",\"jid\":\"e0d48711e21cd37c90e98186\",\"created_at\":1469519195.722636,\"enqueued_at\":1469519195.722796}">]
      INFO: Pushed 1 messages back to Redis
      TestProcessWorker JID-e0d48711e21cd37c90e98186 INFO: fail: 121.278 sec

      Повторный запуск сайдкика приводит к тому, что он пытается перезапустить процессы, которые были помечены статусом "fail".


      Теперь давайте используем воркера из статьи и килять будем воркер, а не процесс:


      INFO: Starting processing, hit Ctrl-C to stop
      TestKillerWorker JID-e0d48711e21cd37c90e98186 INFO: start
      
      # в этом месте кладем в редис соответствующий киллер-ключ
      
      TestKillerWorker JID-e0d48711e21cd37c90e98186 INFO: done: 0.396 sec

      Во втором случае процесс готов обрабатывать следующую задачу, в отличие от первого, где он вообще выключается со всеми работающими процессами.


  1. Dreyk
    26.07.2016 10:20

    Килять треды — не самая лучшая затея


    1. aratak
      26.07.2016 11:09

      Очень рад, что вы согласны выводами статьи. К большому сожалению лучшего способа, чем килять рядом запущенный тред, который знает, что его могут в любое время кильнуть, найдено не было. Может быть есть какие-нибудь идеи?


      1. Dreyk
        26.07.2016 11:14
        +1

        я вначале тоже неправильно понял, что вы называете процессом.

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


        1. aratak
          26.07.2016 20:54

          Выключение ретрая и убиение процесса сайдкика переводит эту джобу в состояние "fail", а хотелось бы считать это успешным завершением.


          Кроме того, при обилии такого рода задач, запуск нового сайдкик-процесса с поднятием всего окружения делает это неоправданно дорогой манипуляцией.


  1. iqiaqqivik
    26.07.2016 11:58
    -1

    Я всегда рассуждаю так: если мне надо убить запущенный sidekiq-worker, значит я облажался с архитектурой и мне нужно подкрутить логику вокруг этого воркера, а не доставать из кармана 45-й.


    1. aratak
      26.07.2016 12:18

      И вы совершенно правы. В случае, описанным в статье пришлось вовсе отказаться от сайдкика.


      1. iqiaqqivik
        26.07.2016 12:40
        -1

        Случай в статье не описан, но я убежден, что отказ от сайдкика — такой же беспомощный костыль, как и убийство воркера.


  1. Envek
    26.07.2016 20:41

    Интересно, можно ли наподобие этого сделать механизм автоматического перезапуска всего процесса Sidekiq после выполнения тяжёлой по памяти задачи (чтобы вернуть память ОС, потому что та память, которую Ruby съел, он уже не отдаст до самой своей смерти)?


    Сейчас у нас для этой задачи monit запряжён, рестартует sidekiq'а спустя полчаса после того, как тот станет потреблять больше полугига.


    1. aratak
      26.07.2016 20:48

      Для этого у сайдкика есть официальный API по работе с процессами, хотя я согласен с вами, что перезапуск извне выглядит эффективнее и надежнее.


      1. iqiaqqivik
        27.07.2016 09:53

        А холодный перезапуск всего юнита выглядит вообще как панацея.


    1. iqiaqqivik
      27.07.2016 09:43

      > та память, которую Ruby съел, он уже не отдаст до самой своей смерти
      Ruby отдаст все, кроме того, что было аллоцировано под новые Ruby Heaps. Писать код так, чтобы количество отъеденных RValues (от которого напрямую зависит размер локального хипа) — довольно просто, при некоторой сноровке.


      1. Dreyk
        27.07.2016 10:54

        по моим наблюдениям, со временем руби-процесс таки отдает практически все назад. Можете подсказать, где толково описано про RValues и Heap?


        1. iqiaqqivik
          27.07.2016 12:27
          +3

          https://ruby-hacking-guide.github.io/gc.html — про gc
          https://blog.engineyard.com/2010/mri-memory-allocation-a-primer-for-developers — тоже полезно, чуть более прикладной вариант
          http://stackoverflow.com/a/20608455/2035262 — совсем вкратце, мой ответ на SO про это

          Надо бы заметку написать, но руки не доходят.


  1. Nakilon
    01.08.2016 19:07
    -1

    > сайдкик-процесс должен сам себя убивать, а мы лишь должны сказать ему когда это сделать

    pipes? sockets? mmap?

    > И в случае с руби лучше всего воспользоваться внешним механизмом передачи сообщений и редис в данном случае подойдет идеально.
    > author: CTO at…

    Рельсисты такие рельсисты…