systemd Type=: chọn simple, forking hay notify?
Unit báo active nhưng daemon đã chết? Hiểu Type=simple, exec, forking, oneshot và notify để chọn đúng main PID, thay vì theo dõi shell wrapper.
Vì sao systemd báo unit đang active dù tiến trình đã chết
Một service unit của systemd giữ trạng thái active khi tiến trình duy nhất mà systemd xem là tiến trình chính vẫn còn chạy. Type= trong phần [Service] quyết định tiến trình đó là tiến trình nào. Nếu chọn sai giá trị, systemd sẽ theo dõi một shell wrapper hoặc một tiến trình cha tồn tại trong thời gian ngắn, trong khi daemon bạn cần theo dõi đã chết bên trong cùng unit. Unit đang phản ánh đúng trạng thái của tiến trình mà nó được cấu hình để theo dõi.
Thay đổi chính sách restart sẽ không giải quyết vấn đề này. Restart= chỉ hoạt động khi tiến trình chính thoát, nên Restart=always không bao giờ được kích hoạt khi main PID (process identifier) vẫn thuộc về một tiến trình đang chạy. Trước tiên, hãy sửa Type=. Việc systemd làm gì sau khi tiến trình chính thực sự thoát là một quyết định riêng, được trình bày 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 thứ nhất kiểm soát thứ tự khởi động. 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 trạng thái "started" 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 mà 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 nhau. 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 hẹp hơn. Đây là tiến trình duy nhất mà khi thoát sẽ kết thúc unit, đồng thời exit status 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 cho sự nhầm lẫn này.
Type=simple báo started trước khi binary chạy
Type=simple là giá trị mặc định khi đã đặt ExecStart= và không có Type= hoặc BusName=. systemd tạo process, lập tức coi unit đã started và xem process đó là main PID. Các unit phụ thuộc bắt đầu ngay, trước cả khi binary của service được thực thi.
Chi tiết cuối cùng này giải thích một trường hợp 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 quá trình thực thi thất bại. systemd ghi nhận trường hợp này bằng exit code 203. Trong bảng của systemd, mã này có tên EXEC và được định nghĩa là không thể thực thi binary của service. Vì vậy, việc systemctl start trả về 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ả thao tác fork và thực thi binary đều thành công. Nếu binary bị thiếu hoặc User= không thể được phân giải, start job sẽ fail ngay thay vì báo thành công rồi âm thầm fail sau đó.
Type=exec được bổ sung trong systemd 240, nên mọi bản phân phối server hiện nay đề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 hệ thống của bạn bằng systemctl --version.
Cái giá phải trả là thêm một bước đồng bộ khi khởi động. Đổi lại, 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 process trong ExecStart= sẽ fork một child rồi chủ động thoát. systemd chờ process đầu tiên đó thoát, sau đó mới coi unit là đã khởi động. Child còn lại là daemon.
Vấn đề nằm ở việc xác định process. Process do systemd khởi chạy đã kết thúc, nên systemd phải xác định process nào còn lại là process chính. Đặt PIDFile= thành file mà daemon ghi vào, thường là một đường dẫn bên dưới /run, rồi systemd đọc PID từ file đó. systemd cũng kiểm tra PID trong file có trỏ đến một process đã thuộc service này hay không. Vì vậy, file cũ trỏ đến một process không liên quan sẽ bị từ chối thay vì được tin tưởng.
Nếu không có PIDFile=, GuessMainPID= sẽ được áp dụng và mặc định là yes. Cách phỏng đoán này chỉ đáng tin cậy khi service ổn định ở một process duy nhất. Tài liệu nêu rõ giới hạn: nếu daemon gồm nhiều hơn một process, kết quả phỏng đoán có thể sai và cơ chế phát hiện lỗi sẽ không hoạt động. Unit cũng có thể có main PID bằng 0, nghĩa là systemd không có process nào để giám sát.
Hầu hết daemon có fork đều có tùy chọn giữ process chạy ở foreground. Dùng tùy chọn đó với Type=exec và xóa dòng PIDFile=. Ít thành phần hơn sẽ giảm khả năng bị mất PID.
Type=oneshot cho tác vụ chạy xong rồi kết thúc
Type=oneshot dùng cho 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 thứ 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=.
Có hai hành vi chỉ áp dụng cho oneshot. Đâ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 bị tắt theo mặc định, nên oneshot bị treo sẽ chờ vô hạn trừ khi bạn 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ủ ý của hiện tượng được mô tả ở đầu trang và là hành vi đúng khi nhiệm vụ của unit là để lại trạng thái thay vì duy trì một tiến trình đang chạy: 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 và vẫn ở trạng thái active vì các container do 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, đây là phần còn lại của chạy job bằng systemd timer thay vì cron.
Type=notify cho phép service thông báo khi đã sẵn sàng
Type=notify chuyển việc quyết định sang service. systemd giữ job khởi động ở trạng thái chờ cho đến khi process gửi READY=1 qua Unix socket có đường dẫn được truyền trong biến môi trường NOTIFY_SOCKET. C interface là sd_notify(3), và nhiều server đã hỗ trợ interface này.
Đây là câu trả lời chính xác cho câu hỏi “service đã khởi động chưa”. simple và exec báo đã 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à làm hỏng connection đầu tiên. notify báo đã khởi động đúng thời điểm service tự thông 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 bật tùy chọn này. Nếu message đến từ child process 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 process gửi đã thoát. Service tự nói giao thức này sẽ đáng tin cậy hơn.
Có 2 setting liên quan cần biết. Type=notify-reload, có từ systemd 253, mở rộng cùng cơ chế handshake này cho thao tác reload. Khi đó, systemctl reload chỉ trả về sau khi service báo reload đã hoàn tất, thay vì trả về ngay khi signal được gửi. WatchdogSec= yêu cầu service dùng cơ chế notify gửi keep-alive message theo một khoảng thời gian. systemd 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 để giao tiếp với nhau. Nó yêu cầu BusName= và trở thành mặc định ngay khi đặt BusName=. Chỉ dùng loại này 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 đến khi các job đang xếp hàng được dispatch, với giới hạn tối đa 5 giây. Loại này tồn tại để output trên console khi boot không xen lẫn với các status message. Nó không phải công cụ kiểm soát thứ tự và không phù hợp với 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 hoạt động trong khi exporter chạy ở foreground. Nếu server dừng, shell không nhận biết, nên PID chính vẫn còn hoạt động, unit vẫn ở trạng thái active và Restart= không có gì để xử lý. Cả hai tiến trình luôn nằm trong cgroup của unit, nên systemctl stop vẫn dọn dẹp đúng cách. Vấn đề nằm ở 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ố lượng tiến trình chạy lâu mà unit thực sự có.
Nếu chỉ có một tiến trình, hãy thay shell bằng tiến trình đó.
#!/bin/bash
export APP_ENV=production
exec /opt/app/bin/server --config /etc/app.yamlexec thay shell bằng chương trình được chỉ định và giữ nguyên PID, nên PID mà systemd đã ghi nhận giờ thuộc về daemon. Tốt hơn nữa, hãy xóa wrapper. Environment= và EnvironmentFile= truyền các biến, còn ExecStartPre= thực hiện bước setup, để systemd có thể khởi chạy daemon trực tiếp và biết PID của nó ngay từ đầu.
Nếu có hai tiến trình, không có PID duy nhất nào đạ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 tiến trình một unit là cách bố trí mà systemd giám sát tốt, đồng thời đây là cách duy nhất để mỗi tiến trình có hành vi restart riêng.
ExitType=cgroup thay đổi điều gì
ExitType= được thêm vào systemd 250. Giá trị mặc định là main: unit được xem là đã dừng khi tiến trình chính thoát. Với ExitType=cgroup, unit được xem là đang chạy chừng nào vẫn còn tiến trình trong cgroup của nó.
[Service]
Type=simple
ExitType=cgroup
ExecStart=/opt/app/launcherCấu hình này giải quyết một vấn đề cụ thể. Một launcher khởi động công việc thực rồi thoát sẽ khiến systemd xem unit là đã dừng và kill các tiến trình còn lại khi dùng ExitType=main. Với ExitType=cgroup, unit theo dõi toàn bộ group thay vì chỉ theo dõi launcher.
Cần hiểu rõ những gì cấu hình này không giải quyết. ExitType=cgroup giữ unit ở trạng thái active khi còn ít nhất một tiến trình tồn tại, nên unit chứa hai daemon vẫn active sau khi một daemon dừng. Cấu hình này xử lý trường hợp launcher. Nó không biến một unit thành supervisor cho nhiều tiến trình độc lập. ExitType= cũng không thể kết hợp với Type=oneshot.
cgroup cũng là nơi systemd 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 tiến trình do unit tạo ra, bất kể Type= chỉ ra điều gì về main PID. Phần đó được trình bày trong giới hạn memory và CPU của service bằng systemd.
Cách xác định tiến trình mà systemd thực sự đang giám sát
Thực hiện theo đúng thứ tự trên unit bạn đang debug. Trước tiên đọc cấu hình systemd đã nạp, 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 tất cả drop-in áp dụng cho unit đó. Nhờ vậy, bạn đang đọc cấu hình systemd đã nạp 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 mà bạn khô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 phần 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 thì đó là trường hợp wrapper đã nêu ở trên. Nếu cgroup có nhiều process hơn dự kiến, có thể unit đang dùng launcher hoặc daemon dạng forking.
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 trong một lần. journalctl -u giới hạn kết quả ở lần boot hiện tại bằng -b, hiển thị các sự kiện start và stop mà systemd đã ghi nhận cho unit, cùng với exit code mà systemd nhận được. Nếu daemon ghi vào file log riêng thay vì journal, hãy đọc cả file đó, vì systemd chỉ ghi nhận được những gì 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à systemd không thể chấp nhận. daemon-reload yêu cầu systemd đọc lại các unit file từ disk. Type= đã thay đổi sẽ không áp dụng cho unit đang chạy, nên 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 readiness:
Type=notify, vànotify-reloadnếu chương trình cũng xác nhận việc reload. - Daemon bắt buộc chuyển sang background:
Type=forkingvớiPIDFile=, hoặc dùng tùy chọn chuyển sang foreground cùngType=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 state. - 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 daemon của bên thứ ba cần loại nào, hãy đọc unit file được đóng gói trước. Chạy systemctl cat trên unit do bản phân phối cung cấp sẽ cho biết Type= mà upstream đã chọn. Lựa chọn đó đã được nhiều người kiểm thử hơn so với lựa chọn của bạn.
FAQ
Vì sao unit systemd của tôi vẫn ở trạng thái active khi tiến trình đã chết?
Vì tiến trình mà systemd xem là tiến trình chính vẫn còn chạy. systemd chỉ theo dõi một PID cho mỗi service, được chọn theo Type=, thay vì theo dõi mọi tiến trình 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 khởi 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 và so sánh hai kết quả.
Type=simple khác Type=exec như thế nào?
Type=simple xem unit là đã khởi động ngay khi systemd tạo tiến trình, trước khi binary được thực thi. Vì vậy, đường dẫn sai trong ExecStart= vẫn khiến start job thành công, rồi 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 tiến trình là PID chính. Type=exec cần systemd 240 trở lên.
Tôi vẫn cần PIDFile= khi dùng Type=forking không?
Có, bất cứ khi nào daemon ghi file đó. Nếu không có, systemd sẽ dùng GuessMainPID=. Đây chỉ là phỏng đoán và chỉ đáng tin cậy với service ổn định ở trạng thái một tiến trình. 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 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 tiến trình đ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 chuyển sang inactive, khiến systemctl stop không có gì để stop và không có cách chạy cleanup ExecStop=. Khi có tùy chọn này, unit vẫn ở trạng thái active dù không có tiến trình nào. Đây là hành vi được chủ ý 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 hành vi supervision cũ.