Deploy service lên VM production, health check qua, dashboard xanh. Vài ngày sau service chết — process bị OOM killer giết, không có restart policy, không có alert. Chạy lại bằng tay node server.js & rồi hy vọng không chết nữa.
Đây là lỗ hổng kiến thức mà nhiều backend dev không nhận ra cho đến khi dính incident: không hiểu process trên Linux hoạt động thế nào, signal là gì, và tại sao cần systemd thay vì nohup ... &. Bài này đi từ process lifecycle, cách signal hoạt động, đến viết systemd unit file cho service production.
Process lifecycle — từ fork đến exit
Mọi chương trình đang chạy trên Linux đều là một process. Mỗi process có PID (Process ID) duy nhất, có process cha (parent), có bộ nhớ riêng, có file descriptor riêng. Hiểu lifecycle của process là nền tảng để debug mọi thứ liên quan đến vận hành.
fork/exec — cách process được sinh ra
Trên Linux, process mới không được “tạo từ hư không”. Nó được tạo qua hai bước: fork() tạo bản sao của process hiện tại (child process), rồi exec() thay thế bản sao đó bằng chương trình mới. Khi bạn gõ node server.js trên terminal, shell (bash/zsh) gọi fork() để tạo child process, rồi child đó gọi exec("node", ["server.js"]) để chạy Node.js.
Mỗi process có một parent — process đã fork() ra nó. Process đầu tiên trên hệ thống là PID 1 — trước đây là init, giờ hầu hết distro dùng systemd. PID 1 là “tổ tiên” của mọi process khác trên hệ thống, và nó có vai trò đặc biệt mà mình sẽ nói ở phần orphan process.
Xem process đang chạy bằng ps:
ps aux | grep node
# USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
# app 1234 2.3 1.5 123456 78900 ? Ssl 09:00 0:45 node server.js
ps -ef --forest
# Hiển thị cây process — thấy parent/child rõ ràng
htop cho trải nghiệm trực quan hơn — tree view, sort theo CPU/memory, kill process trực tiếp. Mình dùng htop hàng ngày khi SSH vào server để kiểm tra nhanh hệ thống.
Zombie process — process chết nhưng chưa được dọn
Zombie là trạng thái mà process đã exit nhưng parent chưa gọi wait() để đọc exit status. Trên ps, zombie hiện trạng thái Z:
ps aux | grep Z
# app 5678 0.0 0.0 0 0 ? Z 09:15 0:00 [worker] <defunct>
Tại sao zombie tồn tại? Khi child process exit, kernel giữ lại một entry nhỏ trong process table chứa exit code — chờ parent đọc. Nếu parent không bao giờ gọi wait() (vì nó bận, hoặc vì code không handle SIGCHLD signal), entry đó nằm mãi — thành zombie.
Zombie không chiếm CPU hay memory đáng kể — mỗi zombie chỉ tốn vài byte trong process table. Nhưng nếu tích luỹ hàng nghìn zombie, bạn có thể hết PID (mặc định Linux cho phép tối đa 32768 hoặc 4194304 PID tuỳ config). Hết PID thì không thể tạo process mới — hệ thống gần như freeze.
Fix zombie: không kill được zombie trực tiếp (nó đã chết rồi). Phải fix ở parent — hoặc sửa code parent để handle SIGCHLD và gọi wait(), hoặc kill parent process. Khi parent chết, zombie được “nhận nuôi” bởi PID 1 (systemd), và systemd tự động gọi wait() để dọn chúng.
Mình từng gặp server có hơn 3000 zombie process — tất cả là child của một worker manager viết bằng Python không handle SIGCHLD. Restart worker manager là fix ngay — nhưng bài học là phải kiểm tra zombie count định kỳ trong monitoring.
Orphan process — con mồ côi
Khi parent process chết trước child, child trở thành orphan. Kernel tự động “reparent” orphan cho PID 1 (systemd). Systemd sẽ quản lý orphan và dọn dẹp khi nó exit — đây là lý do PID 1 phải biết cách reap child process.
Orphan không nguy hiểm bằng zombie — nó vẫn chạy bình thường, chỉ đổi parent thành PID 1. Nhưng orphan hay gây nhầm lẫn khi debug vì nó “biến mất” khỏi process tree ban đầu. Đây chính là vấn đề của pattern nohup ./server & — server chạy dưới nền như orphan, không ai quản lý, không restart khi crash.
Signal — cách nói chuyện với process
Signal là cơ chế IPC (Inter-Process Communication) đơn giản nhất trên Unix/Linux. Kernel hoặc process khác gửi signal cho một process, process nhận signal và phản ứng — hoặc theo default action, hoặc theo handler mà code đã đăng ký.
Các signal quan trọng
SIGTERM (15) — yêu cầu process dừng một cách graceful. Đây là signal mặc định khi bạn chạy kill <pid>. Process nhận SIGTERM được kỳ vọng dọn dẹp: đóng database connection, flush buffer, hoàn thành request đang xử lý, rồi exit. SIGTERM có thể bị catch và ignore — process có quyền quyết định làm gì khi nhận.
SIGKILL (9) — giết process ngay lập tức. Kernel xử lý trực tiếp — process không có cơ hội catch, ignore, hay dọn dẹp. File đang ghi dở có thể corrupt, transaction bị bỏ ngang. kill -9 là phương án cuối cùng, không phải lệnh mặc định.
SIGHUP (1) — ban đầu nghĩa là “terminal bị đóng”. Giờ nhiều daemon dùng SIGHUP để reload config mà không cần restart — Nginx, HAProxy, PostgreSQL đều hỗ trợ. kill -HUP <pid> gửi signal reload, zero downtime.
SIGINT (2) — khi bạn bấm Ctrl+C trên terminal. Hầu hết application handle SIGINT giống SIGTERM — graceful shutdown.
SIGUSR1 và SIGUSR2 — signal “tuỳ chỉnh”, application tự quyết định dùng cho gì. Node.js dùng SIGUSR1 để bật debugger. Tiện cho on-the-fly diagnostics trên production.
Tại sao SIGKILL nên là phương án cuối
Khi gửi SIGTERM, application có thời gian dọn dẹp. Web server hoàn thành request đang xử lý rồi mới exit — user không thấy connection reset. Database client đóng connection đàng hoàng — connection pool phía DB không bị leak. Message consumer commit offset trước khi dừng — không xử lý lại message đã xong.
SIGKILL bỏ qua tất cả. TCP connection bị đóng đột ngột — client thấy “connection reset by peer”. File handle không được close — nếu đang ghi WAL hay log thì data có thể corrupt. Shared memory, semaphore, lock file không được cleanup — process mới start lên có thể thấy stale lock.
Pattern đúng mà systemd và Kubernetes đều dùng: gửi SIGTERM trước, chờ grace period (thường 30 giây), rồi mới SIGKILL nếu process vẫn chưa exit.
Handle signal trong application code
Application production cần handle ít nhất SIGTERM và SIGINT. Đây là pattern cơ bản cho ba ngôn ngữ phổ biến.
Node.js:
const server = http.createServer(app);
function gracefulShutdown(signal) {
console.log(`Received ${signal}, shutting down gracefully...`);
server.close(() => {
// Đóng DB connections, flush metrics
db.end().then(() => process.exit(0));
});
// Force exit nếu cleanup quá lâu
setTimeout(() => process.exit(1), 10_000);
}
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
Go:
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-quit
log.Println("Shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Fatal("Forced shutdown:", err)
}
}()
server.ListenAndServe()
Python:
import signal, sys
def handle_shutdown(signum, frame):
print(f"Received signal {signum}, cleaning up...")
server.shutdown()
db.close()
sys.exit(0)
signal.signal(signal.SIGTERM, handle_shutdown)
signal.signal(signal.SIGINT, handle_shutdown)
Điểm chung: catch signal, dừng nhận request mới, chờ request đang xử lý hoàn thành (với timeout), dọn dẹp resource, rồi exit. Timeout quan trọng vì nếu cleanup treo vĩnh viễn thì process không bao giờ exit — systemd sẽ phải SIGKILL.
systemd — quản lý service đúng cách
Trước systemd, người ta dùng nohup, screen, tmux, hoặc viết init script bằng bash. Mỗi cách đều có vấn đề: không tự restart khi crash, log bị mất, dependency giữa service không được quản lý, start/stop không nhất quán. systemd giải quyết tất cả và giờ là init system mặc định trên hầu hết Linux distro — Ubuntu, Debian, Fedora, CentOS, Arch.
Cấu trúc unit file
Service trong systemd được định nghĩa bằng unit file — file text với cú pháp INI đơn giản. Đặt file trong /etc/systemd/system/ cho admin-defined service:
# /etc/systemd/system/myapp.service
[Unit]
Description=My Application Server
After=network.target postgresql.service
Wants=postgresql.service
[Service]
Type=simple
User=app
Group=app
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/node /opt/myapp/server.js
ExecStop=/bin/kill -TERM $MAINPID
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp
[Install]
WantedBy=multi-user.target
Ba section chính: [Unit] mô tả service và dependency (After = start sau service nào, Wants = muốn service nào chạy kèm nhưng không bắt buộc). [Service] là phần quan trọng nhất — chỉ định cách chạy, user, restart policy. [Install] nói service thuộc target nào — multi-user.target nghĩa là start khi hệ thống boot vào multi-user mode (tương đương runlevel 3).
Sau khi tạo hoặc sửa unit file:
sudo systemctl daemon-reload # Reload systemd config
sudo systemctl enable myapp.service # Bật auto-start khi boot
sudo systemctl start myapp.service # Start ngay
sudo systemctl status myapp.service # Xem trạng thái
Type — cách systemd nhận biết service đã sẵn sàng
Type=simple (mặc định): systemd coi service là sẵn sàng ngay khi ExecStart process khởi chạy. Phù hợp cho hầu hết application — Node.js, Go, Python web server — vì chúng chạy foreground và sẵn sàng nhận request ngay sau khi start.
Type=forking: process ExecStart fork child rồi parent exit. systemd chờ parent exit rồi coi child là main process. Pattern cũ mà nhiều daemon C truyền thống dùng (Nginx, Apache). Cần PIDFile= để systemd biết PID của child.
Type=notify: process báo cho systemd “tôi sẵn sàng” qua sd_notify protocol. Chính xác hơn simple — systemd biết chắc service đã bind port và sẵn sàng nhận request, không chỉ là process đã start. Go và Python có thư viện sd_notify để implement.
Mình khuyên dùng Type=simple cho đa số trường hợp. Chỉ chuyển sang notify khi service có quá trình init phức tạp (load model ML, warm cache) và bạn muốn systemd chờ init xong mới coi là “active”.
Restart policy — tự hồi phục khi crash
Đây là lý do lớn nhất để dùng systemd thay vì nohup. Khi process crash, systemd tự restart theo policy bạn cấu hình.
Restart=on-failure — restart khi process exit với exit code khác 0, hoặc bị signal kill (trừ SIGTERM/SIGINT). Đây là lựa chọn phổ biến nhất cho production service. Nếu bạn cố ý stop service (systemctl stop), systemd gửi SIGTERM → process exit 0 → không restart. Nếu process crash (segfault, OOM kill, unhandled exception) → restart.
Restart=always — restart bất kể lý do exit. Dùng khi service phải luôn chạy — nhưng cẩn thận: nếu service liên tục crash ngay sau start, systemd sẽ restart loop. Cần kết hợp với StartLimitIntervalSec và StartLimitBurst để tránh restart storm.
Restart=no (mặc định) — không restart. Đây là mặc định nếu bạn không set — và đây chính xác là lý do nhiều service chết im lặng trên production khi dùng unit file mà quên set Restart.
Restart=on-failure
RestartSec=5 # Chờ 5 giây giữa mỗi lần restart
StartLimitIntervalSec=300 # Trong 300 giây...
StartLimitBurst=5 # ...không restart quá 5 lần
Config trên nghĩa là: nếu service crash 5 lần trong 5 phút, systemd dừng cố restart và đánh dấu service là “failed”. Điều này tránh restart loop vô hạn khi có bug chết ngay lúc start — ví dụ file config sai syntax, port đã bị chiếm.
ExecStop và TimeoutStopSec
Khi bạn chạy systemctl stop myapp, systemd gửi signal cho process. Mặc định gửi SIGTERM, chờ TimeoutStopSec (mặc định 90 giây), rồi SIGKILL nếu process chưa exit.
ExecStop=/bin/kill -TERM $MAINPID
TimeoutStopSec=30
30 giây grace period nghĩa là application có tối đa 30 giây để graceful shutdown — hoàn thành request đang xử lý, đóng connection, flush data. Nếu 30 giây chưa xong, SIGKILL. Chọn giá trị phù hợp với application — web server thường 30 giây đủ, nhưng batch job có thể cần lâu hơn.
Mình từng set TimeoutStopSec=10 cho service có long-running WebSocket connection. Khi restart, 10 giây không đủ để đóng hàng trăm connection — systemd SIGKILL, client thấy connection drop đột ngột. Tăng lên 60 giây fix vấn đề.
WatchdogSec — systemd kiểm tra health
WatchdogSec=30
Khi bật watchdog, application phải gửi “heartbeat” cho systemd mỗi 15 giây (nửa WatchdogSec). Nếu systemd không nhận heartbeat trong 30 giây, nó coi service đã “treo” (hung) và restart.
Đây là cách phát hiện process zombie — process vẫn chạy (PID tồn tại, không crash) nhưng không làm gì hữu ích. CPU thấp, memory ổn, nhưng không phản hồi request. Watchdog bắt được trường hợp này mà health check HTTP thông thường cũng bắt được — nhưng watchdog tích hợp sẵn trong systemd, không cần tool bên ngoài.
Implement heartbeat trong code:
# Python với systemd-python
from systemd.daemon import notify
import threading
def watchdog_ping():
notify("WATCHDOG=1")
threading.Timer(10, watchdog_ping).start()
watchdog_ping()
journalctl — đọc log service
systemd thu thập stdout/stderr của mọi service vào journal — hệ thống log có cấu trúc, có index, query được. Không cần lo log file xoay vòng (logrotate), không cần grep file text.
# Xem log service cụ thể
journalctl -u myapp.service
# Follow log real-time (như tail -f)
journalctl -u myapp.service -f
# Log từ lần boot hiện tại
journalctl -u myapp.service -b
# Log trong khoảng thời gian
journalctl -u myapp.service --since "2026-05-22 09:00" --until "2026-05-22 10:00"
# Log với priority ERROR trở lên
journalctl -u myapp.service -p err
# Output dạng JSON (tiện cho pipeline)
journalctl -u myapp.service -o json-pretty
# Xem log của lần restart gần nhất
journalctl -u myapp.service -n 100 --no-pager
Mình dùng journalctl -u myapp -f nhiều nhất — follow log real-time khi debug. Kết hợp --since để xem log quanh thời điểm incident. Output dạng JSON (-o json) tiện khi cần pipe qua jq để filter theo field cụ thể.
Journal lưu tại /var/log/journal/ và được quản lý tự động — xoay vòng theo dung lượng hoặc thời gian. Cấu hình giới hạn trong /etc/systemd/journald.conf với SystemMaxUse=2G và MaxRetentionSec=30day.
cgroup resource limit — kiểm soát tài nguyên
Một service bị memory leak ăn hết RAM của server, OOM killer giết process quan trọng khác — đây là kịch bản mà cgroup resource limit ngăn chặn. systemd tự động tạo cgroup cho mỗi service, bạn chỉ cần set giới hạn trong unit file.
[Service]
MemoryMax=512M # Giới hạn RAM tối đa 512MB
MemoryHigh=400M # Kernel bắt đầu throttle khi vượt 400MB
CPUQuota=200% # Tối đa 2 CPU cores
TasksMax=256 # Tối đa 256 process/thread
MemoryMax là hard limit — khi process vượt, kernel kill nó (OOM). MemoryHigh là soft limit — kernel throttle (làm chậm) memory allocation, cho application cơ hội giải phóng bộ nhớ. Kết hợp cả hai: MemoryHigh cảnh báo sớm, MemoryMax là hàng rào cuối.
CPUQuota=200% nghĩa là service được dùng tối đa 2 CPU cores. TasksMax giới hạn số process/thread — ngăn fork bomb hoặc thread leak. Kiểm tra resource usage bằng systemctl status myapp.service hoặc systemd-cgtop.
Mình từng cứu production nhờ MemoryMax: service Java bị memory leak, cgroup limit 2GB kill đúng service lỗi thay vì OOM killer giết database. Restart policy tự restart, user chỉ thấy vài giây downtime.
Viết unit file cho service thật
Dưới đây là unit file production-ready cho một Go service:
[Unit]
Description=Order Processing Service
Documentation=https://wiki.internal/order-service
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service
[Service]
Type=notify
User=orderapp
Group=orderapp
WorkingDirectory=/opt/order-service
# Bảo mật
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/order-service/data
PrivateTmp=true
# Chạy service
EnvironmentFile=/etc/order-service/env
ExecStart=/opt/order-service/bin/order-svc
ExecReload=/bin/kill -HUP $MAINPID
# Restart policy
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=300
StartLimitBurst=5
# Graceful shutdown
TimeoutStopSec=30
KillMode=mixed
KillSignal=SIGTERM
# Resource limits
MemoryMax=1G
MemoryHigh=800M
CPUQuota=200%
TasksMax=512
# Watchdog
WatchdogSec=30
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=order-svc
[Install]
WantedBy=multi-user.target
Vài điểm đáng chú ý. Requires=postgresql.service nghĩa là nếu PostgreSQL stop, service này cũng stop — dependency cứng. Wants thì mềm hơn. After chỉ nói thứ tự start, không nói dependency. KillMode=mixed gửi SIGTERM cho main process và SIGKILL cho child process còn sót sau timeout.
Các directive bảo mật (NoNewPrivileges, ProtectSystem, ProtectHome, PrivateTmp) là hardening cơ bản. ProtectSystem=strict làm filesystem read-only trừ path trong ReadWritePaths — nếu service bị compromise, attacker không ghi được file ra ngoài thư mục cho phép.
Quản lý service sau khi tạo unit file:
sudo systemctl daemon-reload
sudo systemctl enable --now order-svc.service
sudo systemctl status order-svc.service
# Reload config (SIGHUP)
sudo systemctl reload order-svc.service
# Restart (stop rồi start)
sudo systemctl restart order-svc.service
# Xem tất cả service đang fail
sudo systemctl --failed
PID 1 trong Docker và Kubernetes
Khi chạy application trong container, process của bạn thường là PID 1 — và PID 1 trên Linux có trách nhiệm đặc biệt: reap zombie child process và forward signal. Hầu hết application không được viết để làm PID 1.
Vấn đề cụ thể: Docker gửi SIGTERM đến PID 1 khi docker stop. Nếu PID 1 là node server.js và code Node.js handle SIGTERM, mọi thứ OK. Nhưng nếu Dockerfile dùng shell form:
# Shell form — PID 1 là /bin/sh, node là child
CMD node server.js
# Exec form — PID 1 là node
CMD ["node", "server.js"]
Shell form chạy /bin/sh -c "node server.js" — PID 1 là shell, Node.js là child. Shell nhận SIGTERM nhưng không forward cho child. Docker chờ grace period rồi SIGKILL — Node.js bị giết không graceful. Luôn dùng exec form.
Nếu application spawn child process (worker pool, subprocess), cần init process đúng. tini hoặc dumb-init là hai lightweight init process phổ biến:
RUN apt-get install -y tini
ENTRYPOINT ["tini", "--"]
CMD ["node", "server.js"]
tini làm PID 1, forward signal cho child, và reap zombie. Kubernetes cũng gặp vấn đề tương tự — terminationGracePeriodSeconds gửi SIGTERM cho PID 1, nếu PID 1 không forward thì application không shutdown gracefully.
Anti-pattern hay gặp
Chạy service bằng nohup ./server &. Process chạy dưới nền, không restart khi crash, log ghi vào nohup.out không ai đọc, PID phải tự ghi nhớ. Mất SSH session thì khó quản lý. Dùng systemd — luôn luôn.
Luôn dùng kill -9. SIGKILL là vũ khí hạng nặng — dùng khi SIGTERM không có tác dụng sau vài chục giây. Mặc định chỉ cần kill <pid> (gửi SIGTERM). Nếu process không phản hồi SIGTERM, đó là bug trong application code — fix code, không phải đổi signal.
Chạy service dưới user root. Service bị compromise = attacker có root access toàn bộ server. Tạo user riêng cho mỗi service, chỉ cho quyền tối thiểu cần thiết. systemd User= và Group= làm việc này dễ dàng.
Không có restart policy. Service crash lúc 3 giờ sáng, không ai biết cho đến sáng hôm sau. Restart=on-failure mất 1 dòng config nhưng giải quyết 90% trường hợp downtime không đáng có.
Ignore signal trong application code. Application không handle SIGTERM — khi systemd stop service, process không dọn dẹp, connection leak, data không flush. 10 dòng code handle graceful shutdown tiết kiệm hàng giờ debug trên production.
Không set resource limit. Một service bị memory leak ăn hết RAM, OOM killer giết process khác — có thể là database hoặc monitoring agent. MemoryMax trong unit file cô lập blast radius.
Log ra file thay vì stdout/stderr. Service ghi log vào /var/log/myapp.log, quên setup logrotate, disk đầy, server chết. Ghi ra stdout/stderr, để systemd journal quản lý — tự xoay vòng, tự giới hạn dung lượng, query được bằng journalctl.
Tóm tắt
Process trên Linux được sinh qua fork/exec, mỗi process có parent, và khi parent không dọn child đã exit thì sinh zombie. Orphan process được PID 1 (systemd) nhận nuôi — đây là vai trò quan trọng của init system.
Signal là cách giao tiếp với process — SIGTERM cho graceful shutdown (luôn xử lý trong code), SIGKILL chỉ dùng khi không còn cách nào khác, SIGHUP cho reload config. Application production phải handle ít nhất SIGTERM và SIGINT với logic dọn dẹp có timeout.
systemd quản lý service lifecycle — unit file định nghĩa cách chạy, restart policy tự hồi phục khi crash, cgroup limit cô lập tài nguyên, journalctl cho log có cấu trúc. Restart=on-failure và MemoryMax là hai directive mà mọi service production cần có.
Trong container, PID 1 phải forward signal và reap zombie — dùng exec form trong Dockerfile hoặc tini/dumb-init làm init process. Không chạy service bằng nohup, không luôn kill -9, không chạy dưới root, không bỏ qua signal handling. Những quy tắc này đơn giản nhưng phân biệt giữa service “chạy được” và service “vận hành được trên production”.