loading...

Laravel with PHP7.4 in an Alpine Container

jackmiras profile image Jack Miras Originally published at dev.to Updated on ・12 min read

When deploying a Laravel application, the goal is to make sure that the deployment process is as fast and secure as possible. A big part of achieving this goal is choosing the right base Linux image to compose the container image where the application will be running and later deployed.

Alpine Linux has shown that there is no faster distro when working with a container for any language. Since Docker's first release, the popularity of the Alpine distro has grown and keeps growing because it is a tiny, container, and security-focused distro.

To be able to run an application just PHP and Composer isn't enough NGINX and Supervisor it's also required, and here is where a little bit of complexity comes in. But don't worry, the Dockerfile it's going to be dissected, and you will get to understand why things are the way they are.

Down below, there is an entire Dockerfile used locally and in production to serve a Laravel application. Notice that it's not optimized to have a minimal amount of layers, and that is on purpose since we will grab small pieces of the file and understand what each part does.

FROM alpine:edge

WORKDIR /var/www/html/

# Essentials
RUN echo "UTC" > /etc/timezone
RUN apk add --no-cache zip unzip curl sqlite nginx supervisor

# Installing bash
RUN apk add bash
RUN sed -i 's/bin\/ash/bin\/bash/g' /etc/passwd

# Installing PHP
RUN apk add --no-cache php \
    php-common \
    php-fpm \
    php-pdo \
    php-opcache \
    php-zip \
    php-phar \
    php-iconv \
    php-cli \
    php-curl \
    php-openssl \
    php-mbstring \
    php-tokenizer \
    php-fileinfo \
    php-json \
    php-xml \
    php-xmlwriter \
    php-simplexml \
    php-dom \
    php-pdo_mysql \
    php-pdo_sqlite \
    php-tokenizer \
    php7-pecl-redis

# Installing composer
RUN curl -sS https://getcomposer.org/installer -o composer-setup.php
RUN php composer-setup.php --install-dir=/usr/local/bin --filename=composer
RUN rm -rf composer-setup.php

# Configure supervisor
RUN mkdir -p /etc/supervisor.d/
COPY .docker/supervisord.ini /etc/supervisor.d/supervisord.ini

# Configure php-fpm
RUN mkdir -p /run/php/
RUN touch /run/php/php7.4-fpm.pid
RUN touch /run/php/php7.4-fpm.sock

COPY .docker/php-fpm.conf /etc/php7/php-fpm.conf

# Configure nginx
RUN echo "daemon off;" >> /etc/nginx/nginx.conf
COPY .docker/default.conf /etc/nginx/conf.d/default.conf
COPY .docker/fastcgi-php.conf /etc/nginx/fastcgi-php.conf

RUN mkdir -p /run/nginx/
RUN touch /run/nginx/nginx.pid

RUN ln -sf /dev/stdout /var/log/nginx/access.log
RUN ln -sf /dev/stderr /var/log/nginx/error.log

# Build process
# COPY . .
# RUN composer install --no-dev

# Container execution
EXPOSE 80
CMD ["supervisord", "-c", "/etc/supervisor.d/supervisord.ini"]
Enter fullscreen mode Exit fullscreen mode

Defining image bases

The first step towards the construction of a Dockerfile is to create the file itself and define a Linux distribution and its version. Once that is done, you can start composing your Dockerfile with the instructions needed to build your container image.

FROM alpine:edge

WORKDIR /var/www/html/
Enter fullscreen mode Exit fullscreen mode

The FROM instruction sets the Base Image for subsequent instructions. Notice that alpine:edge is defined, which sets alpine as the base Linux image. After the distro name, there is a : used to specify a tag or version, so when the instruction FROM alpine:edge gets interpreted, it will set alpine at the edge version as the base image.

While the WORKDIR instruction sets the working directory for any RUN, CMD, ENTRYPOINT, COPY and ADD instructions that follow it in the Dockerfile. So when the instruction WORKDIR /var/www/html/ is interpreted, every command execution in the Dockerfile will take place into /var/www/html/.

The edge tag is generally used when you want to use a non-stable version of this distro. At the time of writing, this was the only version of Alpine where PHP7.4 was available out of the box in the distro package manager repository.

Software installation

