The Ultimate Guide to Containerize PHP

craftech_docker_php_guide_post

Even though it is earning more and more detractors and competitors, PHP resists death. According to W3 stats, almost 78,1% of websites use PHP as server language.

Thus, as DevOps, we will most likely find ourselves “dockerizing” an app in this language at one point or another.

In this entry we will explore how PHP works and the necessary steps to implement a microservice architecture. We are taking as a example a simple app using Laravel framework.

We will start with an overview of the architecture we want to dockerize, followed by a review of the folder structure of a Laravel project. Finally, we will set up a dockerfile and dockercompose to appreciate the results.

Pre-requisites

Learning by doing is best. So in the case you are not setting up a PHP dockerization, it is recommended to install the following resources while reading this post:

#Docker

#Docker compose

#Dive

#Application code

Structure

Before getting to know our project’s own characteristics, it is important to understand the standard structure of Laravel projects.

In order to dockerize our app it is not necessary at the moment to get into details, so I will simply mention the directories and files we will use the most.

containerize PHP
  • app: Directory where the source code of the app is found
  • bootstrap: Here are the bootstraps scripts, which start running as soon as the app starts preparing the environment for its execution
  • config: configuration parameters and files
  • databases: In this folder we will find the necessary files for the correct functioning of the database. It will be important to keep it in mind due to two of its subdirectories:
    • Seeds: contain files that will be used to fill out the databases.
    • Migrations: contain the necessary files to generate migrations. The seeds, as well as the migrations, will be frequently active during developing stages.
  • public: Its contents include an index.php file needed to start the app
  • resources: Our main focus for this folder is the views subdirectory, where the HTML that interact with the user can be found
  • vendor: This folder will be VITAL. Lavarel uses Composer as its dependencies admin. Each dependency installed with Composer will be saved in this directory. They play a role when we use the multi stage builds.

ENVIRONMENT VARIABLES

The apps develop specific configurations starting off of environment variables held in an .env file

Due to the fact that they may contain sensitive information and in order to avoid issues in the configuration between devices, this file must be included in the .gitignore. Each developer must have a .env.example file with default values. This is the file that is generally shared in the repositories.

A .env file can have different variables depending on the components used, in this blogpost we will particularly work with the following fields:

APP_ENV = 
APP_DEBUG = 
APP_KEY = 
APP_URL = 
#Parametros para conexion con base de datos MySQL
DB_CONNECTION = 
DB_HOST = 
DB_PORT = 
DB_DATABASE = 
DB_USERNAME = 
DB_PASSWORD = 
#Parametros para conexion con Redis
REDIS_HOST = 
REDIS_PASSWORD = 
REDIS_PORT = 

As we continue to move on with the dockerization, the corresponding values will be completed.

Architecture

As we dockerize, it is fundamental to define the microservices and how they will link with each other.

PHP Containers

Considerations:

  • Each Laravel application will be served by a proper NginX instance as server, linked through an internal-network-app-x
  • The database and Redis cache are shared by both applications
  • There is a project-external-network to which all services will be able to connect
  • Laravel containers expose port 9000, which is the default port of PHP-FPM
  • NginX instances map the HTTP 80 port to 808x. We map to relatively high port numbers to avoid reserved ports

About PHP, servers and CGI

Before moving on, I will use of this space to clarify some frequently confusing topics.

  • PHP is a general purpose language focused on scripting alongside the server. .php files are not static, they can’t serve without being previously processed. Because of this, the web server will send a request to a PHP analyzer for this task
  • CGI is the protocol that standardizes the content that will be sent to the analyzer to process the request. It is essentially a protocol to communicate web servers with software
  • FastCGI is a CGI implementation that improves performance avoiding unnecessary repetition of tasks
  • PHP-FPM (FastCGI Process Manager) is the official PHP accepted CGI program, which analyzes the requests, evokes the PHP interpreter and relays an answer back

Since servers (NginX, Apache, etc) cannot execute PHP code natively, it receives HTTP requests that then go to a PHP-FPM to be executed and finally conveys the result.

Why do we use NginX?Doesn’t Artisan provide its own server?

Those who have toyed around a little bit with PHP code may have used Artisan:serve as server. However, it is only a development server and it is not recommended in a productive environment. It is useful for minimum traffic and doesn’t provide config options other than host and port. If needing to redirect or access head configs, artisan:serve won’t make it possible.

