Laravel in Kubernetes Part 2 - Dockerizing Laravel

Laravel in Kubernetes Part 2 - Dockerizing Laravel

In this part of the series, we are going to Dockerise our Laravel application with different layers, for all the different technical pieces of our application (FPM, Web Server, Queues, Cron etc.)

We will do this by building layers for each process, copy in the codebase, and build separate containers for them.

TLDR;

Laravel In Kubernetes Part 2
Laravel In Kubernetes Part 2. GitHub Gist: instantly share code, notes, and snippets.

Table of contents

Prerequisites

  • A Laravel application. You can see Part 1 if you haven't got an application yet
  • Docker running locally

Getting started

Laravel 8.0 ships with Sail, which already runs Laravel applications in Docker, but it is not entirely production ready, and might need to be updated according to your use case and needs for sizing, custom configs etc. It only has a normal PHP container, but we might need a few more containers for production.

We need a FPM container to process requests, a PHP CLI container to handle artisan commands, and for example running queues, and an Nginx container to serve static content etc.

As you can already see, simply running one container would not serve our needs, and doesn't allow us to scale or manage different pieces of our application differently from the others.

In this post we'll cover all of the required containers, and what each of them are specialised for.

Why wouldn't we use the default sail container

The default sail container contains everything we need to run the application, to the point where it has too much for deployment

For local development it is perfectly fine, but for production use cases, it's too big, and there is too much stuff in there to keep our container lightweight and secure.

The more we have in the containers, the more attack vectors, and size we can expect for our image.

Docker Containers

The first core container we will cover, is the one actually running the application.

The base container

We need to build a base container, which contains all our code, and installed dependencies.

Once we have built the base, we can build the other layers from that, only using the specific parts we need.

We also want to start with the smallest possible container for size and distribution.

We start with a Composer image which is based of php-8 in an alpine distro image

This will help us install dependencies of our application.

The Dockerfile

In the root of your project, create a file called Dockerfile with the following content

FROM composer:2.1 as code_base

# First, create the application directory, and some auxilary directories for scripts and such
RUN mkdir -p /opt/apps/laravel-in-kubernetes /opt/apps/laravel-in-kubernetes/bin

# Next, set our working directory
WORKDIR /opt/apps/laravel-in-kubernetes

# We need to create a composer group and user, and create a home directory for it, so we keep the rest of our image safe,
# And not accidentally run malicious scripts
RUN addgroup -S composer \
    && adduser -S composer -G composer \
    && chown -R composer /opt/apps/laravel-in-kubernetes

# Next we want to switch over to the composer user before running installs.
USER composer

# Copy in our dependency files
COPY --chown=composer composer.json composer.lock ./

# Install all the dependencies without running any installation scripts.
# The reason we skip scripts, is the code base hasn't been copied in yet and script will likely fail,
# as artisan isn't in yet.
# This also helps us to cache previous runs and layers.
# As long as comoser.json and composer.lock doesn't change the install will be cached.
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist

# Copy in our actual source code so we can run the installation scripts we need
COPY --chown=composer . .

# Install all the dependencies running installation scripts as well
RUN composer install --no-dev --prefer-dist

The .dockerignore file

We also need to add a .dockerignore file so we can prevent Docker from copying in node_modules and the vendor directory, as we want to build any binaries for the specific architecture in the image.

In the root of your project, create a file called .dockerignore with the following contents

/vendor
/node_modules

Building the Docker container

We can now build the Docker image and make sure it builds correctly, and installs all our dependencies

docker build . --target code_base

CLI Container

We are going to need a CLI container to run Queue jobs, Crons, Migrations, and Artisan commands

We'll create a new Dockerfile layer, specifically for usage as a CLI layer.

In the same Dockerfile add a new piece for CLI usage.

FROM php:8.0-alpine as cli

WORKDIR /opt/apps/laravel-in-kubernetes

# We need to install some requirements into our image, used to compile our PHP extensions, as well as install all the extensions themselves.
# You can see a list of required extensions here: https://laravel.com/docs/8.x/deployment#server-requirements
RUN apk add --virtual build-dependencies --no-cache openssl ca-certificates libxml2-dev oniguruma-dev && \
    docker-php-ext-install -j$(nproc) pdo pdo_mysql bcmath ctype fileinfo dom tokenizer mbstring tokenizer && \
    apk del build-dependencies

