Docker Compose: Defining and running multi-container docker applications

Docker Compose Overview

Docker compose is a tool to define and run multi-container docker applications. With docker compose, you configure all your application’s services (containers) in a single yaml file, then spin up all of them with a single command.

For example, Let’s say that your application depends on a database, a queue, a cache, and another API service. You can define all of these dependencies as services in a docker-compose.yml file and start everything with a single command. No need for installing and running all the services independently.

This lets you create your development and testing workflows in a hassle-free way.

Note that, docker compose is mostly used for development and testing workflows because It runs all the containers on a single host by default. But docker has released more production oriented features recently that allow you to deploy compose apps on a swarm cluster.

In this article, we’ll specifically look at how to use docker compose for orchestrating containers on a single host in your development environment.

A sample application

We’ll create a simple application in Go that contains an API to display the “Quote of the day”.

The app fetches the quote of the day from a public API hosted at http://quotes.rest/, then it caches the result in Redis. For subsequent API calls, the app will return the result from Redis cache instead of fetching it from the public API.

Following is the directory structure of our application -

go-docker-compose
	↳ model
		↳ quote.go
	↳ app.go

app.go

package main

import (
	"context"
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/callicoder/go-docker-compose/model"
	"github.com/go-redis/redis"
	"github.com/gorilla/mux"
)

func indexHandler(w http.ResponseWriter, r *http.Request) {
	w.Write([]byte("Welcome! Please hit the `/qod` API to get the quote of the day."))
}

func quoteOfTheDayHandler(client *redis.Client) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		currentTime := time.Now()
		date := currentTime.Format("2006-01-02")

		val, err := client.Get(date).Result()
		if err == redis.Nil {
			log.Println("Cache miss for date ", date)
			quoteResp, err := getQuoteFromAPI()
			if err != nil {
				w.Write([]byte("Sorry! We could not get the Quote of the Day. Please try again."))
				return
			}
			quote := quoteResp.Contents.Quotes[0].Quote
			client.Set(date, quote, 24*time.Hour)
			w.Write([]byte(quote))
		} else {
			log.Println("Cache Hit for date ", date)
			w.Write([]byte(val))
		}
	}
}

func main() {
	// Create Redis Client
	client := redis.NewClient(&redis.Options{
		Addr:     getEnv("REDIS_URL", "localhost:6379"),
		Password: getEnv("REDIS_PASSWORD", ""),
		DB:       0,
	})

	_, err := client.Ping().Result()
	if err != nil {
		log.Fatal(err)
	}

	// Create Server and Route Handlers
	r := mux.NewRouter()

	r.HandleFunc("/", indexHandler)
	r.HandleFunc("/qod", quoteOfTheDayHandler(client))

	srv := &http.Server{
		Handler:      r,
		Addr:         ":8080",
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 10 * time.Second,
	}

	// Start Server
	go func() {
		log.Println("Starting Server")
		if err := srv.ListenAndServe(); err != nil {
			log.Fatal(err)
		}
	}()

	// Graceful Shutdown
	waitForShutdown(srv)
}

func waitForShutdown(srv *http.Server) {
	interruptChan := make(chan os.Signal, 1)
	signal.Notify(interruptChan, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)

	// Block until we receive our signal.
	<-interruptChan

	// Create a deadline to wait for.
	ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
	defer cancel()
	srv.Shutdown(ctx)

	log.Println("Shutting down")
	os.Exit(0)
}

func getQuoteFromAPI() (*model.QuoteResponse, error) {
	API_URL := "http://quotes.rest/qod.json"
	resp, err := http.Get(API_URL)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()
	log.Println("Quote API Returned: ", resp.StatusCode, http.StatusText(resp.StatusCode))

	if resp.StatusCode >= 200 && resp.StatusCode <= 299 {
		quoteResp := &model.QuoteResponse{}
		json.NewDecoder(resp.Body).Decode(quoteResp)
		return quoteResp, nil
	} else {
		return nil, errors.New("Could not get quote from API")
	}

}

func getEnv(key, defaultValue string) string {
	value := os.Getenv(key)
	if value == "" {
		return defaultValue
	}
	return value
}

model/quote.go

package model

type QuoteData struct {
	Id         string   `json:"id"`
	Quote      string   `json:"quote"`
	Length     string   `json:"length"`
	Author     string   `json:"author"`
	Tags       []string `json:"tags"`
	Category   string   `json:"category"`
	Date       string   `json:"date"`
	Permalink  string   `json:"parmalink"`
	Title      string   `json:"title"`
	Background string   `json:"Background"`
}

type QuoteResponse struct {
	Success  APISuccess   `json:"success"`
	Contents QuoteContent `json:"contents"`
}

type QuoteContent struct {
	Quotes    []QuoteData `json:"quotes"`
	Copyright string      `json:"copyright"`
}

type APISuccess struct {
	Total string `json:"total"`
}

Building the application

You can initialize and build the application by typing the following commands -

