lenec ru

← все посты

Развёртывание Node-приложения на VPS через systemd

15K

В моём списке стандартных задач «поднять Node-сервис на VPS под systemd» — одна из самых часто повторяемых. Раз в пару недель кто-нибудь спрашивает «а как ты деплоишь без докера». Расскажу свой шаблон. Цель — получить долгоживущий процесс с автозапуском, корректным выключением, логами в journald и health-check.

Контекст: Ubuntu 24.04 на VPS, Node 20 через nvm или из NodeSource, приложение на TypeScript, собранное в dist/. Никакого pm2 — systemd сам прекрасно справляется и интегрируется с journald.

Структура каталогов на сервере

Я держу простую и предсказуемую раскладку. Это не догма, но за 4 года работы ни разу не пожалел:

/opt/myapp/                 # код приложения
  current/                  # симлинк на текущий релиз
  releases/
    2026-05-23-1234abc/     # каждый релиз — отдельная папка
  shared/
    .env                    # переменные окружения
    uploads/                # пользовательские файлы
    logs/                   # на случай, если нужны файлы помимо journald

При деплое я кладу новый релиз в releases/, переключаю симлинк current/ и перезапускаю service. Откат — переключение симлинка на предыдущий релиз. Никаких git pull в проде, ничего, что меняет код прямо в работающей директории.

Системный пользователь

Сервис должен жить от не-root пользователя. У меня всегда:

sudo useradd --system --home /opt/myapp --shell /usr/sbin/nologin app
sudo chown -R app:app /opt/myapp

Без shell, без homedir в реальном смысле слова, только для запуска процесса. Если что-то взломают — у атакующего нет интерактивного входа.

systemd unit

Файл /etc/systemd/system/myapp.service:

[Unit]
Description=MyApp Node service
After=network.target
Wants=network-online.target

[Service]
Type=simple
User=app
Group=app
WorkingDirectory=/opt/myapp/current
ExecStart=/usr/bin/node /opt/myapp/current/dist/server.js
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=3
KillSignal=SIGTERM
TimeoutStopSec=30

Environment=NODE_ENV=production
Environment=NODE_OPTIONS=--enable-source-maps
EnvironmentFile=/opt/myapp/shared/.env

StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp

# Безопасность
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/myapp/shared/logs /opt/myapp/shared/uploads

[Install]
WantedBy=multi-user.target

Что важно объяснить:

  • Type=simple. systemd считает процесс запущенным, как только запустил ExecStart. Для Node-приложения этого достаточно, потому что веб-сервер не форкается.
  • Restart=on-failure + StartLimit. Если приложение падает — перезапускаем, но не более трёх раз в минуту. Иначе логи и CPU забьются циклом «упало → стартанули → упало».
  • KillSignal=SIGTERM + TimeoutStopSec=30. 30 секунд на graceful shutdown. Если не закрылись — пристрелит SIGKILL. В коде должен быть обработчик SIGTERM, который доделывает HTTP-запросы.
  • EnvironmentFile. Все секреты в .env с правами 600 для пользователя app. systemd подхватит при запуске, в ps и environ их не будет видно посторонним процессам, если включить ProtectKernelTunables и компанию.
  • NoNewPrivileges, PrivateTmp, ProtectSystem. Включаешь, проверяешь. На моём опыте не ломает почти ничего, кроме случаев, когда приложение пишет в произвольные места ФС. Если пишет — добавляй пути в ReadWritePaths.

Graceful shutdown в коде

Без него systemd честно подождёт TimeoutStopSec и пристрелит. Минимум:

const server = app.listen(port, () => console.log('listening', port));