Now that the container image base is defined, it's time to start looking into the software that we need to install to run the application. As mentioned, PHP, Composer, NGINX, and Supervisor are softwares to install, but that's not all. As this software has dependencies, they also have to be installed. Here is the installation process broken into explained pieces so you can understand it.

Install essentials

RUN echo "UTC" > /etc/timezone
RUN apk add --no-cache zip unzip curl sqlite nginx supervisor
Enter fullscreen mode Exit fullscreen mode

The first RUN instruction will execute any commands in a new layer on top of the current image and commit the results. Hence when RUN echo "UTC" > /etc/timezone is interpreted, the echo command will print out the UTC string into /etc/timezone file. As a result of the command execution, UTC becomes the standard timezone.

In the second RUN instruction, an apk command appears, apk is Alpine package manager, another well-known package manager is apt from Ubuntu. With that said, when RUN apk add --no-cache zip unzip curl sqlite nginx supervisor is processed and installs those softwares in the base image.

Install bash

RUN apk add bash
RUN sed -i 's/bin\/ash/bin\/bash/g' /etc/passwd
Enter fullscreen mode Exit fullscreen mode

The first RUN instruction tells that bash has to be installed. The second instruction sets it as a standard shell by replacing the string /bin/ash by /bin/bash into the /etc/passwd file. This change is because Alpine standard shell ash works differently, and these differences can get in your way when you or your team need to execute some shell script in the container.

Install PHP

RUN apk add --no-cache php \
    php-common \
    php-fpm \
    php-pdo \
    php-opcache \
    php-zip \
    php-phar \
    php-iconv \
    php-cli \
    php-curl \
    php-openssl \
    php-mbstring \
    php-tokenizer \
    php-fileinfo \
    php-json \
    php-xml \
    php-xmlwriter \
    php-simplexml \
    php-dom \
    php-pdo_mysql \
    php-pdo_sqlite \
    php-tokenizer \
    php7-pecl-redis
Enter fullscreen mode Exit fullscreen mode

In this RUN instruction, it tells that PHP and all listed extensions have to get installed. As mentioned before, this Dockerfile is used to serve Laravel application, so the PHP extensions are arbitrary and may change depending o the framework or application you are trying to run.

Lastly, you can find what each PHP extension does by checking PHP extensions documentation and the PHP extension community library PECL pages and search for them.

Install Composer

RUN curl -sS https://getcomposer.org/installer -o composer-setup.php
RUN php composer-setup.php --install-dir=/usr/local/bin --filename=composer
RUN rm -rf composer-setup.php
Enter fullscreen mode Exit fullscreen mode

In this RUN instruction composer binary, composer-setup.php gets downloaded from the composer's official page. Then in the second instruction, the binary is being used to install composer into /usr/local/bin directory. Lastly, the binary gets removed after composer installation since his binary has no use to the system any longer.

Software configuration

Now that all needed software it's installed, they have to be configured and tight together to make the serving of a Laravel application work as expected.

Configure supervisor

RUN mkdir -p /etc/supervisor.d/
COPY .docker/supervisord.ini /etc/supervisor.d/supervisord.ini
Enter fullscreen mode Exit fullscreen mode

In this RUN instruction, the Dockerfile is specifying that the directory supervisor.d has to be created inside the /etc/ directory. This directory will hold initializer files that specify sets of instructions that the Supervisor will run upon when the OS starts, in this case when the container starts, since these two events can not happen without each other.

In the second RUN instruction, the supervisord.ini file gets copied from a local .docker folder into /etc/supervisor.d/ container folder. As mentioned above, this file contains the instructions that Supervisor will run upon, and these instructions are:

[supervisord]
nodaemon=true

[program:nginx]
command=nginx
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

[program:php-fpm]
command=php-fpm7
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
Enter fullscreen mode Exit fullscreen mode

Explaining supervisor.ini

  • nodaemon=true

Start Supervisor in the foreground instead of daemonizing.

  • command=nginx

The command that will run when Supervisor starts.

  • stdout_logfile=/dev/stdout

Redirect all output to the Alpine standard output device that is the container itself, allowing us to see Supervisor logs about NGINX execution when running docker logs MY_CONTAINER or docker-compose up to start container stack.

  • stdout_logfile_maxbytes=0

The maximum number of bytes that can get consumed by stdout_logfile before it rotates, since files didn't get written, this has to get deactivated by setting maxbytes to 0.

  • stderr_logfile=/dev/stderr

