diff --git a/Dockerfile.shared-httpd b/Dockerfile.shared-httpd index 1c4dd3c..5e0c157 100644 --- a/Dockerfile.shared-httpd +++ b/Dockerfile.shared-httpd @@ -13,6 +13,7 @@ COPY ./scripts/detect-memory.sh /scripts/detect-memory.sh COPY ./scripts/create-apache-mpm-config.sh /scripts/create-apache-mpm-config.sh COPY ./scripts/log-rotate.sh /scripts/log-rotate.sh COPY ./scripts/entrypoint-shared-httpd.sh /scripts/entrypoint-shared-httpd.sh +COPY ./scripts/stop-services.sh /scripts/stop-services.sh COPY ./scripts/tune-mpm.sh /scripts/tune-mpm.sh RUN chmod +x /scripts/* diff --git a/scripts/entrypoint-fpm.sh b/scripts/entrypoint-fpm.sh index e9a0c7b..a378ed9 100755 --- a/scripts/entrypoint-fpm.sh +++ b/scripts/entrypoint-fpm.sh @@ -16,6 +16,14 @@ adduser -u $uid $user mkdir -p /home/$user/public_html mkdir -p /home/$user/logs/php-fpm +# Create the pool's error_log (php_admin_value[error_log] in +# create-php-config.sh) BEFORE the chown -R below, and chown it explicitly. +# It used to be `touch`ed as root at the very end, after the chown, so on a +# site's first start the pool (customer uid) could not open it and PHP errors +# fell back to FastCGI stderr -> shared-httpd's error log until the next +# restart's chown -R fixed the owner. +touch /home/$user/logs/php-fpm/error.log +chown $user:$user /home/$user/logs/php-fpm/error.log ln -sf /home/$user/logs/php-fpm /var/log/php-fpm @@ -89,8 +97,26 @@ crontab -u $user /home/$user/crontab /usr/sbin/crond -# Tail PHP-FPM logs (becomes PID 1 process) -touch /home/$user/logs/php-fpm/error.log -tail -f /home/$user/logs/php-fpm/* +# Graceful shutdown. bash is PID 1 here, and PID 1 only receives signals it +# has a handler for, so without this trap `docker stop`'s SIGTERM was dropped: +# docker waited the full 10s and SIGKILLed php-fpm mid-request (exit 137). +# php-fpm QUIT = graceful (workers finish their current request), then crond. +source /scripts/stop-services.sh +TAIL_PID="" +shutdown() { + trap - TERM INT + echo "entrypoint: received stop signal, shutting down" + stop_service php-fpm /run/php-fpm/php-fpm.pid QUIT 6 'php-fpm: master' + stop_service crond /run/crond.pid TERM 1 '^/usr/sbin/crond' + [ -n "$TAIL_PID" ] && kill "$TAIL_PID" 2>/dev/null + exit 0 +} +trap shutdown TERM INT + +# Tail PHP-FPM logs to docker logs. Backgrounded + `wait` (instead of a +# foreground tail) so bash can run the trap as soon as a signal arrives. +tail -f /home/$user/logs/php-fpm/* & +TAIL_PID=$! +wait "$TAIL_PID" exit 0 diff --git a/scripts/entrypoint-shared-httpd.sh b/scripts/entrypoint-shared-httpd.sh index 8f54dbb..cf36cc8 100755 --- a/scripts/entrypoint-shared-httpd.sh +++ b/scripts/entrypoint-shared-httpd.sh @@ -59,11 +59,30 @@ fi # Start cron for log rotation /usr/sbin/crond -# Tail Apache logs (becomes PID 1 process) +# Graceful shutdown. bash is PID 1 here, and PID 1 only receives signals it +# has a handler for, so without this trap `docker stop`'s SIGTERM was dropped: +# docker waited the full 10s and SIGKILLed apache mid-request (exit 137), for +# every site on the host at once. graceful-stop (WINCH) lets in-flight requests +# finish; after 7s it falls back to TERM. Timeouts sum to < 10s so this +# completes inside docker's default stop timeout. +source /scripts/stop-services.sh +TAIL_PID="" +SLEEP_PID="" +shutdown() { + trap - TERM INT + echo "entrypoint-shared-httpd: received stop signal, shutting down" + stop_service httpd /etc/httpd/run/httpd.pid WINCH 7 '^/usr/sbin/httpd' + stop_service crond /run/crond.pid TERM 1 '^/usr/sbin/crond' + [ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null + [ -n "$TAIL_PID" ] && kill "$TAIL_PID" 2>/dev/null + exit 0 +} +trap shutdown TERM INT + +# Tail Apache logs to docker logs. # Use a loop to pick up new log files as vhosts are added. # tail -f only watches files that exist at start time. touch /var/log/httpd/error_log -TAIL_PID="" while true; do LOG_FILES=$(find /var/log/httpd/ -name '*.log' -o -name '*_log' 2>/dev/null | sort) if [ -n "$TAIL_PID" ]; then @@ -72,6 +91,10 @@ while true; do fi tail -f $LOG_FILES & TAIL_PID=$! - # Re-check for new log files every 60 seconds - sleep 60 + # Re-check for new log files every 60 seconds. Background + `wait` (not a + # foreground sleep) so a stop signal runs the trap immediately instead of + # after up to 60s. + sleep 60 & + SLEEP_PID=$! + wait "$SLEEP_PID" done diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 4790eb5..ddb36f5 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -100,7 +100,32 @@ fi crontab -u $user /home/$user/crontab /usr/sbin/crond -tail -f /var/log/httpd/* + +# Graceful shutdown. bash is PID 1 here, and PID 1 only receives signals it +# has a handler for, so without this trap `docker stop`'s SIGTERM was dropped: +# docker waited the full 10s and SIGKILLed apache and php-fpm mid-request +# (exit 137). Stop apache first (graceful-stop: finish in-flight requests, +# accept no new ones), then php-fpm (QUIT: workers finish their current +# request), then crond. Timeouts sum to < 10s so this completes inside +# docker's default stop timeout. +source /scripts/stop-services.sh +TAIL_PID="" +shutdown() { + trap - TERM INT + echo "entrypoint: received stop signal, shutting down" + stop_service httpd /etc/httpd/run/httpd.pid WINCH 4 '^/usr/sbin/httpd' + stop_service php-fpm /run/php-fpm/php-fpm.pid QUIT 3 'php-fpm: master' + stop_service crond /run/crond.pid TERM 1 '^/usr/sbin/crond' + [ -n "$TAIL_PID" ] && kill "$TAIL_PID" 2>/dev/null + exit 0 +} +trap shutdown TERM INT + +# Stream apache logs to docker logs. Backgrounded + `wait` (instead of a +# foreground tail) so bash can run the trap as soon as a signal arrives. +tail -f /var/log/httpd/* & +TAIL_PID=$! +wait "$TAIL_PID" exit 0 diff --git a/scripts/stop-services.sh b/scripts/stop-services.sh new file mode 100644 index 0000000..37cac71 --- /dev/null +++ b/scripts/stop-services.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +## stop-services.sh — sourced by the bash PID-1 entrypoints (entrypoint.sh, +## entrypoint-fpm.sh, entrypoint-shared-httpd.sh). Not executed directly. +## +## Why this exists: those entrypoints end with bash as PID 1 tailing logs. +## The kernel never delivers a signal to PID 1 unless PID 1 installed a handler +## for it, so `docker stop`'s SIGTERM was silently dropped. Docker waited the +## full stop timeout (10s) and then SIGKILLed the whole container, killing +## apache / php-fpm mid-request (exit 137). The entrypoints now trap TERM/INT +## and call stop_service for each daemon they started, then exit 0. +## +## Budget: docker's default stop timeout is 10s. Callers pass per-service +## timeouts that sum to < 10s so the graceful path always completes before +## docker gives up and SIGKILLs. + +# A pid counts as alive if it exists and is not a zombie. bash (PID 1) reaps +# the daemons it adopted, but between exit and reap a zombie still answers +# `kill -0`, so check /proc state too. +_svc_alive() { + [ -n "$1" ] || return 1 + kill -0 "$1" 2>/dev/null || return 1 + ! grep -qs '^State:[[:space:]]*Z' "/proc/$1/status" +} + +# stop_service NAME PIDFILE GRACEFUL_SIGNAL GRACE_SECONDS [PGREP_PATTERN] +# Sends GRACEFUL_SIGNAL, waits up to GRACE_SECONDS for the process to exit, +# then sends TERM and waits 1s more, then KILL. Pid comes from PIDFILE, or +# from `pgrep -o -f PGREP_PATTERN` when the pidfile is missing/stale. +stop_service() { + local name=$1 pidfile=$2 sig=$3 grace=$4 pat=${5:-} pid="" i + [ -r "$pidfile" ] && pid=$(tr -dc '0-9' < "$pidfile") + if ! _svc_alive "$pid" && [ -n "$pat" ]; then + pid=$(pgrep -o -f "$pat" 2>/dev/null) + fi + if ! _svc_alive "$pid"; then + echo "entrypoint: $name not running" + return 0 + fi + echo "entrypoint: stopping $name (pid $pid) with SIG$sig" + kill -s "$sig" "$pid" 2>/dev/null + for ((i = 0; i < grace * 10; i++)); do + _svc_alive "$pid" || { echo "entrypoint: $name stopped"; return 0; } + sleep 0.1 + done + echo "entrypoint: $name still running after ${grace}s, sending SIGTERM" + kill -TERM "$pid" 2>/dev/null + for ((i = 0; i < 10; i++)); do + _svc_alive "$pid" || { echo "entrypoint: $name stopped"; return 0; } + sleep 0.1 + done + echo "entrypoint: $name still running, sending SIGKILL" + kill -KILL "$pid" 2>/dev/null + return 0 +}