# Next we have to copy in our code base from our initial build which we installed in the previous stage
COPY --from=code_base /opt/apps/laravel-in-kubernetes /opt/apps/laravel-in-kubernetes

We can build this layer to make sure everything works correctly

$ docker build . --target cli
[...]
 => => writing image sha256:b6a7b602a4fed2d2b51316c1ad90fd12bb212e9a9c963382d776f7eaf2eebbd5 

The CLI layer has successfully built, and we can move onto the next layer

FPM Container

We can now also build out the specific parts of the running application, the first of which, is the container which runs fpm for us.

In the same Dockerfile, we will create another stage to our docker build called fpm_server with the following contents

FROM php:8.0-fpm-alpine as fpm_server

WORKDIR /opt/apps/laravel-in-kubernetes

# We need to install some requirements into our image, used to compile our PHP extensions, as well as install all the extensions themselves.
# You can see a list of required extensions here: https://laravel.com/docs/8.x/deployment#server-requirements
RUN apk add --virtual build-dependencies --no-cache openssl ca-certificates libxml2-dev oniguruma-dev && \
    docker-php-ext-install -j$(nproc) pdo pdo_mysql bcmath ctype fileinfo dom tokenizer mbstring tokenizer && \
    apk del build-dependencies

# Next we have to copy in our code base from our initial build which we installed in the previous stage
COPY --from=code_base /opt/apps/laravel-in-kubernetes /opt/apps/laravel-in-kubernetes

Next we need to build the image to this target again, to make sure everything works correctly

$ docker build . --target fpm_server
[...]
=> => writing image sha256:ead93b67e57f0cdf4ec9c1ca197cf8ca1dacb0bb030f9f57dc0fccf5b3eb9904

Web Server container

Lastly, we need to build a web server image which is used to serve static content, and send any PHP requests to our PFM container.

This is quite important, as we can serve static content through our PHP app, but Nginx is a lot better at it than PHP, and can serve static content a lot more efficiently.

The first thing we need is a nginx configuration for our web server.

Create a directory called docker in the root of your project

mkdir -p docker

Inside of that folder, you can create a file called nginx.conf with the following content

server {
    listen 80 default_server;
    listen [::]:80 default_server;

    # We need to set the root for our sevrer,
    # so any static file requests gets loaded from the correct path
    root /opt/apps/laravel-in-kubernetes/public;

    index index.php index.html index.htm index.nginx-debian.html;

    # _ makes sure that nginx does not try to map requests to a specific hostname
    # This allows us to specify the urls to our application as infrastructure changes,
    # without needing to change the application
    server_name _;

    # At the root location,
    # we first check if there are any static files at the location, and serve those,
    # If not, we check whether there is an indexable folder which can be served,
    # Otherwise we forward the request to the PHP server
    location / {
        # Using try_files here is quite important as a security concideration
        # to prevent injecting PHP code as static assets,
        # and then executing them via a URL.
        # See https://www.nginx.com/resources/wiki/start/topics/tutorials/config_pitfalls/#passing-uncontrolled-requests-to-php
        try_files $uri $uri/ /index.php?$query_string;
    }

    # Some static assets are loaded on every page load,
    # and logging these turns into a lot of useless logs.
    # If you would prefer to see these requests for catching 404's etc.
    # Feel free to remove them
    location = /favicon.ico { access_log off; log_not_found off; }
    location = /robots.txt  { access_log off; log_not_found off; }

    # When a 404 is returned, we want to display our applications 404 page,
    # so we redirect it to index.php to load the correct page
    error_page 404 /index.php;

    # Whenever we receive a PHP url, or our root location block gets to serving through fpm,
    # we want to pass the request to FPM for processing
    location ~ \.php$ {
        #NOTE: You should have "cgi.fix_pathinfo = 0;" in php.ini
        include fastcgi_params;
        fastcgi_intercept_errors on;
        fastcgi_pass laravel-in-kubernetes:9000;
        fastcgi_param SCRIPT_FILENAME $document_root/$fastcgi_script_name;
    }

    location ~ /\.ht {
        deny all;
    }

    location ~ /\.(?!well-known).* {
        deny all;
    }
}