Redirect all errors to the Alpine standard error device that is the container itself, allowing us to see Supervisor logs about NGINX execution when running docker logs MY_CONTAINER or docker-compose up to start container stack.

  • stderr_logfile_maxbytes=0

The maximum number of bytes that can get consumed by stderr_logfile before it rotates, since files didn't get written, this has to get deactivated by setting maxbytes to 0.

Configure PHP-FPM

RUN mkdir -p /run/php/
RUN touch /run/php/php7.4-fpm.pid
RUN touch /run/php/php7.4-fpm.sock

COPY .docker/php-fpm.conf /etc/php7/php-fpm.conf
Enter fullscreen mode Exit fullscreen mode

In the first RUN statement, the Dockerfile is specifying that the directory php has to get created inside the /run/ directory. This directory will hold .pid files that contain the process id specific to software.

The second statement, create the file php7.4-fpm.pid inside of /run/php/ directory. Now the Alpine distro has where to store the process id that will get created when PHP-FPM starts.

The third statement, create the file php7.4-fpm.sock inside of /run/php/ directory. Now when NGINX gets configured to connect into PHP-FPM as it's the FastCGI, a socket connection is possible.

The fourth statement copies a php-fpm.conf file from a local .docker folder into /etc/php7/ container folder. This file contains all the configurations that PHP-FPM will run upon, and here are the configurations:

;;;;;;;;;;;;;;;;;;;;
; FPM Configuration ;
;;;;;;;;;;;;;;;;;;;;;

; All relative paths in this configuration file are relative to PHP's install
; prefix (/usr). This prefix can be dynamically changed by using the
; '-p' argument from the command line.

;;;;;;;;;;;;;;;;;;
; Global Options ;
;;;;;;;;;;;;;;;;;;

[global]
; Pid file
; Note: the default prefix is /var
; Default Value: none
pid = /run/php/php7.4-fpm.pid

; Error log file
; If it's set to "syslog", log is sent to syslogd instead of being written
; in a local file.
; Note: the default prefix is /var
; Default Value: log/php-fpm.log
error_log = /proc/self/fd/2

; syslog_facility is used to specify what type of program is logging the
; message. This lets syslogd specify that messages from different facilities
; will be handled differently.
; See syslog(3) for possible values (ex daemon equiv LOG_DAEMON)
; Default Value: daemon
;syslog.facility = daemon

; syslog_ident is prepended to every message. If you have multiple FPM
; instances running on the same server, you can change the default value
; which must suit common needs.
; Default Value: php-fpm
;syslog.ident = php-fpm

; Log level
; Possible Values: alert, error, warning, notice, debug
; Default Value: notice
;log_level = notice

; If this number of child processes exit with SIGSEGV or SIGBUS within the time
; interval set by emergency_restart_interval then FPM will restart. A value
; of '0' means 'Off'.
; Default Value: 0
;emergency_restart_threshold = 0

; Interval of time used by emergency_restart_interval to determine when
; a graceful restart will be initiated.  This can be useful to work around
; accidental corruptions in an accelerator's shared memory.
; Available Units: s(econds), m(inutes), h(ours), or d(ays)
; Default Unit: seconds
; Default Value: 0
;emergency_restart_interval = 0

; Time limit for child processes to wait for a reaction on signals from master.
; Available units: s(econds), m(inutes), h(ours), or d(ays)
; Default Unit: seconds
; Default Value: 0
;process_control_timeout = 0

; The maximum number of processes FPM will fork. This has been design to control
; the global number of processes when using dynamic PM within a lot of pools.
; Use it with caution.
; Note: A value of 0 indicates no limit
; Default Value: 0
; process.max = 128

; Specify the nice(2) priority to apply to the master process (only if set)
; The value can vary from -19 (highest priority) to 20 (lower priority)
; Note: - It will only work if the FPM master process is launched as root
;       - The pool process will inherit the master process priority
;         unless it specified otherwise
; Default Value: no set
; process.priority = -19

; Send FPM to background. Set to 'no' to keep FPM in foreground for debugging.
; Default Value: yes
daemonize = no

; Set open file descriptor rlimit for the master process.
; Default Value: system defined value
;rlimit_files = 1024

; Set max core size rlimit for the master process.
; Possible Values: 'unlimited' or an integer greater or equal to 0
; Default Value: system defined value
;rlimit_core = 0

