systemd Type=: chọn đúng simple, forking hay notify
Unit báo active nhưng daemon đã chết? Chọn đúng Type= simple, exec, forking, oneshot hoặc notify và xác định main PID thật thay vì theo dõi shell wrapper.
Vì sao systemd báo unit đang active dù process đã chết
Một unit service của systemd duy trì trạng thái active khi process duy nhất mà systemd gọi là main process vẫn còn tồn tại. Type= trong phần [Service] quyết định process đó là process nào. Nếu chọn sai giá trị, systemd sẽ theo dõi một shell wrapper hoặc process cha chạy trong thời gian ngắn, trong khi daemon bạn cần theo dõi lại chết bên trong cùng unit đó. Unit đang phản ánh đúng trạng thái của process mà nó được cấu hình để theo dõi.
Thay đổi restart policy sẽ không giải quyết vấn đề này. Restart= chỉ hoạt động khi main process thoát, nên Restart=always sẽ không bao giờ được kích hoạt nếu main PID (process identifier) vẫn thuộc về một process đang chạy. Hãy sửa Type= trước. Việc systemd làm gì sau khi main process thực sự thoát là một quyết định riêng, được đề cập trong hướng dẫn về Restart= và RestartSec=.
Type= thực sự quyết định điều gì
Mỗi giá trị Type= trả lời đồng thời 2 câu hỏi. Khi nào systemd có thể xem unit này đã khởi động, và tiến trình nào là tiến trình chính.
Câu trả lời đầu tiên kiểm soát thứ tự. Một unit khai báo unit của bạn trong After= sẽ chờ đến khi systemd gọi unit của bạn là đã khởi động. Một Type= báo "đã khởi động" quá sớm sẽ cho phép các unit phụ thuộc chạy trước khi service của bạn có thể phản hồi.
Câu trả lời thứ hai kiểm soát việc giám sát. systemd đưa mọi tiến trình do một unit tạo ra vào một cgroup (control group), một tính năng của kernel dùng để nhóm các tiến trình để có thể giới hạn và kill chúng cùng lúc. cgroup là cách systemctl stop dọn dẹp: KillMode= mặc định là control-group, nên khi dừng một unit, systemd sẽ gửi signal đến mọi tiến trình bên trong unit đó. Main PID có phạm vi hẹp hơn. Đây là tiến trình duy nhất mà khi thoát sẽ kết thúc unit và trạng thái thoát của nó trở thành kết quả của unit. Nhầm cgroup với main PID là nguyên nhân bắt đầu gây ra sự nhầm lẫn.
Type=simple báo started trước khi binary chạy
Type=simple là giá trị mặc định khi đã đặt ExecStart= nhưng không có Type= và BusName=. systemd tạo process, ngay lập tức xem unit là đã started và coi process đó là main PID. Các unit tiếp theo bắt đầu ngay, trước cả khi service binary được thực thi.
Chi tiết cuối cùng này giải thích một tình huống thường gây bất ngờ. Lỗi đánh máy trong đường dẫn ExecStart= vẫn tạo ra một start job thành công. Lỗi chỉ xuất hiện ngay sau đó khi việc thực thi thất bại. systemd ghi nhận trường hợp này bằng exit code 203. Bảng riêng của systemd gọi lỗi này là EXEC và định nghĩa đây là lỗi không thể thực thi service binary. Vì vậy, việc systemctl start trả về mà không có lỗi không chứng minh binary của bạn tồn tại.
Dùng simple cho chương trình chạy ở foreground và không tự chuyển vào background. Cấu hình này phù hợp với hầu hết daemon hiện đại và gần như mọi chương trình bạn tự viết.
Type=exec chờ chương trình thực sự khởi động
Type=exec là simple với thêm một bước. systemd chỉ xem unit đã khởi động sau khi cả fork và việc thực thi binary đều thành công. Binary bị thiếu hoặc User= không thể được phân giải giờ sẽ làm chính start job thất bại, thay vì báo thành công rồi âm thầm thất bại một lúc sau.
Type=exec được thêm vào systemd 240, nên mọi bản phân phối server hiện tại đều có. Ubuntu 24.04 đi kèm systemd 255 và Debian 13 đi kèm systemd 257, tính đến tháng 8 năm 2026. Kiểm tra phiên bản trên máy bằng systemctl --version.
Đổi lại, quá trình start có thêm một bước đồng bộ. Lợi ích là systemctl start trả về exit status chính xác. Với chương trình chạy foreground, nên dùng exec thay cho simple.
Type=forking và cách main PID bị mất
Type=forking cho systemd biết tiến trình trong ExecStart= sẽ fork một tiến trình con rồi chủ động thoát. systemd chờ tiến trình đầu tiên đó thoát, sau đó mới đánh dấu unit đã khởi động. Tiến trình con còn lại là daemon. Cách này có từ thời SysV, khi không có cơ chế nào giám sát daemon sau khi init script trả về và PID file là bản ghi duy nhất về tiến trình đang chạy. Đây là một hạn chế nằm gần trung tâm của lý do systemd thay thế init script.
Vấn đề nằm ở việc xác định danh tính. Tiến trình do systemd khởi chạy đã kết thúc, nên systemd phải xác định tiến trình nào còn lại là tiến trình chính. Đặt PIDFile= thành file mà daemon ghi vào, thường là một path dưới /run, để systemd đọc PID từ đó. systemd cũng kiểm tra PID trong file có trỏ đến một tiến trình đã thuộc service này hay không. Vì vậy, một file cũ trỏ đến tiến trình không liên quan sẽ bị từ chối thay vì được tin cậy.
Nếu không có PIDFile=, GuessMainPID= sẽ được áp dụng và mặc định là yes. Cách đoán này chỉ đáng tin cậy khi service ổn định thành một tiến trình duy nhất. Tài liệu nêu rõ giới hạn này: nếu daemon gồm nhiều hơn một tiến trình, kết quả đoán có thể sai và cơ chế phát hiện lỗi sẽ ngừng hoạt động. Unit cũng có thể kết thúc với main PID là 0, nghĩa là systemd không còn tiến trình nào để giám sát.
Hầu hết daemon có fork đều có tùy chọn để chạy ở foreground. Dùng tùy chọn đó với Type=exec rồi xóa dòng PIDFile=. Ít thành phần hơn sẽ giảm số cách làm mất PID.
Type=oneshot cho tác vụ chạy xong rồi kết thúc
Type=oneshot dùng khi tiến trình cần chạy rồi thoát. systemd chỉ đánh dấu unit là đã khởi động sau khi tiến trình thoát, nên oneshot phù hợp với mọi tác vụ mà unit khác phải chờ. Đây cũng là giá trị mặc định ngầm định khi unit không chỉ định Type= hoặc ExecStart=.
oneshot có 2 hành vi riêng. Đây là type duy nhất chấp nhận nhiều hơn một dòng ExecStart=, và các dòng đó chạy theo thứ tự. Timeout khởi động của nó cũng được tắt mặc định. Vì vậy, một oneshot bị treo sẽ chờ vô thời hạn nếu bạn không tự đặt TimeoutStartSec=.
Sau khi tiến trình thoát, unit trở về trạng thái inactive. RemainAfterExit=yes giữ nó ở trạng thái active dù hoàn toàn không có tiến trình nào đang chạy. Đây là phiên bản có chủ đích của hiện tượng được nêu ở đầu trang, và là hành vi đúng khi nhiệm vụ của unit là để lại một trạng thái thay vì duy trì một tiến trình: nạp ruleset của firewall hoặc khởi động một container stack. Đây là pattern đứng sau Docker Compose stack tự khởi động lại sau reboot, trong đó unit chạy lệnh compose, thoát, rồi vẫn ở trạng thái active vì các container mà nó khởi động tiếp tục chạy sau khi unit kết thúc. Unit oneshot cũng là loại unit được một lịch chạy kích hoạt. Đây là phần còn lại của việc chạy một job bằng systemd timer thay vì cron.
Type=notify cho phép service báo khi đã sẵn sàng
Type=notify chuyển quyết định này cho service. systemd giữ start job ở trạng thái chờ cho đến khi process gửi READY=1 qua Unix socket có path được truyền trong biến môi trường NOTIFY_SOCKET. Interface C là sd_notify(3), và nhiều server đã hỗ trợ nó.
Đây là câu trả lời chính xác cho câu hỏi “đã khởi động chưa”. simple và exec báo service đã khởi động trước khi service đọc xong cấu hình hoặc mở listening socket. Vì vậy, unit phụ thuộc có thể khởi động quá sớm và fail ở lần kết nối đầu tiên. notify chỉ báo đã khởi động tại thời điểm service tự báo rằng nó đã sẵn sàng.
systemd chỉ chấp nhận message đó từ main process. Đây là ý nghĩa của NotifyAccess=main, và Type=notify ngầm định điều này. Nếu message đến từ child hoặc helper, hãy đặt NotifyAccess=all. Shell script có thể gọi systemd-notify --ready, nhưng lệnh này chạy dưới dạng một process ngắn hạn riêng. Vì vậy, nó cần NotifyAccess=all, và systemd có thể không xác định được nguồn của message nếu sender đã thoát. Service tự nói giao thức này sẽ đáng tin cậy hơn.
Có 2 setting liên quan đáng biết. Type=notify-reload, có từ systemd 253, mở rộng cùng handshake này cho thao tác reload. Khi đó, systemctl reload trả về sau khi service báo reload đã hoàn tất, thay vì trả về ngay sau khi signal được gửi. WatchdogSec= yêu cầu service hỗ trợ notify gửi keep-alive message theo một khoảng thời gian. systemd sẽ xem việc bỏ lỡ deadline là một lỗi.
Type=dbus và Type=idle
Type=dbus chờ đến khi service đăng ký một name trên D-Bus, message bus mà các service hệ thống và desktop dùng để trao đổi với nhau. Nó yêu cầu BusName= và trở thành giá trị mặc định ngay khi đặt BusName=. Chỉ dùng nó cho service thực sự đăng ký một bus name.
Type=idle hoạt động giống simple, nhưng trì hoãn việc chạy chương trình cho đến khi các job đang xếp hàng được dispatch, với giới hạn tối đa 5 giây. Tùy chọn này tồn tại để output trên console trong lúc boot không xen vào các status message. Nó không phải công cụ ordering và không nên dùng cho service thông thường.
Vì sao wrapper script khiến systemd theo dõi nhầm PID
Đây là dạng cấu hình gây ra triệu chứng ban đầu.
[Service]
Type=simple
ExecStart=/opt/app/run.sh#!/bin/bash
export APP_ENV=production
/opt/app/bin/server --config /etc/app.yaml &
/opt/app/bin/exporter --port 9101systemd ghi nhận shell là PID chính. Shell vẫn chạy trong khi exporter chạy ở foreground. Nếu server dừng, shell không phát hiện được. Vì vậy PID chính vẫn còn, unit vẫn ở trạng thái active, và Restart= không có gì để xử lý. Cả hai process luôn nằm trong cgroup của unit, nên systemctl stop vẫn dọn dẹp đúng cách. Phần bị hỏng là cơ chế giám sát, không phải việc dọn dẹp.
Cách sửa phụ thuộc vào số process chạy lâu mà unit thực sự có.
Nếu chỉ có một process, hãy thay shell bằng process đó.
#!/bin/bash
export APP_ENV=production
exec /opt/app/bin/server --config /etc/app.yamlexec thay shell bằng program được chỉ định và giữ nguyên PID. Vì vậy PID mà systemd đã ghi nhận giờ thuộc về daemon. Tốt hơn nữa là bỏ wrapper. Environment= và EnvironmentFile= truyền các biến, còn ExecStartPre= thực hiện bước thiết lập. Nhờ đó systemd có thể khởi chạy daemon trực tiếp và biết PID của daemon ngay từ đầu.
Nếu có hai process, không có một PID duy nhất đại diện cho unit. Hãy tách chúng thành hai unit và sắp xếp thứ tự bằng After= và Wants=. Mỗi process một unit là cách bố trí mà systemd giám sát tốt nhất. Đây cũng là cách duy nhất để mỗi process có cơ chế restart riêng.
ExitType=cgroup thay đổi gì
ExitType= được thêm vào systemd 250. Giá trị mặc định là main: unit được xem là đã dừng khi process chính thoát. Với ExitType=cgroup, unit được xem là đang chạy chừng nào còn process nào sống trong cgroup của nó.
[Service]
Type=simple
ExitType=cgroup
ExecStart=/opt/app/launcherTùy chọn này giải quyết một vấn đề cụ thể. Một launcher khởi chạy công việc thực rồi thoát sẽ khiến systemd xem unit là đã dừng khi dùng ExitType=main và kill các process còn lại. Với ExitType=cgroup, unit theo dõi toàn bộ group thay vì chỉ process launcher.
Cần hiểu rõ tùy chọn này không giải quyết vấn đề gì. ExitType=cgroup giữ unit ở trạng thái active khi còn ít nhất một process đang chạy. Vì vậy, một unit chứa hai daemon vẫn active sau khi một daemon bị dừng. Tùy chọn này xử lý trường hợp launcher. Nó không biến một unit thành supervisor cho nhiều process độc lập. ExitType= cũng không thể kết hợp với Type=oneshot.
cgroup cũng là nơi ghi nhận việc sử dụng tài nguyên. Vì vậy, các giới hạn như MemoryMax= và CPUQuota= áp dụng cho mọi process do unit tạo ra, bất kể Type= quy định gì về main PID. Phần này được trình bày trong giới hạn memory và CPU của service bằng systemd.
Cách xác định process mà systemd thực sự đang theo dõi
Thực hiện lần lượt các bước này trên unit bạn đang debug. Trước tiên đọc cấu hình systemd đã load, sau đó đọc những gì systemd đang theo dõi, rồi so sánh với process table.
systemctl cat app.service
systemctl show -p Type,ExitType,GuessMainPID,PIDFile,MainPID,RemainAfterExit app.servicesystemctl cat in unit file cùng với mọi drop-in áp dụng cho unit đó. Nhờ vậy, bạn đọc đúng cấu hình systemd đã load thay vì file mà bạn nhớ là đã chỉnh sửa. systemctl show in các giá trị hiệu lực, bao gồm cả những giá trị mặc định bạn chưa từng ghi lại. Ghi lại giá trị của MainPID trước khi tiếp tục.
systemd-cgls --unit=app.service
ps -o pid,ppid,stat,etime,args -p "$(systemctl show -p MainPID --value app.service)"systemd-cgls liệt kê mọi process trong cgroup của unit. Dòng ps mô tả process duy nhất mà systemd giám sát. Đọc hai kết quả này cùng nhau. MainPID bằng 0 nghĩa là systemd không có process nào để theo dõi. MainPID trỏ đến một shell trong khi cgroup cũng chứa daemon của bạn là trường hợp wrapper được nêu ở trên. Nếu cgroup có nhiều process hơn dự kiến, có launcher hoặc daemon forking tham gia.
systemctl status app.service
journalctl -u app.service -bsystemctl status in state line và cây cgroup cùng nhau, nên thường trả lời được cả hai câu hỏi ngay lập tức. journalctl -u giới hạn trong lần boot này bằng -b sẽ hiển thị các sự kiện start và stop mà systemd đã ghi lại cho unit, cùng với exit code mà systemd nhận được. Nếu daemon ghi vào log file riêng thay vì journal, hãy đọc cả file đó, vì systemd chỉ có thể ghi lại những gì được gửi đến nó.
Khi thay đổi Type=, hãy reload rồi restart.
systemd-analyze verify /etc/systemd/system/app.service
sudo systemctl daemon-reload
sudo systemctl restart app.servicesystemd-analyze verify phân tích file và báo cáo các setting mà nó không thể chấp nhận. daemon-reload khiến systemd đọc lại các unit file từ disk. Type= đã thay đổi không áp dụng cho unit đang chạy, vì vậy restart là bắt buộc.
Sau đó kiểm tra thay đổi. Lấy PID của process bạn thực sự cần theo dõi từ systemd-cgls rồi kill process đó. Chạy systemctl is-active app.service ngay sau đó. Nếu Type= đúng, unit sẽ rời khỏi trạng thái active. Nếu unit vẫn active, systemd vẫn đang theo dõi một process khác.
Nên dùng Type= nào của systemd
- Chương trình chạy ở foreground:
Type=exec. - Chương trình hỗ trợ thông báo sẵn sàng:
Type=notify, vànotify-reloadnếu chương trình cũng xác nhận việc reload. - Daemon bắt buộc phải chuyển sang background:
Type=forkingvớiPIDFile=, hoặc dùng tùy chọn chạy foreground của nó vớiType=exec. - Script thực hiện công việc rồi thoát:
Type=oneshot, và thêmRemainAfterExit=yeskhi mục đích là để lại trạng thái. - Launcher thoát trong khi các tiến trình con vẫn tiếp tục chạy:
Type=simplevớiExitType=cgroup.
Nếu không chắc một daemon của bên thứ ba cần loại nào, trước tiên hãy đọc unit file đi kèm package. Chạy systemctl cat trên unit do distribution cung cấp sẽ hiển thị Type= mà upstream đã chọn. Lựa chọn đó đã được nhiều người kiểm thử hơn cấu hình của bạn.
FAQ
Tại sao unit systemd của tôi vẫn ở trạng thái active dù process đã chết?
Vì process mà systemd xem là process chính vẫn còn chạy. systemd theo dõi một PID duy nhất cho mỗi service, được chọn theo Type=, thay vì theo dõi mọi process trong cgroup của unit. Script wrapper được khởi chạy bằng Type=simple là nguyên nhân thường gặp: shell là PID chính, nên unit vẫn ở trạng thái active khi daemon do shell chạy ở background bị thoát. Chạy systemctl show -p MainPID app.service, sau đó liệt kê cgroup của unit bằng systemd-cgls --unit=app.service rồi so sánh hai kết quả.
Type=simple và Type=exec khác nhau thế nào?
Type=simple xem unit đã khởi động ngay khi systemd tạo process, trước khi binary được thực thi. Vì vậy, đường dẫn sai trong ExecStart= vẫn tạo ra một start job thành công, sau đó process mới fail. Type=exec chờ đến khi việc thực thi thành công, nên start job tự báo lỗi. Cả hai đều xem cùng một process là PID chính. Type=exec yêu cầu systemd 240 hoặc mới hơn.
Tôi còn cần PIDFile= khi dùng Type=forking không?
Có, bất cứ khi nào daemon ghi một PID file. Nếu không có, systemd sẽ chuyển sang dùng GuessMainPID=. Đây chỉ là phỏng đoán và chỉ đáng tin cậy với service ổn định ở một process duy nhất. Khi phỏng đoán sai hoặc không thể thực hiện, việc phát hiện lỗi và tự động restart sẽ không còn hoạt động cho unit đó. Trỏ PIDFile= đến đúng path mà daemon ghi, thường nằm dưới /run.
Khi nào tôi nên dùng RemainAfterExit=yes?
Khi mục đích của unit là thay đổi trạng thái hệ thống thay vì duy trì một process đang chạy. Một unit Type=oneshot tải các rule của firewall hoặc khởi động một container stack sẽ thoát ngay khi hoàn tất công việc. Nếu không có RemainAfterExit=yes, unit sẽ chuyển sang inactive, khiến systemctl stop không còn gì để stop và không có cách chạy thao tác cleanup ExecStop=. Khi có tùy chọn này, unit vẫn ở trạng thái active dù không có process nào, và đó là hành vi được mong muốn trong trường hợp này.
Thay đổi Type= có cần daemon-reload không?
Có, đồng thời cũng cần restart unit. systemctl daemon-reload khiến systemd đọc lại các unit file trên disk, nhưng instance đang chạy vẫn giữ Type= mà nó đã khởi động cùng. Chạy sudo systemctl daemon-reload rồi sudo systemctl restart app.service trước khi kiểm tra. Nếu không, bạn vẫn đang theo dõi behaviour supervision cũ.