Yet another issue the integrated server might present is that the same microservice would receive and process HTTP requests, interpreting PHP code and delivering an answer. It is not a particularly efficient structure.

These points, and the fact that we aim at creating a project that’s easy to carry over to a productive environment, we choose to make a distinction between server and application.

So…what about Apache?

We are actually able to employ any other server for the project. However, NginX is an amazing PHP complement.

NginX is a high performance, low memory-consuming, easily configured server, which is why the author favors it over many others in this post.

Either way, the microservice structure allow us to swap any server of our preference for NginX if we were to choose to.

Let’s Dockerize!

Having stated the previous, let’s begin with the interesting part: dockerizing the app.

Dockerization will use a multi stage build. During the first stage, dependency packages will be generated, and in the second, we will copy the result of the former and install other components.

Stage 1: Composer

Composer makes use of the composer.lock file as a guideline on which packages to install. Packages are installed on the vendor folder, which is the one we should copy on the next stage.

The composer version will depend on our application’s PHP version. In this particular case, it is composer 2.1.5.

To begin with, just the composer.json file is needed, but other files may be required for different dependencies. To continue with our example, we should also copy the database directory since a classmap: database is defined on the composer.json file.

Then, composer installation is executed.

Composer.lock vs composer.json: The presence of both these files may generate confusion. To consider: composer.json is the file that composer will read to install the dependencies. It acts as a “general” guide, wildcards may be included when the time comes to identify versions of the packages. So, this file indicates which packages MUST be installed, not those that WERE actually installed. On the other side, composer.lock is generated by composer after install execution. This file clarifies which packages have been installed, as well as its versions. It may or not be included on the dockerfile, but we recommend to include it to guarantee the versions of the installed dependencies.

FROM composer:2.1.5 as vendor

WORKDIR /builder

# In the .jason composer there is a "classmap: database". So, copy data base folder:
COPY database/ database/
COPY composer.json ./

# Install dependencies definied in composer.json
# --no-interaction: It doesn't suposse interaction with the user
# --no-dev: It ignores the requiere-dev defined packages installment.
RUN composer install \
    --ignore-platform-reqs \
    --no-interaction \
    --no-plugins \
    --no-scripts \
    --prefer-dist

Mistakes in this stage may arise because of the installation options required by the app. Here there are some general options, but those may change according to the case. Admitted options by composer can be found in this link.

Stage 2: Laravel

Using a PHP image with alpine linux, we will install the necessary dependencies and copy the packages installed by composer in the previous stage. We will also copy a php config file (if available) and the app codes themselves.

FROM php:7.2-fpm-alpine3.12

WORKDIR /var/www

# Copy composer dependences installed in the previous stage
COPY --from=vendor /builder/vendor/ ./vendor/
# Install the system's dependencies.It depends on the apps'type
# We will install just the necessary for the blog case and to connect to MySQL.
# --no-cache: Once the installment is over, APK deletes unnecessary cache.
RUN apk update && apk add --no-cache \
    libpng \
    libpng-dev \
    libzip-dev && \
    docker-php-ext-install pdo_mysql exif pcntl bcmath gd zip 

# If there is a PHP config file in the directory,
# docker-config/php/config, copy it.
COPY docker-config/php/config/ $PHP_INI_DIR/conf.d/
# Copy the app code
COPY . .

docker-php-ext-install: script that simplifies PHP extensions installation. More information here

At this point, we have to configure clearances correctly. We will create a new user, that will possess the necessary files to uplift the service.

# Create system's user
RUN adduser --disabled-password -u 1000 -h /home/php php \
    && addgroup php www-data \
    && addgroup php root


RUN chown -R php:www-data public/ storage/ bootstrap/cache/ && \
    chmod -R 0777  storage/ bootstrap/cache/ public/

Alternative

Applications may have many files and the image’s size may increase considerably if we copy in different layers or modify licenses. In this case, we can copy and modify the clearances in a single instruction (generating a single layer). This can be achieved with the flag —chown in the instruction COPY.

The following portion should be replaced:

COPY . .

RUN chown -R php:www-data public/ storage/ bootstrap/cache/ && \
    chmod -R 0777  storage/ bootstrap/cache/ public/

In the first case, we modified clearances for a specific set of files, in the second, to EVERY copied file.


Lastly, port 9000 is exposed and the php-fpm command is executed as soon as the container is lifted.

# Expose the defult PHP-FPM port, for the sake of documentation 
EXPOSE 9000

