Aplikacja Node.js uruchomiona ręcznie w sesji SSH nie wstanie po restarcie serwera. Jako usługa systemd wstanie, ale tylko wtedy, gdy jednostka ma właściwe PATH, właściwy katalog stanu PM2 i sandbox, który nie wyłącza silnika V8. Stan active w systemctl status mówi przy tym tylko tyle, że działa proces główny jednostki, a nie to, że działają aplikacje.
W tym artykule przygotowujemy dwie jednostki systemd dla aplikacji Node.js: prostą, w której systemd uruchamia node bezpośrednio, oraz jednostkę dla PM2. Pokazujemy, które ustawienia sandboxa działają z Node, a które go wyłączają, na co zwracać uwagę przy PM2 i jak przed restartem serwera sprawdzić, że wstanie to samo, co działa teraz. Każde opisane zachowanie sprawdziliśmy na Ubuntu 24.04 i Debianie 13.
Spis treści
- Dwa sposoby uruchamiania Node.js przez systemd: bezpośrednio albo przez PM2
- Przygotowanie: konto usługowe, Node.js z nvm i aplikacja testowa
- Jednostka systemd uruchamiająca Node.js bezpośrednio
- Jak PM2 działa pod systemd: demon, PM2_HOME, dump.pm2 i pm2 resurrect
- Co generuje pm2 startup i kiedy potrzebna jest własna jednostka
- Jednostka systemd dla PM2 krok po kroku
- Sandbox jednostki Node.js: ProtectHome, ReadWritePaths i MemoryDenyWriteExecute
- Na co zwracać uwagę: pm2 save, PM2_HOME w powłoce i PATH jednostki
- Jak testować jednostkę przed restartem serwera
- Checklista dla nowej aplikacji Node.js pod systemd
Dwa sposoby uruchamiania Node.js przez systemd: bezpośrednio albo przez PM2
W pierwszym sposobie systemd uruchamia node server.js jako główny proces jednostki. Sam zbiera wyjście aplikacji do dziennika, sam podnosi proces po awarii i sam go zatrzymuje. To najmniej elementów i najprostsza diagnostyka.
PM2 to menedżer procesów dla aplikacji Node.js, instalowany przez npm. Dokumentacja PM2 opisuje go jako demona, który zarządza aplikacjami i utrzymuje je w działaniu, a rejestr npm jako menedżer procesów dla środowiska produkcyjnego z wbudowanym równoważeniem obciążenia (licencja AGPL-3.0). W praktyce PM2 uruchamia aplikacje według pliku konfiguracyjnego, restartuje je po awarii i zbiera ich logi.
W drugim sposobie systemd uruchamia PM2, a PM2 uruchamia aplikacje. PM2 daje tryb cluster (kilka procesów aplikacji na jednym porcie), pm2 reload podmieniający procesy bez zatrzymywania całości oraz jeden plik ecosystem.config.js dla wielu aplikacji. Kosztem jest dodatkowa warstwa: systemd pilnuje tylko demona PM2, a to, które aplikacje wrócą po restarcie, zależy od pliku stanu zapisanego przez PM2.
Dla jednej aplikacji w jednym procesie wystarcza jednostka bez PM2. Przy kilku aplikacjach albo trybie cluster wygodniejszy jest PM2 pod systemd. W obu przypadkach obowiązują te same zasady sandboxa i te same testy.
Przygotowanie: konto usługowe, Node.js z nvm i aplikacja testowa
Aplikacja działa na własnym koncie bez uprawnień roota, w przykładach deploy. Node.js instalujemy przez nvm (Node Version Manager), który trzyma każdą wersję w osobnym katalogu w domu konta. Wybieramy wersję ze wsparciem: według harmonogramu wydań Node.js (plik schedule.json, commit 143dd650ca) linia 22 jest wspierana do 30 kwietnia 2027, linia 24 do 30 kwietnia 2028, a linia 20 ma EOL (End of Life) od 30 kwietnia 2026.
Wszystkie przykłady sprawdziliśmy 29 września 2026 na:
- Ubuntu 24.04.5 LTS, jądro 6.8.0-142-generic, systemd 255.4-1ubuntu8.17;
- Debian 13 (trixie), jądro 6.12.107+deb13-cloud-amd64, systemd 257.13-1~deb13u1;
- nvm v0.40.8, Node.js 22.23.3, npm 10.9.9 i PM2 6.0.8. Zachowanie PM2 opisane niżej sprawdziliśmy też w PM2 5.4.3 i w PM2 7.0.4, najnowszym wydaniu w dniu testu.
Linki do dokumentacji systemd prowadzą do wersji latest na freedesktop.org, która w dniu pisania odpowiada systemd 262.
Jako root wykonujemy wyłącznie czynności administracyjne: zakładamy konto, zapisujemy pliki jednostek i wywołujemy systemctl. Node.js, nvm, PM2 i aplikacje nigdy nie działają jako root. Polecenia, które ich dotyczą, poprzedzamy sudo -u deploy -H: root przełącza się wtedy na konto deploy, a -H ustawia jego katalog domowy jako HOME, z którego korzystają nvm i PM2. Jednostki systemd uruchamiają te same programy jako deploy dzięki dyrektywie User=deploy. Wymaga to pakietu sudo, który na Ubuntu 24.04 ma priorytet important, a na Debianie 13 optional, więc w minimalnej instalacji Debiana może go nie być. W blokach z wynikiem znak # na początku linii oznacza polecenie roota, a $ polecenie wpisane w powłoce konta deploy.
Instalacja Node.js przez nvm dla konta usługowego
Na serwerze, jako root. Numery wersji zastępujemy wybranymi; skrypt instalacyjny nvm (README nvm) warto przed uruchomieniem przejrzeć, bo wykonujemy go przez potok. PM2 instalujemy tylko wtedy, gdy wybieramy drugi sposób uruchamiania.
useradd -m -s /bin/bash deploy
sudo -u deploy -H bash -c 'curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.8/install.sh | bash'
sudo -u deploy -H bash -c '. ~/.nvm/nvm.sh && nvm install 22.23.3'
sudo -u deploy -H bash -c '. ~/.nvm/nvm.sh && nvm exec 22.23.3 npm install -g pm2@6.0.8'
Powodzenie potwierdza ls /home/deploy/.nvm/versions/node/v22.23.3/bin/: są tam node, npm i, po drugim poleceniu, pm2. Każda wersja Node ma w nvm własny katalog, więc PM2 instaluje się osobno w każdej wersji, której używamy.
Aplikacja testowa, która podaje wersję Node i sprawdza zapis
Do sprawdzania jednostek używamy małej aplikacji. Odpowiada swoją nazwą, wersją Node i numerem PID (Process Identifier), więc widać, który proces obsługuje port. Pod adresem /write próbuje zapisać plik w katalogu data, co pozwala sprawdzić sandbox. Zapisujemy ją jako /home/deploy/apps/demo/server.js, właścicielem katalogu /home/deploy/apps/demo i podkatalogu data jest konto deploy.
const http = require('http');
const fs = require('fs');
const path = require('path');
const port = Number(process.env.PORT || 3001);
http.createServer((req, res) => {
if (req.url === '/write') {
try {
fs.writeFileSync(path.join(__dirname, 'data', 'probe.txt'), String(Date.now()));
res.end('write ok\n');
} catch (e) {
res.statusCode = 500;
res.end(`write failed: ${e.code}\n`);
}
return;
}
res.end(`${process.env.APP_NAME} node=${process.version} pid=${process.pid}\n`);
}).listen(port, () => console.log(`${process.env.APP_NAME} listening on ${port}`));
Jednostka systemd uruchamiająca Node.js bezpośrednio
Plik zapisujemy jako /etc/systemd/system/demo-native.service. Konto, katalogi, port i wersję Node zastępujemy własnymi.
[Unit]
Description=Aplikacja Node.js bez PM2
Wants=network-online.target
After=network-online.target
[Service]
Type=exec
User=deploy
Group=deploy
WorkingDirectory=/home/deploy/apps/demo
Environment=NODE_ENV=production PORT=3010 APP_NAME=native
ExecStart=/home/deploy/.nvm/versions/node/v22.23.3/bin/node server.js
Restart=on-failure
RestartSec=2s
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/deploy/apps/demo/data
[Install]
WantedBy=multi-user.target
Type=execuznaje usługę za uruchomioną, gdy systemd wykona binarkę Node. PID procesu Node jest głównym PID jednostki, więc nie potrzeba pliku PID.ExecStartpodaje pełną ścieżkę donodez katalogu nvm. systemd nie czyta~/.bashrc, w którym instalator nvm dopisuje swoją konfigurację, więc samonodenie zostałoby znalezione.Environment=przekazuje aplikacji konfigurację. Hasła trzymamy w osobnym pliku z uprawnieniami0600i wskazujemy go dyrektywąEnvironmentFile=. Na labie zwykły użytkownik widział wartość zEnvironment=zarówno wsystemctl cat, jak i wsystemctl show -p Environment, a pliku zEnvironmentFile=nie mógł odczytać, choć proces dostał obie zmienne.Restart=on-failurezRestartSec=2spodnosi aplikację po awarii. Sekcję sandboxa opisujemy dalej.Wants=iAfter=network-online.targetopóźniają start do osiągnięcia tego celu. Nie gwarantuje to, że host ma już każdy adres; ograniczenia opisujemy w artykule o usłudze, która nie wstaje po restarcie serwera.
Walidacja, włączenie i sprawdzenie, na serwerze jako root:
systemd-analyze verify /etc/systemd/system/demo-native.service
systemctl daemon-reload
systemctl enable --now demo-native
curl -s http://127.0.0.1:3010/ # native node=v22.23.3 pid=...
journalctl -u demo-native -n 2 -o cat # native listening on 3010
systemd-analyze verify nie wypisuje nic, gdy plik jest poprawny. Wyjście aplikacji trafia prosto do dziennika jednostki. Na labie po kill -9 procesu Node systemd uruchomił go ponownie w ciągu 3 sekund, a NRestarts wzrósł do 1. Wycofanie: systemctl disable --now demo-native, usunięcie pliku i systemctl daemon-reload.
Jak PM2 działa pod systemd: demon, PM2_HOME, dump.pm2 i pm2 resurrect
PM2 składa się z dwóch części. Demon (w ps widoczny jako PM2 v6.0.8: God Daemon) uruchamia procesy aplikacji i pilnuje ich. Polecenie pm2 jest tylko klientem, który łączy się z demonem przez gniazda rpc.sock i pub.sock w katalogu PM2_HOME, domyślnie ~/.pm2. Jeden katalog to jeden demon; dokumentacja PM2 pokazuje, że niezależne instancje PM2 uruchamia się właśnie przez różne wartości PM2_HOME. Jeśli w danym katalogu demon nie działa, polecenie pm2 go uruchamia.
pm2 save zapisuje bieżącą listę procesów do pliku PM2_HOME/dump.pm2, a pm2 resurrect uruchamia demona i odtwarza procesy z tego pliku (dokumentacja PM2: Startup Script). Jednostka systemd wywołuje w ExecStart właśnie pm2 resurrect. Polecenie kończy pracę po uruchomieniu demona, dlatego jednostka ma Type=forking, a systemd śledzi demona przez PID z pliku wskazanego w PIDFile=.
Procesy aplikacji są dziećmi demona, więc należą do cgroup (control group) jednostki. Dotyczy to także procesów uruchomionych później poleceniem pm2 z sesji SSH. Prześledźmy jedno polecenie: root w sesji SSH wykonuje jako konto deploy polecenie pm2 restart portal z PM2_HOME=/home/deploy/.pm2-node22. Klient pm2 działa w cgroup sesji, łączy się przez rpc.sock z demonem jednostki pm2-deploy-node22, a demon zamyka stary proces i uruchamia nowy. Nowy proces trafia do cgroup jednostki:
# pid=$(ss -ltnpH 'sport = :3003' | grep -oE 'pid=[0-9]+' | cut -d= -f2) # port aplikacji portal
# cat /proc/$pid/cgroup
0::/system.slice/pm2-deploy-node22.service
To samo polecenie bez PM2_HOME trafiłoby do ~/.pm2. Nie ma tam demona, więc pm2 uruchomiłby nowy, w cgroup sesji SSH, czyli poza systemd.
W trybie cluster port nasłuchu trzyma sam demon, który pełni rolę procesu nadrzędnego klastra. Dlatego ss pokazuje przy porcie aplikacji w tym trybie numer PID demona, a nie workera.
Najważniejsza konsekwencja: po restarcie jednostka odtwarza stan z dump.pm2, a nie z pamięci demona. Na labie aplikacja uruchomiona bez późniejszego pm2 save po systemctl restart jednostki już nie wróciła.
Co generuje pm2 startup i kiedy potrzebna jest własna jednostka
PM2 potrafi sam przygotować jednostkę systemd. Polecenie pm2 startup uruchamiamy jako konto deploy. Konto nie może zapisywać w /etc/systemd/system, więc PM2 niczego nie zmienia, tylko wypisuje polecenie, które trzeba wykonać z uprawnieniami roota:
# sudo -u deploy -H env PATH=/home/deploy/.nvm/versions/node/v22.23.3/bin:/usr/bin:/bin pm2 startup systemd
[PM2] Init System found: systemd
[PM2] To setup the Startup Script, copy/paste the following command:
sudo env PATH=$PATH:/home/deploy/.nvm/versions/node/v22.23.3/bin /home/deploy/.nvm/versions/node/v22.23.3/lib/node_modules/pm2/bin/pm2 startup systemd -u deploy --hp /home/deploy
To jedyne miejsce, w którym pm2 działa jako root, i tylko po to, żeby zapisać plik jednostki. Wypisane polecenie wykonujemy jako root, bez sudo na początku. Na labie PM2 6.0.8 zapisało plik /etc/systemd/system/pm2-deploy.service (właściciel root, uprawnienia 644) i włączyło jednostkę (systemctl enable), ale jej nie uruchomiło. Po wykonaniu polecenia nie działał żaden demon PM2 ani żaden proces Node roota. PM2 założył jednak katalog /root/.pm2 z podkatalogami logs, modules i pids. Demon PM2 i aplikacje startują później jako deploy, bo tak stanowi User=deploy w wygenerowanej jednostce. Najważniejsze linie:
[Service]
Type=forking
User=deploy
Environment=PATH=/home/deploy/.nvm/versions/node/v22.23.3/bin:/usr/bin:/bin:...
Environment=PM2_HOME=/home/deploy/.pm2
PIDFile=/home/deploy/.pm2/pm2.pid
Restart=on-failure
ExecStart=/home/deploy/.nvm/versions/node/v22.23.3/lib/node_modules/pm2/bin/pm2 resurrect
ExecReload=/home/deploy/.nvm/versions/node/v22.23.3/lib/node_modules/pm2/bin/pm2 reload all
ExecStop=/home/deploy/.nvm/versions/node/v22.23.3/lib/node_modules/pm2/bin/pm2 kill
PM2 5.4.3 i 7.0.4 generują identyczny szablon, różniący się tylko ścieżkami. Przy jednej wersji Node taka jednostka działa. Własną piszemy z trzech powodów:
- Jedna nazwa i jeden katalog stanu na konto. Jednostka zawsze nazywa się
pm2-<konto>i używa~/.pm2. Wywołaniepm2 startupz PM2 zainstalowanego w innej wersji Node nadpisało na labie ten sam plik, zmieniając tylko ścieżki. Opcja--service-namezmienia nazwę jednostki, ale nie katalog: w PM2 7.0.4 z tą opcją nadal powstałoPM2_HOME=/home/deploy/.pm2. Jeśli na jednym koncie mają działać aplikacje na dwóch wersjach Node, każda wersja potrzebuje własnegoPM2_HOMEi własnej jednostki. - Wersja Node wpisana na stałe. Ścieżka do konkretnej wersji trafia do
PATHiExecStart. Dokumentacja PM2 zaleca po aktualizacji Node usunięcie jednostki (pm2 unstartup) i wygenerowanie jej od nowa. - Brak kontroli i sandboxa. Jednostka nie sprawdza, czy istnieje zapisany stan, i nie ma żadnych ograniczeń sandboxa.
- Uruchomienie
pm2jako root. Przy własnej jednostce root zapisuje tylko plik tekstowy i wywołujesystemctl, apm2jako root nie jest potrzebny w ogóle.
Jednostkę wygenerowaną przez PM2 usuwamy jako root poleceniami systemd, bez uruchamiania pm2: systemctl disable --now pm2-deploy, usunięcie pliku /etc/systemd/system/pm2-deploy.service i systemctl daemon-reload. Katalog /root/.pm2 można wtedy usunąć. PM2 ma do tego polecenie pm2 unstartup, ale ono również wymaga uruchomienia pm2 jako root.
Jednostka systemd dla PM2 krok po kroku
Zasada: jedna wersja Node = jeden katalog PM2_HOME = jedna jednostka. Nazwa jednostki i katalogu zawiera wersję, żeby już w systemctl było widać, co jest czym, i żeby kolejna wersja Node mogła kiedyś stanąć obok. W tym wariancie pm2 nigdy nie działa jako root: root zapisuje plik jednostki i wywołuje systemctl, a demon PM2 i aplikacje startują jako deploy.
Krok 1. Plik ecosystem.config.js z aplikacjami
PM2 czyta listę aplikacji z pliku ecosystem.config.js. W przykładzie shop-prod działa w trybie cluster w dwóch procesach na porcie 3001, a shop-dev i portal w trybie fork na portach 3002 i 3003. Plik zapisujemy jako /home/deploy/apps/demo/ecosystem.config.js:
module.exports = {
apps: [
{ name: 'shop-prod', script: './server.js', cwd: '/home/deploy/apps/demo',
instances: 2, exec_mode: 'cluster', env: { PORT: 3001, APP_NAME: 'shop-prod' } },
{ name: 'shop-dev', script: './server.js', cwd: '/home/deploy/apps/demo',
instances: 1, exec_mode: 'fork', env: { PORT: 3002, APP_NAME: 'shop-dev' } },
{ name: 'portal', script: './server.js', cwd: '/home/deploy/apps/demo',
instances: 1, exec_mode: 'fork', env: { PORT: 3003, APP_NAME: 'portal' } },
],
};
Krok 2. Katalog PM2_HOME i pierwszy zapisany stan procesów
Jednostka z kroku 3 odmawia startu bez pliku dump.pm2, a sandbox wymaga istnienia katalogu PM2_HOME. Stan przygotowujemy więc ręcznie: uruchamiamy aplikacje, zapisujemy listę i zamykamy tymczasowego demona. Polecenia wykonujemy na serwerze jako root, z katalogu, do którego konto deploy ma dostęp. Zmienna P22 skraca wywołanie pm2 jako konto deploy, z właściwym PATH i PM2_HOME.
cd /home/deploy
P22="sudo -u deploy -H env PATH=/home/deploy/.nvm/versions/node/v22.23.3/bin:/usr/bin:/bin PM2_HOME=/home/deploy/.pm2-node22"
$P22 pm2 start /home/deploy/apps/demo/ecosystem.config.js
$P22 pm2 save # [PM2] Successfully saved in /home/deploy/.pm2-node22/dump.pm2
$P22 pm2 kill # [PM2] [v] PM2 Daemon Stopped
Krok 3. Plik jednostki pm2-deploy-node22.service
Plik zapisujemy jako /etc/systemd/system/pm2-deploy-node22.service. Konto, ścieżki i wersję zastępujemy własnymi; linię ReadWritePaths= dla katalogu danych aplikacji dopasowujemy po audycie opisanym w części o sandboxie.
[Unit]
Description=PM2 (node22) dla konta deploy
Documentation=https://pm2.keymetrics.io/docs/usage/startup/
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=10
[Service]
Type=forking
User=deploy
Group=deploy
WorkingDirectory=/home/deploy
Environment=HOME=/home/deploy
Environment=PM2_HOME=/home/deploy/.pm2-node22
Environment=PATH=/home/deploy/.nvm/versions/node/v22.23.3/bin:/usr/local/bin:/usr/bin:/bin
PIDFile=/home/deploy/.pm2-node22/pm2.pid
ExecStartPre=/usr/bin/test -s /home/deploy/.pm2-node22/dump.pm2
ExecStart=/home/deploy/.nvm/versions/node/v22.23.3/bin/pm2 resurrect
ExecReload=/home/deploy/.nvm/versions/node/v22.23.3/bin/pm2 reload all
ExecStop=/home/deploy/.nvm/versions/node/v22.23.3/bin/pm2 kill
Restart=on-failure
RestartSec=5s
TimeoutStopSec=30s
LimitNOFILE=infinity
UMask=0077
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/deploy/.pm2-node22 /home/deploy/apps/demo/data
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictRealtime=yes
RestrictNamespaces=yes
RestrictSUIDSGID=yes
LockPersonality=yes
SystemCallArchitectures=native
CapabilityBoundingSet=
AmbientCapabilities=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target
Co robią najważniejsze linie:
Environment=PM2_HOMEiPIDFile=wskazują katalog tej jednej wersji Node. Dwie jednostki nie mogą dzielić katalogu, bo walczyłyby o te same gniazda i ten samdump.pm2.Environment=PATHzaczyna się od katalogubinwersji Node. Skryptpm2ma w nagłówku#!/usr/bin/env node, więc bez tego wpisu nie znajdzie interpretera.ExecStartPre=/usr/bin/test -snie pozwala uruchomić pustego demona, gdydump.pm2nie istnieje albo ma zero bajtów. Nie wykrywa jednak pliku z pustą listą[].Restart=on-failurezRestartSec=5spodnosi jednostkę po awarii demona. Limity startów stoją w sekcji[Unit]: po dziesięciu restartach w ciągu 60 sekund systemd przestaje ponawiać (StartLimitIntervalSec=).ExecReload=pm2 reload allpodmienia procesy aplikacji bez zamykania demona. Na labie posystemctl reloadwszystkie aplikacje dostały nowe numery PID, a PID demona się nie zmienił.ExecStop=pm2 killzamyka demona razem ze wszystkimi aplikacjami. Każdysystemctl restartjednostki to więc przerwa dla wszystkich jej aplikacji.
Jeśli aplikacja przy starcie potrzebuje bazy danych na tym samym hoście, dopisujemy w [Unit] Wants=mariadb.service i After=mariadb.service. Gdy aplikacja nie ponawia połączenia z bazą, można dodać opóźnienie ExecStartPre=/usr/bin/sleep 20. Na labie systemctl restart trwał z nim 22 sekundy. To obejście, a nie gwarancja gotowości bazy.
Krok 4. Walidacja, włączenie i pierwszy start jednostki
Na serwerze, jako root:
systemd-analyze verify /etc/systemd/system/pm2-deploy-node22.service
systemctl daemon-reload
systemctl enable --now pm2-deploy-node22
systemctl status pm2-deploy-node22 --no-pager
systemd-analyze verify nie wypisuje nic, gdy plik jest poprawny. W dzienniku jednostki powinny pojawić się linie Restoring processes located in /home/deploy/.pm2-node22/dump.pm2 i Started pm2-deploy-node22.service, a systemctl status pokazuje active (running). Wycofanie zmiany: systemctl disable --now pm2-deploy-node22, usunięcie pliku jednostki i systemctl daemon-reload. Katalog PM2_HOME ze stanem zostaje.
Sandbox jednostki Node.js: ProtectHome, ReadWritePaths i MemoryDenyWriteExecute
Dyrektywy sandboxa ograniczają to, co proces może zrobić w systemie, nawet jeśli aplikacja zostanie przejęta. systemd-analyze security ocenia ekspozycję jednostki w skali od 0 do 10. Na obu systemach labu jednostka PM2 bez sekcji sandboxa dostała 9.2 UNSAFE, z sandboxem bez ProtectHome= 3.2 OK, a w wersji z kroku 3 3.1 OK. PM2 i aplikacje działały z obiema wersjami sandboxa.
ProtectSystem=strict nie chroni katalogu /home
Dokumentacja ProtectSystem= mówi, że wartość strict montuje w trybie tylko do odczytu całą hierarchię systemu plików poza /dev, /proc i /sys. Na labie aplikacja z jednostki z ProtectSystem=strict bez problemu zapisała jednak plik w swoim katalogu w /home. W przestrzeni montowań jednostki katalog główny był tylko do odczytu, a /home nie:
# awk '$5=="/" || $5 ~ /^\/home/ {print $5, $6}' /proc/<PID aplikacji>/mountinfo
/ ro,nosuid,relatime
/home rw,nosuid,relatime
/home/deploy/.pm2-node22 rw,nosuid,relatime
Wyjaśnienie jest w kodzie systemd, a nie w dokumentacji. Tabela trybu strict w pliku src/core/namespace.c (tag v262, linie 250-263; w tagu v255 to samo w liniach 207-220) oznacza /home, /run/user i /root jako zapisywalne, a komentarz nad nią mówi wprost, że katalogami domowymi zarządza osobno ProtectHome=. Dla aplikacji z katalogiem w /home ProtectSystem=strict bez ProtectHome= nie chroni więc ani kodu aplikacji, ani Node.js z nvm.
Z ProtectHome=read-only ta sama próba zapisu kończy się błędem EROFS, a dopiero wpisanie katalogu danych w ReadWritePaths= przywraca zapis. Odpowiedzi aplikacji testowej pod adresem /write:
ProtectHome=read-only -> write failed: EROFS
ProtectHome=read-only
ReadWritePaths=... /home/deploy/apps/demo/data -> write ok
Przed włączeniem ProtectHome=read-only trzeba więc ustalić, gdzie aplikacja zapisuje: katalogi uploadów, cache i własnych logów. Logi procesów PM2 domyślnie trafiają do PM2_HOME/logs, który jest już na liście. Pomocne jest sprawdzenie plików zmienionych od startu aplikacji, np. w ciągu ostatniej godziny:
sudo -u deploy find /home/deploy/apps -xdev -type f -mmin -60
ReadWritePaths= wymaga istniejących katalogów
Każda ścieżka w ReadWritePaths= musi istnieć przed startem jednostki. Przy brakującym katalogu PM2_HOME jednostka nie dochodzi nawet do ExecStartPre i kończy się kodem 226/NAMESPACE. Komunikat zależy od wersji systemd:
# Ubuntu 24.04, systemd 255
Failed to set up mount namespacing: /home/deploy/.pm2-node22: No such file or directory
# Debian 13, systemd 257
Failed at step NAMESPACE spawning /usr/bin/test: No such file or directory
Ścieżkę opcjonalną można poprzedzić znakiem -; systemd pominie ją wtedy, jeśli nie istnieje (dokumentacja ReadWritePaths=). Katalogu PM2_HOME tak nie oznaczamy, bo bez niego jednostka i tak nie ma czego uruchomić.
MemoryDenyWriteExecute=yes wyłącza każdą aplikację Node.js
MemoryDenyWriteExecute=yes zabrania tworzenia pamięci jednocześnie zapisywalnej i wykonywalnej. Silnik V8 kompiluje JavaScript do kodu maszynowego w trakcie działania, czyli w trybie JIT (Just-In-Time), i takiej pamięci potrzebuje. Po dodaniu tej dyrektywy już proces kontrolny pm2 resurrect z ExecStart, czyli Node, kończył się sygnałem SIGTRAP ze śladem stosu V8. Demon nie powstawał, a żadna aplikacja nie odpowiadała:
20: 0x189e254 v8::internal::Runtime_BytecodeBudgetInterruptWithStackCheck_Ignition(...) [node]
pm2-deploy-node22.service: Control process exited, code=dumped, status=5/TRAP
Na Debianie 13 kod był ten sam (status=5/TRAP), a wynik jednostki brzmiał signal zamiast core-dump. Dokumentacja tej dyrektywy ostrzega, że jest niezgodna z silnikami JIT. W jednostkach Node.js jej nie używamy.
NoNewPrivileges=yes działał z PM2 i aplikacją testową bez zastrzeżeń. Blokuje on jednak podniesienie uprawnień przez programy setuid, więc aplikacja, która wysyła pocztę przez lokalny sendmail z bitem setuid, może przestać to robić. Ten sam mechanizm przy funkcji mail() opisujemy w artykule o hardeningu PHP-FPM.
Na co zwracać uwagę: pm2 save, PM2_HOME w powłoce i PATH jednostki
Każda zmiana listy procesów wymaga pm2 save
Nowa aplikacja uruchomiona przez pm2 start działa od razu, ale do dump.pm2 trafia dopiero po pm2 save. Bez niego po restarcie jednostki albo serwera nie wróci, a aplikacja usunięta przez pm2 delete wróci. systemctl status nie pokaże żadnego z tych stanów, bo jednostka z punktu widzenia systemd działa, dopóki działa demon. Po każdym pm2 start, pm2 delete i zmianie liczby instancji wykonujemy pm2 save z tym samym PM2_HOME, którego używa jednostka.
Pusty pm2 save nie nadpisuje dump.pm2
Po usunięciu ostatniej aplikacji pm2 save niczego nie zapisuje, a stary plik zostaje nietknięty. Tak było w PM2 5.4.3, 6.0.8 i 7.0.4:
[PM2] Saving current process list...
[PM2][WARN] PM2 is not managing any process, skipping save...
[PM2][WARN] To force saving use: pm2 save --force
pm2 save --force zapisuje pustą listę [], czyli plik o rozmiarze 2 bajtów. Taki plik przechodzi test test -s z ExecStartPre, więc ta kontrola nie zatrzyma startu jednostki. Jednostkę, która nie ma już żadnych aplikacji, wyłączamy (systemctl disable --now) zamiast zostawiać ją z pustym stanem.
Każde polecenie pm2 bez PM2_HOME uruchamia demona w ~/.pm2
W PM2 5.4.3, 6.0.8 i 7.0.4 wystarczyło nawet pm2 --version, żeby w podanym katalogu powstał demon, jeśli żaden tam nie działał. Uruchomiony z sesji SSH ląduje w cgroup tej sesji, czyli poza systemd. Przykład z powłoki konta deploy:
$ pm2 ls # bez PM2_HOME, w ~/.pm2 żaden demon nie działa
[PM2] Spawning PM2 daemon with pm2_home=/home/deploy/.pm2
[PM2] PM2 Successfully daemonized
$ cat /proc/$(pgrep -f 'God Daemon \(/home/deploy/.pm2\)')/cgroup
0::/user.slice/user-1000.slice/session-175.scope
Taki demon nie wstanie po restarcie, a aplikacja uruchomiona w nim zajmie port niezależnie od jednostki. Dlatego skrypty wdrożeń ustawiają PM2_HOME jawnie przy każdym wywołaniu pm2. Jeśli konto używa tylko jednej wersji Node, można też wyeksportować PM2_HOME w profilu konta. Przy sudo -u deploy pm2 ... wywołanym z katalogu /root PM2 kończy się błędem spawn .../bin/node EACCES, bo konto deploy nie ma dostępu do bieżącego katalogu; wystarczy wcześniej cd /home/deploy.
Brak katalogu nvm w PATH jednostki
Jednostka widzi tylko PATH ze swojego pliku. Bez katalogu bin wersji Node skrypt pm2 nie znajduje interpretera i jednostka kończy się kodem 127:
# Ubuntu 24.04
/usr/bin/env: 'node': No such file or directory
# Debian 13
env: 'node': No such file or directory
pm2-deploy-node22.service: Control process exited, code=exited, status=127/n/a
Nieudany start jest ponawiany aż do limitu
Przy braku dump.pm2 ExecStartPre kończy się kodem 1, a Restart=on-failure ponawia start co 5 sekund. Na labie jednostka była w stanie activating (auto-restart) przez około 70 sekund, a po dziesiątej próbie systemd zapisał Start request repeated too quickly i przestawił ją w stan failed. Ponawianie przerywa systemctl stop.
Jak testować jednostkę przed restartem serwera
Poniższe testy wykonujemy na serwerze jako root. Pierwsze trzy niczego nie zmieniają; ostatnie dwa powodują przerwę w działaniu aplikacji.
Poprawność pliku i ocena sandboxa
systemd-analyze verify /etc/systemd/system/pm2-deploy-node22.service
systemd-analyze security --no-pager pm2-deploy-node22.service | tail -1
# → Overall exposure level for pm2-deploy-node22.service: 3.1 OK
Porównanie działających procesów PM2 z dump.pm2
Test odpowiada na pytanie, czy po restarcie wrócą te same procesy, które działają teraz. Skrypt najpierw sprawdza, że jednostka działa, bo pm2 jlist przy wyłączonym demonie uruchomiłby nowego. Zapisujemy go np. jako /usr/local/sbin/pm2-dump-check:
#!/bin/bash
# pm2-dump-check JEDNOSTKA KONTO PM2_HOME KATALOG_BIN_NODE
set -euo pipefail
unit=$1; user=$2; home=$3; bin=$4
if ! systemctl is-active --quiet "$unit"; then
echo "$unit nie dziala - nie wywoluje pm2, bo uruchomiloby nowego demona"; exit 2
fi
sudo -u "$user" -H env PATH="$bin:/usr/bin:/bin" PM2_HOME="$home" pm2 jlist \
| python3 -c '
import collections, json, sys
live = collections.Counter(p["name"] for p in json.load(sys.stdin))
dump = collections.Counter(p["name"] for p in json.load(open(sys.argv[1])))
for name in sorted(set(live) | set(dump)):
mark = "OK " if live[name] == dump[name] else "ROZNICA"
print(f"{mark} {name}: dziala {live[name]}, w dump.pm2 {dump[name]}")
sys.exit(0 if live == dump else 1)
' "$home/dump.pm2"
Wywołanie i wynik po uruchomieniu na labie aplikacji extra bez pm2 save:
# cd /home/deploy
# pm2-dump-check pm2-deploy-node22 deploy /home/deploy/.pm2-node22 \
/home/deploy/.nvm/versions/node/v22.23.3/bin
ROZNICA extra: dziala 1, w dump.pm2 0
OK portal: dziala 1, w dump.pm2 1
OK shop-dev: dziala 1, w dump.pm2 1
OK shop-prod: dziala 2, w dump.pm2 2
Kod wyjścia 0 oznacza zgodność, 1 różnicę, a 2 zatrzymaną jednostkę. Skrypt porównuje nazwy i liczbę instancji, a nie wersje Node; wersję sprawdzamy w polu node_version z pm2 jlist.
Właściciel portów i zbędne demony PM2
ss -ltnpH 'sport = :3001' # PID procesu na porcie
cat /proc/<PID>/cgroup # oczekiwane: system.slice/pm2-deploy-node22.service
ps -eo pid,user,args | grep '[P]M2 v' # jeden demon na każdą aktywną jednostkę, konto deploy
systemctl list-unit-files 'pm2-*' # które jednostki PM2 wstaną po restarcie
Proces w cgroup session-*.scope albo demon z katalogiem ~/.pm2 oznacza PM2 uruchomiony poza systemd. Demon PM2 z użytkownikiem root w drugiej kolumnie oznacza, że PM2, a z nim aplikacje, uruchomiono jako root.
Awaria demona i restart serwera
Zabicie demona sprawdza, czy systemd podniesie jednostkę i czy pm2 resurrect odtworzy aplikacje:
kill -9 "$(systemctl show -p MainPID --value pm2-deploy-node22)"
systemctl show pm2-deploy-node22 -p ActiveState -p NRestarts
Na labie po 9 sekundach jednostka była znowu active, NRestarts=1, a wszystkie aplikacje miały stan online. Ostatnim testem jest restart serwera, po którym sprawdzamy dziennik od startu systemu:
journalctl -b -u pm2-deploy-node22 -o short-monotonic | grep -E 'Starting|Started|Failed'
[ 8.360221] ubu2404 systemd[1]: Starting pm2-deploy-node22.service - PM2 (node22) dla konta deploy...
[ 10.893519] ubu2404 systemd[1]: Started pm2-deploy-node22.service - PM2 (node22) dla konta deploy.
Po restarcie powtarzamy porównanie z dump.pm2 i kontrolę portów. Jednostka wyłączona przed restartem powinna pozostać nieaktywna.
Checklista dla nowej aplikacji Node.js pod systemd
- Czy aplikacja, a przy PM2 także demon PM2, działa na własnym koncie bez uprawnień roota, na wspieranej wersji Node?
- Czy
ExecStartalboPATHjednostki wskazuje katalogbintej wersji Node? - Czy przy PM2 każda wersja Node ma własny
PM2_HOMEi własną jednostkę? - Czy po każdej zmianie listy procesów wykonano
pm2 savewe właściwymPM2_HOME, a porównanie zdump.pm2nie pokazuje różnic? - Czy działa dokładnie jeden demon PM2 na każdą aktywną jednostkę i żaden w
~/.pm2? - Czy procesy na portach aplikacji należą do cgroup właściwej jednostki?
- Czy jednostka ma
ProtectHome=, aReadWritePaths=obejmuje wszystkie katalogi, do których aplikacja zapisuje? - Czy jednostka nie ma
MemoryDenyWriteExecute=yes? - Czy skrypty wdrożeń ustawiają
PM2_HOMEprzy każdym wywołaniupm2? - Czy restart jednostki, a potem serwera, przeszedł na kopii środowiska i czy monitoring sprawdza aplikacje, a nie tylko stan jednostki?
Aplikacje Node.js pod systemd, które wstają po restarcie serwera
Przygotowujemy jednostki systemd dla aplikacji Node.js, dobieramy sandbox do tego, co aplikacja naprawdę zapisuje, i sprawdzamy restart na kopii środowiska, zanim trafi na produkcję.
Powiązany artykuł
Hardening usług systemd - Nginx, PHP-FPM i MariaDB
Jak ograniczyć uprawnienia usług przez drop-iny, nie psując przy tym ich działania.
Zobacz też