Load balancing with Consul & Nginx

2018-09-07

Load balancing with Consul & Nginx

The last few weeks I played with Docker, Consul and other tools, so I tried to load-balance multiple Docker containers with an API. I decided to share with you how I built the system so I wrote this blog post. I hope you find it useful in some way. We will use Consul, Docker, Docker compose, Nginx, Python and Supervisord to create a load-balancing system that gets its configuration updated when the group that load-balance scales up or down.

Requirements

The only requirements on your machine are Docker and Docker compose. You can follow the docker installation guide here. To install Docker compose you can use pip install docker-compose. Remember to use a virtual environment so you don't pollute your global python environment.

An overview

/assets/images/overview-diagram.png

In the system there is at least 5 containers running:

  • 1 Load balancer

  • 1 API (two in the figure)

  • 3 Consul Servers (forming a cluster)

Each container have it's own Consul agent running, where the service is registered. Then the local Consuls contact with the Consul cluster. On every update of the API service group, the load balancer gets its configuration updated, so it knows to which containers should redirect the requests.

Base image

Since all our Docker container will be running common services, we can create a base image. As we will running multiple services inside our Docker containers, we will use a service manager. In this case, a option that Docker gives in their documentation is to use Supervisord to manage multiple services.

So our base image will contain:

  • Supervisord: managing processes.

  • Consul agent: will communicate with the Consul cluster.

  • Register service: a "one-shot" process to register the service into the local Consul agent.

Base Dockerfile

# Dockerfile
FROM ubuntu:18.04

# Same exposed ports than consul
EXPOSE 8301 8301/udp 8302 8302/udp 8500 8600 8600/udp 8300

# unzip and wget for consul installation, python3 for supervisord
RUN apt-get update && apt-get install unzip wget python3 python3-distutils -y

# Pip the PyPA way, setuptools and wheel (note the --no-cache-dir to prevent pollution)
RUN wget -q https://bootstrap.pypa.io/get-pip.py -O /tmp/get-pip.py && \
   python3 /tmp/get-pip.py pip setuptools wheel --no-cache-dir

# Supervisord from PyPI doesn't support Python 3, so I download the master branch from GitHub
RUN python3 -m pip install https://github.com/Supervisor/supervisor/archive/master.zip --no-cache-dir && \
   mkdir -p /var/log/supervisor /etc/supervisor/conf.d/

# Download consul and move it to the PATH
ENV CONSUL_URL https://releases.hashicorp.com/consul/1.2.2/consul_1.2.2_linux_amd64.zip
RUN wget -q $CONSUL_URL -O /tmp/consul.zip && unzip /tmp/consul.zip && mv /consul /bin/consul && \
    mkdir /etc/consul.d/

# Configuration for starting the consul process, for the one shot registering and for
# supervisord itself
COPY consul.conf /etc/supervisor/conf.d/consul.conf
COPY register.conf /etc/supervisor/conf.d/register.conf
COPY supervisord.conf /etc/supervisor/supervisord.conf
ENTRYPOINT ["supervisord", "-c", "/etc/supervisor/supervisord.conf"]

# Cleanup!
RUN  apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

The Dockerfile:

  1. expose some ports for Consul communications.

  2. install unzip, python and wget so we can install and run things.

  3. install packages for python: pip, setuptools and wheel.

  4. install supervisord to manage processes.

  5. install Consul.

  6. add the configuration files for supervisord.

  7. set the entrypoint to use supervisord.

  8. clean the packages lists and the temporal directory.

Supervisord configuration

# supervisord.conf
[unix_http_server]
file = /tmp/supervisor.sock   ; the path to the socket file

[supervisord]
logfile = /var/log/supervisor/supervisord.log
logfile_maxbytes = 50MB        ; max main logfile bytes b4 rotation; default 50MB
logfile_backups = 10           ; # of main logfile backups; 0 means none, default 10
loglevel = info                ; log level; default info; others: debug,warn,trace
pidfile = /var/run/supervisord.pid
nodaemon = true
minfds = 1024                  ; min. avail startup file descriptors; default 1024
minprocs = 200                 ; min. avail process descriptors;default 200
directory = /tmp

[rpcinterface:supervisor]
supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface

[supervisorctl]
serverurl = unix:///tmp/supervisor.sock ; use a unix:// URL  for a unix socket