Once we have that completed, we can create the new Docker image stage which contains the Nginx layer

FROM nginx:1.20-alpine as web_server

# Set the working directory, same as previously
WORKDIR /opt/apps/laravel-in-kubernetes

COPY ./docker/nginx.conf /etc/nginx/nginx.conf

# Copy in ONLY the public directory of our project.
# This is where all the static assets will live, which nginx will serve for us.
# Any PHP requests will be passed down to FPM
COPY --from=code_base /opt/apps/laravel-in-kubernetes/public /opt/apps/laravel-in-kubernetes/public

CMD ["nginx", "-c", "/etc/nginx/nginx.conf"]

We can now build up to this stage to make sure it builds successfully.

docker build . --target web_server

Cron container

We also want to create a Cron layer, which we can use to run the default scheduler in Laravel.

We want to specify crond to run in the foreground as well, and make it the primary command when the container starts.

FROM cli as cron

# Set the working directory, same as previously
WORKDIR /opt/apps/laravel-in-kubernetes

# We want to create a laravel.cron file with Laravel cron settings, which we can import into crontab,
# and run crond as the primary command in the forground
RUN touch laravel.cron && \
    echo "* * * * * cd /opt/apps/laravel-in-kubernetes && php artisan schedule:run" >> laravel.cron && \
    crontab laravel.cron

CMD ["crond", "-l", "2", "-f"]

We can build the container to make sure everything works correctly.

$ docker build . --target cron
 => => writing image sha256:b6fb826820e0669563a8746f83fb168fe39393ef6162d65c64439aa26b4d713b  

The default build

In our Dockerfile, which now has 4 stages, code_base, cli, fpm_server, and web_server we have no sensible default to build from.

We can specify this right at the end of our Dockerfile, by specifying a last FROM statement with the default stage

# [...]

FROM cli

Hardcoded values

One problem we have currently, is the fpm url is hardcoded into our nginx configuration. When we get to Kubernetes, this might change depending on our use cases, and choices.

You can check the nginx config file we created at line 104.

# [...]
fastcgi_pass laravel-in-kubernetes-fpm:9000;

# [...]

This will come to bight us when we try to use it and the hostname for the fpm server changes.

Nginx 1.19 Docker images support using templates for nginx configurations where we can use environment variables

Let's do that now.

First thing we need to do is rename our nginx.conf file to nginx.conf.template.

This can now be used as a base for our final nginx configuration.

The next thing we need to do is add an environment variable to replace our fpm host

In our docker/nginx.conf.template file at line 104

# [...]
fastcgi_pass ${FPM_HOST};
# [...]

As you can see from that file, we now have the option to set a FPM_HOST variable.

Next, we need to make sure to copy in the template to the right directory.

# Replace
COPY docker/nginx.conf /etc/nginx/nginx.conf

# With
COPY docker/nginx.conf.template /etc/nginx/templates/default.conf.template

That should sort us out.

Let's build and make sure everything works.

$ docker build . --target web_server

Remember that we need to now pass in a FPM_HOST to our nginx image for the fpm integration to work correctly

Docker Compose

Next, we can test our Docker images locally by building a docker-compose file which runs each stage of our image together so we can use it in that way locally, and reproduce it when we get to Kubernetes

First step is to create a docker-compose.yml file.

Laravel sail already comes with one prefilled, but we are going to change it up a bit to have all our separate containers running, so we can validate what will run in Kubernetes early in our cycle.

First thing we want to do is move the sail docker-compose file to a backup file called docker-compose.yml.backup.

Next, we want to create a base docker-compose.yml for our new image stages

