# --------------------------------------------------------------------------- # Lsky Pro Docker 镜像 —— mole404/lsky-pro-docker # # 上游应用:https://github.com/lsky-org/lsky-pro (GPL-3.0,官方已停止维护) # 上游镜像:https://github.com/HalcyonAzure/lsky-pro-docker (AGPL-3.0,本仓库由它衍生) # # 本仓库 = 上游应用源码快照(vendored 在 src/)+ Docker 构建脚本 + 少量前端补丁。 # # 与上游镜像的差异(详见 README): # 1. 应用源码**已 vendored 进本仓库**(src/ = 上游 commit 38d52c46… 即 2025-11-24 的 master 完整树)。 # 构建不再联网拉 GitHub archive:上游已明示停止维护,万一仓库被删/归档/改名也还能重建镜像; # 之后要改 PHP 代码,直接改 src/ 下的文件、提交即可,不用再维护 overlay 叠加。 # 2. 前端补丁已就地落在 src/ 里(iOS Safari 长按弹菜单 + 菜单交互修复)。 # "我们相对上游改了什么"见 patches/ios-longpress.patch,可用 tools/diff-vs-upstream.sh 重新生成/核对。 # 3. 基础镜像显式写成 Debian bookworm 变体(与 2024-04 那版线上镜像同一 Debian 大版本) # 4. install-php-extensions 钉到具体版本,不再用 latest # 5. 构建期自证:① 我们从不修改的上游文件按上游 md5 校验(快照没被动过) # ② 补丁产物与「有意偏离上游」的 composer.lock 按预期 md5 校验(改了不更新就构建失败) # 6. 运行时加固(F23,2026-09-30):PHP 关掉 expose_php / display_errors # (/usr/local/etc/php/conf.d/zz-lsky-hardening.ini)、Apache 关掉版本号外泄 # (/etc/apache2/conf-enabled/zz-lsky-hardening.conf:ServerTokens Prod + ServerSignature Off), # 两处都带构建期断言;CI 的「Verify published image」再对真实响应头断言一次。 # 7. 认证类 POST 加路由级节流(src/routes/auth.php:login/register/forgot-password/confirm-password) # 8. 测试版内存收紧(2026-10-01):PHP memory_limit 收到 64M(可运行时用 env PHP_MEMORY_LIMIT # 覆盖,entrypoint 渲染进上面的 hardening ini)、opcache memory_consumption 128→64、 # Apache MaxConnectionsPerChild 保持默认 5(低流量站,每 ~3 分钟换班开销可忽略, # 取最严的内存纪律;可运行时用 env APACHE_MAX_CONNECTIONS_PER_CHILD 覆盖,0 = 永不回收)。 # 详见各段注释与 CI「Verify published image」的新断言。 # # 构建:docker build -t lsky-pro-docker . # --------------------------------------------------------------------------- # 上游源码快照的版本号(只用于版本标记与镜像元数据;代码本体是仓库里的 src/)。 # 改这里 = 必须同时用 tools/vendor-upstream.sh 重新 vendor src/,否则标记与实际代码不符(自证 1 会失败)。 ARG LSKY_COMMIT=38d52c4609eb85236b45ac75acac2ced55174953 # PHP 版本:8.1 已于 2025-12-31 EOL,8.2 的 EOL 是 2026-12-31(都太近), # 所以选 8.3(安全维护到 2027-12-31)。依赖侧配套升到同一大版本线内的最新: # laravel/framework 9.52.22(9.x 最后一个补丁)+ symfony/* 6.4 LTS(Laravel 9 的 ^6.0 正好允许)。 # 要回退 PHP:改这个数字即可,但请连镜像 tag 一起回退(新 lock 里的包可能要求 ≥8.3)。 ARG PHP_VERSION=8.3 ARG DEBIAN_RELEASE=bookworm ARG PHP_EXT_INSTALLER_VERSION=2.12.0 # --------------------------------------------------------------------------- # F15:基镜像钉 digest —— 上游重推 tag(同 tag 指向新内容)时,构建出来的仍是同一份东西。 # 两个阶段用的**不是**同一个基镜像(builder = cli,runtime = apache),各自钉自己的。 # 更新方法(改 PHP_VERSION / DEBIAN_RELEASE 时必须一并更新,否则构建会因 manifest 不匹配而失败): # 1) 取 token(本机没有 docker,别用 docker manifest): # curl -s 'https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/php:pull' # -> 取返回 JSON 里的 .token # 2) 读 digest(tag 换成要查的那个): # curl -sI -H "Authorization: Bearer $TOKEN" # -H 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' # https://registry-1.docker.io/v2/library/php/manifests/8.3-cli-bookworm # -> 看响应头 Docker-Content-Digest(8.3-apache-bookworm 同理,两个都要查) # 3) 把拿到的 sha256 填进下面的 ARG(**tag 保留**,digest 附在 @ 之后,形如 php:8.3-apache-bookworm@sha256:…) # 注意:钉死后上游的安全修复**不会**自己进来 —— 想拿新修复就得重跑上面两步、更新 digest,再重建镜像。 # 试构建(CI 的 workflow_dispatch 换 PHP 版本)也要一并覆盖对应的 digest ARG: # 带 `@digest` 时 **digest 优先、tag 只作装饰**,只换 PHP_VERSION 不换 digest 会静默构建出旧版本。 # CI 已自动化:build-image.yaml 的 Resolve 步骤会按请求的 PHP 版本解析出对应 digest 并覆盖这两个 ARG # (也可在 workflow_dispatch 里手工传 php_cli_digest / php_apache_digest)。上面这套手工方法仅用于本地/离线场景。 # 实测(2026-09-30,Docker Hub Registry API 原始响应头,见提交说明/报告): # php:8.3-cli-bookworm -> sha256:4687aec76c4a895b68b91bcd5e48f1ba7a3dea120f6de50bba7e1a93af5372dd # php:8.3-apache-bookworm -> sha256:06df07e2e1d72581dde4fd97bc98d2d0b78686fc13d6a041c520da9a6ce73055 ARG PHP_CLI_IMAGE_DIGEST=sha256:4687aec76c4a895b68b91bcd5e48f1ba7a3dea120f6de50bba7e1a93af5372dd ARG PHP_APACHE_IMAGE_DIGEST=sha256:06df07e2e1d72581dde4fd97bc98d2d0b78686fc13d6a041c520da9a6ce73055 FROM php:${PHP_VERSION}-cli-${DEBIAN_RELEASE}@${PHP_CLI_IMAGE_DIGEST} AS build ARG LSKY_COMMIT ARG FORK_SHA=unknown WORKDIR /build # 安装必要的依赖 # curl —— 装 composer # unzip —— composer 解压 dist 包要用(去掉它会导致 composer install 直接失败:CI 实测踩过) # git —— 部分包会走 source 安装时的兜底 RUN docker-php-ext-install ftp # 依赖安装(builder 阶段的平台要求必须满足,否则 composer install 直接失败) # F16:composer 安装器**先校验、再执行**(不能像以前那样 `curl … | php` 直接把网上的字节喂给解释器)。 # 官方做法(2026-09-30 实查 https://getcomposer.org/download/):预期 sha384 来自 # https://composer.github.io/installer.sig,与 https://getcomposer.org/installer 分属两个域名, # 单一站点被投毒时另一侧能拦住。校验不过(sig 拉不到 / 格式不对 / 对不上)就不往下走 = 构建失败。 # 这里**故意不写死**哈希:installer 会随每个 composer 版本重新生成(官方文档明确警告别复制传播这段代码), # 写死的话 composer 一发新版、构建就必然红。想连 composer 版本一起钉,改用带版本号的 # https://getcomposer.org/download//composer.phar + 同目录 .sha256sum 校验。 RUN apt-get update && \ apt-get install -y curl unzip git && \ curl -fsSL -o /tmp/composer-setup.php https://getcomposer.org/installer && \ curl -fsSL -o /tmp/composer-setup.sig https://composer.github.io/installer.sig && \ grep -Eq '^[0-9a-f]{96}$' /tmp/composer-setup.sig && \ printf '%s %s\n' "$(cat /tmp/composer-setup.sig)" /tmp/composer-setup.php | sha384sum -c - && \ php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer && \ rm -f /tmp/composer-setup.php /tmp/composer-setup.sig && \ apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* # 应用源码:本仓库 src/ 里的 vendored 快照(= 上游那个 commit 的完整树,304 个文件)。 # 前端补丁已经就地在 src/ 里改好,这里不做任何"叠加覆盖"。改代码 = 改 src/ 然后提交。 COPY src/ /build/ # 自证 1:vendored 快照确实是上游那一版。 # 下面这个文件我们从不修改,md5 等于上游原值;对不上就说明 src/ 被误改 # (或与 LSKY_COMMIT 不符)→ 直接构建失败,不产出不可信的镜像。 RUN printf '%s\n' \ '3b58bff18126a61c142cc576457c82a6 ./public/index.php' \ | md5sum -c - # 自证 1b:有意偏离上游、但仍需锁死的文件。 # config/convention.php 与 routes/web.php 被本 fork 改过 —— 移除了「画廊」「系统升级」, # 且首页 / 在登录状态下改为跳转「我的图片」(游客上传页只留给未登录用户) # 两个功能(删配置项常量、删路由表条目,详见 README)。 # app/Services/ImageService.php 同样是有意偏离的:移除 SVG 支持时删掉了几个「只可能因为 # svg 数据而命中」的分支(上传跳过图片处理 / 跳过违规扫描的 in_array svg、makeThumbnail # 里 svg 直拷原文件),详见 README「与上游的差异」第 7 条。 # routes/auth.php 也是有意偏离(F23 防爆破):给 login / register / forgot-password / # confirm-password 四个 POST 端点加了 throttle(详见文件内注释)。它同样被钉住, # 改了不更新这行 md5 就构建失败。 # 这几个文件以后只要被改动(哪怕手滑),构建就会红 —— md5 必须随改动同步更新。 RUN printf '%s\n' \ '8136e73b50315d783f105dd4ac9971bb ./config/convention.php' \ '881fdbaed19ef39027783f093448875d ./routes/web.php' \ 'c1ab546f3e7f5237c1d45435171858ce ./routes/auth.php' \ 'e7edc0fc9dc8c654c4debca0e1d2eac4 ./app/Services/ImageService.php' \ | md5sum -c - # 有意偏离上游的另一个文件:composer.lock。 # 上游冻结在 2024-12 的解析结果(laravel 9.52.17 / symfony 6.0.x —— 两者都已 EOL), # 我们把它升到「同一大版本线内的安全版本」: # laravel/framework 9.52.22(9.x 最后一个补丁;9.52.21 -> 9.52.22 是一次安全修复)、symfony/* 6.4 LTS(Laravel 9 的 ^6.0 允许)、 # guzzlehttp/guzzle 7.15.5、guzzlehttp/psr7 2.13.1、phpseclib 3.0.57、 # league/commonmark 2.10.3、aws/aws-sdk-php 3.398.1、laminas-diactoros 2.26.0 # psy/psysh 0.11.23(2026-09-30:原 0.11.12 有 GHSA-4486-gxhx-5mg7 —— CWD 下 .psysh.php 自动加载 # 导致本地提权;上游在同一条 0.11 线发了修复,随 laravel/tinker 一起进镜像,故一并升掉。 # osv-scanner.toml 里对应那条白名单已随之删除=扫描真的干净,而不是被豁免。) # —— 修掉了除「Laravel 9 框架自身那几条(9.x 线没有修复版本)」以外的已知公告。 # 所以它不再等于上游值 —— 但仍钉 md5:任何改动都必须同步更新这里(防止有人别处悄悄改)。 RUN printf '%s\n' \ '12ab233331630b2dd1c48cee9e5ce927 ./composer.lock' \ | md5sum -c - # --no-dev:镜像只装运行时依赖。开发包(debugbar / ignition / whoops / phpunit / faker / sail…) # 对线上没有用途,留着既是体积也是暴露面(debugbar 那类在调试模式下会漏内部信息)。 # 连带项:卷里的 bootstrap/cache/packages.php 是"当年装着开发包"时生成的,里面记着这些包的 # ServiceProvider 类名 —— 包没了之后,Laravel 引导时加载该清单会 Class not found → 整站 500。 # 所以 entrypoint 的同步分支会作废这份清单(连同 services.php),CI 里也有对应的回归断言。 RUN php -r "file_exists('.env') || copy('.env.example', '.env');" \ && composer install --no-dev # --------------------------------------------------------------------------- # fork 补丁:iOS 长按菜单修复 + 菜单交互修复(已就地在 src/ 里,这里只做校验) # # 补丁涉及三个文件(两份 JS 内容必须一致,别只改一份): # src/resources/js/context-js.js —— 可读源码 # src/public/js/context-js/context-js.js —— 浏览器实际加载的那份 # src/resources/views/user/images.blade.php —— 引入脚本那一行(资源版本串) # (上游 webpack.mix.js 里本来也是 mix.copy('resources/js/context-js.js', 'public/js/context-js'), # 只是仓库里 public/ 下那份是旧工具链留下的压缩产物,一直没跟着源码更新。) # --------------------------------------------------------------------------- # 自证 2:补丁内容与预期完全一致,blade 的资源版本串也在。 # 改 src/ 里这三个文件后必须同步更新这里的 md5(故意做成"改了不更新就构建失败"), # 并重新生成 patches/ios-longpress.patch(tools/diff-vs-upstream.sh)。 RUN printf '%s\n' \ 'c0e513ec8e93fd81b34b3c6de5cf5eb8 ./public/js/context-js/context-js.js' \ 'c0e513ec8e93fd81b34b3c6de5cf5eb8 ./resources/js/context-js.js' \ 'd9ffacb2bbd8905703c279965a07ddbf ./resources/views/user/images.blade.php' \ | md5sum -c - \ && grep -q "assetVersion('js/context-js/context-js.js')" ./resources/views/user/images.blade.php \ && grep -q 'isIOSWebKit' ./public/js/context-js/context-js.js \ && grep -q 'LONG_PRESS_DELAY = 250' ./public/js/context-js/context-js.js \ && grep -q 'LONG_PRESS_DELAY = 250' ./resources/js/context-js.js \ && grep -q 'installMenuGuard' ./public/js/context-js/context-js.js \ && grep -q 'MENU_OPEN_GRACE' ./public/js/context-js/context-js.js \ && grep -q 'touch-open' ./public/js/context-js/context-js.js \ && grep -q 'fitSubmenu' ./public/js/context-js/context-js.js \ && grep -q 'clampMenu' ./resources/js/context-js.js \ && grep -q 'submenu-inplace' ./public/js/context-js/context-js.js \ && grep -q 'SUBMENU_GUARD' ./public/js/context-js/context-js.js # 代码版本标记:入口脚本用它判断「卷里的代码是不是当前镜像这一版」,不一致才同步(见 entrypoint.sh)。 # 它由「源码 commit + 补丁 md5 + 内容指纹」组成:任何代码/补丁变化都会让它变。 # 改 src/ 下任何文件(哪怕一行)后,都要把对应的 md5 一并更新,否则容器不会重新同步进卷。 # # 内容指纹(F22):上面那两个 md5 只覆盖「我们打补丁的那几个文件」—— app.js / PHP 这类**没被钉 md5** # 的文件改了之后标记不变,卷里的旧代码就永远不会被同步(镜像与卷不同步,且没有任何报错)。 # 所以这里再对三处构建期内容做确定性哈希,把它们也编进标记: # app_src_md5 = app/ config/ routes/ 三个目录下所有文件「按路径排序后内容拼接」的 md5 # (排序用 sort -z,与文件系统返回顺序无关 = 同一份源码在任何机器上结果一致); # app_js_md5 = public/js/app.js 的 md5;app_css_md5 = public/css/app.css 的 md5; # composer_lock_md5 = composer.lock 的 md5(应用根目录,构建后镜像里是 /var/www/lsky/composer.lock)。 # 补这一条的背景:上面的指纹只覆盖「代码」,而 composer.lock 变了(只升级 vendor 依赖、 # 其它 PHP/前端都没动)时,标记不变 → 老卷不会重新同步 → 卷里是旧依赖、镜像里是新依赖。 # composer install 是在 build 阶段、镜像自带的 vendor/ 已经按新 lock 装好; # 加了这一条,只换依赖的镜像也会让标记变、触发一次同步。 # 任何一个不存在 / 命令失败都写 none(标记照写)—— **绝不能让构建失败**。 # 入口脚本比的是整个标记文件的内容,所以多出这几行 = 换镜像时自动触发同步,entrypoint 不需要改。 RUN APP_SRC_MD5=$(find app config routes -type f -print0 2>/dev/null | sort -z | xargs -0 -r cat 2>/dev/null | md5sum | cut -d' ' -f1); \ [ -n "$APP_SRC_MD5" ] || APP_SRC_MD5=none; \ APP_JS_MD5=$(md5sum public/js/app.js 2>/dev/null | cut -d' ' -f1); \ [ -n "$APP_JS_MD5" ] || APP_JS_MD5=none; \ APP_CSS_MD5=$(md5sum public/css/app.css 2>/dev/null | cut -d' ' -f1); \ [ -n "$APP_CSS_MD5" ] || APP_CSS_MD5=none; \ COMPOSER_LOCK_MD5=$(md5sum composer.lock 2>/dev/null | cut -d' ' -f1); \ [ -n "$COMPOSER_LOCK_MD5" ] || COMPOSER_LOCK_MD5=none; \ printf '%s\n' \ "fork_sha=${FORK_SHA}" \ "lsky_commit=${LSKY_COMMIT}" \ "context_js_md5=c0e513ec8e93fd81b34b3c6de5cf5eb8" \ "images_blade_md5=d9ffacb2bbd8905703c279965a07ddbf" \ "app_src_md5=${APP_SRC_MD5}" \ "app_js_md5=${APP_JS_MD5}" \ "app_css_md5=${APP_CSS_MD5}" \ "composer_lock_md5=${COMPOSER_LOCK_MD5}" \ > .code-revision \ && cat .code-revision FROM php:${PHP_VERSION}-apache-${DEBIAN_RELEASE}@${PHP_APACHE_IMAGE_DIGEST} ARG LSKY_COMMIT ARG PHP_EXT_INSTALLER_VERSION ARG FORK_SHA=unknown LABEL org.opencontainers.image.source="https://github.com/mole404/lsky-pro-docker" \ org.opencontainers.image.title="lsky-pro-docker (ios-longpress + menu-ux)" \ org.opencontainers.image.description="Lsky Pro 图床的 Docker 镜像;应用源码 vendored 取自 lsky-org/lsky-pro@${LSKY_COMMIT}(构建不再依赖上游在线),并在其上做了 iOS 长按菜单修复与菜单交互修复(点菜单外只关菜单、触摸端二级菜单点击展开、滚动/缩放/Esc 关闭)" \ org.opencontainers.image.licenses="AGPL-3.0" \ org.opencontainers.image.revision="${FORK_SHA}" \ lsky.source.commit="${LSKY_COMMIT}" # 如果构建速度慢可以换源 # RUN sed -i -E "s@http://***@http://mirrors.cloud.tencent.com@g" /etc/apt/sources.list # 安装相关拓展(钉到具体版本,不使用 latest) # F16:mlocati 的 release **只有脚本本身、没有官方校验资产**(2026-09-30 实查 2.12.0 的 release assets # 只有 1 个),所以期望 sha256 直接写死在仓库里,下载后立即校验,验不过就构建失败。 # 换 PHP_EXT_INSTALLER_VERSION 时**必须**同步更新下面这个值(故意的:忘了就红): # 新值取法:curl -fsSL -o /tmp/ipe "https://github.com/mlocati/docker-php-extension-installer/releases/download/<新版本>/install-php-extensions" && sha256sum /tmp/ipe # 当前值对应 2.12.0(2026-09-30 实测):3f49c71fa66c79b8b2b96bc0ce92885dafd7946f3b5a46f545b390d6f1617e2c ARG PHP_EXT_INSTALLER_SHA256=3f49c71fa66c79b8b2b96bc0ce92885dafd7946f3b5a46f545b390d6f1617e2c RUN curl -fsSL -o /usr/local/bin/install-php-extensions \ "https://github.com/mlocati/docker-php-extension-installer/releases/download/${PHP_EXT_INSTALLER_VERSION}/install-php-extensions" \ && echo "${PHP_EXT_INSTALLER_SHA256} /usr/local/bin/install-php-extensions" | sha256sum -c - # 开启SSL RUN a2enmod ssl && a2ensite default-ssl # F21 修正(CI 实撞,2026-09-30):Debian 自带的 /etc/apache2/sites-available/default-ssl.conf # 由上面的 a2ensite 启用,而它**仍指向被删掉的那对 snakeoil 证书文件** → Apache 启动时配置校验报 # AH00526: SSLCertificateFile: file '/etc/ssl/certs/ssl-cert-snakeoil.pem' does not exist or is empty # 整个 Apache 起不来(症状很难认:容器进程在、安装页返回 000)。 # 把它也指到入口脚本运行期生成的那对证书(路径固定,与 000-default.conf.template 的 HTTPS vhost 一致)。 # 护栏按**文件路径**判而不是按 "snakeoil" 这个词判 —— 该文件里有一句提到 snakeoil 的注释是正常的。 # (本地在真容器里模拟过:改完 + 证书就位 → apache2ctl -t 输出 Syntax OK) RUN sed -i \ -e 's#/etc/ssl/certs/ssl-cert-snakeoil.pem#/etc/apache2/ssl/lsky-selfsigned.crt#' \ -e 's#/etc/ssl/private/ssl-cert-snakeoil.key#/etc/apache2/ssl/lsky-selfsigned.key#' \ /etc/apache2/sites-available/default-ssl.conf \ && ! grep -rn '/etc/ssl/certs/ssl-cert-snakeoil\|/etc/ssl/private/ssl-cert-snakeoil' /etc/apache2/ \ && grep -q 'lsky-selfsigned.crt' /etc/apache2/sites-available/default-ssl.conf # --------------------------------------------------------------------------- # F23:Apache 版本号外泄 —— 关掉 Server 头里的精确版本与页脚签名。 # 实测(2026-09-30,本机 docker run 已发布镜像):响应头 `Server: Apache/2.4.68 (Debian)`。 # (同一次探测还看到 `X-Powered-By: PHP/8.3.35` —— 那条由下面的 PHP ini 加固负责。) # ServerTokens Prod → Server 头只发 "Apache"(不带版本 / 模块 / OS); # ServerSignature Off → 错误页与目录列表不再追加 "Apache/2.4.68 (Debian) Server at …" 页脚 # (Debian 出厂默认本就是 Off,这里显式写死:基镜像换版本时默认值可能变)。 # 为什么放 conf-enabled 的独立文件、而不是 000-default.conf.template 那个 vhost 模板: # ① ServerTokens 是**全局(main server config)指令**,写进 里 Apache 会直接 # 拒绝加载该配置("ServerTokens not allowed in context")→ 整站起不来; # 它必须落在全局上下文,而模板整份就是 vhost。 # ② 那个模板同时管 HTTPS vhost、目录权限等一堆东西,改它等于改 vhost 行为,风险面远大于 # 「加一个 conf-enabled 文件」。 # ③ apache2.conf 先 IncludeOptional mods-enabled/ 再 IncludeOptional conf-enabled/,入口 # 脚本写 mpm.conf 也走这里;conf-enabled 不会被入口脚本覆写,换镜像照样生效。 # 构建期自证:① 文件真的在;② 两行**逐行精确**在里面(grep -qx,不是模糊包含)。 # 语法由真容器自证(CI 的 smoke 步骤会起容器跑 Apache,配置写错站点就起不来)。 # --------------------------------------------------------------------------- RUN printf '%s\n' \ '# fork 加固(F23):不对外泄露 Apache 精确版本' \ 'ServerTokens Prod' \ 'ServerSignature Off' \ > /etc/apache2/conf-enabled/zz-lsky-hardening.conf \ && test -f /etc/apache2/conf-enabled/zz-lsky-hardening.conf \ && grep -qx 'ServerTokens Prod' /etc/apache2/conf-enabled/zz-lsky-hardening.conf \ && grep -qx 'ServerSignature Off' /etc/apache2/conf-enabled/zz-lsky-hardening.conf # ftp 必须**显式**装进运行时镜像:官方 php:8.1 镜像自带 ftp,php:8.3 的没有 # (2026-09-28 换 8.3 时 CI 实测:builder 阶段 composer install 直接失败, # 报 league/flysystem-ftp requires ext-ftp)。它是 Lsky 的 FTP 存储驱动要用的扩展, # 不能依赖"从基镜像继承"。CI 里也加了 ftp 的硬断言。 # # opcache(测试版收紧,先量后改):原值 memory_consumption=128 / max_accelerated_files=10000。 # 实测(2026-10-01,本机真容器 Apache 进程内 opcache_get_status): # · 真实负载(安装页 + 登录/注册/首页等通跑一轮)预热集合 = 827 个脚本 / 已用 25.0MB; # · 全树编译(把 /var/www/lsky 下 10842 个 .php 全部塞进缓存)上限 = 9949 个脚本 / 255.9MB # (在 256MB 上限处被截断 —— 这是"理论上限",没有任何单次请求会走到)。 # 真实使用约 25MB,故 memory_consumption 收到 64(≈实测 2.6 倍,且不低于 64): # 够用又有余量,比原来的 128 省 64MB 共享内存(本机 965MB,内存是瓶颈)。 # max_accelerated_files 保持 10000 —— 已远超实测预热集合(827)的 12 倍;哈希表本身只占 KB 级, # 调低没有收益、调高没有意义(64MB 的脚本缓存也装不下全树)。这组值下 opcache 不会因容量不足频繁重启。 # validate_timestamps 保持 1(显式写死,防基镜像默认变化):单机热更新靠它 —— 卷里代码一改, # 带 mtime 校验的请求就会重新编译;关掉它换镜像后要重启容器才生效。 RUN apt-get update && \ apt-get install -y gettext && \ apt-get clean && rm -rf /var/cache/apt/* && rm -rf /var/lib/apt/lists/* && rm -rf /tmp/* && \ a2enmod rewrite && chmod +x /usr/local/bin/install-php-extensions && \ install-php-extensions imagick bcmath pdo_mysql pdo_pgsql redis ftp && \ \ { \ echo 'post_max_size = 100M;';\ echo 'upload_max_filesize = 100M;';\ echo 'max_execution_time = 600S;';\ } > /usr/local/etc/php/conf.d/docker-php-upload.ini; \ \ { \ echo 'opcache.enable=1'; \ echo 'opcache.interned_strings_buffer=8'; \ echo 'opcache.max_accelerated_files=10000'; \ echo 'opcache.memory_consumption=64'; \ echo 'opcache.save_comments=1'; \ echo 'opcache.revalidate_freq=1'; \ echo 'opcache.validate_timestamps=1'; \ } > /usr/local/etc/php/conf.d/opcache-recommended.ini; \ \ echo 'apc.enable_cli=1' >> /usr/local/etc/php/conf.d/docker-php-ext-apcu.ini; \ \ echo 'memory_limit=512M' > /usr/local/etc/php/conf.d/memory-limit.ini; \ \ mkdir /var/www/data; \ chown -R www-data:root /var/www; \ chmod -R g=u /var/www # --------------------------------------------------------------------------- # F23:PHP ini 加固 —— 关掉「版本号外泄」与「错误直出」。 # 实测(2026-09-30,本机 docker run 已发布镜像):响应头带 `X-Powered-By: PHP/8.3.35`。 # 官方 php 镜像**不带 php.ini**(只有 php.ini-production/-development 两个样本),于是 # PHP 编译期的默认值直接生效:expose_php=1、display_errors=1。 # expose_php=0 → 不再发 X-Powered-By(少给扫描器一条精确版本情报); # display_errors=0 → 运行期错误不往 HTTP 响应里吐(Laravel 自己的错误处理/日志不受影响)。 # 文件名用 zz- 前缀:conf.d 里的 ini 按**文件名 ASCII 序**加载、后加载的覆盖先前的, # zz- 排最后 = 以后基镜像或扩展安装器再加什么 ini,都盖不掉这两项(顺序就是这里的关键)。 # 本次(测试版)追加 memory_limit=64M:先读现状 —— PHP 编译期默认 128M,而镜像里 # (上游)memory-limit.ini 写的是 512M,所以**原来的生效值是 512M**。把 64M 放进这份 # zz- 文件即可生效(排最后、覆盖 memory-limit.ini),不必去改那份上游 ini。 # 默认 64M 而不是写死:entrypoint.sh 运行期会把 PHP_MEMORY_LIMIT(默认 64M)渲染进这一项, # 在 compose 里改一个 env 就能回 128M/256M、不用重建镜像(脏值只忽略该项并警告)。 # 其余 ini 未动:upload(100M 上传上限)保持原样;opcache-recommended.ini 的 # memory_consumption 本次从 128 收到 64(依据见上面 opcache 段的实测注释)。 # 构建期自证:用 php -r **真读回 ini_get()** 断言已是目标值 —— 不是 grep 配置文件里有没有那几行 # (那只证明"文件写了",证明不了"PHP 真的读到了")。expose_php/display_errors 判定用 # filter_var(FILTER_VALIDATE_BOOL),兼容 Off / false / 0 / 空串 几种写法;memory_limit 直接比对 # 字符串 "64M"。CLI 与 Apache 模块用的是同一份 /usr/local/etc/php/conf.d。 # --------------------------------------------------------------------------- RUN printf '%s\n' \ '; fork 加固(F23 + 测试版):不泄露版本号 / 错误不直出 / 收紧内存上限' \ 'expose_php=0' \ 'display_errors=0' \ 'memory_limit=64M' \ > /usr/local/etc/php/conf.d/zz-lsky-hardening.ini \ && test -f /usr/local/etc/php/conf.d/zz-lsky-hardening.ini \ && php -r 'foreach (["expose_php", "display_errors"] as $k) { if (filter_var(ini_get($k), FILTER_VALIDATE_BOOL)) { fwrite(STDERR, "ini 加固自证失败:".$k." 仍开着 (".var_export(ini_get($k), true).")".PHP_EOL); exit(1); } echo $k."=".var_export(ini_get($k), true)." 已关闭".PHP_EOL; }' \ && php -r '$m = ini_get("memory_limit"); if ($m !== "64M") { fwrite(STDERR, "memory_limit 自证失败:读回 ".var_export($m, true).",期望 64M(zz-lsky-hardening.ini 是否被别的 ini 覆盖?)".PHP_EOL); exit(1); } echo "memory_limit=".$m."(entrypoint 可用 PHP_MEMORY_LIMIT 运行期覆盖)".PHP_EOL;' # F21:不再随镜像发货证书。 # 原来这里是 `COPY ./ssl /etc/ssl` —— 把仓库里那对 Debian snakeoil 证书(ssl/certs + ssl/private, # 在 GitHub 上人人可见)拷进镜像,模板的 HTTPS vhost 直接引用它 = 每个使用者共用同一份公开私钥。 # 现在改成运行期自签:入口脚本首次启动时用 openssl 生成到 /etc/apache2/ssl/(模板指向那里), # 所以镜像里**不再有任何证书/私钥文件**。openssl 必须存在 —— 由下面的护栏钉住。 COPY --from=build /build /var/www/lsky/ COPY ./000-default.conf.template /etc/apache2/sites-enabled/ COPY ./ports.conf.template /etc/apache2/ # Apache MPM 模板:入口脚本会把它(按 APACHE_* 渲染后)写到 /etc/apache2/conf-enabled/mpm.conf, # 覆盖 mods-enabled 里 mpm_prefork.conf 的出厂值(apache2.conf 先 include mods-enabled 再 include conf-enabled)。 COPY ./mpm.conf.template /etc/apache2/ COPY entrypoint.sh / # 数据安全护栏:强制同步的排除清单缺任何一项都可能覆盖站点数据/配置,缺了就构建失败。 # (镜像自带入口脚本运行时才会执行,这里只做静态断言,保证清单不被误删。) RUN grep -q -- '--exclude=.\/.env' /entrypoint.sh \ && grep -q -- '--exclude=.\/storage' /entrypoint.sh \ && grep -q -- '--exclude=.\/database' /entrypoint.sh \ && grep -q -- '--exclude=.\/bootstrap\/cache' /entrypoint.sh \ && grep -q -- '--exclude=.\/public\/i' /entrypoint.sh \ && grep -q 'views/\*.php' /entrypoint.sh \ && grep -q 'code-revision' /entrypoint.sh \ && test -f /var/www/lsky/.code-revision \ && grep -q '^fork_sha=' /var/www/lsky/.code-revision \ && grep -q '^composer_lock_md5=' /var/www/lsky/.code-revision \ && grep -q 'tar cf - --exclude=./.env --exclude=./.code-revision' /entrypoint.sh \ && grep -q 'cp -a "$IMAGE_MARKER" "$VOLUME_MARKER"' /entrypoint.sh # MPM 模板护栏:模板必须真的进镜像、入口脚本必须真的往 conf-enabled 写(漏了 COPY 或改了目标路径就构建失败)。 # 这里只做静态断言;渲染/覆盖是否生效由 CI 里的真容器自证(Verify Apache MPM defaults and overrides)。 RUN test -f /etc/apache2/mpm.conf.template \ && test -d /etc/apache2/conf-enabled \ && grep -q 'conf-enabled/mpm.conf' /entrypoint.sh \ && grep -q 'APACHE_START_SERVERS' /etc/apache2/mpm.conf.template \ && grep -q 'APACHE_MIN_SPARE_SERVERS' /etc/apache2/mpm.conf.template \ && grep -q 'APACHE_MAX_SPARE_SERVERS' /etc/apache2/mpm.conf.template \ && grep -q 'APACHE_MAX_REQUEST_WORKERS' /etc/apache2/mpm.conf.template \ && grep -q 'APACHE_MAX_CONNECTIONS_PER_CHILD' /etc/apache2/mpm.conf.template \ && grep -q 'APACHE_KEEP_ALIVE' /etc/apache2/mpm.conf.template # F10 护栏:健康检查要用的 curl 在运行镜像里真的存在(php:*-apache 自带,但别依赖「我记得它自带」)。 RUN command -v curl > /dev/null # F21 护栏:运行期自签 HTTPS 证书要用 openssl(php:*-apache 基于 debian:bookworm,自带 openssl; # 但别依赖「我记得它自带」)。缺了它入口脚本只能打警告,HTTPS vhost 起不来。 RUN command -v openssl > /dev/null WORKDIR /var/www/html/ VOLUME /var/www/html ENV WEB_PORT=8089 ENV HTTPS_PORT=8088 EXPOSE ${WEB_PORT} EXPOSE ${HTTPS_PORT} # F10:健康检查。原来没有 —— 「Apache 进程还在」不代表站点还活着(卷同步失败的容器进程照样在)。 # 探本机 http://127.0.0.1:${WEB_PORT}/:shell 形式下 ${WEB_PORT} 是**运行期**由 ENV 展开的, # 所以 compose/`-e WEB_PORT=xxxx` 换了端口也照样探对端口(不用改这里)。 # curl -f:HTTP 状态 ≥400 或连不上都算失败(非 0 退出 = unhealthy);302/200 都算健康 # —— 未安装状态下 / 会 302 去安装页,登录后首页也可能 302,这两种都不该被判定为病态。 # start-period=90s:入口脚本要渲染 Apache/MPM 配置再把应用播种进卷(CI 里最长等到 60s), # 这段时间内的失败不计入 retries,避免容器刚起来就 HEALTING→unhealthy。 # 依赖 curl 在运行镜像里必然存在(php:*-apache 官方镜像自带;下面的护栏会钉住这一点)。 HEALTHCHECK --interval=30s --timeout=5s --start-period=90s --retries=3 \ CMD curl -fsS -o /dev/null "http://127.0.0.1:${WEB_PORT}/" RUN chmod a+x /entrypoint.sh ENTRYPOINT ["/entrypoint.sh"] CMD ["apachectl","-D","FOREGROUND"]