diff --git a/Dockerfile b/Dockerfile index 4103ce1..a320b6c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,9 +27,10 @@ ENV RUNPHP_INDEX_FILE="index.php" ENV RUNPHP_VERSION=${TAG_NAME} # PHP Preloading - "none", "composer-classmap" or "src" -ENV RUNPHP_PRELOAD_STRATEGY="src" +ENV RUNPHP_PRELOAD_STRATEGY="none" # PHP Preloading - "include" or "compile" ENV RUNPHP_PRELOAD_ACTION="include" +RUN mkdir /var/php-opcache # Setup the XHprof output dir, install additional libs ENV XHPROF_OUTPUT="/tmp/xhprof" diff --git a/README.md b/README.md index d6f98d2..7cd7e56 100644 --- a/README.md +++ b/README.md @@ -127,14 +127,38 @@ define `RUNPHP_EXTRA_PREPEND="/some/prepend.php"` in your environment. If you also enable profiling (see below on how to do this), your prepend file is included in the profile. -### PHP Preloading +### PHP OPcache Preloading -runphp supports a few PHP preloading strategies, as no one-solution fits all. +runphp supports a few PHP preloading strategies, as no one solution fits all. They are controlled via environment variables as follows: +* `RUNPHP_PRELOAD_STRATEGY="src"` - `composer-classmap`, `src`, `paths` or `none` (default) +* `RUNPHP_PRELOAD_ACTION="compile"` - `include` or `compile` (default) * `RUNPHP_COMPOSER_PATH="/app"` -* `RUNPHP_PRELOAD_STRATEGY="src"` - "none", "composer-classmap" or "src" -* `RUNPHP_PRELOAD_ACTION="include"` - "include" or "compile" +* `RUNPHP_PRELOAD_PATHS="/app/src,/app/vendor"` - comma-separated list of paths to recurse & preload + +#### CLI and OPcache Preloading + +Generally, running a PHP application on the command line will parse all the PHP sources from first principles, as there +is no shared memory for OPCache to use between requests (as there is in a web server-like environment). + +However, it is possible to configure OPCache to compile the PHP scripts into binary OPcache artefacts, which are stored +on disk, and can be used by the CLI to reduce up-front parse time. + +Because CloudRun uses an in-memory filesystem, this has the potential to improve cold start times for larger CLI +applications where parsing hundreds/thousands of PHP files is undesirable. + +In order to take advantage of this strategy, we need to +- Configure OPCache to use the filesystem for storing compiled artefacts +- Compile your application's PHP code during the Docker build process + +There is an additional filesize overhead for the compiled OPcache artefacts, which will vary depending on the size of +your application. + +To enable this behaviour, you should add the following to your application's Dockerfile: +```Dockerfile +RUN +``` ### Startup Messages runphp can produce a few useful startup messages, such as whether it has detected itself as running on Google Cloud. diff --git a/manifest/runphp-foundation/bin/build-generate-opcache.php b/manifest/runphp-foundation/bin/build-generate-opcache.php new file mode 100644 index 0000000..7df186a --- /dev/null +++ b/manifest/runphp-foundation/bin/build-generate-opcache.php @@ -0,0 +1,71 @@ + + */ + +declare(strict_types=1); + +namespace ThinkFluent\RunPHP; + +// Just in case opcache is not available +if (!function_exists('opcache_compile_file')) { + echo 'OPCache extension is not installed/enabled', PHP_EOL; + return; +} + +if ('cli' !== PHP_SAPI) { + echo 'OPCache build-stage compiling is only available in CLI mode', PHP_EOL; + return; +} + +// Check the opcache config is enabled and disk persistence enabled +$opcacheStatus = opcache_get_status(false); +if (empty($opcacheStatus['opcache_enabled'])) { + echo 'OPCache is not enabled for CLI', PHP_EOL; + return; +} + +// Check for disk persistence, target folder +$str_opcache_target = rtrim($opcacheStatus['file_cache'] ?? '', '/'); +if (empty($str_opcache_target)) { + echo 'OPCache is not configured for disk persistence', PHP_EOL; + return; +} + +// Only in production... +require_once __DIR__ . '/../src/Runtime.php'; +$obj_runtime = Runtime::get(); +if (Runtime::MODE_PROD === $obj_runtime->getMode()) { + require_once __DIR__ . '/../src/PreloadConfig.php'; + require_once __DIR__ . '/../src/Preloader.php'; + echo 'Running build-stage OPCache compiler', PHP_EOL; + $obj_preloader = new Preloader(PreloadConfig::fromEnv()); + $obj_preloader->run(); + + // Output for the build stage + $str_cache_size_on_disk = trim(shell_exec(sprintf('du -hs %s', $str_opcache_target))); + echo sprintf( + 'OPCache compiled file count: %d, size on disk: %s, cache location: %s', + $obj_preloader->getProcessedFileCount(), + $str_cache_size_on_disk, + $str_opcache_target + ), PHP_EOL; + + // Record some stats in the built image + file_put_contents( + sprintf('%s/compile.json', $str_opcache_target), + json_encode([ + 'dtm' => (new \DateTime())->format('c'), + 'compiled_files' => $obj_preloader->getProcessedFileCount(), + 'size_on_disk' => $str_cache_size_on_disk, + 'status_after' => opcache_get_status(false) + ], JSON_PRETTY_PRINT) + ); +} else { + echo 'OPCache build-stage compilation is ONLY available in PRODUCTION mode, skipping.', PHP_EOL; + return; +} diff --git a/manifest/runphp-foundation/etc/production/opcache-cli-build.ini b/manifest/runphp-foundation/etc/production/opcache-cli-build.ini new file mode 100644 index 0000000..54169f2 --- /dev/null +++ b/manifest/runphp-foundation/etc/production/opcache-cli-build.ini @@ -0,0 +1,9 @@ +[opcache] +; Determines if Zend OPCache is enabled +opcache.enable=1 + +; Determines if Zend OPCache is enabled for the CLI version of PHP +opcache.enable_cli=1 + +; Where should we store the opcache file cache? +opcache.file_cache=/var/php-opcache diff --git a/manifest/runphp-foundation/etc/production/runphp-production-701-opcache-cli.ini b/manifest/runphp-foundation/etc/production/runphp-production-701-opcache-cli.ini new file mode 100644 index 0000000..4fbbe80 --- /dev/null +++ b/manifest/runphp-foundation/etc/production/runphp-production-701-opcache-cli.ini @@ -0,0 +1,12 @@ +[opcache] +; Determines if Zend OPCache is enabled +opcache.enable=1 + +; Determines if Zend OPCache is enabled for the CLI version of PHP +opcache.enable_cli=1 + +; Where should we store the opcache file cache? +opcache.file_cache=/var/php-opcache + +; Skip consistency checks for the opcache file cache +opcache.file_cache_consistency_checks=0 \ No newline at end of file diff --git a/manifest/runphp-foundation/runtime/preload.php b/manifest/runphp-foundation/runtime/preload.php index 0d8c7b1..d5eb501 100644 --- a/manifest/runphp-foundation/runtime/preload.php +++ b/manifest/runphp-foundation/runtime/preload.php @@ -3,7 +3,7 @@ * runphp preload file, included by opcache.ini * * - Check we're OK to preload - * - Try and find the Composer classmap, and compile those files + * - Pull preload config from ENV by default * * @author Tom Walder */ @@ -23,7 +23,8 @@ require_once __DIR__ . '/../src/Runtime.php'; $obj_runtime = Runtime::get(); if (Runtime::MODE_PROD === $obj_runtime->getMode()) { + require_once __DIR__ . '/../src/PreloadConfig.php'; require_once __DIR__ . '/../src/Preloader.php'; - (new Preloader())->run(); + (new Preloader(PreloadConfig::fromEnv()))->run(); } diff --git a/manifest/runphp-foundation/src/PreloadConfig.php b/manifest/runphp-foundation/src/PreloadConfig.php new file mode 100644 index 0000000..45b4050 --- /dev/null +++ b/manifest/runphp-foundation/src/PreloadConfig.php @@ -0,0 +1,80 @@ +str_strategy = $str_strategy; + return $this; + } + public function getStrategy(): string + { + return $this->str_strategy; + } + + public function setAction(string $str_action): self + { + $this->str_action = $str_action; + return $this; + } + + public function getAction(): string + { + return $this->str_action; + } + + public function setComposerPath(string $str_composer_path): self + { + $this->str_composer_path = $str_composer_path; + return $this; + } + + public function getComposerPath(): string + { + return $this->str_composer_path; + } + + public function setPaths(array $arr_paths): self + { + $this->arr_paths = $arr_paths; + return $this; + } + + public function getPaths(): array + { + return $this->arr_paths; + } + + public static function fromEnv(): self + { + $env = Runtime::get()->env(); + $obj_config = new self(); + $obj_config->setStrategy($env[self::ENV_STRATEGY] ?? Preloader::STRATEGY_NONE); + $obj_config->setAction($env[self::ENV_ACTION] ?? Preloader::ACTION_COMPILE); + $obj_config->setComposerPath($env[self::ENV_COMPOSER_PATH] ?? ''); + // Paths (explode from ENV var) + $str_paths = $env[self::ENV_PATHS] ?? ''; + if (!empty($str_paths)) { + $obj_config->setPaths(explode(',', $str_paths)); + } + return $obj_config; + } +} diff --git a/manifest/runphp-foundation/src/Preloader.php b/manifest/runphp-foundation/src/Preloader.php index d28838f..8ac2d79 100644 --- a/manifest/runphp-foundation/src/Preloader.php +++ b/manifest/runphp-foundation/src/Preloader.php @@ -3,7 +3,7 @@ namespace ThinkFluent\RunPHP; /** - * Preload process for PHP startup, using Composer classmap + * Preload process for PHP startup, using Composer classmap or other "find" strategies * * @todo Add alternate preload strategies, e.g. find/iterate and include() over opcache_compile_file() * @@ -12,58 +12,75 @@ class Preloader { // File list strategy - private const - ENV_STRATEGY = 'RUNPHP_PRELOAD_STRATEGY', + public const + STRATEGY_NONE = 'none', + STRATEGY_PATHS = 'paths', STRATEGY_SRC = 'src', STRATEGY_CLASSMAP = 'composer-classmap'; // How should we pre-compile the PHP files? - private const - ENV_ACTION = 'RUNPHP_PRELOAD_ACTION', + public const ACTION_INCLUDE = 'include', ACTION_COMPILE = 'compile'; + private PreloadConfig $obj_config; + + private bool $bol_autoload = false; + + private int $int_files = 0; + + public function __construct(PreloadConfig $obj_config) + { + $this->obj_config = $obj_config; + } + /** * Run the pre-compile process */ public function run() { - $obj_runtime = Runtime::get(); + if (self::STRATEGY_NONE === $this->obj_config->getStrategy()) { + return; + } // Determine method - switch ($obj_runtime->env()[self::ENV_ACTION] ?? 'unknown') { + switch ($this->obj_config->getAction()) { case self::ACTION_INCLUDE: $fnc_method = [$this, 'includeFile']; + $this->bol_autoload = true; break; case self::ACTION_COMPILE: default: - $fnc_method = [$this, 'compileFile']; + $fnc_method = [$this, 'compileFile']; break; } // Find the source file list - switch ($obj_runtime->env()[self::ENV_STRATEGY] ?? 'unknown') { + switch ($this->obj_config->getStrategy()) { case self::STRATEGY_CLASSMAP: $arr_files = $this->getComposerClassmapFiles(); break; case self::STRATEGY_SRC: - $arr_files = $this->getSrcFiles(); + $arr_files = $this->findComposerSrcFiles(); + break; + + case self::STRATEGY_PATHS: + $arr_files = $this->findFilesForPaths(); break; default: - // Nothing to do return; } - // Pre-compile - foreach ($arr_files as $str_file) { - $fnc_method($str_file); + // Pre-compile, skipping any files already in the cache + $opcacheStatus = opcache_get_status(true); + foreach (array_unique($arr_files) as $str_file) { + if (!isset($opcacheStatus['scripts'][$str_file])) { + $fnc_method($str_file); + } } - - // Confirm output - // error_log(sprintf("PHP preload done, source files [%d]", count($arr_files))); } /** @@ -73,6 +90,7 @@ public function run() */ private function includeFile(string $str_file) { + $this->int_files++; include_once $str_file; } @@ -83,6 +101,7 @@ private function includeFile(string $str_file) */ private function compileFile(string $str_file) { + $this->int_files++; opcache_compile_file($str_file); } @@ -93,21 +112,20 @@ private function compileFile(string $str_file) */ private function getComposerClassmapFiles(): array { - $arr_files = []; - $str_composer_path = rtrim((string)getenv('RUNPHP_COMPOSER_PATH'), '/'); - $str_autoload_file = $str_composer_path . '/vendor/autoload.php'; + $str_composer_path = rtrim($this->obj_config->getComposerPath(), '/'); if (is_dir($str_composer_path)) { - if (is_readable($str_autoload_file)) { + $str_autoload_file = $str_composer_path . '/vendor/autoload.php'; + if ($this->bol_autoload && is_readable($str_autoload_file)) { require_once $str_autoload_file; } $str_classmap = $str_composer_path . '/vendor/composer/autoload_classmap.php'; if (is_readable($str_classmap)) { $arr_files = include_once $str_classmap; + if (is_array($arr_files) && !empty($arr_files)) { + return $arr_files; + } } } - if (is_array($arr_files) && !empty($arr_files)) { - return $arr_files; - } return []; } @@ -116,31 +134,50 @@ private function getComposerClassmapFiles(): array * * @return array */ - private function getSrcFiles(): array + private function findComposerSrcFiles(): array { - $arr_files = []; - $str_composer_path = rtrim((string)getenv('RUNPHP_COMPOSER_PATH'), '/'); + $str_composer_path = rtrim($this->obj_config->getComposerPath(), '/'); $str_src_path = $str_composer_path . '/src'; - $str_autoload_file = $str_composer_path . '/vendor/autoload.php'; if (is_dir($str_src_path)) { - if (is_readable($str_autoload_file)) { + $str_autoload_file = $str_composer_path . '/vendor/autoload.php'; + if ($this->bol_autoload && is_readable($str_autoload_file)) { require_once $str_autoload_file; } - $obj_directory = new \RecursiveDirectoryIterator($str_src_path); - $obj_full_tree = new \RecursiveIteratorIterator($obj_directory); + } + return $this->findFilesInPath($str_src_path); + } + + private function findFilesForPaths(): array + { + $arr_file_sets = []; + foreach ($this->obj_config->getPaths() as $str_path) { + $arr_file_sets[] = $this->findFilesInPath($str_path); + } + return array_merge(...$arr_file_sets); + } + + private function findFilesInPath(string $str_src_path): array + { + $str_src_path = rtrim($str_src_path, '/'); + if (is_dir($str_src_path)) { $obj_php_files = new \RegexIterator( - $obj_full_tree, - '/.+((? $file) { $arr_files[] = $file[0]; } - } - if (is_array($arr_files) && !empty($arr_files)) { return $arr_files; } return []; } -} \ No newline at end of file + public function getProcessedFileCount(): int + { + return $this->int_files; + } +} diff --git a/manifest/usr/local/bin/runphp-generate-opcache b/manifest/usr/local/bin/runphp-generate-opcache new file mode 100755 index 0000000..9d0cc4a --- /dev/null +++ b/manifest/usr/local/bin/runphp-generate-opcache @@ -0,0 +1,15 @@ +#!/bin/sh +set -ex + +# Copy in the build-time-only PHP ini file +cp -p /runphp-foundation/etc/production/opcache-cli-build.ini "$PHP_INI_DIR/conf.d/" + +# Run the compile script, which will generate the opcache files +/usr/local/bin/php /runphp-foundation/bin/build-generate-opcache.php + +# Remove the build-time-only PHP ini file +rm -f "$PHP_INI_DIR/conf.d/opcache-cli-build.ini" + +# Copy the ADDITIONAL production-only, cli PHP ini file to where it needs to be to be enabled at startup time +cp -p /runphp-foundation/etc/production/runphp-production-701-opcache-cli.ini /runphp-foundation/etc/production/php-conf.d/ +