function shutdown(signal: string) {
  console.log(`received ${signal}, shutting down`);
  server.close((err) => {
    if (err) {
      console.error('close error', err);
      process.exit(1);
    }
    process.exit(0);
  });
  // если за 25 сек не закрылись — выходим принудительно
  setTimeout(() => process.exit(1), 25_000).unref();
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

Если в приложении есть BullMQ-воркеры или подключения к БД — добавляй их остановку перед process.exit. Иначе пара тысяч активных коннектов к Postgres могут зависнуть в TIME_WAIT.

Активация и проверка

sudo systemctl daemon-reload
sudo systemctl enable --now myapp.service
sudo systemctl status myapp.service
# логи
sudo journalctl -u myapp.service -f --since '1h ago'

journalctl -u myapp -f — мой основной способ смотреть логи. Никаких дополнительных файлов: всё проиндексировано, понятен since, grep работает.

Несколько процессов одного приложения

Иногда хочется запустить N инстансов и балансировать на nginx. Можно через NODE_CLUSTER (cluster API Node), но я предпочитаю template-юнит с инстансами:

# /etc/systemd/system/myapp@.service
[Unit]
Description=MyApp instance %i
...
[Service]
Environment=PORT=300%i
ExecStart=/usr/bin/node /opt/myapp/current/dist/server.js
# остальное — как в myapp.service
[Install]
WantedBy=multi-user.target
sudo systemctl enable --now myapp@1.service
sudo systemctl enable --now myapp@2.service
sudo systemctl enable --now myapp@3.service

Получаем три инстанса на портах 3001, 3002, 3003. nginx балансирует между ними upstream-ом. Удобно, что каждый инстанс перезапускается отдельно.

nginx как reverse proxy

Тут шаблон уже совсем стандартный, привожу для полноты:

upstream myapp {
  server 127.0.0.1:3001;
  server 127.0.0.1:3002;
  keepalive 32;
}

server {
  listen 443 ssl http2;
  server_name app.example.ru;

  ssl_certificate     /etc/letsencrypt/live/app.example.ru/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/app.example.ru/privkey.pem;

  location / {
    proxy_pass http://myapp;
    proxy_http_version 1.1;
    proxy_set_header Connection '';
    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;
  }
}

В Node не забудь app.set('trust proxy', 1), чтобы req.ip и req.protocol работали правильно за nginx.

Деплой-скрипт

На самом простом проекте у меня обычный bash, который запускается из CI:

#!/bin/bash
set -e
COMMIT=$(git rev-parse --short HEAD)
DATE=$(date +%F-%H%M)
RELEASE="${DATE}-${COMMIT}"

rsync -az --delete --exclude=node_modules --exclude=.git ./ app@srv:/opt/myapp/releases/${RELEASE}/

ssh app@srv bash <<EOF
set -e
cd /opt/myapp/releases/${RELEASE}
pnpm install --frozen-lockfile --prod=false
pnpm build
pnpm prune --prod
ln -sfn /opt/myapp/releases/${RELEASE} /opt/myapp/current_new
mv -Tf /opt/myapp/current_new /opt/myapp/current
sudo systemctl restart myapp.service
EOF

echo "deployed ${RELEASE}"

Атомарный mv важен: если переключение симлинка прервётся, не будет полупустого состояния. Откат — то же самое, но указываешь старый RELEASE.

Health check

В Node-приложении endpoint /healthz, который проверяет соединение с БД и возвращает 200. nginx может опрашивать его, но проще делать это извне через мониторинг. У меня обычно UptimeRobot или собственный сервис, который пингует раз в минуту и алертит, если сервис не отвечает.

Логи и ротация

journald делает ротацию сам, размер ограничивается /etc/systemd/journald.conf:

SystemMaxUse=2G
SystemMaxFileSize=200M
MaxRetentionSec=14day

На больших нагрузках я держу 2 ГБ — этого хватает, чтобы покопаться в инцидентах. Если хочется отдельный файл (например, для отправки в внешний agent) — пишу в stdout JSON, и vector/fluent-bit читает journald и пересылает в нужное место.

Что я считаю обязательным

  • Не root-пользователь.
  • Restart с лимитом, чтобы не залупить логи.
  • SIGTERM-обработчик в Node.
  • EnvironmentFile с правами 600.
  • journald, не файлы.
  • Атомарный деплой через симлинк.
  • nginx + Let's Encrypt поверх, ничего не отдаём наружу напрямую.

Это базовая обвязка, которая работает у меня на десятках сервисов. Без Docker, без PM2, без оркестратора. Иногда docker всё-таки нужен — но только когда есть реальные причины: специфические зависимости, гетерогенный стек, оркестрация. Для одного-двух Node-приложений systemd закрывает вопрос полностью.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.