Docker part 2 - Composing with Docker Compose

In part one we went through how we can create a Docker container from a simple node application. In this part, let's look at how we can link our application to some other services and containers and have them all working in tandem.

To facilitate this, I've extended the Node application from part one to include some code that reads some data from a Mongo database. The database will live inside a second Docker container, and we're going to link them together by making use of Docker Compose.

Docker Compose

Unless your application is incredibly basic, you will quickly find the need to start connecting to other services and applications. Perhaps you need some kind of database such as MongoDB or MySql, or maybe you want to bring in a cache service like Redis, or even some micro-services. Using Docker images alone, you would have to spin up all of the containers manually with the correct parameters every time. Over time, you would probably write some kind of shell script to correctly start all of the containers and services that your application requires. Luckily, Docker Compose is designed to take this kind of pain away from you.

Installation

If you have installed the Docker tools for your OS, then you will already have Docker Compose. It exists on your system as a command-line tool that you can begin working with straight away.

So how do I use it?

Much like the Dockerfile that we created in part 1 of this series, Docker Compose requires that you create a docker-compose.yml file that lives as part of your application, which describes all of the containers and settings that your app needs to run. Then, to bring your app and its related services online, simply use:

$ docker-compose up

to start, and:

$ docker-compose stop

to stop them all.

Creating the docker-compose.yml file

Docker Compose reads all your settings from the docker-compose.yml file that should exist as part of your application (and should be checked in to source control). Let's have a look at a sample:

version: '2'  
services:  
  app:
    build: .
    image: mynodeapp
    ports:
    - "3000:3000"
    links:
    - db
  db:
    image: mongo:latest

I'm not saying ignore the first line, but basically at the time of writing, this should always be set to '2'.

The rest of the file simply defines the services that we want. First of all, we have our app, which instructs Compose to build the image mynodeapp from the current directory. From the command line, you can use $ docker-compose build to build all of the images that are referred to in the docker-compose.yml file. You can omit the build setting if you feel like building the image separately using the normal docker build command. In this file, we can also specify most of the run conditions for the image; settings that we would normally specify in the docker run command, such as ports and disk volumes. This actually makes Compose very useful even if you're using it to define just one service.

The most important thing I want to draw your attention to is the db service. Alongside our app service, we tell Compose to also create a service from the mongo:latest image. As part of the app service, we then specify a link to the db service.

Now, watch what happens when we bring these services up in the command line. Here's the output when I run it:

▶ docker-compose up
Creating network "blogdockernode_default" with the default driver  
Creating blogdockernode_db_1  
Creating blogdockernode_app_1  
Attaching to blogdockernode_db_1, blogdockernode_app_1  
db_1   | 2016-08-20T21:44:40.159+0000 I CONTROL  [initandlisten] MongoDB starting : pid=1 port=27017 dbpath=/data/db 64-bit host=c2cce4d0525c  
db_1   | 2016-08-20T21:44:40.160+0000 I CONTROL  [initandlisten] db version v3.2.7  
db_1   | 2016-08-20T21:44:40.160+0000 I CONTROL  [initandlisten] git version: 4249c1d2b5999ebbf1fdf3bc0e0e3b3ff5c0aaf2  
db_1   | 2016-08-20T21:44:40.160+0000 I CONTROL  [initandlisten] OpenSSL version: OpenSSL 1.0.1e 11 Feb 2013  
db_1   | 2016-08-20T21:44:40.160+0000 I CONTROL  [initandlisten] allocator: tcmalloc  
db_1   | 2016-08-20T21:44:40.160+0000 I CONTROL  [initandlisten] modules: none  
db_1   | 2016-08-20T21:44:40.160+0000 I CONTROL  [initandlisten] build environment:  
db_1   | 2016-08-20T21:44:40.160+0000 I CONTROL  [initandlisten]     distmod: debian71  
db_1   | 2016-08-20T21:44:40.160+0000 I CONTROL  [initandlisten]     distarch: x86_64  
db_1   | 2016-08-20T21:44:40.160+0000 I CONTROL  [initandlisten]     target_arch: x86_64  
db_1   | 2016-08-20T21:44:40.160+0000 I CONTROL  [initandlisten] options: {}  
db_1   | 2016-08-20T21:44:40.164+0000 I STORAGE  [initandlisten] wiredtiger_open config: create,cache_size=1G,session_max=20000,eviction=(threads_max=4),config_base=false,statistics=(fast),log=(enabled=true,archive=true,path=journal,compressor=snappy),file_manager=(close_idle_time=100000),checkpoint=(wait=60,log_size=2GB),statistics_log=(wait=0),  
app_1  | Server starter on port 3000  
db_1   | 2016-08-20T21:44:40.635+0000 I FTDC     [initandlisten] Initializing full-time diagnostic data capture with directory '/data/db/diagnostic.data'  
db_1   | 2016-08-20T21:44:40.635+0000 I NETWORK  [HostnameCanonicalizationWorker] Starting hostname canonicalization worker  
db_1   | 2016-08-20T21:44:40.741+0000 I NETWORK  [initandlisten] waiting for connections on port 27017  

