Getting started with Docker part 3: Multi-container apps

Getting started with Docker part 3: Multi-container apps

17 Apr 2022 docker mysql php

Welcome to the third part of my fourt-part guide to learning Docker, in the context of PHP and MySQL.

So far we've seen how to build and run containers, and last time out we saw how we could use volumes to persist data and files within our app.

Now, though, for the main event; where Docker really shines is in multi-container apps. Why would we need multiple containers? Read on!

Setup 🔗

Right now our app writes to a JSON file, and is working well, but this approach is hardly scalable. Let's refactor our app to use a MySQL database!

First off, let's rewrite app/index.php. Replace its current content with the following. Much of it is the same, but our PHP parts have changed.

<?php //connect to MySQL $db = new mysqli('db-container', 'todo-user', 'todo-pass', 'todos'); if ($db->connect_errno) die('Failed to connect to DB!'); //get current todos $todos = $db->query('SELECT * FROM todos'); //save any submitted to-do item if (!empty($_POST['item'])) { $stmt = $db->prepare('INSERT INTO todos SET task = ?'); $stmt->bind_params('s', $task); $task = $_POST['item']; $stmt->execute(); header('location: ?'); } ?> <doctype html> <head> <title>To-do app</title> </head> <body> <h1>To-dos</h1> <!-- new item form --> <form method='post'> <input type='text' name='item' /> <button>Add</button> </form> <!-- current items --> <h2>Current items</h2> <?php if (!$todos->num_rows) { ?> <p><em>none found...</em></p> <?php } ?> <ul> <?php while ($row = $todos->fetch_assoc()) { ?> <li><?= $row['task'] ?></li> <?php } ?> </ul> </body>

With that out of the way, let's meet multi-container apps.

Multi-container apps 🔗

It's possible - even common - for containerised apps to run multiple containers, one per "service" that's required. In our case, there's two services: PHP and MySQL.

But why not just run both within the one container? Technically this is possible, but a core concept of containerised development is that a container should have a singular focus, not do multiple things.

If you're familiar with component-based JavaScript development this will sound familiar; there, like here with containers, each component should focus on one part of the UI, not multiple.

There's several reasons for this. For one thing, it allows us to start different parts of our app in isolation. If you want to inspect or work on the DB, we don't also need to start PHP. For another, while you may use a containerised database in development, in production you may use a managed service database (particularly if you're using a cloud platform), so you don't want to bundle your app with your DB.

With this in mind, let's create a MySQL container to run alongside our PHP container. We'll use the official MySQL image. From your terminal (anywhere), run the following:

docker run ` --name db-container ` -d ` -v dbdata:/var/lib/mysql ` -e MYSQL_ROOT_PASSWORD=secret ` -e MYSQL_USER=app ` -e MYSQL_PASSWORD=secret2 ` -e MYSQL_DATABASE=todos ` mysql:5.7

You may be wondering, how come we didn't set up a Dockerfile this time, like we did for the PHP container? We could have done, but it would contain simply...

FROM mysql:5.7

...and if a Dockerfile contains only a FROM command, we might as well go straight to that image in our run command, and cut out the Dockerfile middleman.

Note also how we specify a named volume, dbdata, which we met in part 2? That's so our database data persists between creating/removing the container. We also pass in some environment variables - to set up a default user and create a database, todos.

Environment variables are great for configuring a container on the fly. There's various ways of passing them in, and they turn up in whatever way the app's environment recognises environment variables. For example, in PHP, they would be available via the $_ENV superglobal.

But how do we know we can pass in these variables, and what they should be called? Because the MySQL image we're using accepts them and acts on them if we pass them. In our case, this means it creates the user and database we specified, and connects them to one another permissions-wise.

Passing sensitive data into a container in this way is not ideal, but it serves to introduce the concept of environment variables. We'll make this more secure in part four.

Prepping MySQL 🔗

Let's check we can connect to MySQL and our DB by exec'ing into the container:

docker exec -it db-container mysql -u app -p -D todos

Note the -it argument. This is actually the combination of two arguments - i and t - and makes our exec command interactive. This means that when the MySQL daemon responds with a password prompt, we have an interface in which to enter it.

Enter the app user's password that we set in the environment variable ("secret2") and we should be in! Once in, let's create a table in which to store our to-do items.

CREATE TABLE todos ( id INT NOT NULL AUTO_INCREMENT, task VARCHAR(100) NOT NULL, PRIMARY KEY (id) );

Our simple table has just two columns: an integer, which will store auto-generated, numeric task IDs, and a text field, which will stoore the to-do task itself.

Our DB container is now all set. Type exit plus enter/return to get out of our container and back to our terminal.

Container networking 🔗

So now we need to get our containers to talk to one another, but how? Remember, containers exist in total isolation from one another. Docket networks to the rescue!

First, let's stop/remove the PHP container from part 2 (if it still exists)...

docker rm -f php-container

...then recreate it (this time without the named volume, because we'll no longer be storing data in a JSON file):

docker run ` --name php-container ` -d ` -p 80:80 ` -v "$(pwd)/app/index.php:/var/www/html/index.php" ` php-app

We should now have both our containers running. We can verify this either via Docker Desktop or via:

docker container list

...which will show us which containers are running.

Now that our containers are running, let's create a network and add them both to it.

docker network create todo docker network connect todo php-container docker network connect todo db-container

This shows a more procedural style of creating networks and attaching containers to them. It's also possible to merge this with the run command, via the --network and --network-alias arguments.

Let's recall the part of our PHP code where we connect to MySQL. The first argument to the mysqli constructor represents the host:

$db = new mysqli('db-container', 'app', 'secret2', 'todos');

There's magic happening here. When we joined the DB container to our todos network, Docker assigned it a network alias equal to its container name (though it's possible to override this) and, in so doing, created a hostname with that name. So right now, db-container is effectively a domain!

Let's inspect the network to check that all worked. The following will output diagnostic info about our network, as JSON, including the containers that are joined to it:

docket network inspect todos

Finally, let's head back to the browser. Open http://localhost and you should find our app works just like before, except it's now using a database!

Summary 🔗

It's common for Docker apps to use multiple containers rather than just one. This has several advantages; separating our concerns, like in other areas of web development, is generally a good idea.

We can get multiple containers to talk to one another via networks. These can be made, and joined, either at the time we spin up our app (via environment variables) or procedurally afterwards. Each container on a network is mounted as a contactable hostname, usually named after the container name.

So what's left? Everything works great, but we have to remember and run quite a few commands (some of them quite long) to spin up our app: two for the containers, and three more to create and join the network. Wouldn't it be nice if we do all this with one command? We'll meet Docker Compose in part 4. See you there!

Did I help you? Feel free to be amazing and buy me a coffee on Ko-fi!