fly-apps / laravel-docker

Base Docker images for use with Laravel on Fly.io
38 stars 8 forks source link

[prototype] nginx unit version of image #6

Closed fideloper closed 10 months ago

fideloper commented 10 months ago

Notes:

  1. The multi-stage build allows us to create a small final image, which is great (~285m, smaller than my original ~565m when not doing multi-stage)
    • the --copy-from appears to be fine - I can just grab the PHP .so file and nothing else for the final image built. I'm not 100% confident there's no edge cases with that, however. But seems reasonable that it would Just Work™
  2. Nginx Unit correctly sends content types for static files (.css, .js, .xml, .txt, etc)
  3. Nginx Unit does NOT protect dot files or similar by default. There's likely a configuration for that!
kohenkatz commented 10 months ago

@fideloper I saw your article about using Unit, and I had a few notes. I figured this was probably a good place to leave them.

First, I'm curious if you looked at the Dockerfile that Unit uses for their official images: https://github.com/nginx/unit/blob/master/pkg/docker/Dockerfile.php8.2

One major difference is that they start from an official PHP base image, then build unit from source.

Another difference is in their entrypoint script. On the initial container startup, they check for configuration files, scripts, and certificates, and add them to the unit configuration. This makes it possible for a single Unit image to be easily used in a variety of situations.

I've been using unit in production for a few months, and here's my configuration.

First, I have two separate "base" images I build, since they don't change very often. One is Unit, and the other is PHP CLI:

Dockerfile.base-unit

ARG UNIT_VERSION=1.31.0
ARG PHP_VERSION=8.2
FROM unit:${UNIT_VERSION}-php${PHP_VERSION} as unit-base

RUN mkdir -p /srv/app
WORKDIR /srv/app

ARG EXT_INSTALLER_VERSION=2.1.38
COPY support/docker/install-extensions.sh /usr/local/bin/install-extensions
RUN sh /usr/local/bin/install-extensions "${EXT_INSTALLER_VERSION}"

Dockerfile.base-cli

ARG PHP_VERSION=8.2
FROM php:${PHP_VERSION}-alpine

RUN mkdir -p /srv/app
WORKDIR /srv/app

ARG EXT_INSTALLER_VERSION=2.1.38
COPY support/docker/install-extensions.sh /usr/local/bin/install-extensions
RUN sh /usr/local/bin/install-extensions "${EXT_INSTALLER_VERSION}"

Here's what install-extensions.sh looks like:

#!/bin/sh

EXT_INSTALLER_VERSION=$1

if [ -z "$1" ]; then
    EXT_INSTALLER_URL="https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions"
else
    EXT_INSTALLER_URL="https://github.com/mlocati/docker-php-extension-installer/releases/download/${EXT_INSTALLER_VERSION}/install-php-extensions"
fi

set -x

curl -sSLf -o /usr/local/bin/install-php-extensions "${EXT_INSTALLER_URL}"

chmod +x /usr/local/bin/install-php-extensions

install-php-extensions ctype gd gmp intl mbstring pcntl pdo_pgsql redis soap xsl zip

php -m

Relevant to this ~tweet~ X post, I had the same problem until I discovered https://github.com/mlocati/docker-php-extension-installer, which is a wonderful tool.

Application Dockerfile

ARG CI_REGISTRY_IMAGE
ARG BASE_IMAGE_TAG=develop
FROM ${CI_REGISTRY_IMAGE}/cli-base:${BASE_IMAGE_TAG} as phpbuild

RUN mkdir -p /srv/app
WORKDIR /srv/app

COPY --from=composer /usr/bin/composer /usr/bin/composer

COPY . /srv/app

RUN composer install --prefer-dist --no-dev --optimize-autoloader --no-interaction && \
    php artisan horizon:publish

## Install Node.js dependencies and build assets

FROM node:18-alpine as jsbuild

RUN mkdir -p /srv/app
WORKDIR /srv/app

COPY package.json /srv/app/
COPY package-lock.json /srv/app/

RUN npm ci

COPY webpack.mix.js /srv/app/
COPY ./lang/. /srv/app/lang/
COPY ./resources/. /srv/app/resources/

RUN mkdir -p /srv/app/public && \
    npx browserslist@latest --update-db && \
    npm run prod

## Nginx Unit runner for web backend

ARG CI_REGISTRY_IMAGE
ARG BASE_IMAGE_TAG=develop
FROM ${CI_REGISTRY_IMAGE}/unit-base:${BASE_IMAGE_TAG} as web