version: '3'
services:
    laravel.fpm:
        build:
            context: .
            target: fpm_server
        image: laravel-in-kubernetes/fpm_server
        environment:
            APP_DEBUG: "true"
        volumes:
            # Here we mount in our codebase so any changes are immediately reflected into the container
            - '.:/opt/apps/laravel-in-kubernetes'
        networks:
            - laravel-in-kubernetes
    laravel.web:
        build:
            context: .
            target: web_server
        image: laravel-in-kubernetes/web_server
        ports:
            - '8080:80'
        environment:
            # We need to pass in the new FOM hst as the name of the fpm container on port 9000
            FPM_HOST: "laravel.fpm:9000"
        volumes:
            # Here we mount in our codebase so any changes are immediately reflected into the container
            - './public:/opt/apps/laravel-in-kubernetes/public'
        networks:
            - laravel-in-kubernetes
    laravel.cron:
        build:
            context: .
            target: cron
        image: laravel-in-kubernetes/cron
        volumes:
            # Here we mount in our codebase so any changes are immediately reflected into the container
            - '.:/opt/apps/laravel-in-kubernetes'
        networks:
            - laravel-in-kubernetes

# Create a bridged network to be used by containers.
# This will allow us to easily target other containers in the same stack
networks:
    laravel-in-kubernetes:

If we run these containers, we should be able to access the home page from localhost:8080

Our containers are now running properly. Nginx is passing our request onto FPM, and FPM is creating a response from our code base, and sending that back to our browser.

Our crons are also running correctly in the cron container. You can see this, by checking the logs for the cron container.

$ docker-compose logs laravel.cron
Attaching to laravel-in-kubernetes_laravel.cron_1
laravel.cron_1  | No scheduled commands are ready to run.

Running Mysql in docker-compose.yml

We need to run Mysql in docker as well for local development.

Sail does ship with this by default, and if you check the docker-compose.yml.backup file, you will notice a mysql service, which we can copy over as exists, and add to our docker-compose.yml.

Docker Compose will automatically load the .env file from our project, and these are the values referenced in the docker-compose.yml.backup which Sail ships with

services:
    [...]
    mysql:
        image: 'mysql:8.0'
        ports:
            - '${FORWARD_DB_PORT:-3306}:3306'
        environment:
            MYSQL_ROOT_PASSWORD: '${DB_PASSWORD}'
            MYSQL_DATABASE: '${DB_DATABASE}'
            MYSQL_USER: '${DB_USERNAME}'
            MYSQL_PASSWORD: '${DB_PASSWORD}'
            MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'
        volumes:
            - 'sailmysql:/var/lib/mysql'
        networks:
            - sail
        healthcheck:
          test: ["CMD", "mysqladmin", "ping", "-p${DB_PASSWORD}"]
          retries: 3
          timeout: 5s

We also need to update the docker-compose service name with the correct value.

services:
    [...]
    mysql:
        [...]
        networks:
            # Replace network name with our network
            - laravel-in-kubernetes
        [...]

One more change we need to make is update the volume for mysql, as the volume does not currently exist.

Right at the bottom of the docker-compose file, add the volume, and update the volume name on the Mysql service

services:
    [...]
    mysql:
        [...]
        volumes:
            - 'laravel-in-kubernetes-mysql:/var/lib/mysql'

volumes:
    laravel-in-kubernetes-mysql:

We can now run docker-compose up again, and Mysql  should be running alongside our other services.

docker-compose up -d

Running migrations in docker-compose

To test out our Mysql service and that our application can actually connect to Mysql, we can run migrations in the FPM container, as it has all of the right dependencies.

$ docker-compose exec laravel.fpm php artisan migrate
Migration table created successfully.
Migrating: 2014_10_12_000000_create_users_table
Migrated:  2014_10_12_000000_create_users_table (35.78ms)
Migrating: 2014_10_12_100000_create_password_resets_table
Migrated:  2014_10_12_100000_create_password_resets_table (25.64ms)
Migrating: 2019_08_19_000000_create_failed_jobs_table
Migrated:  2019_08_19_000000_create_failed_jobs_table (30.73ms)

This means our application can connect to the database, and our migrations have been run.

With the volume we attached, we should be able to restart all of the containers, and our data will stay persisted.

Onto Kubernetes

Now that we have docker-compose running locally, we can move forward onto building our images and pushing them to a registry.

Laravel in Kubernetes Part 3 - Container Registries
In this post, we will take our new Dockerfile and layers, and build the images, and push them up to a registry, so we can easily use them in Kubernetes. TLDR - Laravel In Kubernetes Part 3Laravel In Kubernetes Part 3. GitHub Gist: instantly share code, notes, and snippets.Gist262588213843476Table