Socket activation и кастомные target'ы
Socket activation позволяет systemd держать сокет открытым пока сервис стартует лениво при первом подключении — обеспечивая рестарты без простоя и более быструю загрузку. Кастомные target группируют связанные сервисы в именованную точку синхронизации.
Ты перезапускаешь API-сервис для деплоя нового бинарника. За две секунды пока новый процесс стартует, каждое входящее соединение получает “connection refused”. Балансировщик нагрузки может повторить попытку, но мобильный клиент скорее всего покажет ошибку. В systemd есть примитив, который полностью устраняет этот разрыв: socket unit. systemd держит слушающий сокет открытым во время рестарта — ядро ставит соединения в очередь в своём backlog — и передаёт сокет новому процессу в момент его готовности. Клиент никогда не видит разрыва. Это socket activation, и он встроен в systemd без каких-либо изменений кода в твоём сервисе.
После этого урока ты сможешь объяснить как работает socket activation и почему он обеспечивает рестарты без простоя, написать парные юниты .socket и .service, описать когда использовать Accept=yes vs Accept=no, и создать кастомный .target юнит для группировки и синхронизации связанных сервисов.
Socket activation разделяет “держать сокет” и “запускать сервис.” systemd владеет сокетом; он запускает сервис по требованию. Ключевая идея: TCP-backlog ядра может буферизовать входящие соединения даже пока процесс ещё не слушает. systemd создаёт сокет, ядро принимает соединения в backlog, и когда приходит первое соединение, systemd запускает сервис и передаёт ему файловый дескриптор. connect() клиента завершается успешно немедленно — он лишь кратко блокируется в backlog ядра.
Socket unit и парный service unit разделяют имя (всё до расширения):
sshd.socket → systemd держит сокет
sshd.service → systemd запускает это при входящем соединенииSocket unit всегда включён; service unit запускается лениво.
Написание socket unit. Минимальная форма для TCP-сокета на порту 8080:
# /etc/systemd/system/myapi.socket
[Unit]
Description=My API socket
[Socket]
ListenStream=8080
# Accept=no (по умолчанию): один экземпляр сервиса обрабатывает все соединения
# Accept=yes: systemd форкает новый экземпляр сервиса для каждого соединения (inetd-стиль)
[Install]
WantedBy=sockets.targetListenStream= для TCP (stream-сокеты). Для UDP используй ListenDatagram=. Для Unix domain socket используй путь: ListenStream=/run/myapi.sock.
Accept=no (по умолчанию) — почти всегда правильный выбор: systemd запускает один экземпляр сервиса, передаёт ему слушающий сокет через файловый дескриптор 3 (протокол SD_LISTEN_FDS), и сервис сам обрабатывает все соединения. Accept=yes порождает новый экземпляр сервиса для каждого соединения — полезно только для очень простых per-connection обработчиков (классическая модель inetd). Большинство современных сервисов используют Accept=no.
Парный сервис получает сокет через SD_LISTEN_FDS. Сервис не вызывает bind() или listen() сам — он получает уже привязанный, уже слушающий сокет от systemd через переменную окружения и унаследованные файловые дескрипторы:
# /etc/systemd/system/myapi.service
[Unit]
Description=My API service
Requires=myapi.socket
After=myapi.socket
[Service]
Type=notify
ExecStart=/opt/myapi/server
# Порт НЕ указывать здесь — сокет наследуется от myapi.socket
Restart=on-failure
User=myapi
[Install]
# Нет WantedBy здесь — socket unit подтягивает сервис
Also=myapi.socketДиректива Also= в [Install] означает “когда включаешь этот сервис, также включи myapi.socket”. Это держит пару в синхронизации.
Для рестартов без простоя: systemctl restart myapi.service останавливает и запускает сервис пока myapi.socket продолжает слушать. Ядро ставит в очередь входящие соединения во время разрыва. Новый процесс сервиса стартует, вызывает sd_listen_fds() для получения fd сокета, и соединения из очереди обслуживаются нормально.
# Включить оба вместе (Also= обрабатывает сокет)
sudo systemctl enable --now myapi.service
# Перезапустить только сервис — сокет продолжает слушать всё время
sudo systemctl restart myapi.service
# Проверить что сокет держится открытым
systemctl status myapi.socketКастомные target’ы группируют связанные сервисы и предоставляют именованную точку синхронизации. Target — просто unit-файл с расширением .target и секцией [Unit] — без секции [Service]. Его назначение — быть тем, против чего другие юниты могут объявлять After=, Requires= или WantedBy=.
Пример: target “бэкенд-стека”, группирующий базу данных, кеш и API:
# /etc/systemd/system/backend.target
[Unit]
Description=Full backend stack
Requires=postgresql.service redis.service myapi.service
After=postgresql.service redis.service myapi.service
[Install]
WantedBy=multi-user.targetТеперь можно:
# Поднять весь бэкенд-стек
sudo systemctl start backend.target
# Остановить весь стек
sudo systemctl stop backend.target
# Изолировать только этот target (останавливает всё остальное — использовать осторожно)
sudo systemctl isolate backend.targetДругие юниты могут объявлять After=backend.target чтобы гарантировать что весь стек поднят перед их стартом.
PartOf= заставляет сервис останавливаться при остановке владеющего target’а, без жёсткой зависимости. Это правильная директива для семантики “член группы”:
# /etc/systemd/system/myworker.service
[Unit]
Description=Background worker
PartOf=backend.target
After=backend.targetС PartOf=backend.target, если запустить systemctl stop backend.target, worker тоже останавливается. Но если worker падает сам по себе, backend.target не затрагивается. Это отличается от Requires= (которое остановило бы target при падении члена). Используй PartOf= когда юниты логически являются частью группы, но их индивидуальный сбой не должен разрушать всю группу.
# Проверить распространение остановки
sudo systemctl start backend.target
sudo systemctl stop backend.target
systemctl status myworker.service
# Active: inactive (dead) — остановлен потому что его target остановилсяРеализовать рестарты без простоя для Go HTTP-сервера через socket activation.
Стандартная библиотека Go поддерживает socket activation через net.FileListener:
// main.go — получает сокет от systemd вместо его привязки
package main
import (
"net"
"net/http"
"os"
)
func main() {
// SD_LISTEN_FDS: systemd передаёт сокеты начиная с fd 3
f := os.NewFile(3, "socket")
ln, err := net.FileListener(f)
if err != nil {
// Fallback: привязать свой сокет (для локальной разработки без systemd)
ln, err = net.Listen("tcp", ":8080")
if err != nil {
panic(err)
}
}
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
http.Serve(ln, nil)
}Unit-файлы:
# myapp.socket
[Socket]
ListenStream=8080
[Install]
WantedBy=sockets.target# myapp.service
[Service]
Type=simple
ExecStart=/opt/myapp/server
Restart=on-failureДеплой нового бинарника:
# Скопировать новый бинарник
sudo cp myapp-v2 /opt/myapp/server
# Перезапустить сервис — сокет слушает всё время рестарта
sudo systemctl restart myapp.service
# Подтвердить: соединения во время рестарта были в очереди, не отклонены
curl http://localhost:8080/ # должно успешно ответить сразу после рестарта▸Почему это работает
macOS launchd имеет socket activation с OS X 10.4 (2005) — это на несколько лет раньше аналога systemd. Ключ Sockets в launchd plist эквивалентен ListenStream= в .socket unit. Системные демоны macOS, например sshd, используют launchd socket activation — вот почему ssh соединяется с macOS-машиной мгновенно, хотя sshd мог не работать непрерывно. systemd принял ту же модель и стандартизировал её через переменную окружения SD_LISTEN_FDS и функцию sd_listen_fds() из libsystemd.
▸Частая ошибка
Не указывай ListenStream=0.0.0.0:8080 — префикс 0.0.0.0: не нужен и игнорируется в новых версиях systemd, которые могут выводить предупреждение. Просто используй ListenStream=8080 для слушания на всех интерфейсах или ListenStream=127.0.0.1:8080 для ограничения localhost. Для Unix domain socket используй абсолютный путь: ListenStream=/run/myapi/myapi.sock. Также: не забудь Also=myapi.socket в секции [Install] сервиса, иначе systemctl enable myapi.service не включит socket unit и сервис никогда не будет активирован.
Ты запускаешь `systemctl restart myapi.service` пока активен парный `myapi.socket`. Что происходит с входящими TCP-соединениями за две секунды рестарта сервиса?
Socket activation разделяет “держать сокет” (.socket unit, всегда активен) и “запускать сервис” (.service unit, запускается по требованию). systemd владеет сокетом; ядро ставит соединения в очередь в своём backlog при рестартах сервиса, обеспечивая поведение без простоя без координации на уровне приложения. Сервис получает уже привязанный сокет через fd 3 (SD_LISTEN_FDS). Accept=no (по умолчанию) — правильный выбор для сервисов с собственным циклом обработки соединений. Кастомные target’ы — именованные точки синхронизации: объявляй Requires= и After= внутри target, а другие юниты используют WantedBy= или PartOf= для присоединения. PartOf= распространяет остановку (но не сбой) от target к члену — правильная директива для членства в группе без жёсткой связи по сбоям.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.