Files
cloud-apache-container/scripts/entrypoint-litespeed.sh
T

322 lines
14 KiB
Bash
Raw Normal View History

#!/usr/bin/env bash
## entrypoint-litespeed.sh — PID 1 for cac-litespeed:phpNN.
## Built on litespeedtech/openlitespeed:1.8.x-lsphp83 prebuilt base. Native
## LSAPI (no FPM proxy), one customer per container.
##
## Process supervision: starts OLS via `openlitespeed -n` (no-daemon +
## crash-guard, per OLS source: lshttpdmain.cpp). SIGTERM is forwarded.
## crond runs in the background for customer crontabs; OLS itself is the
## process we wait on (if OLS dies, the container exits and Docker
## restarts it per its restart policy).
set -euo pipefail
: "${PHPVER:=83}"
: "${environment:=PROD}"
: "${LSCACHE_AUTOINSTALL:=1}"
export CONTAINER_ROLE="litespeed_only"
export PHPVER environment LSCACHE_AUTOINSTALL
## ---- env validation ----
if [ -z "${uid:-}" ] || [ -z "${user:-}" ]; then
echo "FATAL: 'uid' and 'user' env vars are required (panel sets these from WHP_UID/WHP_USER)." >&2
exit 1
fi
: "${domain:=localhost}"
export user domain
## ---- user + directories ----
if ! id -u "$user" >/dev/null 2>&1; then
## Ubuntu's useradd; mirror what the AL10 entrypoints do with adduser
useradd -u "$uid" -m -s /bin/bash "$user"
fi
mkdir -p "/home/$user/public_html"
## Log dirs mirror cac:phpNN exactly — apache/ for web server access+error,
## php-fpm/ for PHP errors. OLS isn't Apache and lsphp isn't php-fpm, but
## the customer-facing paths stay identical so log-gathering, analytics,
## and the customer's "where do I find my access log?" mental model all
## just work without per-image-family special cases.
mkdir -p "/home/$user/logs/apache" "/home/$user/logs/php-fpm"
mkdir -p "/home/$user/lscache"
mkdir -p /tmp/lshttpd/swap
chmod 1777 /tmp/lshttpd
## ---- memory + lsphp pool sizing ----
# shellcheck source=/dev/null
source /scripts/detect-memory-litespeed.sh
echo "Container memory: ${CONTAINER_MEMORY_MB}MB | LSAPI_CHILDREN=${LSAPI_CHILDREN} | PHPVER=${PHPVER}"
## ---- self-signed cert (idempotent) ----
mkdir -p /usr/local/lsws/conf/cert
if [ ! -f /usr/local/lsws/conf/cert/self.crt ]; then
openssl req -x509 -newkey rsa:2048 -nodes -days 3650 \
-keyout /usr/local/lsws/conf/cert/self.key \
-out /usr/local/lsws/conf/cert/self.crt \
-subj "/CN=${domain}" 2>/dev/null
fi
## ---- render httpd_config + vhconf from templates ----
/scripts/create-vhost-litespeed.sh
## ---- point PHP error_log at the same customer-visible path that
## cac:phpNN uses for php-fpm errors. Drop-in compat: customer code that
## was tailing /home/$user/logs/php-fpm/error.log on the old image will
## see lsphp's PHP errors in the exact same file on the new image.
## Rendered as a tiny ini in lsphp's scan dir; PHP merges it after the
## production-tuning overrides at startup.
## Captured, then matched in the shell. As a single pipeline this was
## `lsphp -i | awk '…{print;exit}'`: awk stops at the "Scan this dir" line,
## which is near the top of the output, so lsphp can still be writing when awk
## closes the pipe. lsphp then dies 141, `set -o pipefail` (line 12) makes that
## the pipeline's status, and because this is a BARE ASSIGNMENT `set -e` KILLS
## PID 1 — the container never starts. (Its twin in entrypoint-lsphp.sh chose a
## degraded fallback instead; this one just exits.) That is a race on whether
## the reader closes before the writer's last write() returns, not a function of
## how big the output is: see the long note over the probe helpers in
## entrypoint-lsphp.sh for the measurements. The rule is simply that no
## `writer | early-exiting-reader` belongs in a pipefail script.
##
## A here-string would remove the pipeline, but bash spills a here-string to
## /tmp/sh-thd.XXXXXX above a build-dependent size (65536 for the bash 5.2.21 in
## this image, between 4096 and 16384 for Debian's 5.2.15) — and on this line,
## a bare assignment, a temp file it cannot create is again `set -e` killing
## PID 1: `docker run --read-only` reproduces exactly that. So the extraction is
## done with parameter expansion, which allocates nothing.
##
## Same answer as the awk it replaces: first line starting "Scan this dir", then
## the text between the FIRST and SECOND '=> ' on it (awk's $2 under -F'=> '),
## empty if the line carries no separator, empty if there is no such line.
## `|| true` on the capture keeps a genuinely failing lsphp as an empty
## SCAN_DIR — which the `-n` test below already handles — not a boot failure.
LSPHP_INFO=$(/usr/local/lsws/lsphp"${PHPVER}"/bin/lsphp -i 2>/dev/null || true)
SCAN_DIR=""
## The leading newline is what makes a match on LINE 1 behave like every other
## line, exactly as awk's `^` anchor does.
scan_rest=$'\n'"$LSPHP_INFO"
if [[ $scan_rest == *$'\nScan this dir'* ]]; then
## `#` takes the SHORTEST prefix, i.e. the FIRST matching line — awk's `exit`.
scan_rest=${scan_rest#*$'\nScan this dir'}
scan_line="Scan this dir${scan_rest%%$'\n'*}"
if [[ $scan_line == *'=> '* ]]; then
SCAN_DIR=${scan_line#*'=> '}
SCAN_DIR=${SCAN_DIR%%'=> '*}
fi
unset scan_line
fi
unset scan_rest
if [ -n "$SCAN_DIR" ]; then
cat > "$SCAN_DIR/99-user-error-log.ini" <<EOF
; rendered at container start by entrypoint-litespeed.sh
error_log = /home/${user}/logs/php-fpm/error.log
log_errors = On
EOF
## Per-site opcache override (panel: Advanced Tuning → OpCache size).
## Falls back to the global lsphp-overrides.ini values (64 MB / 8000 files)
## when the env vars aren't set. Numeric range/sanity is enforced in the
## WHP panel before the env var lands here.
if [ -n "${OPCACHE_MEMORY_MB:-}" ] || [ -n "${OPCACHE_MAX_FILES:-}" ]; then
{
echo "; rendered at container start by entrypoint-litespeed.sh"
echo "; per-site override from WHP whp.sites.opcache_*_override"
[ -n "${OPCACHE_MEMORY_MB:-}" ] && echo "opcache.memory_consumption = ${OPCACHE_MEMORY_MB}"
[ -n "${OPCACHE_MAX_FILES:-}" ] && echo "opcache.max_accelerated_files = ${OPCACHE_MAX_FILES}"
} > "$SCAN_DIR/99-user-opcache.ini"
fi
fi
## ---- ownership: OLS runs as $user end-to-end (server-level user set by
## create-vhost-litespeed.sh, no setUIDMode). So OLS runtime dirs need to
## be customer-owned for log writes, swap files, lsphp socket creation.
## Master still starts as root for port binding, then drops privs to $user.
chown -R "$user:$user" /usr/local/lsws/logs /usr/local/lsws/conf/cert /tmp/lshttpd 2>/dev/null || true
chown -R "$user:$user" "/home/$user"
chmod 755 "/home/$user"
## ---- drop healthz so docker HEALTHCHECK passes before customer files
## Always rewrite as customer; suexec lsphp will read it as that uid too.
sudo -u "$user" sh -c "echo ok > /home/$user/public_html/healthz"
## ---- DEV: local mariadb + memcached for parity with cac entrypoints ----
if [ "$environment" = "DEV" ]; then
echo "Starting Dev Deployment (litespeed)"
mkdir -p "/home/$user/_db_backups"
## mariadb-server + memcached are NOT baked into the image (saves ~500MB
## on PROD pulls). Install them at runtime, but only once per container —
## the command -v guard means a restart of an already-bootstrapped
## container skips the apt step and DEV boot stays ~1.5s like PROD.
## First-boot in DEV adds ~30-60s for the apt install; acceptable
## tradeoff per the design spec.
if ! command -v mysqld >/dev/null 2>&1; then
echo "DEV first boot: installing mariadb-server + memcached..."
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
mariadb-server memcached
apt-get clean
rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
fi
mkdir -p /run/mysqld && chown mysql:mysql /run/mysqld
nohup mysqld --user=mysql &>/dev/null &
if [ ! -f "/home/$user/mysql_creds" ]; then
sleep 10
mysql_user=$(openssl rand -hex 7)
mysql_password=$(openssl rand -hex 12)
mysql_db="devdb_$(openssl rand -hex 3)"
mysql -e "CREATE DATABASE $mysql_db;"
mysql -e "CREATE USER '$mysql_user'@'localhost' IDENTIFIED BY '$mysql_password';"
mysql -e "GRANT ALL PRIVILEGES ON *.* TO '$mysql_user'@'localhost' WITH GRANT OPTION;"
mysql -e "FLUSH PRIVILEGES;"
{
echo "MySQL User: $mysql_user"
echo "MySQL Password: $mysql_password"
echo "MySQL Database: $mysql_db"
} > "/home/$user/mysql_creds"
cat "/home/$user/mysql_creds"
fi
/usr/bin/memcached -d -u "$user"
fi
## ---- user crontab ----
if [ ! -f "/home/$user/crontab" ]; then
{
echo "# User crontab for $user"
echo "# Add your cron jobs here"
} > "/home/$user/crontab"
chown "$user:$user" "/home/$user/crontab"
fi
crontab -u "$user" "/home/$user/crontab"
service cron start >/dev/null 2>&1 || /usr/sbin/cron
## ---- LSCache plugin (background, non-fatal) ----
( /scripts/install-lscache-wp.sh "$user" >>/var/log/lscache-install.log 2>&1 || true ) &
## Stream OLS + customer logs to PID-1 stdout so `docker logs` works. Started
## once, before the supervisor loop — it follows the files across OLS restarts.
touch /usr/local/lsws/logs/error.log /usr/local/lsws/logs/access.log
touch "/home/$user/logs/apache/error_log" "/home/$user/logs/apache/access_log"
touch "/home/$user/logs/php-fpm/error.log"
chown "$user:$user" "/home/$user/logs/apache/error_log" \
"/home/$user/logs/apache/access_log" \
"/home/$user/logs/php-fpm/error.log"
tail -F /usr/local/lsws/logs/error.log \
/usr/local/lsws/logs/access.log \
"/home/$user/logs/apache/error_log" \
"/home/$user/logs/apache/access_log" \
"/home/$user/logs/php-fpm/error.log" 2>/dev/null &
## ---- supervise OLS in DAEMON mode (NOT `openlitespeed -n` + wait) ----
## OLS performs INTERNAL graceful self-restarts: the LiteSpeed Cache /
## QUIC.cloud integration refreshes the QUIC.cloud IP allowlist on a schedule
## and, when it changes, sends SIGUSR1 → "request a graceful server restart".
## In `-n` foreground mode the OLD main PID exits after the zero-downtime
## handoff; a bare `wait` on that PID lets bash (PID 1) exit and tears the whole
## container down. Worse, that exit is *clean*, so `RestartPolicy` doesn't
## reliably catch it — the container just stops and HAProxy serves 503 until
## someone manually starts it. (Root-caused on whp02 alsacorp, 2026-06-06.)
##
## Daemon mode is OLS's native model: it owns the SIGUSR1 handoff, keeps the
## listeners bound across generations, and rewrites lshttpd.pid to the new main.
## PID 1 just FOLLOWS the pidfile — a graceful self-restart is invisible here
## (zero downtime), and we only ever relaunch on a genuine crash (no live main).
STOP_REQUESTED=0
term_handler() {
STOP_REQUESTED=1
/usr/local/lsws/bin/lswsctrl stop >/dev/null 2>&1 || true
}
trap term_handler TERM INT
## Authoritative, path-independent liveness check: `lswsctrl status` prints
## "litespeed is running with PID N." when up (and "...is not running" when
## down). We match the running message specifically — a bare grep for "running"
## would also match "not running". (This image keeps the pidfile under
## /tmp/lshttpd, not logs/, so we never hard-code a pidfile path.)
##
## Read into a variable and match with a here-string rather than piping into
## `grep -qi`: `grep -q` closes the pipe on its first match, and under the
## `set -o pipefail` at the top of this file a writer that is still writing when
## that happens dies 141 and the pipeline reports FALSE — i.e. "OLS is down"
## precisely because the "running" line matched, which here means a spurious
## relaunch of a healthy OLS, five of which trip the crash-loop cap and exit
## PID 1. (Same defect that shipped in entrypoint-lsphp.sh's cac_path_parity
## probe.) The reason to change it is STRUCTURAL — a pipefail script must not
## pipe into an early-exit reader, whatever the payload — because "lswsctrl
## prints one short line so it always wins" is a size argument, and size
## arguments about this race are wrong: see the measurements over the probe
## helpers in entrypoint-lsphp.sh, where 41 KB SIGPIPEd 11% of the time into a
## 64 KB pipe. A non-zero `lswsctrl` still means "not running", exactly as
## pipefail made it mean before.
##
## A here-string is the right shape HERE, where those helpers use `[[ ]]`: the
## reason to avoid `<<<` there is that bash spills a large here-string to
## /tmp/sh-thd.XXXXXX and so makes a writable temp dir a boot precondition. The
## threshold is 65536 bytes in this image's bash 5.2.21 and no lower than 4096
## in any bash this repo has met; `lswsctrl status` prints well under 100 bytes
## and cannot approach it, so no temp file is ever created and the case-
## insensitive match stays a plain `grep -i` instead of a hand-rolled glob.
ols_running() {
local st
st=$(/usr/local/lsws/bin/lswsctrl status 2>/dev/null) || return 1
grep -qi 'running with pid' <<<"$st"
}
## Crash-loop cap: if OLS can't stay up, bail out so Docker's restart policy and
## the site-health monitor escalate instead of us hot-looping forever.
MAX_STARTS=5
WINDOW=60
starts=""
start_ols() {
/usr/local/lsws/bin/lswsctrl start >/dev/null 2>&1 || true
## wait up to 10s for the daemon to report running
for _ in $(seq 1 20); do
ols_running && return 0
sleep 0.5
done
return 1
}
if ! start_ols; then
echo "entrypoint: OLS failed to start (not running after 10s)." >&2
exit 1
fi
echo "entrypoint: OLS started in daemon mode — $(/usr/local/lsws/bin/lswsctrl status 2>/dev/null || true)"
while true; do
if ols_running; then
sleep 3
continue
fi
## Not running this instant. This is EITHER a clean shutdown OR the brief
## handoff window of a graceful self-restart (status momentarily reports down
## while the new main takes over). Grace, then re-check before judging.
sleep 2
if [ "$STOP_REQUESTED" -eq 0 ] && ols_running; then
continue
fi
if [ "$STOP_REQUESTED" -eq 1 ]; then
echo "entrypoint: SIGTERM received, OLS stopped — exiting."
exit 0
fi
## Genuine crash: not running and no shutdown requested. Relaunch, capped.
now=$(date +%s)
starts="$starts $now"
pruned=""
for t in $starts; do
[ $((now - t)) -lt "$WINDOW" ] && pruned="$pruned $t"
done
starts="$pruned"
n=$(echo $starts | wc -w)
echo "entrypoint: OLS not running — relaunching (attempt $n/$MAX_STARTS within ${WINDOW}s)." >&2
if [ "$n" -ge "$MAX_STARTS" ]; then
echo "entrypoint: OLS crash-looping ($n starts in ${WINDOW}s) — bailing out for Docker restart policy / monitor." >&2
exit 1
fi
start_ols || true
done