From f568624956a7e3ef2abece14897a07a80124fa1c Mon Sep 17 00:00:00 2001 From: mrkmntal Date: Sat, 29 Aug 2026 17:59:30 -0400 Subject: [PATCH] Finish of changes for 0.4, unique non POSIX signal handling for nginx and php-fpm added --- DEVLOG.md | 6 ++ README.md | 123 ++++++++++++++++++++++++++++++++++++++++- main.cpp | 8 +-- src/docker_manager.cpp | 5 ++ tuxreaperdasm.c | 99 ++++++++++++++++++++++++++------- tuxreaperdgnu.c | 95 +++++++++++++++++++++++++------ 6 files changed, 293 insertions(+), 43 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index f98c74e..87f9604 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -6,8 +6,14 @@ - Fixed `tuxreaperdasm.c` on ARM64 by using the architecture-specific `O_DIRECTORY` value (`0x4000` on `aarch64`, `0x10000` on `x86_64`). The hardcoded x86_64 value caused `openat("/proc", ...)` to fail with `EINVAL`, which broke `/proc` scanning, descendant waiting, and SIGTERM forwarding on ARM64 Chromebooks/VMs. - Verified graceful Apache shutdown on ARM64: tuxreaperd remaps `SIGTERM` to `SIGWINCH` for Apache processes and waits for descendants to finish, allowing in-flight HTTP responses to complete before the container exits. +- Added nginx-aware `SIGTERM`→`SIGQUIT` remapping in both `tuxreaperdgnu.c` and `tuxreaperdasm.c`. When `docker stop` sends `SIGTERM`, nginx processes (`/usr/sbin/nginx`, `/usr/local/nginx/sbin/nginx`) receive `SIGQUIT` for graceful shutdown instead of `SIGTERM`. +- Added PHP-FPM-aware `SIGTERM`→`SIGQUIT` remapping with prefix matching (e.g., `/usr/sbin/php-fpm` matches `/usr/sbin/php-fpm8.4`) so PHP-FPM receives the correct graceful-shutdown signal when the container stops. +- Documented that PHP-FPM graceful shutdown requires `process_control_timeout` to be set in the PHP-FPM pool configuration (e.g., `process_control_timeout = 60s`). Without it, the PHP-FPM master exits immediately on `SIGQUIT` and in-flight requests are dropped. +- Set the container stop timeout to 60 seconds in `DockerManager::createContainer` (`--stop-timeout=60`) and raised the TUI stop default to 60 seconds, aligning Docker's grace period with `tuxreaperd`'s descendant wait so graceful shutdowns are not cut short by a premature SIGKILL. - Added `--debug-reaper` flag to `compile.sh` to build tuxreaperd with `TUXREAPERD_DEBUG` and run verbose tests; useful for diagnosing `/proc` scanning, signal broadcasts, and descendant waits. - Added adaptive TTY/non-TTY spinner to `compile.sh` for clearer feedback during the test phase. +- Added a "Why tuxreaperd?" comparison section to `README.md` with a feature matrix and ASCII size chart contrasting `tuxreaperd` with `tini`, `dumb-init`, and `mini-init-asm`. +- Added a "Web workload guide" section to `README.md` covering Apache, nginx, and PHP-FPM signal conventions, required PHP-FPM `process_control_timeout` configuration, container startup examples, and the `sleep.php` verification test. ### Version diff --git a/README.md b/README.md index f63c700..2aed961 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,132 @@ It gives you a guided, keyboard-first TUI for common Docker operations without m - Direct `fork`/`exec` process execution for CLI-backed streaming and interactive commands. - Persistent container listings that retain exited containers. - Robust stop handling with state polling, timeout retry, and idempotent stop responses. -- `tuxreaperd` micro init system: a tiny statically-linked C daemon mounted into created containers as PID 1, acting as a subreaper so orphaned processes never linger as zombies, broadcasting lifecycle signals (SIGTERM, SIGQUIT, SIGINT, SIGHUP, SIGUSR1, SIGUSR2) to the whole process tree via `kill(-1, sig)`, and propagating exit status. Includes architecture-specific `O_DIRECTORY` handling for `x86_64` and `aarch64`, plus Apache-aware `SIGTERM`→`SIGWINCH` remapping for graceful shutdown on both architectures. [Processes don't fear tuxreaperd!](tuxreaperdpromo.jpg) +- `tuxreaperd` micro init system: a tiny statically-linked C daemon mounted into created containers as PID 1, acting as a subreaper so orphaned processes never linger as zombies, broadcasting lifecycle signals (SIGTERM, SIGQUIT, SIGINT, SIGHUP, SIGUSR1, SIGUSR2) to the whole process tree via `kill(-1, sig)`, and propagating exit status. Includes architecture-specific `O_DIRECTORY` handling for `x86_64` and `aarch64`, Apache-aware `SIGTERM`→`SIGWINCH`, nginx-aware `SIGTERM`→`SIGQUIT`, and PHP-FPM-aware `SIGTERM`→`SIGQUIT` remapping (with prefix matching for versioned binaries) for graceful shutdown, plus a 60-second container stop timeout so descendants have time to finish. Note: PHP-FPM graceful shutdown also requires `process_control_timeout` to be set in the PHP-FPM pool config (e.g., `process_control_timeout = 60s`). [Processes don't fear tuxreaperd!](tuxreaperdpromo.jpg) - About screen in-app with project/version/repository info. --- +## Why tuxreaperd? + +Most container inits are generic: they spawn one child, forward signals, and reap zombies. That is enough for simple apps, but it breaks down for classic web engines that violate standard UNIX signal conventions: + +- **Apache HTTPD** uses `SIGWINCH` for graceful shutdown. +- **Nginx** uses `SIGQUIT` for graceful shutdown. +- **PHP-FPM** uses `SIGQUIT` for graceful shutdown. + +`tuxreaperd` encodes those quirks directly. It scans `/proc//exe` during signal broadcast and translates `SIGTERM` into the correct graceful-shutdown signal for each daemon, then waits up to 60 seconds for worker descendants to drain. + +| Feature / Scenario | `tini` | `dumb-init` | `mini-init-asm` | `tuxreaperd` (~4.7 KB) | +| --- | --- | --- | --- | --- | +| Automatic per-binary signal translation | No | Manual `--rewrite` only | No | Yes (Apache / nginx / PHP-FPM) | +| Zero-config web graceful stops | No | No | No | Yes | +| Post-exit descendant draining | No | No | No | Yes, 60-second bounded drain | +| Multi-pass `/proc` signal sweeps | No | No | No | Two-pass sweep | +| Freestanding / no libc | No | No (unless musl) | Yes | Yes | +| Restart-on-crash | No | No | Yes (`EP_RESTART_ENABLED`) | No | +| Configurable grace → `SIGKILL` timeout | No | No | Yes (`EP_GRACE_SECONDS`) | No (fixed 60s drain) | + +Static binary size comparison (KB): + +```text +tuxreaperd |## 4.7 +dumb-init (musl) |######## 20 +tini |######### 23 +mini-init-asm |################ 41 + +dumb-init (glibc static) is ~700+ KB and omitted from the chart for scale. +``` + +`mini-init-asm` and `dumb-init` are excellent generic inits with useful extras like restart-on-crash or configurable kill timeouts, but they treat workloads as black boxes. `tuxreaperd` trades that generic flexibility for deep, zero-configuration automation of the three web-engine families that actually need signal translation. + +--- + +## Web workload guide + +`tuxreaperd` is built for containerized web engines that violate standard UNIX signal conventions. This guide covers Apache HTTPD, nginx, and PHP-FPM. + +### Signal conventions + +| Engine | Graceful shutdown signal | Fast shutdown signal | +| --- | --- | --- | +| Apache HTTPD | `SIGWINCH` | `SIGTERM` | +| Nginx | `SIGQUIT` | `SIGTERM` / `SIGINT` | +| PHP-FPM | `SIGQUIT` | `SIGTERM` | + +When `docker stop` sends `SIGTERM` to PID 1, `tuxreaperd` inspects `/proc//exe` and automatically translates the signal: + +- Apache processes receive `SIGWINCH`. +- Nginx processes receive `SIGQUIT`. +- PHP-FPM processes (matched by the `/usr/sbin/php-fpm` prefix) receive `SIGQUIT`. + +### Required PHP-FPM configuration + +PHP-FPM has a known quirk: on `SIGQUIT` its master process exits immediately unless `process_control_timeout` is configured. Without it, the FastCGI socket closes and nginx drops in-flight requests. + +Add this to your PHP-FPM pool config (e.g., `/etc/php/8.4/fpm/pool.d/www.conf`): + +```ini +[global] +process_control_timeout = 60s +``` + +Then restart PHP-FPM. + +### Starting services inside the container + +Create the container with tux-dock, which already injects `tuxreaperd` as PID 1 and sets `--stop-timeout=60`. Then start your web services as descendants: + +```bash +# Start PHP-FPM in the background +docker exec my-container /usr/sbin/php-fpm8.4 --nodaemonize & + +# Start nginx in the foreground (or background) +docker exec my-container /usr/sbin/nginx -g 'daemon off;' +``` + +Make sure the binary paths match the ones `tuxreaperd` recognizes: +- `/usr/sbin/apache2`, `/usr/sbin/httpd`, `/usr/local/apache2/bin/httpd` +- `/usr/sbin/nginx`, `/usr/local/nginx/sbin/nginx` +- `/usr/sbin/php-fpm*`, `/usr/local/sbin/php-fpm*` + +### Verification: long-running PHP request + +Create `/var/www/html/sleep.php`: + +```php + +``` + +Start the request, then stop the container: + +```bash +curl -i http://localhost:8087/sleep.php +# In another terminal: +docker stop my-container +``` + +Expected result: the full response body is printed, including the “Test finished at: …” line, with no `curl: (18) transfer closed with outstanding read data remaining` error. + +### Troubleshooting + +- **Connection drops early** → Verify `process_control_timeout` is set in PHP-FPM and restart it. +- **Container stops instantly** → The container must be recreated to pick up `--stop-timeout=60`; existing containers keep their original stop timeout. +- **PHP-FPM not detected** → Confirm the binary path starts with `/usr/sbin/php-fpm` or `/usr/local/sbin/php-fpm`. + +--- + ## Build Requirements - **C++17 or newer** compiler (e.g. `g++`, `clang++`) diff --git a/main.cpp b/main.cpp index 0629b70..18069e4 100644 --- a/main.cpp +++ b/main.cpp @@ -649,18 +649,18 @@ void TuxDockApp::ActionStopContainer() { OpenInput( "Stop Container", "Shutdown grace period in seconds for " + name + - " (default 10, max " + std::to_string(kMaxStopTimeout) + "):", + " (default 60, max " + std::to_string(kMaxStopTimeout) + "):", [this, id](bool ok, const std::string& value) { if (!ok) return; - int seconds = 10; + int seconds = 60; if (!value.empty()) { try { seconds = std::stoi(value); } catch (...) { - seconds = 10; + seconds = 60; } } - if (seconds < 0) seconds = 10; + if (seconds < 0) seconds = 60; if (seconds > kMaxStopTimeout) seconds = kMaxStopTimeout; BeginStopOperation(id, seconds); }); diff --git a/src/docker_manager.cpp b/src/docker_manager.cpp index 690081a..1c2334f 100644 --- a/src/docker_manager.cpp +++ b/src/docker_manager.cpp @@ -117,6 +117,11 @@ bool DockerManager::createContainer(const std::string& name, std::vector args{"docker", "create", "--name", name}; + // Give the container a 60-second stop grace period so tuxreaperd can wait + // for descendants (e.g., nginx workers) to finish gracefully before Docker + // sends SIGKILL. + args.push_back("--stop-timeout=60"); + // Port mappings for (const auto& port : ports) { args.push_back("-p"); diff --git a/tuxreaperdasm.c b/tuxreaperdasm.c index 812179c..c5866bc 100644 --- a/tuxreaperdasm.c +++ b/tuxreaperdasm.c @@ -578,8 +578,10 @@ static void my_execvp(const char *file, char *const argv[], char *const envp[]) } /* Apache hijacks SIGWINCH for graceful shutdown. When the outside world - sends SIGTERM, we translate it to SIGWINCH for Apache processes and send - the original signal to everyone else. */ + sends SIGTERM, we translate it to SIGWINCH for Apache processes. + Nginx and PHP-FPM both use SIGQUIT for graceful shutdown, so we + translate SIGTERM to SIGQUIT for those processes. Everyone else + receives the original signal. */ static const char * const apache_exes[] = { "/usr/sbin/apache2", "/usr/sbin/httpd", @@ -589,6 +591,30 @@ static const char * const apache_exes[] = { #define APACHE_IN_SIG 15 /* SIGTERM */ #define APACHE_OUT_SIG 28 /* SIGWINCH */ +static const char * const nginx_exes[] = { + "/usr/sbin/nginx", + "/usr/local/nginx/sbin/nginx", + 0 +}; +#define NGINX_IN_SIG 15 /* SIGTERM */ +#define NGINX_OUT_SIG 3 /* SIGQUIT */ + +/* PHP-FPM installs versioned binaries like /usr/sbin/php-fpm8.4, so these + rules use prefix matching to catch any version without maintaining a list. */ +static const char * const phpfpm_exes[] = { + "/usr/sbin/php-fpm", + "/usr/local/sbin/php-fpm", + 0 +}; +#define PHPFPM_IN_SIG 15 /* SIGTERM */ +#define PHPFPM_OUT_SIG 3 /* SIGQUIT */ + +struct sig_rule { + const char *const *target_exes; + int out_sig; + int prefix_match; /* 0 = exact, 1 = prefix */ +}; + #define DESCENDANT_TIMEOUT_SECONDS 60 #define BROADCAST_SCAN_DELAY_US 100000 #define DESCENDANT_POLL_INTERVAL_US 50000 @@ -670,9 +696,9 @@ static long parse_pid(const char *s) { } /* Iterate /proc, read each process's /proc//exe symlink, and send - out_sig to matching PIDs while sending in_sig to everyone else. - Skip PID 1 (the reaper itself). */ -static void proc_remap_signal(int in_sig, int out_sig, const char *const *target_exes) { + the first matching rule's out_sig to matching PIDs while sending in_sig + to everyone else. Skip PID 1 (the reaper itself). */ +static void proc_remap_signal(int in_sig, const struct sig_rule *rules, int num_rules) { int procfd = (int)sys_openat(AT_FDCWD, "/proc", O_RDONLY | O_DIRECTORY, 0); #ifdef TUXREAPERD_DEBUG debug_write("[tuxreaperd] proc_remap_signal openat fd="); @@ -729,23 +755,36 @@ static void proc_remap_signal(int in_sig, int out_sig, const char *const *target if (linklen <= 0) continue; linkbuf[linklen] = '\0'; - int matched = 0; - for (int i = 0; target_exes[i]; i++) { - long target_len = my_strlen(target_exes[i]); - if (linklen == target_len && my_strcmp(linkbuf, target_exes[i]) == 0) { - matched = 1; + int sent_sig = in_sig; + for (int r = 0; r < num_rules; r++) { + int matched = 0; + for (int i = 0; rules[r].target_exes[i]; i++) { + long target_len = my_strlen(rules[r].target_exes[i]); + if (rules[r].prefix_match) { + if (linklen >= target_len && + my_strncmp(linkbuf, rules[r].target_exes[i], target_len) == 0) { + matched = 1; + break; + } + } else { + if (linklen == target_len && + my_strcmp(linkbuf, rules[r].target_exes[i]) == 0) { + matched = 1; + break; + } + } + } + if (matched) { + sent_sig = rules[r].out_sig; break; } } - int sent_sig = matched ? out_sig : in_sig; #ifdef TUXREAPERD_DEBUG debug_write("[tuxreaperd] scan pid="); debug_write_int(pid); debug_write(" exe="); debug_write(linkbuf); - debug_write(" matched="); - debug_write_int(matched); debug_write(" sig="); debug_write_int(sent_sig); debug_write("\n"); @@ -764,17 +803,15 @@ static void sleep_us(long us) { sys_nanosleep(&req, 0); } -static void broadcast_signal(int in_sig, int out_sig, const char *const *target_exes) { +static void broadcast_signal(int in_sig, const struct sig_rule *rules, int num_rules) { #ifdef TUXREAPERD_DEBUG debug_write("[tuxreaperd] broadcasting in_sig="); debug_write_int(in_sig); - debug_write(" out_sig="); - debug_write_int(out_sig); debug_write("\n"); #endif - proc_remap_signal(in_sig, out_sig, target_exes); + proc_remap_signal(in_sig, rules, num_rules); sleep_us(BROADCAST_SCAN_DELAY_US); - proc_remap_signal(in_sig, out_sig, target_exes); + proc_remap_signal(in_sig, rules, num_rules); } static int count_descendants(void) { @@ -872,10 +909,30 @@ static void handle_pending_signals(void) { debug_write_int(sig); debug_write("\n"); #endif - if (sig == APACHE_IN_SIG) { - broadcast_signal(APACHE_IN_SIG, APACHE_OUT_SIG, apache_exes); + if (sig == APACHE_IN_SIG || sig == NGINX_IN_SIG || sig == PHPFPM_IN_SIG) { + struct sig_rule rules[3]; + int num_rules = 0; + if (sig == APACHE_IN_SIG) { + rules[num_rules].target_exes = apache_exes; + rules[num_rules].out_sig = APACHE_OUT_SIG; + rules[num_rules].prefix_match = 0; + num_rules++; + } + if (sig == NGINX_IN_SIG) { + rules[num_rules].target_exes = nginx_exes; + rules[num_rules].out_sig = NGINX_OUT_SIG; + rules[num_rules].prefix_match = 0; + num_rules++; + } + if (sig == PHPFPM_IN_SIG) { + rules[num_rules].target_exes = phpfpm_exes; + rules[num_rules].out_sig = PHPFPM_OUT_SIG; + rules[num_rules].prefix_match = 1; + num_rules++; + } + broadcast_signal(sig, rules, num_rules); } else { - broadcast_signal(sig, sig, apache_exes); + broadcast_signal(sig, 0, 0); } } } diff --git a/tuxreaperdgnu.c b/tuxreaperdgnu.c index c040245..5585116 100644 --- a/tuxreaperdgnu.c +++ b/tuxreaperdgnu.c @@ -28,7 +28,9 @@ static void debug(const char *fmt, ...) { /* Apache hijacks SIGWINCH (window resize) for graceful shutdown. Yes, really. So when the outside world sends SIGTERM, we translate it to SIGWINCH for anything whose /proc//exe smells like apache2. - Other processes receive the original signal unchanged. */ + Nginx and PHP-FPM both use SIGQUIT for graceful shutdown, so we + translate SIGTERM to SIGQUIT for those processes. Other processes + receive the original signal unchanged. */ static const char * const apache_exes[] = { "/usr/sbin/apache2", "/usr/sbin/httpd", @@ -38,6 +40,30 @@ static const char * const apache_exes[] = { #define APACHE_IN_SIG SIGTERM #define APACHE_OUT_SIG SIGWINCH +static const char * const nginx_exes[] = { + "/usr/sbin/nginx", + "/usr/local/nginx/sbin/nginx", + 0 +}; +#define NGINX_IN_SIG SIGTERM +#define NGINX_OUT_SIG SIGQUIT + +/* PHP-FPM installs versioned binaries like /usr/sbin/php-fpm8.4, so these + rules use prefix matching to catch any version without maintaining a list. */ +static const char * const phpfpm_exes[] = { + "/usr/sbin/php-fpm", + "/usr/local/sbin/php-fpm", + 0 +}; +#define PHPFPM_IN_SIG SIGTERM +#define PHPFPM_OUT_SIG SIGQUIT + +struct sig_rule { + const char *const *target_exes; + int out_sig; + int prefix_match; /* 0 = exact, 1 = prefix */ +}; + /* How long to keep the container alive after the main child exits, waiting for remaining descendants (e.g., Apache workers) to finish gracefully. */ #define DESCENDANT_TIMEOUT_SECONDS 60 @@ -76,7 +102,7 @@ static void proxy_signal_handler(int sig) { } } -static void proc_remap_signal(int in_sig, int out_sig, const char *const *target_exes) { +static void proc_remap_signal(int in_sig, const struct sig_rule *rules, int num_rules) { DIR *proc = opendir("/proc"); if (!proc) { debug("[tuxreaperd] opendir(/proc) failed: %s", strerror(errno)); @@ -100,18 +126,33 @@ static void proc_remap_signal(int in_sig, int out_sig, const char *const *target if (linklen <= 0) continue; linkbuf[linklen] = '\0'; - int matched = 0; - for (int i = 0; target_exes[i]; i++) { - size_t target_len = strlen(target_exes[i]); - if ((size_t)linklen == target_len && strcmp(linkbuf, target_exes[i]) == 0) { - matched = 1; + int sent_sig = in_sig; + for (int r = 0; r < num_rules; r++) { + int matched = 0; + for (int i = 0; rules[r].target_exes[i]; i++) { + size_t target_len = strlen(rules[r].target_exes[i]); + if (rules[r].prefix_match) { + if ((size_t)linklen >= target_len && + strncmp(linkbuf, rules[r].target_exes[i], target_len) == 0) { + matched = 1; + break; + } + } else { + if ((size_t)linklen == target_len && + strcmp(linkbuf, rules[r].target_exes[i]) == 0) { + matched = 1; + break; + } + } + } + if (matched) { + sent_sig = rules[r].out_sig; break; } } - int sent_sig = matched ? out_sig : in_sig; - debug("[tuxreaperd] scan pid=%ld exe=%s matched=%d sending sig=%d", - pid, linkbuf, matched, sent_sig); + debug("[tuxreaperd] scan pid=%ld exe=%s sending sig=%d", + pid, linkbuf, sent_sig); if (kill((pid_t)pid, sent_sig) < 0) { debug("[tuxreaperd] kill(%ld, %d) failed: %s", pid, sent_sig, strerror(errno)); } @@ -120,11 +161,11 @@ static void proc_remap_signal(int in_sig, int out_sig, const char *const *target closedir(proc); } -static void broadcast_signal(int in_sig, int out_sig, const char *const *target_exes) { - debug("[tuxreaperd] broadcasting in_sig=%d out_sig=%d", in_sig, out_sig); - proc_remap_signal(in_sig, out_sig, target_exes); +static void broadcast_signal(int in_sig, const struct sig_rule *rules, int num_rules) { + debug("[tuxreaperd] broadcasting in_sig=%d", in_sig); + proc_remap_signal(in_sig, rules, num_rules); usleep(BROADCAST_SCAN_DELAY_US); - proc_remap_signal(in_sig, out_sig, target_exes); + proc_remap_signal(in_sig, rules, num_rules); } static int count_descendants(void) { @@ -156,10 +197,30 @@ static void handle_pending_signals(void) { if (!(pending & (sig_atomic_t)(1U << (sig - 1)))) continue; debug("[tuxreaperd] handling pending signal %d", sig); - if (sig == APACHE_IN_SIG) { - broadcast_signal(APACHE_IN_SIG, APACHE_OUT_SIG, apache_exes); + if (sig == APACHE_IN_SIG || sig == NGINX_IN_SIG || sig == PHPFPM_IN_SIG) { + struct sig_rule rules[3]; + int num_rules = 0; + if (sig == APACHE_IN_SIG) { + rules[num_rules].target_exes = apache_exes; + rules[num_rules].out_sig = APACHE_OUT_SIG; + rules[num_rules].prefix_match = 0; + num_rules++; + } + if (sig == NGINX_IN_SIG) { + rules[num_rules].target_exes = nginx_exes; + rules[num_rules].out_sig = NGINX_OUT_SIG; + rules[num_rules].prefix_match = 0; + num_rules++; + } + if (sig == PHPFPM_IN_SIG) { + rules[num_rules].target_exes = phpfpm_exes; + rules[num_rules].out_sig = PHPFPM_OUT_SIG; + rules[num_rules].prefix_match = 1; + num_rules++; + } + broadcast_signal(sig, rules, num_rules); } else { - broadcast_signal(sig, sig, apache_exes); + broadcast_signal(sig, NULL, 0); } } }