diff --git a/CHANGELOG.md b/CHANGELOG.md index ca53797..acdb8bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ ### Minor changes +* **2026-10-12** + * Add `APACHE_HTTP_PORT` and `APACHE_HTTPS_PORT` to change the ports Apache listens on (e.g. unprivileged ports) + * Add `COMPOSER_VERSION` to install another Composer version (exact version or `1`, `2`, `2.2` channel), at the container start or at the build of a slim image + * Add `LOCALES` to generate locales at the container start (e.g. `LOCALES=fr_FR.UTF-8 de_DE.UTF-8`): the `locales` package is installed in the fat images (~17MB) and with `ARG INSTALL_LOCALES=1` in the slim images + * Add `DAEMON_COMMAND_*` (and `DAEMON_USER_*`, `docker` by default) to run long-running processes along with the main command (one container per process remains recommended): as soon as one of them exits, the container stops + * Supercronic is supervised the same way: the container stops if Supercronic exits (it kept running without cron jobs before), and the cron jobs start after the startup commands + * **2026-10-09** * Imagick can read SVG images (also WMF, DjVu and OpenEXR): the fat images are ~17MB larger * Add the `opentelemetry` extension on PHP 8.2+ diff --git a/Dockerfile.apache b/Dockerfile.apache index bfc9465..abd1ae2 100644 --- a/Dockerfile.apache +++ b/Dockerfile.apache @@ -2,6 +2,7 @@ # DO NOT EDIT THIS FILE : Make yours changes in /utils/Dockerfile.*.blueprint) ARG INSTALL_CRON=1 ARG INSTALL_COMPOSER=1 +ARG INSTALL_LOCALES=1 ARG PHP_VERSION="8.4" ARG GLOBAL_VERSION="v5" ARG REPO="thecodingmachine/php" diff --git a/Dockerfile.cli b/Dockerfile.cli index 6db6956..61d9127 100644 --- a/Dockerfile.cli +++ b/Dockerfile.cli @@ -2,6 +2,7 @@ # DO NOT EDIT THIS FILE : Make yours changes in /utils/Dockerfile.*.blueprint) ARG INSTALL_CRON=1 ARG INSTALL_COMPOSER=1 +ARG INSTALL_LOCALES=1 ARG PHP_VERSION="8.4" ARG GLOBAL_VERSION="v5" ARG REPO="thecodingmachine/php" diff --git a/Dockerfile.fpm b/Dockerfile.fpm index 7b3b4c2..bfa0abb 100644 --- a/Dockerfile.fpm +++ b/Dockerfile.fpm @@ -2,6 +2,7 @@ # DO NOT EDIT THIS FILE : Make yours changes in /utils/Dockerfile.*.blueprint) ARG INSTALL_CRON=1 ARG INSTALL_COMPOSER=1 +ARG INSTALL_LOCALES=1 ARG PHP_VERSION="8.4" ARG GLOBAL_VERSION="v5" ARG REPO="thecodingmachine/php" diff --git a/Dockerfile.slim.apache b/Dockerfile.slim.apache index 24b60e1..a5c418d 100644 --- a/Dockerfile.slim.apache +++ b/Dockerfile.slim.apache @@ -110,6 +110,7 @@ RUN rm /etc/php/${PHP_VERSION}/cli/php.ini RUN curl -sS --retry 5 --retry-delay 2 https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer &&\ chmod +x /usr/local/bin/composer +COPY utils/select_composer_version.sh /usr/local/bin/select_composer_version.sh # TODO: utils.php in /usr/local/bin... bof! COPY utils/utils.php /usr/local/bin/utils.php @@ -223,6 +224,13 @@ ENV APACHE_DOCUMENT_ROOT= RUN sed -ri -e 's!/var/www/html!${ABSOLUTE_APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf && \ sed -ri -e 's!/var/www/!${ABSOLUTE_APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf +ENV APACHE_HTTP_PORT=80 \ + APACHE_HTTPS_PORT=443 + +RUN sed -ri -e 's/^Listen 80$/Listen ${APACHE_HTTP_PORT}/' -e 's/^(\s*)Listen 443$/\1Listen ${APACHE_HTTPS_PORT}/' /etc/apache2/ports.conf && \ + sed -i 's///' /etc/apache2/sites-available/000-default.conf && \ + sed -i 's///' /etc/apache2/sites-available/default-ssl.conf + # Let's remove the default Apache php.ini file (it will be copied from TEMPLATE_PHP_INI) RUN rm /etc/php/${PHP_VERSION}/apache2/php.ini @@ -322,6 +330,7 @@ COPY utils/apache-expose-envvars.sh /usr/local/bin/apache-expose-envvars.sh COPY utils/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh COPY utils/docker-entrypoint-as-root.sh /usr/local/bin/docker-entrypoint-as-root.sh +COPY utils/supervise.sh /usr/local/bin/supervise.sh COPY extensions/ /usr/local/lib/thecodingmachine-php/extensions RUN ln -s ${PHP_VERSION} /usr/local/lib/thecodingmachine-php/extensions/current @@ -388,6 +397,30 @@ ONBUILD RUN if [ -n "$INSTALL_CRON" ]; then \ && sudo ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic; \ fi +# |-------------------------------------------------------------------------- +# | Composer version +# |-------------------------------------------------------------------------- +# | +# | Installs the Composer version COMPOSER_VERSION instead of the latest one. +# | +ONBUILD ARG COMPOSER_VERSION +ONBUILD RUN if [ -n "$COMPOSER_VERSION" ]; then \ + sudo /usr/local/bin/select_composer_version.sh "$COMPOSER_VERSION"; \ + fi + +# |-------------------------------------------------------------------------- +# | Locales +# |-------------------------------------------------------------------------- +# | +# | Installs the tools generating the locales listed in LOCALES at the container start. +# | +ONBUILD ARG INSTALL_LOCALES +ONBUILD RUN if [ -n "$INSTALL_LOCALES" ]; then \ + sudo apt-get update \ + && sudo apt-get install -y --no-install-recommends locales \ + && sudo apt-get clean \ + && sudo rm -rf /var/lib/apt/lists/* /var/cache/apt/*.bin /tmp/* /var/tmp/* /usr/share/doc/*; \ + fi # |-------------------------------------------------------------------------- # | NodeJS diff --git a/Dockerfile.slim.cli b/Dockerfile.slim.cli index 5a242b8..885fa37 100644 --- a/Dockerfile.slim.cli +++ b/Dockerfile.slim.cli @@ -110,6 +110,7 @@ RUN rm /etc/php/${PHP_VERSION}/cli/php.ini RUN curl -sS --retry 5 --retry-delay 2 https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer &&\ chmod +x /usr/local/bin/composer +COPY utils/select_composer_version.sh /usr/local/bin/select_composer_version.sh # TODO: utils.php in /usr/local/bin... bof! COPY utils/utils.php /usr/local/bin/utils.php @@ -245,6 +246,7 @@ COPY utils/startup_commands.php /usr/local/bin/startup_commands.php COPY utils/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh COPY utils/docker-entrypoint-as-root.sh /usr/local/bin/docker-entrypoint-as-root.sh +COPY utils/supervise.sh /usr/local/bin/supervise.sh COPY extensions/ /usr/local/lib/thecodingmachine-php/extensions RUN ln -s ${PHP_VERSION} /usr/local/lib/thecodingmachine-php/extensions/current @@ -293,6 +295,30 @@ ONBUILD RUN if [ -n "$INSTALL_CRON" ]; then \ && sudo ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic; \ fi +# |-------------------------------------------------------------------------- +# | Composer version +# |-------------------------------------------------------------------------- +# | +# | Installs the Composer version COMPOSER_VERSION instead of the latest one. +# | +ONBUILD ARG COMPOSER_VERSION +ONBUILD RUN if [ -n "$COMPOSER_VERSION" ]; then \ + sudo /usr/local/bin/select_composer_version.sh "$COMPOSER_VERSION"; \ + fi + +# |-------------------------------------------------------------------------- +# | Locales +# |-------------------------------------------------------------------------- +# | +# | Installs the tools generating the locales listed in LOCALES at the container start. +# | +ONBUILD ARG INSTALL_LOCALES +ONBUILD RUN if [ -n "$INSTALL_LOCALES" ]; then \ + sudo apt-get update \ + && sudo apt-get install -y --no-install-recommends locales \ + && sudo apt-get clean \ + && sudo rm -rf /var/lib/apt/lists/* /var/cache/apt/*.bin /tmp/* /var/tmp/* /usr/share/doc/*; \ + fi # |-------------------------------------------------------------------------- # | NodeJS diff --git a/Dockerfile.slim.fpm b/Dockerfile.slim.fpm index 3633cc2..2fe6690 100644 --- a/Dockerfile.slim.fpm +++ b/Dockerfile.slim.fpm @@ -110,6 +110,7 @@ RUN rm /etc/php/${PHP_VERSION}/cli/php.ini RUN curl -sS --retry 5 --retry-delay 2 https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer &&\ chmod +x /usr/local/bin/composer +COPY utils/select_composer_version.sh /usr/local/bin/select_composer_version.sh # TODO: utils.php in /usr/local/bin... bof! COPY utils/utils.php /usr/local/bin/utils.php @@ -262,6 +263,7 @@ COPY utils/startup_commands.php /usr/local/bin/startup_commands.php COPY utils/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh COPY utils/docker-entrypoint-as-root.sh /usr/local/bin/docker-entrypoint-as-root.sh +COPY utils/supervise.sh /usr/local/bin/supervise.sh COPY extensions/ /usr/local/lib/thecodingmachine-php/extensions RUN ln -s ${PHP_VERSION} /usr/local/lib/thecodingmachine-php/extensions/current @@ -316,6 +318,30 @@ ONBUILD RUN if [ -n "$INSTALL_CRON" ]; then \ && sudo ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic; \ fi +# |-------------------------------------------------------------------------- +# | Composer version +# |-------------------------------------------------------------------------- +# | +# | Installs the Composer version COMPOSER_VERSION instead of the latest one. +# | +ONBUILD ARG COMPOSER_VERSION +ONBUILD RUN if [ -n "$COMPOSER_VERSION" ]; then \ + sudo /usr/local/bin/select_composer_version.sh "$COMPOSER_VERSION"; \ + fi + +# |-------------------------------------------------------------------------- +# | Locales +# |-------------------------------------------------------------------------- +# | +# | Installs the tools generating the locales listed in LOCALES at the container start. +# | +ONBUILD ARG INSTALL_LOCALES +ONBUILD RUN if [ -n "$INSTALL_LOCALES" ]; then \ + sudo apt-get update \ + && sudo apt-get install -y --no-install-recommends locales \ + && sudo apt-get clean \ + && sudo rm -rf /var/lib/apt/lists/* /var/cache/apt/*.bin /tmp/* /var/tmp/* /usr/share/doc/*; \ + fi # |-------------------------------------------------------------------------- # | NodeJS diff --git a/README.md b/README.md index a736dfd..0545509 100644 --- a/README.md +++ b/README.md @@ -334,6 +334,17 @@ APACHE_DOCUMENT_ROOT=public/ APACHE_DOCUMENT_ROOT=/var/www/html/public ``` +## Changing Apache ports + +For the *apache* variant, Apache listens on port 80 (and 443 when the `ssl` extension is enabled). +You can change these ports with the `APACHE_HTTP_PORT` and `APACHE_HTTPS_PORT` variables, for instance to use +unprivileged ports: + +```bash +APACHE_HTTP_PORT=8080 +APACHE_HTTPS_PORT=8443 +``` + ## Enabling/disabling Apache extensions You can enable/disable Apache extensions using the `APACHE_EXTENSION_[extension_name]` environment variable. @@ -406,6 +417,42 @@ Beware! The `ARG NODE_VERSION` command must be written before the `FROM`. This i `NODE_VERSION` can take any valid node versions (tested only with the versions provided here but should work too for some previous versions) +## Composer version + +The images come with the latest Composer version. You can install another one with the `COMPOSER_VERSION` environment +variable: an exact version (e.g. `2.4.4`) or a channel (`1`, `2`, or `2.2` for the LTS version). + +```bash +COMPOSER_VERSION=2.4.4 +``` + +The version is installed when the container starts (this requires a network access). With the *slim* images, you can +install it during the build instead, with an ARG before the `FROM`: + +```Dockerfile +ARG COMPOSER_VERSION=2.4.4 +FROM thecodingmachine/php:8.4-v5-slim-apache +``` + +## Locales + +Only the `C`, `C.UTF-8` and `POSIX` locales are available by default. The `LOCALES` environment variable lists the +locales to generate at the container start: + +```bash +LOCALES=fr_FR.UTF-8 de_DE.UTF-8 +# Optional: the default locale of the intl extension +PHP_INI_INTL__DEFAULT_LOCALE=fr_FR.UTF-8 +``` + +The locales tools are **installed by default in the fat images**. If you are using the "*slim*" images, you need to +install them with an ARG before the `FROM`: + +```Dockerfile +ARG INSTALL_LOCALES=1 +FROM thecodingmachine/php:8.4-v5-slim-apache +``` + ## Permissions Ever faced file permission issues with Docker? Good news, this is a thing of the past! @@ -554,6 +601,26 @@ docker run -it --rm --name my-running-script -v "$PWD":/usr/src/myapp -w /usr/sr -v $PWD/my-startup-script.sh:/etc/container/startup.sh thecodingmachine/php:8.4-v5-cli php your-script.php ``` +## Running other processes in the container + +**Prefer one container per process.** For instance, run a Laravel queue worker in its own container (the *cli* image, +with `php artisan queue:work` as command) rather than in the Apache container: Docker and your orchestrator can then +restart, scale, limit and monitor each process. + +When a separate container is not an option, the `DAEMON_COMMAND_XXX` environment variables start long-running +processes along with the main command. They are run by the `docker` user (see [Permissions](#permissions)), or by the +user set in the `DAEMON_USER_XXX` variable with the same suffix (a user name or an ID): + +```bash +DAEMON_COMMAND_1=php artisan queue:work +DAEMON_COMMAND_2=php artisan horizon +DAEMON_USER_2=www-data +``` + +These processes are not restarted: **as soon as one of them (or the main command) exits, the other ones are stopped +and the container stops** with the exit code of this process. Use a restart policy (e.g. `restart: unless-stopped`) +to restart the container. Supercronic (see [Setting up CRON jobs](#setting-up-cron-jobs)) runs the same way. + ## Using the CLI variant The CLI images (thecodingmachine/php:8.4-v5-cli) expect a command to be passed in parameter. diff --git a/tests-suite/tool-composer-version.sh b/tests-suite/tool-composer-version.sh new file mode 100755 index 0000000..ffba447 --- /dev/null +++ b/tests-suite/tool-composer-version.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +. ./config + +############################################################ +## COMPOSER_VERSION installs an exact version at the container start +############################################################ +test_exactVersion() { + RESULT="$(docker run ${RUN_OPTIONS} --rm -e COMPOSER_VERSION=2.4.4 \ + "${REPO}:${TAG_PREFIX}${PHP_VERSION}-${BRANCH}-slim-${BRANCH_VARIANT}${ARCH_SUFFIX}" composer --version --no-ansi 2>/dev/null | tail -n +1 | grep -oP '^Composer version \K\S+')" + assert_equals "2.4.4" "$RESULT" +} +############################################################ +## COMPOSER_VERSION installs the latest version of a channel +############################################################ +test_channel() { + RESULT="$(docker run ${RUN_OPTIONS} --rm -e COMPOSER_VERSION=2.2 \ + "${REPO}:${TAG_PREFIX}${PHP_VERSION}-${BRANCH}-slim-${BRANCH_VARIANT}${ARCH_SUFFIX}" composer --version --no-ansi 2>/dev/null | tail -n +1 | grep -oP '^Composer version \K\S+')" + assert_matches "^2\.2\.[0-9]+$" "$RESULT" +} +############################################################ +## ARG COMPOSER_VERSION installs the version during the build +############################################################ +test_onbuild() { + docker ${BUILDTOOL} -t "${DOCKER1_NAME}" - > /dev/null 2>&1 </dev/null | tail -n +1 | grep -oP '^Composer version \K\S+')" + assert_equals "2.4.4" "$RESULT" +} + +setup_suite() { + export DOCKER1_NAME="test/composer_version_$(unused_port)" +} + +teardown_suite() { + docker rmi --force "${DOCKER1_NAME}" > /dev/null 2>&1 +} diff --git a/tests-suite/tool-daemon.sh b/tests-suite/tool-daemon.sh new file mode 100755 index 0000000..669bece --- /dev/null +++ b/tests-suite/tool-daemon.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +. ./config + +IMAGE="${REPO}:${TAG_PREFIX}${PHP_VERSION}-${BRANCH}-slim-${BRANCH_VARIANT}${ARCH_SUFFIX}" + +############################################################ +## DAEMON_COMMAND runs along with the main command, with the same user +############################################################ +test_runWithMainCommand() { + RESULT="$(docker run ${RUN_OPTIONS} --rm -e DAEMON_COMMAND_1="whoami; sleep 30" "${IMAGE}" sleep 2 2>&1)" + assert_equals "0" "$?" "The container should exit with the code of the main command" + assert_equals "docker" "$RESULT" +} +############################################################ +## DAEMON_USER runs the daemon with another user +############################################################ +test_daemonUser() { + RESULT="$(docker run ${RUN_OPTIONS} --rm -e DAEMON_COMMAND_1="whoami; sleep 30" -e DAEMON_USER_1="www-data" \ + -e DAEMON_COMMAND_2="id -u; sleep 30" -e DAEMON_USER_2="0" "${IMAGE}" sleep 2 2>&1 | sort | tr '\n' ' ')" + assert_equals "0 www-data " "$RESULT" +} +############################################################ +## The container stops as soon as a daemon exits +############################################################ +test_stopWhenDaemonExits() { + START=$(date +%s) + RESULT="$(docker run ${RUN_OPTIONS} --rm -e DAEMON_COMMAND_1="sleep 30" -e DAEMON_COMMAND_2="exit 3" "${IMAGE}" sleep 30 2>&1)" + CODE=$? + DURATION=$(( $(date +%s) - START )) + assert_equals "3" "$CODE" "The container should exit with the code of the daemon" + assert_matches 'DAEMON_COMMAND_2 \(exit 3\) exited with code 3' "$RESULT" + assert "test ${DURATION} -lt 10" "Stopping took ${DURATION}s" +} +############################################################ +## A docker stop stops the main command and the daemons +############################################################ +test_dockerStop() { + if [[ $VARIANT == cli* ]]; then COMMAND="sleep 300"; else COMMAND=""; fi + docker run --name "${STOP_NAME}" ${RUN_OPTIONS} -d -e DAEMON_COMMAND_1="sleep 300" "${IMAGE}" ${COMMAND} > /dev/null + sleep 3 + START=$(date +%s) + docker stop "${STOP_NAME}" > /dev/null 2>&1 + DURATION=$(( $(date +%s) - START )) + CODE="$(docker inspect -f '{{.State.ExitCode}}' "${STOP_NAME}")" + docker rm -f "${STOP_NAME}" > /dev/null 2>&1 + assert "test ${DURATION} -lt 5" "Stopping took ${DURATION}s (killed by timeout?)" + if [[ $VARIANT != cli* ]]; then + assert_equals "0" "$CODE" "The graceful stop should exit with 0" + fi +} + +setup_suite() { + export STOP_NAME="test-daemon-stop-$(unused_port)" +} + +teardown_suite() { + docker rm -f "${STOP_NAME}" > /dev/null 2>&1 +} diff --git a/tests-suite/tool-locales.sh b/tests-suite/tool-locales.sh new file mode 100755 index 0000000..eb041a9 --- /dev/null +++ b/tests-suite/tool-locales.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +. ./config + +############################################################ +## The locales listed in LOCALES are available +############################################################ +test_generateLocales() { + RESULT="$(docker run ${RUN_OPTIONS} --rm -e LOCALES="fr_FR.UTF-8 de_DE.UTF-8" \ + "${REPO}:${TAG_PREFIX}${PHP_VERSION}-${BRANCH}-${BRANCH_VARIANT}${ARCH_SUFFIX}" \ + php -r 'echo setlocale(LC_ALL, "fr_FR.UTF-8"), " ", setlocale(LC_ALL, "de_DE.UTF-8");' 2>/dev/null)" + assert_equals "fr_FR.UTF-8 de_DE.UTF-8" "$RESULT" +} +############################################################ +## LOCALES fails with a message in a slim image without INSTALL_LOCALES +############################################################ +test_displayErrorWhenMissing() { + RESULT=$(docker run ${RUN_OPTIONS} --rm -e LOCALES="fr_FR.UTF-8" \ + "${REPO}:${TAG_PREFIX}${PHP_VERSION}-${BRANCH}-slim-${BRANCH_VARIANT}${ARCH_SUFFIX}" sleep 1 2>&1 | tail -n +1 | grep -o 'Locales are not available in this image') + assert_equals "Locales are not available in this image" "$RESULT" +} diff --git a/tests-suite/tool-supercronic.sh b/tests-suite/tool-supercronic.sh index ba85c01..4f8b031 100755 --- a/tests-suite/tool-supercronic.sh +++ b/tests-suite/tool-supercronic.sh @@ -36,3 +36,11 @@ test_twoCommandInOneRow() { "${REPO}:${TAG_PREFIX}${PHP_VERSION}-${BRANCH}-${BRANCH_VARIANT}${ARCH_SUFFIX}" sleep 1 2>&1 | grep -oP 'msg=docker' | wc -l)" assert '[ "$RESULT" -gt "1" ]' } +############################################################ +## Let's check that the processes left in background by the cron jobs do not become zombies. +############################################################ +test_noZombie() { + RESULT="$(docker run ${RUN_OPTIONS} --rm -e CRON_SCHEDULE_1="* * * * * * *" -e CRON_COMMAND_1="sleep 1 &" -e CRON_USER_1="docker" \ + "${REPO}:${TAG_PREFIX}${PHP_VERSION}-${BRANCH}-${BRANCH_VARIANT}${ARCH_SUFFIX}" bash -c 'sleep 8; ps -eo stat= | grep -c "^Z"' 2>/dev/null | tail -n 1)" + assert '[ "$RESULT" -le "1" ]' "${RESULT} zombie processes" +} diff --git a/tests-suite/variant-apache.sh b/tests-suite/variant-apache.sh index 3cd6175..5cd80f8 100755 --- a/tests-suite/variant-apache.sh +++ b/tests-suite/variant-apache.sh @@ -52,6 +52,13 @@ test_enableApacheModule() { assert_not_matches "does not exist" "$RESULT" "a2enmod/a2dismod received an unknown module" } ############################################################ +## Apache listens on APACHE_HTTP_PORT +############################################################ +test_httpPort() { + RESULT="$(curl -sq http://localhost:${DOCKER4_PORT}/apache/ 2>&1)" + assert_equals "foo" "$RESULT" "Apache does not listen on APACHE_HTTP_PORT" +} +############################################################ ## A stop requested during the initialization is not lost ############################################################ test_stopDuringStartup() { @@ -85,13 +92,20 @@ setup_suite() { docker run --name "${DOCKER3_NAME}" ${RUN_OPTIONS} --rm -e MYVAR=foo -e APACHE_DOCUMENT_ROOT=/var/www/foo/apache -p "${DOCKER3_PORT}:80" -d -v "${SCRIPT_DIR}/assets/":/var/www/foo \ "${REPO}:${TAG_PREFIX}${PHP_VERSION}-${BRANCH}-slim-${BRANCH_VARIANT}${ARCH_SUFFIX}" > /dev/null assert_equals "0" "$?" "Docker run failed" + # SETUP apache4 + export DOCKER4_PORT="$(unused_port)" + export DOCKER4_NAME="test-apache4-${DOCKER4_PORT}" + docker run --name "${DOCKER4_NAME}" ${RUN_OPTIONS} --rm -e MYVAR=foo -e APACHE_HTTP_PORT=8080 -p "${DOCKER4_PORT}:8080" -d -v "${SCRIPT_DIR}/assets/":/var/www/html \ + "${REPO}:${TAG_PREFIX}${PHP_VERSION}-${BRANCH}-slim-${BRANCH_VARIANT}${ARCH_SUFFIX}" > /dev/null + assert_equals "0" "$?" "Docker run failed" # Let's wait for Apache to start waitfor http://localhost:${DOCKER1_PORT} waitfor http://localhost:${DOCKER2_PORT} waitfor http://localhost:${DOCKER3_PORT} + waitfor http://localhost:${DOCKER4_PORT} } teardown_suite() { - docker stop "${DOCKER1_NAME}" "${DOCKER2_NAME}" "${DOCKER3_NAME}" > /dev/null 2>&1 + docker stop "${DOCKER1_NAME}" "${DOCKER2_NAME}" "${DOCKER3_NAME}" "${DOCKER4_NAME}" > /dev/null 2>&1 docker rm -f "${STOP_NAME}" > /dev/null 2>&1 } diff --git a/utils/Dockerfile.blueprint b/utils/Dockerfile.blueprint index 09714c9..42e49a3 100644 --- a/utils/Dockerfile.blueprint +++ b/utils/Dockerfile.blueprint @@ -1,6 +1,7 @@ #syntax=docker/dockerfile-upstream:1 ARG INSTALL_CRON=1 ARG INSTALL_COMPOSER=1 +ARG INSTALL_LOCALES=1 ARG PHP_VERSION="8.4" ARG GLOBAL_VERSION="v5" ARG REPO="thecodingmachine/php" diff --git a/utils/Dockerfile.slim.blueprint b/utils/Dockerfile.slim.blueprint index 198ddd9..97021da 100644 --- a/utils/Dockerfile.slim.blueprint +++ b/utils/Dockerfile.slim.blueprint @@ -109,6 +109,7 @@ RUN rm /etc/php/${PHP_VERSION}/cli/php.ini RUN curl -sS --retry 5 --retry-delay 2 https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer &&\ chmod +x /usr/local/bin/composer +COPY utils/select_composer_version.sh /usr/local/bin/select_composer_version.sh # TODO: utils.php in /usr/local/bin... bof! COPY utils/utils.php /usr/local/bin/utils.php @@ -222,6 +223,13 @@ ENV APACHE_DOCUMENT_ROOT= RUN sed -ri -e 's!/var/www/html!${ABSOLUTE_APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf && \ sed -ri -e 's!/var/www/!${ABSOLUTE_APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf +ENV APACHE_HTTP_PORT=80 \ + APACHE_HTTPS_PORT=443 + +RUN sed -ri -e 's/^Listen 80$/Listen ${APACHE_HTTP_PORT}/' -e 's/^(\s*)Listen 443$/\1Listen ${APACHE_HTTPS_PORT}/' /etc/apache2/ports.conf && \ + sed -i 's///' /etc/apache2/sites-available/000-default.conf && \ + sed -i 's///' /etc/apache2/sites-available/default-ssl.conf + # Let's remove the default Apache php.ini file (it will be copied from TEMPLATE_PHP_INI) RUN rm /etc/php/${PHP_VERSION}/apache2/php.ini @@ -345,6 +353,7 @@ COPY utils/apache-expose-envvars.sh /usr/local/bin/apache-expose-envvars.sh {{end}} COPY utils/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh COPY utils/docker-entrypoint-as-root.sh /usr/local/bin/docker-entrypoint-as-root.sh +COPY utils/supervise.sh /usr/local/bin/supervise.sh COPY extensions/ /usr/local/lib/thecodingmachine-php/extensions RUN ln -s ${PHP_VERSION} /usr/local/lib/thecodingmachine-php/extensions/current @@ -421,6 +430,30 @@ ONBUILD RUN if [ -n "$INSTALL_CRON" ]; then \ && sudo ln -s "/usr/local/bin/${SUPERCRONIC}" /usr/local/bin/supercronic; \ fi +# |-------------------------------------------------------------------------- +# | Composer version +# |-------------------------------------------------------------------------- +# | +# | Installs the Composer version COMPOSER_VERSION instead of the latest one. +# | +ONBUILD ARG COMPOSER_VERSION +ONBUILD RUN if [ -n "$COMPOSER_VERSION" ]; then \ + sudo /usr/local/bin/select_composer_version.sh "$COMPOSER_VERSION"; \ + fi + +# |-------------------------------------------------------------------------- +# | Locales +# |-------------------------------------------------------------------------- +# | +# | Installs the tools generating the locales listed in LOCALES at the container start. +# | +ONBUILD ARG INSTALL_LOCALES +ONBUILD RUN if [ -n "$INSTALL_LOCALES" ]; then \ + sudo apt-get update \ + && sudo apt-get install -y --no-install-recommends locales \ + && sudo apt-get clean \ + && sudo rm -rf /var/lib/apt/lists/* /var/cache/apt/*.bin /tmp/* /var/tmp/* /usr/share/doc/*; \ + fi # |-------------------------------------------------------------------------- # | NodeJS diff --git a/utils/README.blueprint.md b/utils/README.blueprint.md index 67c628d..fdd9be5 100644 --- a/utils/README.blueprint.md +++ b/utils/README.blueprint.md @@ -264,6 +264,17 @@ APACHE_DOCUMENT_ROOT=public/ APACHE_DOCUMENT_ROOT=/var/www/html/public ``` +## Changing Apache ports + +For the *apache* variant, Apache listens on port 80 (and 443 when the `ssl` extension is enabled). +You can change these ports with the `APACHE_HTTP_PORT` and `APACHE_HTTPS_PORT` variables, for instance to use +unprivileged ports: + +```bash +APACHE_HTTP_PORT=8080 +APACHE_HTTPS_PORT=8443 +``` + ## Enabling/disabling Apache extensions You can enable/disable Apache extensions using the `APACHE_EXTENSION_[extension_name]` environment variable. @@ -336,6 +347,42 @@ Beware! The `ARG NODE_VERSION` command must be written before the `FROM`. This i `NODE_VERSION` can take any valid node versions (tested only with the versions provided here but should work too for some previous versions) +## Composer version + +The images come with the latest Composer version. You can install another one with the `COMPOSER_VERSION` environment +variable: an exact version (e.g. `2.4.4`) or a channel (`1`, `2`, or `2.2` for the LTS version). + +```bash +COMPOSER_VERSION=2.4.4 +``` + +The version is installed when the container starts (this requires a network access). With the *slim* images, you can +install it during the build instead, with an ARG before the `FROM`: + +```Dockerfile +ARG COMPOSER_VERSION=2.4.4 +FROM thecodingmachine/php:{{ $image.php_version }}-v5-slim-apache +``` + +## Locales + +Only the `C`, `C.UTF-8` and `POSIX` locales are available by default. The `LOCALES` environment variable lists the +locales to generate at the container start: + +```bash +LOCALES=fr_FR.UTF-8 de_DE.UTF-8 +# Optional: the default locale of the intl extension +PHP_INI_INTL__DEFAULT_LOCALE=fr_FR.UTF-8 +``` + +The locales tools are **installed by default in the fat images**. If you are using the "*slim*" images, you need to +install them with an ARG before the `FROM`: + +```Dockerfile +ARG INSTALL_LOCALES=1 +FROM thecodingmachine/php:{{ $image.php_version }}-v5-slim-apache +``` + ## Permissions Ever faced file permission issues with Docker? Good news, this is a thing of the past! @@ -484,6 +531,26 @@ docker run -it --rm --name my-running-script -v "$PWD":/usr/src/myapp -w /usr/sr -v $PWD/my-startup-script.sh:/etc/container/startup.sh thecodingmachine/php:{{ $image.php_version }}-v5-cli php your-script.php ``` +## Running other processes in the container + +**Prefer one container per process.** For instance, run a Laravel queue worker in its own container (the *cli* image, +with `php artisan queue:work` as command) rather than in the Apache container: Docker and your orchestrator can then +restart, scale, limit and monitor each process. + +When a separate container is not an option, the `DAEMON_COMMAND_XXX` environment variables start long-running +processes along with the main command. They are run by the `docker` user (see [Permissions](#permissions)), or by the +user set in the `DAEMON_USER_XXX` variable with the same suffix (a user name or an ID): + +```bash +DAEMON_COMMAND_1=php artisan queue:work +DAEMON_COMMAND_2=php artisan horizon +DAEMON_USER_2=www-data +``` + +These processes are not restarted: **as soon as one of them (or the main command) exits, the other ones are stopped +and the container stops** with the exit code of this process. Use a restart policy (e.g. `restart: unless-stopped`) +to restart the container. Supercronic (see [Setting up CRON jobs](#setting-up-cron-jobs)) runs the same way. + ## Using the CLI variant The CLI images (thecodingmachine/php:{{ $image.php_version }}-v5-cli) expect a command to be passed in parameter. diff --git a/utils/docker-entrypoint-as-root.sh b/utils/docker-entrypoint-as-root.sh index 539ea9f..c8bb392 100755 --- a/utils/docker-entrypoint-as-root.sh +++ b/utils/docker-entrypoint-as-root.sh @@ -146,16 +146,23 @@ sudo chown docker:docker /opt/php_env_var_cache.php /usr/bin/real_php -d display_errors=stderr /usr/local/bin/generate_conf.php > /etc/php/${PHP_VERSION}/mods-available/generated_conf.ini PHP_VERSION="${PHP_VERSION}" /usr/bin/real_php -d display_errors=stderr /usr/local/bin/setup_extensions.php | sudo bash +if [[ -n "$COMPOSER_VERSION" ]]; then + /usr/local/bin/select_composer_version.sh "$COMPOSER_VERSION" +fi + +if [[ -n "$LOCALES" ]]; then + if ! command -v locale-gen > /dev/null; then + echo 'Locales are not available in this image. If you are using the thecodingmachine/php "slim" variant, do not forget to add "ARG INSTALL_LOCALES=1" in your Dockerfile. Check the documentation for more details.' >&2 + exit 1 + fi + locale-gen --keep-existing $LOCALES > /dev/null +fi + # output on the logs can be done by writing on the "tini" PID. Useful for CRONTAB TINI_PID=`ps -e | grep tini | awk '{print $1;}'` /usr/bin/real_php -d display_errors=stderr /usr/local/bin/generate_cron.php $TINI_PID > /tmp/generated_crontab chmod 0644 /tmp/generated_crontab -# If generated_crontab is not empty, start supercronic -if [[ -s /tmp/generated_crontab ]]; then - supercronic ${SUPERCRONIC_OPTIONS} /tmp/generated_crontab & -fi - if [[ "$IMAGE_VARIANT" == "apache" ]]; then /usr/bin/real_php -d display_errors=stderr /usr/local/bin/enable_apache_mods.php | bash fi @@ -174,7 +181,38 @@ fi # We should run the command with the user of the directory... (unless this is Apache, that must run as root...) if [[ "$@" == "apache2-foreground" ]]; then /usr/local/bin/apache-expose-envvars.sh; - exec "$@"; + MAIN_COMMAND=("$@") else - exec "sudo" "-E" "-H" "-u" "#$DOCKER_USER_ID" "$@"; + MAIN_COMMAND=("sudo" "-E" "-H" "-u" "#$DOCKER_USER_ID" "$@") fi + +# Processes run along with the main command: Supercronic and the DAEMON_COMMAND* commands +: > /tmp/generated_processes +if [[ -s /tmp/generated_crontab ]]; then + printf '%s\0' 'Supercronic' 'exec supercronic $SUPERCRONIC_OPTIONS /tmp/generated_crontab' >> /tmp/generated_processes +fi +for DAEMON_COMMAND_VAR in "${!DAEMON_COMMAND@}"; do + DAEMON_USER_VAR="DAEMON_USER${DAEMON_COMMAND_VAR#DAEMON_COMMAND}" + DAEMON_USER="${!DAEMON_USER_VAR:-#$DOCKER_USER_ID}" + if [[ "$DAEMON_USER" =~ ^[0-9]+$ ]]; then + DAEMON_USER="#$DAEMON_USER" + fi + printf '%s\0' "$DAEMON_COMMAND_VAR (${!DAEMON_COMMAND_VAR})" \ + "exec sudo -E -H -u $(printf '%q' "$DAEMON_USER") bash -c $(printf '%q' "${!DAEMON_COMMAND_VAR}")" >> /tmp/generated_processes +done + +if [[ ! -s /tmp/generated_processes ]]; then + exec "${MAIN_COMMAND[@]}" +fi + +case "$IMAGE_VARIANT" in + apache) IMAGE_STOP_SIGNAL=WINCH ;; + fpm) IMAGE_STOP_SIGNAL=QUIT ;; + *) IMAGE_STOP_SIGNAL=TERM ;; +esac +case "$1" in + apache2-foreground) STOP_SIGNAL=WINCH ;; + php-fpm*) STOP_SIGNAL=QUIT ;; + *) STOP_SIGNAL=TERM ;; +esac +exec /usr/local/bin/supervise.sh /tmp/generated_processes "$IMAGE_STOP_SIGNAL" "$STOP_SIGNAL" "${MAIN_COMMAND[@]}" diff --git a/utils/select_composer_version.sh b/utils/select_composer_version.sh new file mode 100755 index 0000000..bfb4dbe --- /dev/null +++ b/utils/select_composer_version.sh @@ -0,0 +1,20 @@ +#!/bin/bash +# Installs the Composer version $1: an exact version (e.g. 2.4.4) or a channel (1, 2, or 2.2 for the LTS) + +set -e + +CURRENT_VERSION=$(composer --version --no-ansi 2>/dev/null | grep -oP '^Composer version \K\S+' || true) + +case "$1" in + 1|2|2.2) + [[ "$CURRENT_VERSION" == "$1".* ]] && exit 0 + SELF_UPDATE_ARG="--$1" + ;; + *) + [[ "$CURRENT_VERSION" == "$1" ]] && exit 0 + SELF_UPDATE_ARG="$1" + ;; +esac + +HOME=/root COMPOSER_HOME=/root/.config/composer COMPOSER_ALLOW_SUPERUSER=1 \ + composer self-update --no-interaction --no-progress --clean-backups "$SELF_UPDATE_ARG" diff --git a/utils/supervise.sh b/utils/supervise.sh new file mode 100755 index 0000000..24c5dce --- /dev/null +++ b/utils/supervise.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# Usage: supervise.sh
+# +# Runs the main command along with the commands of the file: pairs of a name and a command, separated by NUL characters. +# As soon as one of these processes exits, or SIGTERM, SIGINT or the image stop signal is received, the others are +# stopped (the main command with its stop signal, the others with SIGTERM). The script exits with the exit code of +# the first process that exited. +# When the stop is requested by a signal, the script exits with the exit code of the main command. + +COMMANDS_FILE=$1 +IMAGE_STOP_SIGNAL=$2 +STOP_SIGNAL=$3 +shift 3 + +declare -A NAMES +MAIN_PID= +MAIN_EXIT_CODE= +STOPPING= +STOP_REQUESTED= + +stop() { + STOPPING=$1 + [[ -n "$MAIN_PID" ]] && kill -"$1" "$MAIN_PID" 2> /dev/null + for PID in "${!NAMES[@]}"; do + kill -TERM "$PID" 2> /dev/null + done +} + +for SIGNAL in HUP USR1 USR2 QUIT WINCH; do + trap "[[ -n \"\$MAIN_PID\" ]] && kill -$SIGNAL \"\$MAIN_PID\" 2> /dev/null" "$SIGNAL" +done +trap 'STOP_REQUESTED=1; stop TERM' TERM +trap 'STOP_REQUESTED=1; stop INT' INT +if [[ "$IMAGE_STOP_SIGNAL" != TERM ]]; then + trap 'STOP_REQUESTED=1; stop "$STOP_SIGNAL"' "$IMAGE_STOP_SIGNAL" +fi + +while IFS= read -r -d '' NAME && IFS= read -r -d '' COMMAND; do + bash -c "$COMMAND" & + NAMES[$!]=$NAME +done < "$COMMANDS_FILE" +"$@" <&0 & +MAIN_PID=$! + +# A stop requested while the processes were starting +[[ -n "$STOPPING" ]] && stop "$STOPPING" + +# Checks every second which processes exited (wait -n can miss a process already reaped by bash) +while [[ -z "$MAIN_EXIT_CODE" ]] || (( ${#NAMES[@]} > 0 )); do + sleep 1 & + wait $! + + RUNNING_PIDS=("${!NAMES[@]}") + [[ -z "$MAIN_EXIT_CODE" ]] && RUNNING_PIDS+=("$MAIN_PID") + for PID in "${RUNNING_PIDS[@]}"; do + kill -0 "$PID" 2> /dev/null && continue + wait "$PID" + CODE=$? + + if [[ "$PID" == "$MAIN_PID" ]]; then + MAIN_EXIT_CODE=$CODE + else + EXITED_NAME=${NAMES[$PID]} + unset "NAMES[$PID]" + fi + if [[ -z "$STOPPING" ]]; then + EXIT_CODE=$CODE + if [[ "$PID" != "$MAIN_PID" ]]; then + echo "$EXITED_NAME exited with code $CODE: stopping the container" >&2 + fi + stop "$STOP_SIGNAL" + fi + done +done + +if [[ -n "$STOP_REQUESTED" ]]; then + exit "$MAIN_EXIT_CODE" +fi +exit "$EXIT_CODE"