RUN mkdir -p /srv/app
WORKDIR /srv/app

EXPOSE 80

COPY ./support/docker/unit/* /docker-entrypoint.d/

COPY --chown=unit:unit --from=phpbuild /srv/app/. /srv/app/.
COPY --chown=unit:unit --from=jsbuild /srv/app/public/. /srv/app/public/.

# PHP runner for queues and scheduled tasks

ARG CI_REGISTRY_IMAGE
ARG BASE_IMAGE_TAG=develop
FROM ${CI_REGISTRY_IMAGE}/cli-base:${BASE_IMAGE_TAG} as artisan

RUN mkdir -p /srv/app
WORKDIR /srv/app

COPY ./support/docker/start.sh /usr/local/bin/start.sh
ENTRYPOINT [ "/usr/local/bin/start.sh" ]

COPY --from=phpbuild /srv/app/. /srv/app/.

Having separate unit and cli containers is probably not necessary, but my boss liked the idea, so that's what I did.

A very important line in the file above is this one: COPY ./support/docker/unit/* /docker-entrypoint.d/

In my repository, I include just the following configuration file in there (I added some comments here, they need to be removed to make the file valid JSON):

app.json

{
    "listeners": {
        "*:80": {
            "pass": "routes",
            "forwarded": {
                "protocol": "X-Forwarded-Proto",
                "client_ip": "X-Forwarded-For",
                "recursive": true,
                "source": [
                    "172.17.0.0/16", // Docker internal network IPv4
                    "fd00:0932::/48", // Docker internal network IPv6

                   // Cloudflare networks
                    "173.245.48.0/20",
                    "103.21.244.0/22",
                    "103.22.200.0/22",
                    "103.31.4.0/22",
                    "141.101.64.0/18",
                    "108.162.192.0/18",
                    "190.93.240.0/20",
                    "188.114.96.0/20",
                    "197.234.240.0/22",
                    "198.41.128.0/17",
                    "162.158.0.0/15",
                    "104.16.0.0/13",
                    "104.24.0.0/14",
                    "172.64.0.0/13",
                    "131.0.72.0/22",
                    "2400:cb00::/32",
                    "2606:4700::/32",
                    "2803:f800::/32",
                    "2405:b500::/32",
                    "2405:8100::/32",
                    "2a06:98c0::/29",
                    "2c0f:f248::/32"
                ]
            }
        }
    },

    "access_log": {
        "path": "/srv/app/storage/logs/access.log",
        "format": "$remote_addr - - [$time_local] \"$request_line\" $status $body_bytes_sent \"$header_referer\" \"$header_user_agent\" $header_cf_connecting_ip"
    },

    "applications": {
        "laravel": {
            "type": "php",
            "root": "/srv/app/public/",
            "script": "index.php",
            "processes": {
                "max": 100,
                "spare": 10,
                "idle_timeout": 60
            }
        }
    },

    "routes": [
        {
            "match": {
                "uri": "!/index.php"
            },
            "action": {
                "share": "/srv/app/public$uri",
                "fallback": {
                    "pass": "applications/laravel"
                }
            }
        }
    ]
}

I also use a Docker Bind Mount (in docker-compose.yml) to include a shell script in the configuration directory at runtime. Right now, that script looks like this:

#!/bin/sh

curl -X PUT -d '{"user": {"memory_limit": "384M", "max_execution_time": "120"}}' --unix-socket /var/run/control.unit.sock 'http://localhost/config/applications/laravel/options'

This allows easy modification of the unit settings that persists when the container is updated.

For example, your comment about "protecting dot files or other routes" can probably be done with unit's routes.NAME.match.uri pattern-matching and `routes.NAME.action.return" set to 403 (or any other code). You can do this in the original container creation, or with a script as shown here.

One thing I plan to do with a shell script (but haven't had a chance to test yet) is loading the docker and Cloudflare IP ranges dynamically instead of prepopulating them in the initial setup.

Also, regarding your comment about setting cache headers for static assets, there are examples of doing exactly that in the documentation: https://unit.nginx.org/configuration/#response-headers - is there something more than that which you are trying to do?

fideloper commented 10 months ago

@kohenkatz Thank you, that's super helpful! I appreciate you taking the time to write that all out!

I'm merging what I have into main for now, but the real treat there is finding a way to use official PHP images and add in modules fairly easily. Keeping that bookmarked! (along with the ideas you have on nginx unit of course!)