; Specify the event mechanism FPM will use. The following is available:
; - select     (any POSIX os)
; - poll       (any POSIX os)
; - epoll      (linux >= 2.5.44)
; - kqueue     (FreeBSD >= 4.1, OpenBSD >= 2.9, NetBSD >= 2.0)
; - /dev/poll  (Solaris >= 7)
; - port       (Solaris >= 10)
; Default Value: not set (auto detection)
;events.mechanism = epoll

; When FPM is build with systemd integration, specify the interval,
; in second, between health report notification to systemd.
; Set to 0 to disable.
; Available Units: s(econds), m(inutes), h(ours)
; Default Unit: seconds
; Default value: 10
;systemd_interval = 10

;;;;;;;;;;;;;;;;;;;;
; Pool Definitions ;
;;;;;;;;;;;;;;;;;;;;

; Multiple pools of child processes may be started with different listening
; ports and different management options.  The name of the pool will be
; used in logs and stats. There is no limitation on the number of pools which
; FPM can handle. Your system will tell you anyway :)

; Include one or more files. If glob(3) exists, it is used to include a bunch of
; files from a glob(3) pattern. This directive can be used everywhere in the
; file.
; Relative path can also be used. They will be prefixed by:
;  - the global prefix if it's been set (-p argument)
;  - /usr otherwise
include=/etc/php7/php-fpm.d/*.conf
Enter fullscreen mode Exit fullscreen mode

Notice that the php-fpm.conf don't have any custom configuration or optimization, feel free to configure this file according to your needs.

Configure NGINX

RUN echo "daemon off;" >> /etc/nginx/nginx.conf
COPY .docker/default.conf /etc/nginx/conf.d/default.conf
COPY .docker/fastcgi-php.conf /etc/nginx/fastcgi-php.conf

RUN mkdir -p /run/nginx/
RUN touch /run/nginx/nginx.pid

RUN ln -sf /dev/stdout /var/log/nginx/access.log
RUN ln -sf /dev/stderr /var/log/nginx/error.log
Enter fullscreen mode Exit fullscreen mode

In this first statement, the Dockerfile is instructing that daemon off; string have to get printed into /etc/nginx/nginx.conf file. This configuration tells NGINX to start in the foreground instead of daemonizing.

The second statement, copies a default.conf from a local .docker folder into /etc/nginx/conf.d/ container folder. This file contains all the configurations that NGINX will use to read your files and serve them correctly, and here you can see the configurations:

server {
    listen 80 default_server;

    root /var/www/html/public;

    index index.html index.htm index.php;

    server_name _;

    charset utf-8;

    location = /favicon.ico {
        log_not_found off; access_log off;
    }
    location = /robots.txt {
        log_not_found off; access_log off;
    }

    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }

    location ~ \.php$ {
        include fastcgi-php.conf;
        fastcgi_pass unix:/run/php/php7.4-fpm.sock;
    }

    error_page 404 /index.php;

    location ~ /\.ht {
        deny all;
    }
}
Enter fullscreen mode Exit fullscreen mode

The third statement, copies a fastcgi-php.conf from a local .docker folder into /etc/nginx/ container folder. This file contains all the FastCGI configurations that NGINX needs to read your files and correctly serve them, these configurations are:

# regex to split $uri to $fastcgi_script_name and $fastcgi_path
fastcgi_split_path_info ^(.+?\.php)(/.*)$;

# Check that the PHP script exists before passing it
try_files $fastcgi_script_name =404;

# Bypass the fact that try_files resets $fastcgi_path_info
# see: http://trac.nginx.org/nginx/ticket/321
set $path_info $fastcgi_path_info;
fastcgi_param PATH_INFO $path_info;

fastcgi_index index.php;
include fastcgi.conf;
Enter fullscreen mode Exit fullscreen mode

The fourth statement specifies that the directory nginx has to get created inside the /run/ directory. As mentioned in the PHP-FPM configuration session, the run directory holds .pid files where the process id to a specific software gets written.

In the fifth statement, create the file nginx.pid inside of /run/nginx/ directory. Now the Alpine distro has where to store the process id that will get created when NGINX starts.

The sixth statement instructs that a symbolic link of the Alpine standard output has to gets created at /var/log/nginx/access.log. This configuration, as mentioned in the Supervisor sections, is what allows us to see NGINX logs from containers.

Lastly, the seventh statement instructs that a symbolic link of the Alpine standard error gets created at /var/log/nginx/error.log. This configuration, as mentioned in the Supervisor sections, is what allows us to see NGINX errors from containers.

Build process

The build process is where the application gets copied into the container, and its dependencies get installed, leaving the Laravel application ready to be ready and served by NGINX, PHP-FPM, and Supervisor.

COPY . .
RUN composer install --no-dev
Enter fullscreen mode Exit fullscreen mode

At the COPY statement, all Laravel files and folders from the directory where the Dockerfile is, are copied into the working directory specified at the WORKDIR instruction.

At the RUN statement, production dependencies from the Laravel application get installed, making the application ready to be served by Supervisor, NGINX, and PHP-FPM.

Container execution

Now that everything is installed and properly configured, we need to tell how this container image will start serving the application once the container starts and what TCP port to use.

EXPOSE 80
CMD ["supervisord", "-c", "/etc/supervisor.d/supervisord.ini"]
Enter fullscreen mode Exit fullscreen mode

The EXPOSE instruction informs that the container listens on the specified network ports at runtime, while the purpose of the CMD instruction is to provide a default command for an executing Docker container.


Now your Dockerfile is finally done, and you can build a container from it by executing docker build -t laravel-alpine:latest . --no-cache in your terminal.

Happy coding!

Discussion

pic
Editor guide
Collapse
n3m3s7s profile image
Fabio Politi

Very useful and detailed, thanks!
One question: if both nginx and php-fpm are in the same container, should not be faster to use a socket instead of TCP?

Have you ever tried with swoole, in order to drop nginx entirely?

Thanks and keep up

Collapse
jackmiras profile image
Jack Miras Author

Hey Fabio,

You are right about the socket, I don't know what happened I was reviewing early versions of my Dockerfile for this article and it was a socket connection that later was replaced by a TCP connection, I'm gonna update the article.

About swoole, I've never tried, and being completely honest I've never had heard about it until now. But I took a quick look into the documentation and under the HTTP Server section they mention the use of NGINX.

I guess you can not use NGINX, even with Laravel you can avoid the usage of NGINX by just doing a php artisan serve and the CMD of the container but you will lose the ability to do some fine tunings about request handling that NGINX provides.

I don't discourage anyone to try to remove NGINX completely this is just me sharing the way I do things in production. I don't know other people's context so I'm not gonna say much more than there are fine-tunings that you can do in NGINX that may improve your app performance.

Collapse
n3m3s7s profile image
Fabio Politi

Thanks for having the time to answering me :)

Yes I was pointed both topics out because I guess that using "micro" distro such as Alpine it is almost mandatory if You have to deploy containers in a serverless/managed/whatever context, when the size of the artifacts (builds, images, registries, etc.) is very important as long as "internal" optimizations.

IMHO, at least in my experience, the setup and tuning of these containers is quite different between local development, production with all features that Laravel brings so well and production for services or "microservices", especially if You have to deploy them, for example, in Google Cloud Run or similar;

Swoole itself contains a full HTTP(S)/UDP/SOCKET server (cfr: swoole.co.uk/docs/modules/swoole-h...) with async support (and many other features);
as You can see (and I tell this from a PHP/Nginx/Laravel true lover) configure a proper "env" for PHP and all the dependencies required by Laravel is not so "simple and clean", if we compare to other solutions such as Node, Python and Golang (especially for services they do not require a "full" HTTP server);

I think Nginx is just another "dependency" to install, maintain and configure "properly" but I guess it is mandatory if You have to serve static files or other stuff related to a full powerfull HTTP server;

Swoole has nothing to do with "php artisan serve" (which is very slow and should never be used in production) so the "best fit" is for "services", and so should be the use for "Alpine" and in general "micro" distros;

quoting the man page:

"Compare with PHP-FPM, the default Golang HTTP server, the default Node.js HTTP server, Swoole HTTP server performs much better. It has the similar performance compare with the Nginx static files server."

that - at least for me - is very exciting and with the upcoming release of PHP8 and its JIT compiler I think that is actually possibile to write great applications and/or services with Docker/PHP/Laravel/Lumen, even if "PHP haters" are not so convinced :D

Thanks