Превращаем Puma в TCP-сервер

Updated on May 1, 2021. Эта функция была удалена из Puma в релизе 5.0.0 (2020-09-17)

Puma это один из трех популярных веб-серверов на Ruby (еще можно упомянуть Unicorn и Thin). Одновременно с этим ее документация сильно оставляет желать лучшего. Многие возможности не документированы, малоизвестны и нужно нырять в исходники, чтобы разобраться как с этим работать. Одна из таких возможностей - это запуск Puma в режиме TCP-сервера. Об этом я и расскажу подробнее в это заметке.

TCP-сервер

В TCP-режиме Puma обрабатывает не HTTP-запросы, а входящие TCP-соединения давая приложению к ним полный доступ.

TCP-сервер - это такой компонент, который умеет обрабатывать входящие сетевые соединения по протоколу TCP. TCP и UDP - это самые распространенные современные протоколы транспортного уровня. Большая часть данных в мире передается именно по этим двум протоколам. Поэтому TCP-сервер это неотъемлемая часть серверов для практически любого сетевого протокола - HTTP, FTP, DNS, SMTP…

Простейший TCP-сервер на Ruby может выглядеть следующим образом:

require 'socket'

server = TCPServer.new 2000 # Server bind to port 2000
loop do
  client = server.accept    # Wait for a client to connect
  client.puts "Hello !"
  client.puts "Time is #{Time.now}"
  client.close
end

(пример из документации)

Здесь мы создаем серверный сокет, который слушает входящие соединения на порту 2000. Установив соединение, сервер пишет приветствие, текущее время и закрывает соединение.

Примерно так же работает и NTP-сервер (сервер синхронизации часов компьютером по сети), только там используют протокол UDP.

Конфигурация TCP-режима в Puma

Чтобы перевести Puma в этот режим надо задать опцию tcp_mode, IP и порт, на которых нужно запустить сервер. Это делается либо через опции командной строки:

$ puma --tcp-mode --bind tcp://127.0.0.1:9292

либо в конфиг-файле:

# config/puma.rb
tcp_mode
bind 'tcp://0.0.0.0:9292'

Приложение, которое будет обрабатывать входящие соединения, задается в конфиг-файле следующим образом:

# config/puma.rb
app do |env, socket|
  # ...
end

Пример с эхо-сервером

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

# config/puma.rb
app do |env, socket|
  s = socket.gets
  socket.puts "Echo #{s}"
end

Запустим Puma следующей командой

$ puma —-config config/puma.rb

Где в config/puma.rb находится приведенная выше конфигурация:

tcp_mode
bind 'tcp://0.0.0.0:9292'

app do |env, socket|
  s = socket.gets
  socket.puts "Echo #{s}"
end

Давайте проверим работу нашего сервера и отошлем ему несколько строчек через telnet. Telnet открывает TCP-соединение и шлет введенные в консоли данные по сети.

$ telnet 0.0.0.0 9292
Trying 0.0.0.0...
Connected to 0.0.0.0.
Escape character is '^]'.
foo
Echo foo
^CConnection closed by foreign host.

Мы видим, что в ответ на сообщение foo сервер, как и ожидалось, ответил Echo foo.

Пример с удаленным shell

Давайте рассмотрим более сложный пример и сделаем что-то полезное. Мы сделаем упрощенный вариант SSH/RSH - подключившись к нашему серверу клиент сможет удаленно выполнять shell-команды.

Наше приложение будет принимать TCP-соединение, читать команды, выполнять их в shell и писать клиенту в ответ результат.

Реализация не сильно отличается от эхо-сервера. Создадим следующий конфиг-файл:

# config.rb
require_relative 'puma_shell'

tcp_mode
bind 'tcp://127.0.0.1:9292'
threads 2, 10

app do |env, socket|
  PumaShell.run(env, socket)
end

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

Упрощенно приложение делает следующее:

вывести приветствие

пока не пришла команда quit/exit
  прочитать очередную команду из сокета
  выполнить ее в shell
  написать stdout+stderr команды обратно в сокет

попрощаться

