Socket Activated Containers (Unicorn + Systemd)

У клиента есть большое количество медийных спец проектов (~250). Это виджеты, лендинги, апишки и т.д. С 2012 года это все живет на 1 машине со связкой Nginx + Passenger + Ruby.

Все хорошо, за исключением момента обновления ОС, когда все проблемы со старыми/новыми версиями пакетов вылезают и заявляют о себе в полный голос.

Казалось бы, идеальная история для контейнеров, но есть одно но. Passenger или PHP-FPM умеют то, что из коробки нет даже у Kubernetes — это старт по входящему трафику.

На просторах сети это называется (гуглится) — Socket Activation. На Network или Unix сокет приходит пакет, сервис запускается. Покопавшись на Stack Overflow и некоторых админских форумах, понял, что мысль о том, что контейнер, можно только по необходимости поднимать интересует многих. Такие запросы есть у больших ребят — привет велосипеды (читай свои решения).

Оказывается то, что нужно написали уже лет 7 назад. Пост про реализацию функционала в systemd датирован 2011 годом. Ну и в 2013 уже вышел пост про старт контейнера с помощью той же функциональности. Есть одно но, там используются стандартные средства systemd для виртуализации, нас же интересуют более популярные на данный момент Docker.

Схему забрал из блога Atlassian.

Во всех статьях говорили о proxy сервисе, который принимает трафик и отвечает за запуск контейнера. Gочему нельзя сокет использовать сразу в контейнере? Вопрос этот возникает, потому что на каждый сервис придется делать 3 конфига systemd и минимум 2 сокета, что как мне казалось много и не слишком красиво. Посмотрел потом на конфиги kubernetes и 3 конфига systemd показались маленькими, простыми и понятными.

Но так что с сокетом то? А вот я не нашел способа как его пробросить в контейнер. Если кто знает, с радостью послушаю. Проблема не в софте (unicorn и puma), который может переиспользовать проброшенный сокет. Есть стандартные процедуры для этого. Через ENV переменные передаются ID процесса (LISTEN_PID) и номер слушающего сокета (LISTEN_FDS), после чего софт не должен пытаться открыть новый, а переиспользовать (подключиться) к сокету с соответствующими координатами.

Проблема заключается в том, что ID процесса и ссылку на сокет будут переданы с host машины, контейнер естественно ничего о них не знает.

Шарить данные с хост машиной конечно можно, но безопасно ли?

require 'bundler/setup'
Bundler.require

require 'rack'

app = Proc.new do |env|
  ['200', {'Content-Type' => 'text/html'}, ['Simple app for test']]
end

run app
config.ru
FROM ruby:2.4
RUN mkdir /app
COPY Gemfile Gemfile.lock /app/
WORKDIR /app
RUN bundle install
ADD config.ru /app/
ADD unicorn.rb /app/
CMD ["bundle", "exec", "--keep-file-descriptors", "unicorn", "-c", "unicorn.rb"]
Dockerfile
source 'https://rubygems.org'

gem 'unicorn'
gem 'rack'
Gemfile
GEM
  remote: https://rubygems.org/
  specs:
    kgio (2.11.2)
    rack (2.0.5)
    raindrops (0.19.0)
    unicorn (5.4.0)
      kgio (~> 2.6)
      raindrops (~> 0.7)

PLATFORMS
  ruby

DEPENDENCIES
  rack
  unicorn

BUNDLED WITH
   1.16.1
Gemfile.lock
[Unit]
Description=Socket Activation with Unicorn Service
After=network.target sact-test.socket
Requires=sact-test.socket

[Service]
ExecStart=/usr/bin/docker run --user 1001 --rm --name sact-test-n -v '/apps/sact/sact-git/tmp:/app/tmp' -e 'SOCKET_FILE=/app/tmp/sockets/unicorn.sock' sact-test
ExecStartPost=/bin/sleep 1
SyslogIdentifier=sact-test-docker
ExecStop=/usr/bin/docker stop sact-test-n
sact-test-docker.service
[Unit]
Requires=sact-test-docker.service
After=sact-test-docker.service

[Service]
SyslogIdentifier=sact-test-docker
ExecStart=/lib/systemd/systemd-socket-proxyd /apps/sact/sact-git/tmp/sockets/unicorn.sock
sact-test.service
[Unit]
Description=Socket Activation with Unicorn
PartOf=sact-test.service

[Socket]
SocketUser=sact
SocketGroup=sact
SocketMode=0777
Backlog=2048
ListenStream=/apps/sact/sact-git/tmp/sockets/unicorn-proxy.sock

[Install]
WantedBy=sockets.target
sact-test.socket
upstream app_sact {
    server unix:/apps/sact/sact-git/tmp/sockets/unicorn-proxy.sock;
}

server {
  listen 80;
  charset utf-8;
  set $application_name "sact";

  location / {
    try_files $uri $uri.html @application;
  }

    location @application {
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_hide_header X-User-Id;

        proxy_redirect off;
        proxy_pass http://app_$application_name;
        break;
    }
}
sact.conf
worker_processes 2
timeout 30
listen ENV['SOCKET_FILE'] || 8080, backlog: 2048
working_directory ENV['APP_PATH'] || '/app/
unicorn.rb

Сделал gist с файлами для тестов. Простейшее Rack приложение и набор сервисов для systemd. Чтобы это все завелось надо завести отдельного пользователя, назвал его sact, и сложить Gemfile, Gemfile.lock, unicorn.rb, config.ru файлы в /apps/sact/sact-git . Установить docker, сложить systemd сервисный файлы в /etc/systemd/system и перезагрузить демона через systemctl daemon-reload . Также понадобиться nginx. Но думаю для тех кто это читает не составит большого труда разобраться.

Если обратиться на 80 порт localhost

curl localhost, то получим Simple app for test . Небольшая задержка на старте нас не пугает, потому что подразумевается что к сервисы обращаются не часто.

Что я не успел сделать — отслеживание трафика за определенный момент времени с целью остановить сервис. По идее это просто парсинг логов, если за последние N минут ничего не было, то останавливаем сервис с контейнером. Если мы соберемся делать такое решение на наших 250+ проектов, то обязательно напишу.

Stay tuned…

P.S. Где что подсматривал:

  1. От разработчика systemd про активацию контейнеров.
  2. Это на Python, но можно посмотреть как пробрасывается Socket из Systemd
  3. А это пример на Ruby как работать с сокетом Sytemd
  4. Откуда взял гифку