29. Демоны и фоновые процессы
Демоны и фоновые процессы: Архитектура, жизненный цикл и Go-реализация
В Unix-подобных системах понятие фоновой работы приложения фундаментально отличается от привычного в веб-среде (PHP-FPM, Java Tomcat, Python uWSGI). Там приложение запускается один раз, обрабатывает запросы и завершается по сигналу. В серверной инфраструктуре Linux приложения должны жить вечно, переживать перезагрузки, не мешать администратору в терминале и корректно останавливаться при SIGTERM.
"Термин 'демон' (daemon) пришел в информатику из физики — от знаменитого демона Максвелла, неутомимого невидимого духа, работающего в фоновом режиме и поддерживающего порядок во Вселенной. В Unix демоны — это не зловещие сущности, а скромные рабочие лошадки, которые делают свое дело тихо, без шума в терминале и без лишнего внимания."
Для Go-разработчика понимание этой модели критично. Современные Go-приложения редко «демонизируют» себя вручную. Вместо этого они полагаются на инициализаторы (systemd, supervisord) и стандарты Unix. Разберем, как устроена эта магия под капотом и как писать production-ready сервисы.
Фоновый процесс vs Демон: Где граница?
Термины часто используют как синонимы, но с точки зрения ядра Linux между ними есть архитектурная разница.
- Фоновый процесс (Background Process): Запускается пользователем с суффиксом
&или черезnohup. Он наследует файловые дескрипторы, рабочую директорию и, что важно, контролирующий терминал (CTTY) от родительской оболочки. Если вы закроете терминал, оболочка отправитSIGHUPвсем процессам этой сессии, и фоновый процесс умрет. - Демон (Daemon): Долгоживущий процесс, который:
- Не привязан к управляющему терминалу.
- Запускается автоматически при старте ОС.
- Работает в
rootили от имени непривилегированного пользователя с минимальными правами. - Не выводит логи в stdout/stderr терминала (перенаправляет их в
syslog,journaldили файлы).
Отвязка от терминала: Под капотом setsid()
Классический способ превратить процесс в демон (паттерн SysVinit) состоит из нескольких шагов. Если вы пишете CLI-утилиту, которая должна работать в фоне, вы должны реализовать эту логику вручную.
flowchart TD
A["Родительский процесс (в терминале)"] -->|"fork()"| B["Потомок 1"]
A -->|"exit(0)"| Term["Родитель завершен (терминал свободен)"]
B -->|"setsid()"| C["Лидер новой сессии (нет CTTY)"]
C -->|"fork()"| D["Потомок 2 (не может получить CTTY)"]
B -->|"exit(0)"| Void["Потомок 1 завершен"]
D -->|"chdir('/') & umask(0)"| E["Безопасные пути и права"]
D -->|"Закрытие FD 0, 1, 2"| F["Перенаправление в /dev/null"]
D -->|"Усыновление PID 1"| G["Полноценный демон под init/systemd"]
style A fill:#1e293b,stroke:#818cf8,color:#fff
style C fill:#1e3a5f,stroke:#38bdf8,color:#fff
style D fill:#064e3b,stroke:#34d399,color:#fff
style G fill:#2e1065,stroke:#c084fc,color:#fff
fork(): Родительский процесс завершается. Это делает потомка сиротой, которого немедленно забираетinit(PID 1). Теперь ОС не отправитSIGHUPпри закрытии терминала.setsid(): Создает новую сессию и делает процесс лидером сессии. Это разрывает связь с управляющим терминалом. Теперь процесс не может получить доступ к терминалу и не будет получать от него сигналы.chdir("/"): Смена рабочей директории на корень, чтобы демон не блокировал размонтирование файловых систем.umask(0): Сброс маски прав, чтобы демон полностью контролировал права создаваемых файлов.- Закрытие FD: Закрытие всех унаследованных файловых дескрипторов (
25. Файловые дескрипторы. Все в Linux является файлом), чтобы не держать открытыми сокеты или файлы родительской оболочки.
func daemonize() error {
// Защита от рекурсии: потомок запускает тот же бинарник и снова попал бы в daemonize().
// Поэтому помечаем потомка переменной окружения и в нём ничего не делаем.
if os.Getenv("DAEMONIZED") == "1" {
return nil
}
// 1. Fork+exec нового экземпляра и завершение родителя
pid, err := syscall.ForkExec("/proc/self/exe", os.Args, &syscall.ProcAttr{
Dir: ".",
Env: append(os.Environ(), "DAEMONIZED=1"),
Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd()},
Sys: &syscall.SysProcAttr{Setsid: true}, // новая сессия: отрыв от терминала
})
if err != nil {
return fmt.Errorf("fork failed: %w", err)
}
fmt.Printf("Daemon started with PID: %d\n", pid)
return nil
}
Сигналы и Graceful Shutdown
Демоны обязаны реагировать на сигналы ОС. В отличие от PHP, где скрипт умирает мгновенно, Go позволяет реализовать Graceful Shutdown: завершить обработку текущих запросов, отключить прием новых, сохранить состояние и только потом выйти.
| Сигнал | Назначение | Поведение Go |
|---|---|---|
SIGTERM |
Корректное завершение | Стандарт для systemd stop. Можно перехватить. |
SIGINT |
Прерывание (Ctrl+C) | Обычно отправляется только процессам с терминалом. |
SIGHUP |
Перезагрузка конфига / отрыв от терминала | В systemd отключен по умолчанию. В SysVinit убивает фоновые процессы. |
SIGKILL |
Невозможно перехватить | Мгновенное убийство. kill -9. |
В Go сигналы доставляются через канал (рекомендуется создавать буферизованный канал емкостью не менее 1, чтобы избежать потери сигналов при занятости читателя). Вызов signal.Notify можно выполнять повторно для одного и того же канала — каждый вызов дополняет список отслеживаемых сигналов.
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()
srv := &http.Server{Addr: ":8080"}
go func() {
<-ctx.Done()
fmt.Println("Received shutdown signal, draining...")
ctx, stop := context.WithTimeout(context.Background(), 10*time.Second)
defer stop()
srv.Shutdown(ctx)
}()
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}
systemd и современная парадигма инициализзации
Современный Linux отказался от самодиагностируемых демонов. systemd (PID 1) управляет сервисами через юнит-файлы .service. Это меняет подход к написанию Go-приложений:
- Не нужно делать
setsid()илиfork().systemdсам создает изолированную среду. Type=simple(по умолчанию):systemdсчитает сервис запущенным сразу послеfork()главного процесса, ещё до егоexecve()(поэтому для сервисов, которым важно, что бинарник действительно запустился, чаще лучшеType=exec).Type=notify: Сервис должен отправить сообщение вsd-daemonчерезsd_notify()после инициализации. Это позволяетsystemdждать готовности (например, пока база данных подключится).Type=forking: Для старых демонов.systemdждет, пока родительский процесс завершится, и считает потомка основным.- Логирование:
systemdперехватываетstdoutиstderrи пишет их вjournald. Не нужно настраивать syslog-драйверы вручную.
# /etc/systemd/system/myapp.service
[Unit]
Description=My Go Daemon
After=network.target
[Service]
Type=notify
ExecStart=/usr/local/bin/myapp
Restart=always
User=appuser
Group=appgroup
# systemd автоматически закрывает лишние FD и сбрасывает umask
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
Для интеграции с Type=notify используется официальный пакет: github.com/coreos/go-systemd/v22/daemon (функция daemon.SdNotify).
sequenceDiagram
autonumber
participant Systemd as systemd (PID 1)
participant Daemon as Go Сервис (/usr/local/bin/myapp)
participant DB as База данных
Systemd->>Daemon: Запуск процесса (Type=notify)
Daemon->>DB: Подключение к БД и прогрев пулов
DB-->>Daemon: Успешное подключение
Daemon->>Systemd: daemon.SdNotify(SdNotifyReady) через сокет NOTIFY_SOCKET
Note over Systemd: systemd отмечает сервис как ACTIVE (running)
Systemd-->>Systemd: Запуск зависимых сервисов
Идиоматичный Go-демон: Паттерн для продакшена
Production-ready Go-демон должен объединять три компонента: контекст жизни, обработчик сигналов и пул ресурсов.
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/coreos/go-systemd/v22/daemon"
)
func main() {
// 1. Создаем контекст с сигналом остановки
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
// 2. Инициализация ресурсов (БД, брокеры, пулы)
db, err := initDB(ctx)
if err != nil {
log.Fatalf("Failed to init DB: %v", err)
}
defer db.Close()
// 3. Запуск сервиса
srv := &http.Server{Addr: ":8080", Handler: newRouter(db)}
go func() {
fmt.Println("Server started")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Listen error: %v", err)
}
}()
// 4. Блокировка до сигнала
<-ctx.Done()
fmt.Println("Shutting down gracefully...")
// 5. Graceful Shutdown с таймаутом
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
// 6. Уведомление systemd о завершении
_, _ = daemon.SdNotify(false, daemon.SdNotifyStopping)
fmt.Println("Server exited")
}
Итог
- Демон vs Фоновый процесс: Демон оторван от терминала (
setsid), фоновый процесс наследует сессию оболочки и умирает при закрытии терминала. - Под капотом: Отрыв от терминала меняет принадлежность к процессной группе и сессии в ядре Linux. Это предотвращает рассылку
SIGHUPи освобождает управляющие ресурсы. - Сигналы:
SIGTERM— стандарт для остановки. Go перехватывает его через каналы. Только одна горутина может слушать канал сигналов. - systemd: Современный стандарт. Отказ от ручной демонизации в пользу
Type=notifyи перехвата stdout/stderr вjournald. - Go-паттерн:
signal.NotifyContext+context.WithTimeout+srv.Shutdown()+daemon.SdNotify= надежный сервисный код.
Мы разобрали, как процессы живут в фоне и как ОС управляет их жизненным циклом. Теперь логично перейти к тому, как именно systemd реализует это управление, какие директивы важны для Go-приложений и как работает активация сокетов. В следующей статье мы разберем: 30. systemd. Управление сервисами в Linux.