Docker for local web development, part 2: put your images on a diet
Last updated: 2023-04-17 :: Published: 2020-03-16 :: [ history ]You can also subscribe to the RSS or Atom feed, or follow me on Twitter.
In this series
- Introduction: why should you care?
- Part 1: a basic LEMP stack
- Part 2: put your images on a diet ⬅️ you are here
- Part 3: a three-tier architecture with frameworks
- Part 4: smoothing things out with Bash
- Part 5: HTTPS all the things
- Part 6: expose a local container to the Internet
- Part 7: using a multi-stage build to introduce a worker
- Part 8: scheduled tasks
- Conclusion: where to go from here
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:
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
:
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.
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.
You can also subscribe to the RSS or Atom feed, or follow me on Twitter.