# Start the PHP-FPM process. https://linux.die.net/man/8/php-fpm
# -F: forces to stay in the forefront ignoring the background option
# if it were present in the config file
#  
CMD ["php-fpm", "-F", "-R"]

Docker compose

Having built the image, it’s high time to test it. We should create the project’s docker-compose.

To make it simple, let’s first connect the application and NginX.

services:
  example-api-1:
    image: "example-api-1:latest"
    build:
      context: ./
    container_name: example-api-1
    command: sh -c "php-fpm -F -R"
    env_file:
      - .env
    networks:
      - example-api-1-network

nginx-example-api-1:
    image: nginx:1.21.1-alpine
    container_name: nginx-example-api-1
    restart: unless-stopped
    ports:
      - "8080:80"
    networks:
      - example-api-1-network
    depends_on:
      - example-api-1

To build the project:

docker-compose up --build

Then, we go to the following site localhost:8080 to find the Laravel’s welcome message.

Awesome! At this point we are able to make specific adjustments to our app and connect the databases.

Previously, we had glossed over the .env file. It is now when we begin to complete it.

We will first fill in the Laravel- specific spaces:

  • APP_ENV: Used to indicate the environment we are in, so as to activate certain configurations. In the app code you can ask for the variable’s value to quickly configure the environment of our app. We generally use local to indicate the development environment, and production, for the productive one
  • APP_DEBUG: It controls if depuration information will be shown. It is related to the APP_ENV variable. If it is a development environment, APP_DEBUG may be true, while in a production environment it MUST be false
  • APP_KEY: It is a 32-character chain used to encrypt and decrypt sent and received cookies from the client’s browser. This security feature prevent users from personifying other user. If we don’t have this variable’s value, we can obtain it from our app’s container:
docker exec -it example-api-1 sh

Once there, we execute:

php artisan key:generate
  • APP_URL: It defines the URL of our app. It is a good practice to check its corresponding value, since some external packages read this variable during configuration. If, for example, we intend to deploy this app in a kubernetes’ cluster, this is the needed value: localhost:9000

The very first part of our .env file, should look like this:

APP_ENV = local
APP_DEBUG = true
APP_KEY = # Upload the value on key:generate
APP_URL = localhost:9000

To be sure everything is alright, it is a good stage to rebuild the image again and start the app up.

BD Connection

If we recall the architecture diagram, BD connection is made through an external network shared with all the services. So it is necessary to create the network first.

We have to execute the docker command, then:

docker network create example-external-network 

Once this network is created, we have to add it to the networks’ list in the docker-compose.yaml file, and set each service to connect to it.

services:
  example-api-1:
    image: "example-api-1:latest"
    build:
      context: ./
    container_name: example-api-1
    command: sh -c "php-fpm -F -R"
    env_file:
      - .env
    networks:
      - example-api-1-network
      - example-external-network

.....
.....
.....

networks:
  example-api-1-network:
    name: example-api-1-network
    driver: bridge
  example-external-network:
    external: true

We are able now to connect with a MySWL instance.

First, edit the docker-compose.yaml to build the service:

db-mysql:
    image: mysql:5.7
    container_name: db-example-api
    restart: always
    environment:
      - MYSQL_ROOT_PASSWORD=homestead
      - MYSQL_DATABASE=homestead
      - MYSQL_USER=homestead
      - MYSQL_PASSWORD=homestead
    ports:
      - "13306:3306"
    volumes:
      - "../mysql-volume:/var/lib/mysql"
    networks:
      - project-external-network

We take the credentials and the BD name as environmental variables. Those values should match with the corresponding fields in the .env variable file. Then, complete:

DB_CONNECTION=mysql
DB_HOST=db-mysql
DB_PORT=3306
DB_DATABASE=homestead
DB_USERNAME=homestead
DB_PASSWORD=homestead

In the same file, modify the command executed by example-api-1 to run migrations in the database. This way, we confirm the connection is correct.

We also have to specify the database service as a dependency of our API service.

example-api-1:
		...
		...
    command: sh -c "sleep 10 && php artisan migrate --seed && php-fpm -F -R"
		...
		...
    depends_on:
      - db-mysql
    networks:
      - project-external-network
      - example-api-1-network

Migration command is executed every time the project is uplifted. If there are no migrations, artisan will notify us and omit it.

IMPORTANT: Running migrations every time the project is uplifted is NOT a good idea in a productive environment. We configure it this way now just because we are practicing in a development environment, to constantly test the BD connection. In production, the project should be adjusted to each particular case. For example, run migrations manually.

