From 19092911a39896c1a6ce6d0edac6622547849c66 Mon Sep 17 00:00:00 2001 From: jknapp Date: Tue, 9 Jun 2026 18:28:34 -0700 Subject: [PATCH] feat(cac-lsphp): detached lsphp (LSAPI) site image for the shared-ols tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New slim per-site PHP backend that runs 'lsphp -b 0.0.0.0:9000' (detached LSAPI) and nothing else — the LiteSpeed analogue of cac-fpm, sitting behind a shared OpenLiteSpeed container. Built on the same litespeedtech prebuilt base as cac-litespeed so the lsphp runtime/extensions are identical. - Dockerfile.lsphp: base + lsphpNN-ldap parity, reuses shared lsphp-overrides.ini, exposes only :9000, no webserver started (guaranteed by entrypoint, not by stripping OLS binaries). - entrypoint-lsphp.sh: same uid/user contract + /home/$user/logs layout + ini drop-in mechanism as entrypoint-litespeed.sh; sizes PHP_LSAPI_CHILDREN from container memory (detect-memory-lsphp.sh) with panel override precedence; execs lsphp -b as the per-site user via setpriv (PID 1). - detect-memory-lsphp.sh: LSAPI_CHILDREN sizing, no OLS daemon reserve. - healthcheck-lsphp.sh: TCP :9000 + lsphp-alive (LSAPI isn't FastCGI). - CI: Build-LSPHP-Images job, php81-85 matrix, OLS 1.8.4, cac-lsphp:phpNN. Verified locally: builds php83+php85; sidecar runs lsphp as the per-site user (uid 61045) as PID 1, healthcheck green, and a real shared OLS in front serves PHP over LSAPI (HTTP 200, SAPI=litespeed) with identical docroot path. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitea/workflows/build-push.yaml | 41 ++++++++++++ Dockerfile.lsphp | 62 ++++++++++++++++++ scripts/detect-memory-lsphp.sh | 75 ++++++++++++++++++++++ scripts/entrypoint-lsphp.sh | 106 +++++++++++++++++++++++++++++++ scripts/healthcheck-lsphp.sh | 17 +++++ 5 files changed, 301 insertions(+) create mode 100644 Dockerfile.lsphp create mode 100644 scripts/detect-memory-lsphp.sh create mode 100644 scripts/entrypoint-lsphp.sh create mode 100644 scripts/healthcheck-lsphp.sh diff --git a/.gitea/workflows/build-push.yaml b/.gitea/workflows/build-push.yaml index e2a708c..4a85576 100644 --- a/.gitea/workflows/build-push.yaml +++ b/.gitea/workflows/build-push.yaml @@ -117,6 +117,47 @@ jobs: repo.anhonesthost.net/cloud-hosting-platform/cac-litespeed:php${{ matrix.phpver }} ${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac-litespeed:latest' || '' }} + Build-LSPHP-Images: + runs-on: ubuntu-latest + strategy: + matrix: + # Same PHP matrix as cac-litespeed (81–85): cac-lsphp is the detached + # backend for the shared-ols tier and shares the litespeed prebuilt + # base, which only ships lsphp for 8.1+. Keep this matrix in lockstep + # with Build-LiteSpeed-Images. + phpver: [81, 82, 83, 84, 85] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Gitea + uses: docker/login-action@v3 + with: + registry: repo.anhonesthost.net + username: ${{ secrets.CI_USER }} + password: ${{ secrets.CI_TOKEN }} + + - name: Build and Push lsphp Image + uses: docker/build-push-action@v6 + with: + file: ./Dockerfile.lsphp + platforms: linux/amd64 + push: true + build-args: | + PHPVER=${{ matrix.phpver }} + OLS_VERSION=1.8.4 + # OLS_VERSION pinned to 1.8.4 to match Build-LiteSpeed-Images — same + # prebuilt base, same lsphp binaries. Bump both together. + tags: | + repo.anhonesthost.net/cloud-hosting-platform/cac-lsphp:php${{ matrix.phpver }} + ${{ matrix.phpver == '85' && 'repo.anhonesthost.net/cloud-hosting-platform/cac-lsphp:latest' || '' }} + Build-Shared-httpd: runs-on: ubuntu-latest steps: diff --git a/Dockerfile.lsphp b/Dockerfile.lsphp new file mode 100644 index 0000000..af1599e --- /dev/null +++ b/Dockerfile.lsphp @@ -0,0 +1,62 @@ +## cac-lsphp — per-site DETACHED lsphp (LSAPI) backend for the shared-ols tier. +## +## The LiteSpeed analogue of cac-fpm: a slim, single-tenant PHP backend that +## runs `lsphp -b 0.0.0.0:9000` (detached LSAPI mode) and NOTHING ELSE — no +## webserver. The shared OpenLiteSpeed container (shared-ols) sits in front and +## reaches this over the docker network via an extProcessor of type lsapi, +## address :9000 — structurally identical to how shared-httpd +## reaches a cac-fpm container's php-fpm on :9000. +## +## Built on the SAME LiteSpeed prebuilt base as cac-litespeed so the lsphp +## binary + extension set are byte-for-byte the runtime customers already get +## on the litespeed tier (memcached, redis, imagick, mbstring, mysqlnd, intl, +## gd, soap, bcmath, gmp, sodium, opcache, ... + lsphpNN-ldap added below). +## We do NOT strip the bundled OpenLiteSpeed binaries: the "no webserver" +## guarantee comes from the ENTRYPOINT (it only ever execs lsphp), and deleting +## OLS files from the upstream image risks breaking lsphp's shared libs for no +## real benefit. Only :9000 is EXPOSEd, and OLS is never started. +## +## See the design spec + PoC: whp docs/superpowers/plans/2026-06-09-ols-lsphp-tier.md +## and the LSAPI path-parity finding (feedback_ols_lsapi_no_script_filename_remap). + +ARG OLS_VERSION=1.8.4 +ARG PHPVER=83 +FROM litespeedtech/openlitespeed:${OLS_VERSION}-lsphp${PHPVER} +ARG PHPVER=83 +ENV PHPVER=${PHPVER} + +## Match the cac-litespeed extension surface exactly: the only ext the prebuilt +## base lacks is lsphpNN-ldap. setpriv (util-linux) is already on the Ubuntu +## base; we add nothing else the sidecar doesn't need. All apt cache cleaned in +## the same layer to keep the image small. +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates \ + lsphp${PHPVER}-ldap && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/* + +## Scripts + the SHARED production lsphp ini (reused verbatim from the litespeed +## image — same runtime, same tuning). Scripts layer last (they change most). +COPY ./scripts/entrypoint-lsphp.sh \ + ./scripts/detect-memory-lsphp.sh \ + ./scripts/healthcheck-lsphp.sh \ + /scripts/ +RUN chmod +x /scripts/entrypoint-lsphp.sh /scripts/detect-memory-lsphp.sh /scripts/healthcheck-lsphp.sh + +## Apply production lsphp ini overrides into lsphp's scan dir (path varies by +## PHP minor version; ask lsphp directly — same idiom as Dockerfile.litespeed). +COPY ./configs/litespeed/lsphp-overrides.ini /etc/lsws-templates/lsphp-overrides.ini +RUN bash -c 'set -e; \ + SCAN_DIR=$(/usr/local/lsws/lsphp${PHPVER}/bin/lsphp -i 2>/dev/null | awk -F"=> " "/^Scan this dir/ {print \$2; exit}"); \ + mkdir -p "$SCAN_DIR"; \ + cp /etc/lsws-templates/lsphp-overrides.ini "$SCAN_DIR/99-prod-overrides.ini"; \ + echo "wrote overrides to $SCAN_DIR"' + +EXPOSE 9000 + +## TCP-connect + lsphp-alive check (LSAPI isn't FastCGI, so no cgi-fcgi ping). +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD /scripts/healthcheck-lsphp.sh + +ENTRYPOINT ["/scripts/entrypoint-lsphp.sh"] diff --git a/scripts/detect-memory-lsphp.sh b/scripts/detect-memory-lsphp.sh new file mode 100644 index 0000000..535e9f4 --- /dev/null +++ b/scripts/detect-memory-lsphp.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +## detect-memory-lsphp.sh — sibling of detect-memory-litespeed.sh for the +## cac-lsphp DETACHED sidecar (lsphp -b, no local webserver). +## +## Computes PHP_LSAPI_CHILDREN from the container memory cap. Identical worker +## arithmetic to detect-memory-litespeed.sh, with ONE difference: there is no +## OpenLiteSpeed daemon in this container (OLS runs in the shared-ols tier), so +## the ~40 MB OLS_RESERVE is dropped — every MB above the OS reserve goes to +## lsphp workers. Sourced by entrypoint-lsphp.sh. + +## ---- container memory detection (mirrors detect-memory-litespeed.sh) ---- +CONTAINER_MEMORY_BYTES="" + +if [ -f /sys/fs/cgroup/memory.max ]; then + val=$(cat /sys/fs/cgroup/memory.max 2>/dev/null) + if [ "$val" != "max" ] && [ -n "$val" ]; then + CONTAINER_MEMORY_BYTES=$val + fi +fi + +if [ -z "$CONTAINER_MEMORY_BYTES" ] && [ -f /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then + val=$(cat /sys/fs/cgroup/memory/memory.limit_in_bytes 2>/dev/null) + if [ -n "$val" ] && [ "$val" -lt 8589934592000 ] 2>/dev/null; then + CONTAINER_MEMORY_BYTES=$val + fi +fi + +if [ -z "$CONTAINER_MEMORY_BYTES" ] && [ -f /proc/meminfo ]; then + mem_kb=$(awk '/^MemTotal:/ {print $2}' /proc/meminfo) + if [ -n "$mem_kb" ]; then + CONTAINER_MEMORY_BYTES=$((mem_kb * 1024)) + fi +fi + +if [ -z "$CONTAINER_MEMORY_BYTES" ]; then + CONTAINER_MEMORY_BYTES=$((512 * 1024 * 1024)) +fi + +CONTAINER_MEMORY_MB=$((CONTAINER_MEMORY_BYTES / 1024 / 1024)) + +## ---- budget split (all non-OS memory is the lsphp workers' to use) ---- +OS_RESERVE_MB=50 +DEV_OVERHEAD_MB=0 +if [ "${environment:-PROD}" = "DEV" ]; then + DEV_OVERHEAD_MB=125 +fi + +AVAILABLE_MB=$((CONTAINER_MEMORY_MB - OS_RESERVE_MB - DEV_OVERHEAD_MB)) +if [ "$AVAILABLE_MB" -lt 60 ]; then + AVAILABLE_MB=60 +fi + +## ---- LSAPI children ---- +## Same ~130 MB/worker estimate as cac-litespeed (see detect-memory-litespeed.sh +## for the vantagehealth/brain-jar OOM history that set this). Detached lsphp +## has the SAME per-worker shmem-RSS profile as in-container lsphp — splitting +## the webserver out doesn't change lsphp's memory model, only removes the OLS +## daemon footprint from the budget. +LSPHP_WORKER_ESTIMATE_MB=${LSPHP_WORKER_ESTIMATE_MB:-130} + +calc_lsapi_children=$((AVAILABLE_MB / LSPHP_WORKER_ESTIMATE_MB)) +if [ "$calc_lsapi_children" -lt 2 ]; then + calc_lsapi_children=2 +fi +if [ "$calc_lsapi_children" -gt 50 ]; then + calc_lsapi_children=50 +fi + +## Per-site override precedence — the WHP panel (site-pool-env.php) passes the +## customer's override as LSAPI_CHILDREN and/or FPM_MAX_CHILDREN; either wins +## over the calculated default. entrypoint-lsphp.sh exports the result as +## PHP_LSAPI_CHILDREN (the name lsphp -b reads). +LSAPI_CHILDREN=${LSAPI_CHILDREN:-${FPM_MAX_CHILDREN:-$calc_lsapi_children}} + +export CONTAINER_MEMORY_MB LSAPI_CHILDREN diff --git a/scripts/entrypoint-lsphp.sh b/scripts/entrypoint-lsphp.sh new file mode 100644 index 0000000..f9e7168 --- /dev/null +++ b/scripts/entrypoint-lsphp.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +## entrypoint-lsphp.sh — PID 1 for cac-lsphp:phpNN. +## +## The per-site PHP backend for the SHARED OpenLiteSpeed tier. Runs lsphp in +## DETACHED LSAPI mode (`lsphp -b `) and nothing else — no +## webserver. The shared-ols container connects to this over the docker +## network (extProcessor type lsapi, address :9000) exactly +## like the shared httpd connects to a cac-fpm container's php-fpm on :9000. +## +## Structurally this is the LiteSpeed analogue of entrypoint-fpm.sh: same +## `uid`/`user` contract, same /home/$user/{public_html,logs} layout, same +## per-site ini drop-in mechanism as entrypoint-litespeed.sh. The only +## difference from cac-litespeed is that OLS lives elsewhere, so this PID 1 is +## lsphp itself rather than a webserver supervisor. +## +## IMPORTANT (see whp memory feedback_ols_lsapi_no_script_filename_remap): +## OLS hands lsphp exactly its vhost docRoot path, so the shared-ols vhost +## docRoot and THIS container's docroot mount MUST be the same absolute path. +## The panel mounts the site at /mnt/users// in BOTH containers; +## this entrypoint does not assume any particular path — it just runs lsphp, +## which opens whatever absolute SCRIPT_FILENAME the webserver sends. + +set -euo pipefail + +: "${PHPVER:=83}" +: "${environment:=PROD}" +export CONTAINER_ROLE="lsphp_only" +export PHPVER environment + +## ---- env validation (same contract as entrypoint-fpm / entrypoint-litespeed) ---- +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 +export user + +LSPHP_BIN="/usr/local/lsws/lsphp${PHPVER}/bin/lsphp" +if [ ! -x "$LSPHP_BIN" ]; then + echo "FATAL: lsphp binary not found at $LSPHP_BIN (PHPVER=$PHPVER)." >&2 + exit 1 +fi + +## ---- user + directories (mirror entrypoint-litespeed.sh paths) ---- +if ! id -u "$user" >/dev/null 2>&1; then + useradd -u "$uid" -m -s /bin/bash "$user" +fi +mkdir -p "/home/$user/public_html" "/home/$user/logs/php-fpm" + +## ---- detached-lsphp pool sizing ---- +# shellcheck source=/dev/null +source /scripts/detect-memory-lsphp.sh + +## LSAPI tuning (spec §5.1). PHP_LSAPI_CHILDREN MUST equal the shared-ols vhost +## maxConns — the WHP panel writes both from the single fpm_max_children value, +## so they can't drift. LSAPI_MAX_IDLE is THE RAM win: idle children exit, so an +## idle site's footprint collapses toward baseline (ondemand-like). +export PHP_LSAPI_CHILDREN="${PHP_LSAPI_CHILDREN:-$LSAPI_CHILDREN}" +export PHP_LSAPI_MAX_REQUESTS="${PHP_LSAPI_MAX_REQUESTS:-500}" +export LSAPI_MAX_IDLE="${LSAPI_MAX_IDLE:-30}" +export LSAPI_EXTRA_CHILDREN="${LSAPI_EXTRA_CHILDREN:-5}" +export LSAPI_AVOID_FORK="${LSAPI_AVOID_FORK:-0}" +LSPHP_BIND="${LSPHP_BIND:-0.0.0.0:9000}" + +echo "Container memory: ${CONTAINER_MEMORY_MB}MB | PHP_LSAPI_CHILDREN=${PHP_LSAPI_CHILDREN} | LSAPI_MAX_IDLE=${LSAPI_MAX_IDLE} | PHPVER=${PHPVER} | bind=${LSPHP_BIND}" + +## ---- per-site ini drop-ins (identical mechanism to entrypoint-litespeed.sh) ---- +## error_log → the same customer-visible path cac:phpNN / cac-litespeed use, so +## "where's my PHP error log?" is answered identically across all site types. +SCAN_DIR=$("$LSPHP_BIN" -i 2>/dev/null | awk -F'=> ' '/^Scan this dir/ {print $2; exit}') +if [ -n "$SCAN_DIR" ]; then + mkdir -p "$SCAN_DIR" + cat > "$SCAN_DIR/99-user-error-log.ini" < "$SCAN_DIR/99-user-opcache.ini" + fi +fi + +## ---- ownership ---- +touch "/home/$user/logs/php-fpm/error.log" +chown -R "$user:$user" "/home/$user" +chmod 755 "/home/$user" + +## ---- exec lsphp -b as the customer user (PID 1) ---- +## Bind port is unprivileged (9000), so no root port-bind step is needed — start +## directly as $user. Prefer setpriv (util-linux, on the Ubuntu base); fall back +## to runuser. exec so lsphp becomes PID 1 and receives Docker's signals +## directly (clean stop/restart, matches the php-fpm container's lifecycle). +echo "entrypoint-lsphp: exec $LSPHP_BIN -b $LSPHP_BIND as $user (uid=$uid)" +if command -v setpriv >/dev/null 2>&1; then + exec setpriv --reuid "$uid" --regid "$uid" --init-groups "$LSPHP_BIN" -b "$LSPHP_BIND" +elif command -v runuser >/dev/null 2>&1; then + exec runuser -u "$user" -- "$LSPHP_BIN" -b "$LSPHP_BIND" +else + exec sudo -u "$user" -E "$LSPHP_BIN" -b "$LSPHP_BIND" +fi diff --git a/scripts/healthcheck-lsphp.sh b/scripts/healthcheck-lsphp.sh new file mode 100644 index 0000000..59c3c10 --- /dev/null +++ b/scripts/healthcheck-lsphp.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +## healthcheck-lsphp.sh — liveness for the detached-lsphp sidecar. +## +## LSAPI is not FastCGI, so the cac-fpm `cgi-fcgi ... | grep pong` ping doesn't +## apply here. Minimum viable check (spec §5.1 fallback): the LSAPI listener is +## accepting TCP connections on :9000 AND at least one lsphp process is alive. +## A bound-but-wedged listener with no lsphp would fail the pgrep; a crashed +## listener fails the connect. + +PORT="${LSPHP_HEALTH_PORT:-9000}" + +# bash /dev/tcp connect test (bash is present on the litespeedtech base). +exec 3<>"/dev/tcp/127.0.0.1/${PORT}" 2>/dev/null || exit 1 +exec 3>&- 3<&- + +pgrep -x lsphp >/dev/null 2>&1 || exit 1 +exit 0