You'll see that containers for both services have been created! The standard console output for MongoDB has been listed, as well as one entry from our node application (can you see the service names on the left?)

Looking deeper into the log, you'll see that it has also created a network linking all the containers together. This is exactly how you will be able to communicate with the db container from the application - docker will effectively make the db hostname available to the app container! This means that the url that you will use to connect to your Mongo database from your app will be something like:

mongodb://db:27017/my_database

The name of db comes from the name of the service that you use in your compose file. Docker exposes this name as the host name for the container, allowing you to easily connect to it as normal.

At this point, you can stop the containers from running by pressing Ctrl+C. This is because by default the containers run in interactive mode, attaching themselves to your stdin so that you can see the output. You can run the containers in daemon mode by starting them with the -d flag:

$ docker-compose up -d

You can then stop the containers again by issuing the stop command:

$ docker-compose stop

Note that normally you would be doing this from inside the folder that contains the compose file.

You can also start and stop individual services by specifying the name as part of the command. To start the app service that appears in the example file from above:

// Start the web service
$ docker-compose up app

// Stop the web service again
$ docker-compose stop app

Bear in mind that while Docker is starting and stopping all these containers, it will try and reuse them, unless it has to rebuild them. Stopping and restarting a container does not necessarily guarantee that a freshly-built container is used every time.

Connecting to external containers

One other area I wanted to touch on - perhaps more for my own notes than anything - was connecting to existing Docker containers that you might be running on your system that you want to make use of outside of your compose file. For example, you might have an existing Mongo or MySQL Docker container running that you want to connect to instead of spinning up another one specially for your app.

For this, you can use the external_links configuration setting to specify a container name that is already running on your system. Similar to how the linked services work inside your compose file, the name of the external container that you're connecting to forms the host name that you use in order to connect to its services.

There is an additional complication when connecting to external containers, to do with networking.

Docker has the concept of networks when running containers; the container can either attach to one of the predefined networks, or you can create a new one. The idea then is that all the containers that are attached to the same network can talk to each other.

By default, when you spin up a container using the normal $ docker run... command, it becomes attached to the bridge network by default.

However, when you run containers via Docker Compose, by default a brand new network is created that encompasses only the services that appear in the compose file. This means that these services can't interface with any other containers outside of the compose network.

The easiest way to deal with this is simply to attach your compose services to the bridge network when using external links. Or, at least, connect them to the same network that your existing container is attached to if it's different from the default.

Thus, a compose file demonstrating this might look like the following:

version: '2'  
services:  
  app:
    build: .
    image: mynodeapp
    ports:
    - "3000:3000"
    external_links:
    - mongodb
    network_mode: bridge

Firstly, specify the external link to your existing container by using the external_links config setting, and then attach the service to the bridge network using the network_mode config setting. Now, the app service can interface with your existing container through the host name mongodb. This host name can also be aliased, if you want to call it something else.