osteel's blog Web development resources

Docker for local web development, part 2: put your images on a diet

Been here before?

You can also subscribe to the RSS or Atom feed, or follow me on Twitter.

Docker Alpine

In this series

In this post

Getting started

The assumed starting point of this tutorial is where we left things at the end of the previous part, corresponding to the part-1 branch of the repository.

If you prefer, you can also directly checkout part-2, which is the final result of today's article.

"I'm not fat, I'm big boned"

In part 1 of this series, we went through the steps of creating a simple but functional LEMP stack running on Docker and orchestrated by Docker Compose, resulting in four containers running simultaneously.

These containers are based on images that were downloaded from Docker Hub, each of these images having a different weight. But how much space are we talking about?

Let's find out with this simple command, to be run from the root of our project:

$ docker compose images

It will display a table containing the images used by the application and some information about them, including their weight:

Docker regular images

The total amounts to roughly 1.5 GB, which is not light. Why is that?

Most Linux distributions come with many services that are expected to cover common use cases; they offer a large amount of programs, intended to address a broad audience whose needs may evolve over time. On the other hand, Docker containers are supposed to run a single process, meaning what they need to perform their jobs usually doesn't amount to much, and is unlikely to change over time.

By using standard Linux distributions, we embark a lot of tools and services we don't always need, unnecessarily increasing the size of the images in the process. In turn, this has an impact on performance, security and, sometimes, the cost of deployment.

Is there anything we can do about it?

Alpine Linux

Alpine is a Linux distribution that takes the opposite approach: focused on security and with a small footprint, it features the bare minimum by default and lets you install what you actually need for your application. The dockerised version of Alpine is as small as 4 MB, and most official Docker images provide a version based on this distribution.

Before we modify our setup, let's get rid of the current one:

$ docker compose down -v --rmi all --remove-orphans

This command will stop and/or destroy the containers, as well as remove the volumes and images, allowing us to start afresh.

Replace the content of docker-compose.yml with this one (changes have been highlighted in bold):

version: '3.8'

# Services
services:

  # Nginx Service
  nginx:
    image: nginx:1.21-alpine
    ports:
      - 80:80
    volumes:
      - ./src:/var/www/php
      - ./.docker/nginx/conf.d:/etc/nginx/conf.d
      - phpmyadmindata:/var/www/phpmyadmin
    depends_on:
      - php
      - phpmyadmin

  # PHP Service
  php:
    build: ./.docker/php
    working_dir: /var/www/php
    volumes:
      - ./src:/var/www/php
    depends_on:
      mysql:
        condition: service_healthy

  # MySQL Service
  mysql:
    image: mysql/mysql-server:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_ROOT_HOST: "%"
      MYSQL_DATABASE: demo
    volumes:
      - ./.docker/mysql/my.cnf:/etc/mysql/my.cnf
      - mysqldata:/var/lib/mysql
    healthcheck:
      test: mysqladmin ping -h 127.0.0.1 -u root --password=$$MYSQL_ROOT_PASSWORD
      interval: 5s
      retries: 10

  # PhpMyAdmin Service
  phpmyadmin:
    image: phpmyadmin/phpmyadmin:5-fpm-alpine
    environment:
      PMA_HOST: mysql
    volumes:
      - phpmyadmindata:/var/www/html
    depends_on:
      mysql:
        condition: service_healthy

# Volumes
volumes:

  mysqldata:

  phpmyadmindata:

Let's break this down. On the Nginx side, we simply appended -alpine to the image tag, to pull the Alpine-based version (remember that the available versions of an image are listed on Docker Hub). We also mounted a new named volume phpmyadmindata (declared at the bottom of the file) and used depends_on to indicate that the phpMyAdmin container should be started first.

The reason is that Nginx will now be serving phpMyAdmin as well as our PHP application, where previously the phpMyAdmin image featured its own HTTP server (Apache). As its name suggests, the 5-fpm-alpine tag is the Alpine-based version of the image, whose container runs PHP-FPM as a process and expects PHP files to be handled by an external HTTP server.

