#! arran.dev

Docker Compose: Getting Started (Pt 1)

March 10, 2019

Currently listening to: The Legend of Zelda: Breath of the Wild OST

NOTE: This isn’t a Docker guide. It assumes you have a basic knowlege of Docker and the problems it solves.

Docker Compose is a tool for defining and managing applications which consist of multiple Docker containers.

To begin with, let’s create some working directories;

~ mkdir docker_compose_getting_started
~ cd docker_compose_getting_started
~ mkdir api web

Before we get started on the Docker stuff, we will create two simple applications; an API and a web server. I’ve chosen to write the API in Go and the web server in Node to keep things interesting. All the code is provided so don’t worry if you are unfamiliar with these are technologies.

1) JSON API

1.1) Writing a simple API in Go

In the api directory, create new file: api.go.

In this file we will define some structures and hardcode some data. The main() function simply listens to the root path on port 80 and returns a JSON response containing all the data in response to all requests.

I hardcoded the data in an effort to keep things minimal. By the end of this post you will have the knowledge to enable you to move that data into a real database, also managed by Docker Compose.

package main
import (
"encoding/json"
"log"
"net/http"
)
type director struct {
Name string `json:"name"`
}
type film struct {
Title string `json:"title"`
Year int `json:"year"`
Director director `json:"director"`
}
var spielberg = director{"Steven Spielberg"}
var lucas = director{"George Lucas"}
var kershner = director{"Irvin Kershner"}
var marquand = director{"Richard Marquand"}
var films = [...]film{
{"Indiana Jones: Raiders of the Lost Ark", 1981, spielberg},
{"Indiana Jones: Temple of Doom", 1984, spielberg},
{"Indiana Jones: The Last Crusade", 1989, spielberg},
{"Star Wars: Episode IV - A New Hope", 1977, lucas},
{"Star Wars: Episode V - The Empire Strikes Back", 1980, kershner},
{"Star Wars: Episode VI - Return of the Jedi", 1983, marquand},
}
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
j, _ := json.Marshal(films)
w.Header().Set("Content-Type", "application/json")
w.Write(j)
})
log.Fatal(http.ListenAndServe(":80", nil))
}

Beware, this server is running on port 80 and may conflict with other web servers you have running.

Let’s make sure everything works.

~ go run api.go
..
~ curl localhost
[{"title":"Indiana Jones: Raiders of the Lost Ark","year":1981,"director"....

Looks good! If you don’t have go installed you can skip this step and trust that it works - you’ll be able to run this code in a minute via Docker anyway.

1.2) Dockerizing the Go API

Create another file in the api directory: Dockerfile.

FROM golang:alpine
WORKDIR /go/src/api
COPY . .
RUN go install -v ./...
CMD ["api"]

This Dockerfile is pretty much as simple as it gets. In short we are defining an image using the ‘golang:alpine’ base image, copying our code and running it.

When your application becomes more complex, fetching dependencies etc, this file will need to reflect those changes.

Ok, let’s try out our container using the Docker cli. Note that we’re mapping the port from 80->8091. I’m not going to explain the other flags passed to these commands, please refer to the documentation.

~ docker build -t getting-started-api .
~ docker run -p 8091:80/tcp -it --rm getting-started-api
..
~ curl localhost:8091
[{"title":"Indiana Jones: Raiders of the Lost Ark","year":1981,"director"....

Great, our container works too.

Now you can be sure the code works if you skipped it the first time. If you’re new to Docker, this demonstrates the power of it. You can run code without installing the specific compiler/runtime 👍.

2) Web server

2.1) Writing a simple web server in Node

In the web directory, create new file: index.js.

const http = require('http')
http.createServer(function(req, res) {
const content = '<p>No films.</p>'
res.writeHead(200, { 'Content-Type': 'text/html' })
res.write(`<html><body><h1>Films</h1>${content}</body></html>`)
res.end()
}).listen(80)

Again, keeping this simple. This code will create a web server listening on port 80 which will return some HTML.

~ node index.js
..
~ curl localhost
<html><body><h1>Films</h1>No films.</body></html>

2.2) Dockerizing the Node web server

Next, create a Dockerfile in the web directory.

FROM node:10-alpine
WORKDIR /getting-started-docker-compose/web
COPY . .
RUN npm install --prod
CMD ["node", "index.js"]

Let’s run it and make sure it works. Note that this image has a slightly different name and port.

~ docker build -t getting-started-web .
~ docker run -p 8092:80/tcp --init -it --rm getting-started-web
..
~ curl localhost:8092
<html><body><h1>Films</h1><p>No films.</p></body></html>

Awesome, this one works too.

3) Creating the Docker Compose file

Finally, we’re here. Create docker-compose.yml in the top level directory docker_compose_getting_started.

version: "3"
services:
films-api:
build: ./api
ports:
- 8091:80
films-web:
build: ./web
ports:
- 8092:80
depends_on:
- films-api

That’s it!

We’ve defined two services named films-api and films-web. Until now we had to use docker build to build our images. Docker Compose handles this for us, it just needs to be pointed to the directory containing the Dockerfile.

We have also exposed our ports as we did previously in docker run.

Also we’ve introduced a new concept; depends_on. This tells Docker Compose that films-web depends on films-api. This is especially useful when you have many services and databases. Internally this just ensures that the API is started before the web service. It does NOT wait for the API to be ‘ready’, to handle this please read the documentation.

Let’s give it a spin… with one simple command;

~ docker-compose up --build

Awesome, both services are up and running as expected;

~ curl localhost:8091
[{"title":"Indiana Jones: Raiders of the Lost Ark","year":1981,"director"....
~ curl localhost:8092
<html><body><h1>Films</h1><p>No films.</p></body></html>

You can also run in detached mode by adding the -d flag. Later you can stop it by running docker-compose down.

4) Enabling service communication

We need our web server to fetch the films from the API. Hardcoding IP addresses/host names/ports in our services is far too brittle and error prone.

Fortunately, Docker Compose makes this really easy. Each service joins a common network with the name of the service defined in docker-compose.yml used as the hostname.

Let’s update the web server to talk to the API. Replace index.js with the following code;

const http = require('http')
async function fetch(url) {
return new Promise((resolve, reject) => {
http.get(url, res => {
let data = ''
res.on('data', (chunk) => {
data += chunk
})
res.on('end', () => {
resolve(JSON.parse(data))
})
}).on('error', err => {
reject(err)
})
})
}
http.createServer(async function(req, res){
const films = await fetch('http://films-api')
const content = films
.map(({ title, year, director }) =>
`${title} (${year}) - Directed by ${director.name}`)
.reduce((list, str) => `${list}<li>${str}</li>`, '')
res.writeHead(200, { 'Content-Type': 'text/html' })
res.write(`<html><body><h1>Films</h1><ul>${content}</ul></body></html>`)
res.end()
}).listen(80)

I’ve implemented a naive fetch() function rather than pulling in a dependency - again keeping things simple. You’ll see that we can now refer to the API using the service name defined in the Docker Compose file as the hostname; fetch('http://films-api').

The result is;

~ docker-compose up --build
..
~ curl localhost:8092
<html><body><h1>Films:</h1><ul><li>Indiana Jones: Raiders of the Lost Ark (19..

In part two we will learn how to use volumes in Docker Compose to enable hot reloading and we will move the hardcoded data into a real database.

All code for this post lives on GitHub.

Get in touch on twitter with any feedback.


Arran White

Code & photography. Personal blog of Arran White.