We have also added a 10-minute delay before running the migrations to give the BD service time to go completely online.

Connection with Redis

For this stage, we start by modifying the .env file:

REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

Then, the following section is added to the docker-compose.yml. Notice we are considering an empty password and have implemented a healthcheck:

example-api-1:
	...
	...
	depends_on:
      redis:
        condition: service_healthy

...
...
redis:
    image: redis:6.2.5-alpine3.14
    container_name: redis
    environment:
    - ALLOW_EMPTY_PASSWORD=yes
    volumes:
    - ./data/redis:/data
    ports:
    - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
    networks:
    - example-external-network
...
...

Finally, it is fundamental to install the PHP connector for Redis. We then modify our dockerfile:

...
...
RUN apk update && apk add --no-cache \
    libpng \
    libpng-dev \
    libzip-dev \
    libtool \
    build-base \
    make \
    autoconf && \
    pecl install redis \
    && docker-php-ext-enable redis && \
    docker-php-ext-install pdo_mysql exif pcntl bcmath gd zip
...
...

Ready! We now have a Redis instance.

Consider the fact that installing a php redis connector does NOT provide a redis CLI in the php container. To test the connection, it will be necessary to do it at a code level.

Diving Into the Results

I wouldn’t want to end this blog entry without mentioning dive. It is a very useful tool when it comes to dockerizing. It allow us to analyze image changes layer by layer, helping us to visualize which layers are responsible for incrementing the space used by the image. It also offers an “image efficency” score, based on metric that considers potentially wasted space based off of files that have been modified on more than one layer.

Simply share the name of the image we want to analyze.

dive example-api-1

Execution of dive. It is noticiable that unnecessary files have been copied such as dockerfile, git files, etc.

dockerignore

It is recommended to include a .dockerignore file to avoid sending the daemon unnecessary files to build the image. Its process and syntaxis are very similiar to a .gitignore one. We simply list the files and folders we want to ignore. In this particular case, we may include the node_modules directory, folders with BD tests, etc.

.env.example
/.git
.gitignore
.gitattributes
Dockerfile
docker-compose.yaml
/docker-config

Conclusion

Throughout this entry, we have reviewed the structure of a typical Laravel project, being this the first necessary step to dockerize: get to know the project. Then we have posit a micro services architechture to use on a productive environemnt. We have also created a docker-compose to help us with the tests and finally, we built a dockerfile using multistage build.

Even tough the article is called the ultimate guide, truth is that every project is different and changes should be done to adapt to each context. The aim is not to provide a template to copy-paste blindly, but to gather experiences and share them so that you can dockerize in an enjoyable way next time.

If you ever played WarCraft, this blog entry would be a manual that boosts your Devops status +1.


Final Docker Files

Dockerfile:

# ============================================================
#                   PHP Dependencies builder
# ============================================================

FROM composer:2.1.5 as vendor

WORKDIR /builder

# In the composer.json there is a"classmap: database". So we copy:
COPY database/ database/
COPY ["composer.json","composer.lock","./"]

# composer.json dependencies are installed 
# --no-interaction: It doesn't suppose user interaction 
# --no-dev: Ignores the installment of require-dev defined packages require-dev
#RUN composer install
RUN composer install \
    --ignore-platform-reqs \
    --no-interaction \
    --no-plugins \
    --no-scripts \
    --prefer-dist
# ============================================================
#                          Application
# ============================================================

# The app uses php 7.2

FROM php:7.2-fpm-alpine3.12

WORKDIR /var/www

# It copies composer dependencies installed in the previous stage
COPY --from=vendor /builder/vendor/ ./vendor/
	# Install the system's dependencies. It depends on the kinf of app. 
# Here we will install just the necessary for the sake of the blog's case and to connect
# to MySQL.
# --no-cache: Once the installment is ready, APK eliminates the unnecessary cache.
RUN apk update && apk add --no-cache \
    libpng \
    libpng-dev \
    libzip-dev \
    libtool \
    build-base \
    make \
    autoconf && \
    pecl install redis \
    && docker-php-ext-enable redis && \
    docker-php-ext-install pdo_mysql exif pcntl bcmath gd zip 

RUN pecl channel-update pecl.php.net

# && \
#    pecl bundle redis && cd redis && phpize && \
#    ./configure --enable-redis-igbinary && make && make install && \
#    docker-php-ext-enable igbinary redis