Where to find help? Using an external HTTP server for phpMyAdmin is actually not documented, and I had to dig up some GitHub issue to put me on the right track. This is a good example of where to find help whenever official documentations fall short: browsing GitHub issues is usually a good place to start, as someone is likely to have stumbled upon the same problem before. I also sometimes find it helpful to have a look at the image's Dockerfile, as it is easier to use an image once we understand how it is built.

As a result, we need an Nginx configuration for phpMyAdmin. Let's create a new phpmyadmin.conf file in .docker/nginx/conf.d, alongside php.conf:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
server {
    listen      80;
    listen      [::]:80;
    server_name phpmyadmin.test;
    root        /var/www/phpmyadmin;
    index       index.php;

    location ~* \.php$ {
        fastcgi_pass   phpmyadmin:9000;
        root           /var/www/html;
        include        fastcgi_params;
        fastcgi_param  SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param  SCRIPT_NAME     $fastcgi_script_name;
    }
}

Again, a pretty standard server configuration that looks a lot like php.conf, except that it points to port 9000 of the phpMyAdmin service.

You also need to update your local hosts file to add the new domain name (have a quick look here if you've forgotten how to do that):

127.0.0.1 php.test phpmyadmin.test

Back to docker-compose.yml: similar to our PHP application, the phpmyadmindata volume ensures the phpMyAdmin files are available to Nginx, the only difference being that instead of mounting a local folder of our choice (e.g. src), we let Docker Compose pick a local folder to mount both onto the Nginx and phpMyAdmin containers, effectively making the latter's content available to the former.

Finally, we also got rid of the mapping of port 8080, since we will now be using Nginx's port 80 directly.

Next in line is PHP. If you followed the previous part, you already know that we use a Dockerfile located in .docker/php to describe and build our image.

Replace its content with this one:

1
2
3
FROM php:8.1-fpm-alpine

RUN docker-php-ext-install pdo_mysql

Just like Nginx, the only difference is we appended -alpine at the end of the image tag to get the Alpine-based version instead.

That leaves us with the MySQL service, which hasn't changed at all. The reason is that at the time of writing, there is simply no available Alpine version for the MySQL image, for reasons laid out in this GitHub issue.

We are now ready to test out our new setup. Run the now familiar docker compose up -d again, followed by docker compose images:

Docker Alpine images

The total size of our images now amounts to around 700 MB, which is less than half the initial weight.

And, as a bonus, you can now access phpMyAdmin at phpmyadmin.test, instead of localhost:8080.

Not bad

When not to use Alpine

As often, however, there is no silver bullet. Alpine is great as long as what you need is available from the official package repository (which is well-stocked, to be fair, unlike bathroom hygiene aisles at the moment), but if something is missing you might be in for some fun in order to add it manually. Unless you are well versed in system administration, you probably don't want to go there.

So how to pick the right version of an image? A good approach would be to start with the most minimal available version and move up the footprint ladder in case of lack of dependency support only. Although nothing is ever set in stone and you can always change the base image later, the more complex a Dockerfile, the more painful it can get to port it to a different Linux distribution.

This is why I'm introducing Alpine so early on: instead of going for the first available image without a second thought, consider your options and identify what seems to be the best compromise – it will most likely save you some headaches down the line.

Also remember that, beyond sheer size considerations, the smaller the image, the smaller the potential attack surface.

Conclusion

We now have a better idea of how to pick a base image for our containers, and we optimised our LEMP stack as a result. This is a good place to upgrade our setup to a more complex three-tier architecture and to introduce application frameworks, which we will cover in the next part.

You can subscribe to email alerts below to make sure you don't miss it, or you can also follow me on Twitter where I will share my posts as soon as they are published.

Enjoying the content?

You can also subscribe to the RSS or Atom feed, or follow me on Twitter.

Last updated by osteel on :: [ tutorial docker alpine ]

Comments