При запуске нового приложения важно, чтобы оно легко обновлялось и распространялось. А для некоторых приложений  —  еще и максимизация времени бесперебойной работы. Хороший пример таких приложений  —  API, используемый сразу несколькими клиентами, которые ожидают, что он всегда будет работать без сбоев.

Недавно у меня была задача: создать API с максимальным временем бесперебойной работы при обновлении бинарных файлов. Кратко изложу суть найденных мной решений. Наверняка есть и другие, просто эти два решения применяются чаще.

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

Решения должны работать не только на Linux, но и на любой платформе UNIX.

Знакомы с основами файловых дескрипторов, сигналов, а также различиями между потоками и процессами? Тогда начнем. Почти все примеры здесь на Golang.

Решение 1. Параметры сокетов SO_REUSEPORT задаются для слушателя (см. пример кода)  —  и через один и тот же порт запускается несколько веб-серверов.

Это решение реализовать проще всего. Надо только убедить слушателя TCP применить параметры сокетов:

lc := net.ListenConfig{
Control: control,
}

...

func control(network, address string, c syscall.RawConn) error {
var err error
c.Control(func(fd uintptr) {
err = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1)
if err != nil {
return
}

err = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
if err != nil {
return
}
})
return err
}

Здесь применили SO_REUSEADDR и SO_REUSEPORT: разница между ними хорошо описана здесь на Stackoverflow.

Обычно нельзя слушать порт, уже используемый другим приложением. Но при указании этих параметров сокета  —  можно. Когда несколько приложений слушают один порт, ядро «случайным образом» распределяет входящие запросы между ними. Посмотрите на это в коде.

Решение 2. Запускается новый процесс с указанием наследуемых файловых дескрипторов. Открытый сокет используется совместно с дочерним процессом: он запускается там, где остановился родительский.

Второе решение сложнее, но и сценариев применения у него больше. И не только с сокетами  —  с любыми файловыми дескрипторами (каналами, файлами и т. д.).

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

Взгляните на функцию StartProcess в пакете os. В ее параметре ProcAttr указываются открытые файловые дескрипторы для дочернего процесса, который не будет создавать нового слушателя  —  а просто заберет его у родительского процесса. В коде это легко увидеть.

Протестируем оба примера (размещены на GitHub). Код для первого  —  в socket-option-version. Скомпилировав его с помощью go build -o zero ./main.go и запустив как ./zero, получим вывод.

Проверим функциональность, отправив HTTP-запрос в конечную точку. Обновляем двоичные файлы с помощью сигнала Linux SIGUSR2: отправляем его с другого терминала, например с pkill -SIGUSR2 zero. Приложение «закрыто», и терминал снова готов к использованию.

Отправляем еще один HTTP-запрос и получаем ответ. Лог выводится в терминал, ведь stdout и stderr используются совместно с дочерним процессом  —  любой его вывод будет отображаться в терминале:

Код для второго примера  —  в папке inherit-version. Скомпилировав его с помощью go build -o zero ./main.go и запустив как ./zero, получим те же результаты:

Весь код размещен на GitHub.

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Daniel Sedlak: Zero downtime API in Golang

Предыдущая статьяТестирование больших данных: руководство для начинающих
Следующая статьяСоветы по переходу с AWS CloudFormation на CDK