# If there is a PHP config file,copy it.
COPY docker-config/php/config/ $PHP_INI_DIR/conf.d/


# Create system's user
RUN adduser --disabled-password -u 1000 -h /home/php php \
    && addgroup php www-data \
    && addgroup php root


# Copy app and change the owner with the same command. It avoids modifying the same files
# in two different layers
#COPY --chown=php:www-data . .

RUN chown -R php:www-data public/ storage/ bootstrap/cache/ && \
    chmod -R 0777  storage/ bootstrap/cache/ public/

# Expose the default PHP-FMP port, for the sake of documentation.
EXPOSE 9000

# Start the PHP-FPM process. https://linux.die.net/man/8/php-fpm
# -F: It forces to stay in the forefront, ignoring the background option if
# the config file were present. 
#  
CMD ["php-fpm", "-F", "-R"]

docker-compose.yaml:

version: "3.7"

services:
  example-api-1:
    image: "example-api-1:latest"
    build:
      context: ./
    container_name: example-api-1
    command: sh -c "php-fpm -F -R"
    env_file:
      - .env
    networks:
      - example-api-1-network
      - example-external-network
    depends_on:
      redis:
        condition: service_healthy
      db-mysql:
         condition: "service_healthy"

  nginx-example-api-1:
    image: nginx:1.21.1-alpine
    container_name: nginx-example-api-1
    restart: unless-stopped
    ports:
      - "8080:80"
      - "9000:9000"
    volumes:
      - "./:/var/www"
      - "./docker-config/nginx/local:/etc/nginx/conf.d/"
    networks:
      - example-api-1-network
    depends_on:
      - example-api-1


  db-mysql:
    image: mysql:5.7
    container_name: db-example-api
    restart: always
    environment:
      - MYSQL_ROOT_PASSWORD=homestead
      - MYSQL_DATABASE=homestead
      - MYSQL_USER=homestead
      - MYSQL_PASSWORD=homestead
    ports:
      - "13306:3306"
    healthcheck:
        test: "/etc/init.d/mysql status -u root -p $$MYSQL_ROOT_PASSWORD"
        interval: 1s
        timeout: 10s
        retries: 5
        start_period: 10s
    #volumes: #Uncomment if data persistency is needed
    #  - "../mysql-volume:/var/lib/mysql"
    networks:
      - example-external-network



  redis:
    image: redis:6.2.5-alpine3.14
    container_name: redis
    environment:
    - ALLOW_EMPTY_PASSWORD=yes
    #volumes: #Uncomment if data persistency is needed
    #- ../data/redis:/data
    ports:
    - "6379:6379"
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
    networks:
    - example-external-network


networks:
  example-api-1-network:
    name: example-api-1-network
    driver: bridge
  example-external-network:
    external: true

Troubleshooting

Ideally, things work out fine. However, something may go wrong. That is why there is a list of some (not all) mistakes you may find frequently.

  1. “SQLSTATE[HY000] [1045] Access denied for user”: Docker compose creatres the volumen for the BD data. In our tests we may have created the volumen with worng data. Solution: eliminate the volume directory, review the BD config, and build the project again
  2. “SQLSTATE[HY000] [2002] Connection refused”: Verify if the mistake is made while the BD service is being built. If that is the case, try incrementing sleep time of PHP service
  3. RuntimeException The only supported ciphers are AES-128-CBC and AES-256-CBC with the correct key lengths: Review app key, execute from PHP container: php artisan key:generate, paste in .env and rebuild.
  4. https://github.com/docker-library/docs/tree/master/php#e-package-php-xxx-has-no-installation-candidate ←Read in case you use an image based on Debian instead of Alpine
  5. https://github.com/docker-library/php/issues/1134 ← Error version 3.13 Alpine. In my experience, my local didn’t report the mistake, but once included in a CI/CD pipeline the mistake appeared

References:

What is Craftech?

Craftech is a reliable, nimble and experienced infrastructure development group. Our team only consists of top level engineers, designers and managers. Craftech relies on the use of the latest accelerated development methodologies, tools, technologies and processes to deliver excellence in our solutions, communication and client experiences.

If you have any questions/problems here, please feel free to join us on Craftech’s community Slack and ask around.

Leave a Reply

Your email address will not be published. Required fields are marked *

Let's talk

Interested in working with us? Fill out the form below, and we'll get in touch with you shortly. Let's bring your project to life!