В начале каждой сессии приложение выводит приветствие “You are welcome to Puma Shell” и приглашение для ввода команды “>”. Затем в режиме REPL читает команду, выполняет ее в shell и записывает клиенту stdout и stderr команды. Далее ждет новую команду. Когда пользователь вводит exit или quit, приложение завершает сессию.

Само приложение вынесено в отдельный файл и выглядит следующим образом:

# puma_shell.rb
require 'open3'

class PumaShell
  def self.run(env, socket)
    socket.puts "You are welcome to Puma Shell"

    loop do
      socket.write "> "
      command = socket.gets.chomp

      next if command.empty?
      break if ['exit', 'quit'].include? command

      # Use trick with adding ';' to force Ruby to use shell
      # instead of passing command to the OS directly.
      # It's important for error handling.
      command = command + ';'

      Open3.popen2e(command) do |stdin, stdout_and_stderr, thr|
        output = stdout_and_stderr.read.chomp
        socket.puts output
      end
    end

    socket.puts "Bye!"
  end
end

Давайте запустим сервер и немного поиграем с ним.

Стартуем сервер следующей командой:

$ puma --config config.rb

Puma starting in single mode...
* Version 4.3.0 (ruby 2.6.5-p114), codename: Mysterious Traveller
* Min threads: 2, max threads: 10
* Environment: development
* Mode: Lopez Express (tcp)
* Listening on tcp://127.0.0.1:9292
Use Ctrl-C to stop

Как видим, сервер запустился в TCP-режиме (“Mode: Lopez Express (tcp)”), количество потоков - от 2 до 10 и сервер слушает порт 9292 на сетевом интерфейсе 127.0.0.1, как мы и настроили в конфиг-файле.

Попробуем подключиться к серверу telnet‘ом:

$ telnet 127.0.0.1 9292

Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
You are welcome to Puma Shell

Здесь началась новая сессия и сервер вывел “You are welcome to Puma Shell”. Продолжим сессию и выполним несколько команд:

> uname
Darwin
> pwd
/Users/andrykonchin/projects/andrykonchin.github.io/artifacts
> ls -la
total 24
drwxr-xr-x  5 andrykonchin  staff  160 Nov 23 14:37 .
drwxr-xr-x  9 andrykonchin  staff  288 Nov 23 02:05 ..
-rw-r--r--  1 andrykonchin  staff   73 Nov 23 02:06 Gemfile
-rw-r--r--  1 andrykonchin  staff  163 Nov 23 02:06 Gemfile.lock
-rw-r--r--  1 andrykonchin  staff  658 Nov 23 14:37 config.rb
> foobar
sh: foobar: command not found
> quit
Bye!
Connection closed by foreign host.

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

> foobar
sh: foobar: command not found

При завершении сессии сервер прощается и пишет “Bye!”.

Этот пример все еще игрушечный и его нельзя использовать в production. Здесь не хватает аутентификации и шифрования данных. Чтобы поддерживать большую нагрузку и много клиентов одновременно лучше заменить многопоточную модель на асинхронную обработку запросов (event-loop, user-space threads или аналогичные модели конкурентности).

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

А зачем?

Это вполне логичный вопрос. Зачем использовать для разработки TCP-сервера веб-сервер? Ведь это как микроскопом забивать гвозди.

Давайте перечислим, что нам дает использование Puma:

  • в Puma’е реализована многопоточность (thread pool и reactor)
  • Puma’у можно запускать в claster-режиме, когда поднимается несколько системных процессов, чтобы лучше масштабироваться по ядрам процессора
  • Puma поддерживает два способа управления сервером - через системные сигналы и через CLI утилиту pumactl
  • Puma интегрируется с systemd - наследует файловые дескрипторы (серверные сокеты) используя переменные окружения LISTEN_FDS и LISTEN_PID, которые выставляет systemd
  • Puma собирает статистику обработанных запросов, которая доступны через pumactl

PS

Идея написать эту заметки пришла ко мне, когда в рамках Hacktoberfest я работал над документацией Puma (PR).

Сноска для любознательных. Реализацию TCP-режима можно посмотреть здесь - https://github.com/puma/puma/blob/v4.3.0/lib/puma/server.rb#L177-L202

Ссылки