[include]
files = /etc/supervisor/conf.d/*.conf

There are two remarcable things in the file: nodaemon = true, so it will run in foreground and the [include] section to load the files inside /etc/supervisor/conf.d/.

Consul + Supervisord

# consul.conf
[program:consul]
command = consul agent -retry-join=%(ENV_CONSUL_SERVER)s -data-dir /tmp/consul -config-dir /etc/consul.d
stdout_capture_maxbytes = 1MB
redirect_stderr = true
stdout_logfile = /var/log/supervisor/%(program_name)s.log

We run the Consul agent which will try to connect to the defined CONSUL_SERVER. Also, we set the data and configuration directories. Finally we redirect the stdout and stderr to the file /var/log/supervisor/consul.log.

Register + Supervisord

# register.conf
[program:register]
command = register
startsecs = 0
autorestart = false
startretries = 1
redirect_stderr = true
stdout_logfile = /var/log/supervisor/%(program_name)s.log

This service will be the responsible for registering the service running in the container into the local Consul. The service expects an executable in the PATH called register. As will run only once, the executable itself should take care or retries registering the service.

Building the image

We put all these files into a folder, mine called base-image, and inside it run:

$ docker build . -t base-image:latest

This will create the Docker image so you can use it in other images or start a container from it.

The API

With the base image built, we can build the API on top of it. I've created the API using Python and the framework Flask, but you can implement it in the language that you want, of course.

API with Flask

# app.py
import socket

from flask import Flask
from flask import jsonify


app = Flask(__name__)

@app.route("/hostname")
def hostname():
    return jsonify({"hostname": socket.gethostname()})

if __name__ == '__main__':
    app.run()

The API consists of a single endpoint in /hostname which will return the hostname of the Docker container, so we can determine if the load-balancer is working correctly.

Register script

# register.py
#! /usr/bin/env python3
import time

import requests

consul_register_endpoint = "http://localhost:8500/v1/agent/service/register"

template = {
    "name": "api",
    "tags": ["flask"],
    "address": "",
    "port": flask_run_port,
    "checks": [
        {"http": "http://localhost:5000/hostname", "interval": "5s"}
    ]
}

for retry in range(10):
    res = requests.put(consul_register_endpoint, json=template)
    print("Attempt num:", retry, "Response Status:", res.status_code)
    if res.status_code == 200:
        print("Registering successful!")
        break
    else:
        print(res.text)
    time.sleep(1)
else:
    print("Run out of retires. So something went wrong.")

I've implemented the register executable using Python. It will try to register the service into the local Consul agent 10 times, after that it will stop. There is nothing magic about it: a simple PUT requests to the /v1/agent/service/register of Consul, passing the correct payload. Here, we tell Consul to check the health of our service contacting the /hostname endpoint, but you can create another endpoint for the health check.

Flask + Supervisord

# flask.conf
[program:flask]
command = flask run --host=0.0.0.0
directory = /app/
stdout_capture_maxbytes = 1MB
redirect_stderr = true
stdout_logfile = /var/log/supervisor/%(program_name)s.log

We set the service to start at /app/ directory, where we will copy the files of our API. We start the API using flask run --host=0.0.0.0, so we should set the environment variable FLASK_APP, we will do so in the Dockerfile.

API Dockerfile

# Dockerfile

FROM base-image:latest

ENV PYTHONUNBUFFERED=1 LC_ALL=C.UTF-8 LANG=C.UTF-8 FLASK_APP=app.py
RUN mkdir /app
WORKDIR /app
RUN python3 -m pip install Flask requests -U
ADD . /app/

COPY flask.conf /etc/supervisor/conf.d/flask.conf
COPY register.py /bin/register
RUN chmod +x /bin/register

Since we have a lot of functionallity in the base image, here we only install our API, define a few variables, install some dependencies and copy the register script and the service configuration.

Running and testing the API

Now we have all the files to build our API image and run it:

$ docker build . -t api:latest

# We should define the CONSUL_SERVER, so it wont crash
$ docker run -d --rm -e CONSUL_SERVER=localhost --name api -p 5000:5000 api:latest
$ curl localhost:5000/hostname
{"hostname":"f9e373e68d2e"}

Another thing done! Let's follow :)

The Load Balancer

Load balancer Dockerfile

# Dockerfile
FROM base-image:latest

# APT tasks
RUN apt-get update && apt-get install gnupg -y

# Install nginx
COPY nginx.list /etc/apt/sources.list.d/nginx.list
RUN apt-key adv --keyserver keyserver.ubuntu.com --recv-keys ABF5BD827BD9BF62
RUN apt-get install nginx -y

# Consul template
RUN wget -q https://releases.hashicorp.com/consul-template/0.19.5/consul-template_0.19.5_linux_amd64.zip -O consul-template.zip && \
   unzip consul-template.zip && mv consul-template /bin/consul-template && rm consul-template.zip
RUN mkdir -p /etc/consul-templates/
COPY load-balancer.tpl /etc/consul-templates/load-balancer.tpl

# Our application
RUN python3 -m pip install requests --no-cache-dir
RUN useradd nginx
COPY nginx.conf /etc/nginx/nginx.conf
COPY default.conf /etc/nginx/conf.d/default.conf

# Supervisor files
COPY consul-template.conf /etc/supervisor/conf.d/consul-template.conf
COPY supervisor-nginx.conf /etc/supervisor/conf.d/nginx.conf

COPY register.py /bin/register

# Apt cleanup
RUN apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*

The load balancer needs Consul template too, apart from Nginx. Also, since we are Installing Nginx from the official Nginx repositories, we need to add them to the list and add their key. We copy the register script and configuration files for Nginx too.

Consul template + Supervisord

# consul-template.conf
[program:consul-template]
priority = 1
command = consul-template -template "/etc/consul-templates/load-balancer.tpl:/etc/nginx/conf.d/load-balancer.conf:supervisorctl restart nginx"
stdout_capture_maxbytes = 1MB
redirect_stderr = true
stdout_logfile = /var/log/supervisor/%(program_name)s.log

The 'magic' happens here: we run consul-template which will render the template into the nginx configuration directory and run supervisorctl to restart the Nginx service, so the configuration is reloaded.

Load balancer template

# load-balancer.tpl

upstream backend {
   least_conn;
   {{ range service "api|passing" }}
   server {{ .Address }}:{{ .Port }};{{ end }}
}
server {
   listen      5000;
   server_name localhost;

   location / {
      proxy_pass http://backend;
   }
}

In the template, we are getting the Address and port of all services under the 'api' name. We are filtering them by 'passing' status, so we won't add any unhealthy API to the load balancing.

Nginx + Supervisord

# supervisor-nginx.ini
[program:nginx]
command = nginx
autostart = true
autorestart = unexpected
exitcodes = 0
redirect_stderr = true
stdout_logfile = /var/log/supervisor/%(program_name)s.log

Start Nginx!

Nginx configuration files

# default.conf
server {
   listen       80;
   server_name  localhost;

   location / {
      root   /usr/share/nginx/html;
      index  index.html index.htm;
   }

   error_page   500 502 503 504  /50x.html;
   location = /50x.html {
      root   /usr/share/nginx/html;
   }
}

The default file, serving on 80 the welcome page of Nginx.

# nginx.conf
user  nginx;
worker_processes  1;
daemon off;

error_log  /dev/stdout warn;
pid        /var/run/nginx.pid;


events {
   worker_connections  1024;
}


http {
   include       /etc/nginx/mime.types;
   default_type  application/octet-stream;

   log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                     '$status $body_bytes_sent "$http_referer" '
                     '"$http_user_agent" "$http_x_forwarded_for"';

   access_log  /dev/stdout main;

   sendfile        on;
   keepalive_timeout  65;
   include /etc/nginx/conf.d/*.conf;
}

The main configuration file of Nginx. Note the daemon off and the include *. The former will prevent nginx to go to the background and the latter will include the files placed in /etc/nginx/conf.d/ where consul-template will be rendering the template. Also, we send the output and errors to the /dev/stdout so Supervisord gets the logs.

# nginx.list
deb http://nginx.org/packages/ubuntu/ bionic nginx
deb-src http://nginx.org/packages/ubuntu/ bionic nginx

The repositories...

Register executable

# register.py
#! /usr/bin/env python3
import requests
import time

consul_register_endpoint = "http://localhost:8500/v1/agent/service/register"

template = {
    "name": "nginx",
    "tags": ["nginx"],
    "address": "",
    "port": 80,
    "checks": [
        {
             "http": "http://localhost:80",
             "interval": "5s"
        }
    ]
}

for retry in range(10):
    res = requests.put(consul_register_endpoint, json=template)
    print("Attempt num:", retry, "Response Status:", res.status_code)
    if res.status_code == 200:
        print("Registering successful!")
        break
    else:
        print(res.text)
    time.sleep(1)
else:
    print("Run out of retires. So something went wrong.")

In this case, we use the 80 port to check the health, while the requests will be sent to the 5000.

Docker compose

The final piece of the puzzle: the docker-compose.yml file, which contains all our architecture.

# docker-compose.yml
version: '3.7'
services:
  server-1:
    image: consul
    command: consul agent -server -bootstrap-expect=3 -data-dir /tmp/consul -node=server-1

  server-2:
    image: consul
    command: consul agent -server -bootstrap-expect=3 -data-dir /tmp/consul -retry-join=server-1 -node=server-2

  server-3:
    image: consul
    command: consul agent -server -bootstrap-expect=3 -data-dir /tmp/consul -retry-join=server-1 -node=server-3

  consul-ui:
   image: consul
   command: consul agent -data-dir /tmp/consul -retry-join=server-1 -client 0.0.0.0 -ui -node=client-ui
   ports:
     - 8500:8500

  api:
   image: api:latest
   environment:
     - CONSUL_SERVER=server-1

  load-balancer:
   image: load-balancer:latest
   environment:
     - CONSUL_SERVER=server-1
   ports:
     - 5000:5000

The docker compose defines a series of services:

  1. 3 consul servers running as such, connecting to the docker named server-1 (could be less).

  2. 1 consul agent running with as the UI, exposing its port 8500 to the local machine.

  3. The API with the CONSUL_SERVER defined as server-1.

  4. The Load Balancer with the CONSUL_SERVER defined as server-1 and the 5000 port open to the local machine.

Running the whole

Now it's to time to docker-compose to run!

$ (cd base-image && docker build . -t base-image:latest)
$ (cd api && docker build . -t api:latest)
$ (cd load-balancer && docker build . -t load-balancer:latest)
$ docker-compose up -d
$ firefox http://localhost:8500

Probably you will see the Consul UI with an error. Be patient and refresh, you should see something like this:

/assets/images/consul-ui.png

If you are not seeing this, check the logs with docker-compose logs [load-balancer|api], which will output the Supervisord log. For see each service log run docker-compose exec [load-balancer|api] supervisorctl and play with the Supervisord interactive console.

If all is running correctly, you should see this working.

$ curl http://localhost:5000/hostname
{"hostname":"XXXXXXXXXX"}

Scaling the API

The next step is to scale our API and check if the load-balancer gets updated. We can check the Nginx configuration file before scaling.

docker-compose exec load-balancer cat /etc/nginx/conf.d/load-balancer.conf
upstream backend {
   least_conn;

   server 172.19.0.3:5000;
}

server {
   listen      5000;
   server_name localhost;

   location / {
      proxy_pass http://backend;
   }
}

Now, we run:

$ docker-compose up -d --scale api=3

And three APIs more will be created.

$ docker-compose exec load-balancer cat /etc/nginx/conf.d/load-balancer.conf
upstream backend {
   least_conn;

   server 172.19.0.9:5000;
   server 172.19.0.8:5000;
   server 172.19.0.3:5000;
}
[...]

Great! The configuration file is now updated :)

/assets/images/consul-ui-scaling.png

The number of APIs in the Consul UI has increased also. And if we launch a lot of petitions to the localhost:5000...

$ curl http://localhost:5000/hostname
{"hostname":"6215fd5a5651"}
$ curl http://localhost:5000/hostname
{"hostname":"16f52ff4f50e"}
$ curl http://localhost:5000/hostname
{"hostname":"ef97072c7cca"}

It works :)

Scaling down the API

Now image that the service is getting less petitions, so it auto-scales down the number of API containers. We will do it using Docker compose:

$ docker-compose up -d --scale api=2

And one API containers will be stopped and removed. The configuration file updated, and the curls will return only two hostnames.

$ curl http://localhost:5000/hostname
{"hostname":"1c3b38502212"}
$ curl http://localhost:5000/hostname
{"hostname":"ef97072c7cca"}

Summary

We created a system with a load balancer that updates when the load balanced group changes using Consul, Consul template and Nginx. In my case, I built the API with Python and Flask, but experiment and build your own test API with other frameworks! We ran all in Docker containers, orchestrated by Docker compose. You can check out the repository at

I hope you learned something new! If not, leave a comment and ask for content!

load balancing service registry containers