$ cd go-docker-compose
$ go mod init github.com/callicoder/go-docker-compose
$ go build

That’s it. Note that, I’m using go modules for initializing and managing dependencies. The above commands will create an executable file named go-docker-compose. If you’ve Redis installed in your local machine, then you can run the application and test the API -

$ ./go-docker-compose
2019/02/02 17:19:35 Starting Server
$ curl http://localhost:8080/qod
The free soul is rare, but you know it when you see it - basically because you feel good, very good, when you are near or with them.

Building a Docker image for the application

The next step is to build a docker image for our application. Let’s do that by defining a Dockerfile with the following contents -

Dockerfile

# Dockerfile References: https://docs.docker.com/engine/reference/builder/

# Start from golang:1.12-alpine base image
FROM golang:1.12-alpine

# The latest alpine images don't have some tools like (`git` and `bash`).
# Adding git, bash and openssh to the image
RUN apk update && apk upgrade && \
    apk add --no-cache bash git openssh

# Add Maintainer Info
LABEL maintainer="Rajeev Singh <rajeevhub@gmail.com>"

# Set the Current Working Directory inside the container
WORKDIR /app

# Copy go mod and sum files
COPY go.mod go.sum ./

# Download all dependencies. Dependencies will be cached if the go.mod and go.sum files are not changed
RUN go mod download

# Copy the source from the current directory to the Working Directory inside the container
COPY . .

# Build the Go app
RUN go build -o main .

# Expose port 8080 to the outside world
EXPOSE 8080

# Run the executable
CMD ["./main"]

To learn more, checkout Building Docker Containers for Go Applications

Configuring application’s services with docker-compose.yml

Our application consists of two services -

  • App service that contains the API to display the “quote of the day”.
  • Redis which is used by the app to cache the “quote of the day”.

Let’s define both the services in a docker-compose.yml file -

# Docker Compose file Reference (https://docs.docker.com/compose/compose-file/)

version: '3'

# Define services
services:

  # App Service
  app:
    # Configuration for building the docker image for the service
    build:
      context: . # Use an image built from the specified dockerfile in the current directory.
      dockerfile: Dockerfile
    ports:
      - "8080:8080" # Forward the exposed port 8080 on the container to port 8080 on the host machine
    restart: unless-stopped
    depends_on: 
      - redis # This service depends on redis. Start that first.
    environment: # Pass environment variables to the service
      REDIS_URL: redis:6379    
    networks: # Networks to join (Services on the same network can communicate with each other using their name)
      - backend

  # Redis Service   
  redis:
    image: "redis:alpine" # Use a public Redis image to build the redis service    
    restart: unless-stopped
    networks:
      - backend

# Networks to be created to facilitate communication between containers
networks:
  backend:    

Running the application with docker compose

You can run the application by typing the following command -

$ docker-compose up
Starting go-docker-compose_redis_1 ... done
Starting go-docker-compose_app_1   ... done
Attaching to go-docker-compose_redis_1, go-docker-compose_app_1
redis_1  | 1:C 02 Feb 2019 12:32:45.791 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo
redis_1  | 1:C 02 Feb 2019 12:32:45.791 # Redis version=5.0.3, bits=64, commit=00000000, modified=0, pid=1, just started
redis_1  | 1:C 02 Feb 2019 12:32:45.791 # Warning: no config file specified, using the default config. In order to specify a config file use redis-server /path/to/redis.conf
redis_1  | 1:M 02 Feb 2019 12:32:45.792 * Running mode=standalone, port=6379.
redis_1  | 1:M 02 Feb 2019 12:32:45.792 # WARNING: The TCP backlog setting of 511 cannot be enforced because /proc/sys/net/core/somaxconn is set to the lower value of 128.
redis_1  | 1:M 02 Feb 2019 12:32:45.792 # Server initialized
redis_1  | 1:M 02 Feb 2019 12:32:45.792 # WARNING you have Transparent Huge Pages (THP) support enabled in your kernel. This will create latency and memory usage issues with Redis. To fix this issue run the command 'echo never > /sys/kernel/mm/transparent_hugepage/enabled' as root, and add it to your /etc/rc.local in order to retain the setting after a reboot. Redis must be restarted after THP is disabled.
redis_1  | 1:M 02 Feb 2019 12:32:45.793 * DB loaded from disk: 0.000 seconds
redis_1  | 1:M 02 Feb 2019 12:32:45.793 * Ready to accept connections
app_1    | 2019/02/02 12:32:46 Starting Server

The docker-compose up command starts all the services defined in the docker-compose.yml file. You can interact with the Go service using curl -

$ curl http://localhost:8080/qod
A show of confidence raises the bar

Stopping all the services with docker compose

Once you’re done playing with the application, you can stop all the services with:

$ docker-compose down

Conclusion

In this article, you learned how to run multi-container docker applications using docker compose with the help of a simple Golang application.

You can find the complete code for the application that we built in this article on Github.

I hope you found the article useful. Thanks for reading. Please post your thoughts in the comment section below.

References