Composing Containers

Dave Cross - Sep 11 '19 - - Dev Community

In my previous post I created a couple of Dockerfiles which created two containers that worked together to create a web application. One ran the Perl application itself and the other contained the database. I say they "worked together" but, really, that's a bit of an overstatement. They only worked together because I had written a couple of rough and ready shell scripts which ran the two containers and wired them together.

The better way to get containers working together is to use a tool called docker-compose. So that was my next step. It turned out to be a lot easier than expected and in this article, I'll explain how I did it.

docker-compose is driven by a configuration file called docker-compose.yml. You can see my final version on Github, but let's go through it line by line.

version: '3'
Enter fullscreen mode Exit fullscreen mode

We start by declaring the version of the compose file syntax that we're using. The current version is 3, and there was no reason for me to consider using anything else.

services:
Enter fullscreen mode Exit fullscreen mode

Most of the file is used to define the services used in the system. A "service" is how docker-compose describes a container. My system uses three containers, so we define three services.

  database:
Enter fullscreen mode Exit fullscreen mode

The first service we'll define is the one that builds the database container. I've decided to call it 'database'

    build:
      context: .
      dockerfile: Dockerfile-db
Enter fullscreen mode Exit fullscreen mode

The first section within the database service definition tells docker-compose how to build the container. The context and dockerfile parameters are identical to the parameters you would pass to the docker build command. Here we tell the command to look for a Dockerfile called Dockerfile-db in the current directory (which will be the root directory of the Git checkout).

    container_name: succession-db
Enter fullscreen mode Exit fullscreen mode

The next line gives our container a name.

    environment:
      - MARIADB_ROOT_PASSWORD=sekrit
      - MARIADB_DATABASE=$SUCC_DB_NAME
      - MARIADB_USER=$SUCC_DB_USER
      - MARIADB_PASSWORD=$SUCC_DB_PASS
Enter fullscreen mode Exit fullscreen mode

Then we define a number of environment variables. These are the same as the arguments that we used when called docker run in my previous article.

    ports:
      - "13306:3306"
Enter fullscreen mode Exit fullscreen mode

Finally, for this service, we defined the ports. Again, this is the same as an argument (in this case the -p argument) that is passed to docker run. We're telling Docker to expose port 3306 (the standard MariaDB port) on the container as 13306 on the host.

And that's all we need to build and run the database container.

The next section of the docker-compose.yml file adds something new to the system. In order to speed up the application, I used a memcached server. I haven't created a cache container in my previous articles, but there's no reason to put it off any further.

  cache:
    image: memcached:1.5
Enter fullscreen mode Exit fullscreen mode

It doesn't get much easier than that. I've just pulled in a standard, pre-built memcached container from the Docker hub.

Finally, we need to build and run the container for the actual application. We'll call it "app".

  app:
    build: .
Enter fullscreen mode Exit fullscreen mode

The build section is a bit simpler than the one for the database container. That's because we're using the standard name for a Dockerfile, so we just need to define where it's found (in the current directory).

    container_name: succession
Enter fullscreen mode Exit fullscreen mode

We name the container.

    links:
      - database
      - cache
    depends_on:
      - database
      - cache
Enter fullscreen mode Exit fullscreen mode

And define other containers from our system that this container needs to communicate with and also which containers it depends on.

    environment:
      - SUCC_CACHE_SERVER=cache
      - SUCC_DB_HOST=database
      - SUCC_DB_PORT
      - SUCC_DB_NAME
      - SUCC_DB_USER
      - SUCC_DB_PASS
Enter fullscreen mode Exit fullscreen mode

We then define a number of environment variables that our application requires. As with the database container, these are the same as the -e arguments that we previously passed manually to the docker run command. It's also worth noting that the "hostnames" that our application uses to connect to the database and the cache server are just the names of services that we've defined elsewhere in this file.

    ports:
      - "1701:1701"
Enter fullscreen mode Exit fullscreen mode

And, finally, we define ports that are exposed to the host system. Here, I've chosen an obscure port number for my service to run on and exposed it under the same number (you might remember that the service is about the history of the line of succession to the British throne - and 1701 was the year when the "Act of Settlement" was passed).

Once we've got all of this information in docker-compose.yml, we can run the command docker-compose up and watch our containers being build and run. Once that has finished we can visit http://localhost:1701/ in a browser running on the host system to see our application in action. And the joy of using Docker is that anyone can clone our Git repository and they'll be able to run the same command and see exactly the same behaviour.

One other thing I've done since writing my previous article is to set up my Docker images on the Docker hub. I've also configured it so that every time I commit a change to my Github repo, the images get rebuilt.

So now I have an easily reproducible way to build and run the containers required to drive my application. I expect the next step is to get them running in Amazon's Elastic Container Service. So I expect that's what my next article will be about.

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