In this workshop you will learn the basics of docker
, docker-compose
, and Swagger
through
creating a small collection of microservices and writing documentation/tests for them.
The workshop is composed of three services: backend, frontend, and nginx. The frontend and backend are both JSON APIs written using Django Python. Both applications are complete and do not require any additional coding.
At the end of the workshop, nginx will be configured to serve requests to:
/places
: Acceptslocation
andkeywords
parameters, sends them to the Google Places API and returns an array of location names. This is run on the backend/api/view
: Frontend endpoint which does the same thing as/places
/api/save
: Frontend endpoint which requests fromk/places
then saves each location with the timestamp it was fetched./api/show
: Returns a list of saved locations since the app started.
The infrastructure goals are:
- Configure the backend to cache using Redis
- Configure the backend with a Google Places Webservice API Key
- Configure the frontend to store places in a Postgres database
- Configure Nginx to reverse proxy to both the frontend and backend
- Write Swagger documentation for the API
- Use Swagger code generation in integration tests
This tutorial can be completed in one of two different ways:
git checkout master
: This version has all the "solutions" and can be used for learning by examplegit checkout start
: This version removes several files so you can implement them for a more hands on experience.
Choose your adventure based on your preference. The guide below will mention where you are expected
to implement files, if you use master
they will already be there.
During the backend portion of the workshop you will:
- Write a
Dockerfile
for the django web application - Build the django app image from the
Dockerfile
using the docker cli - Run the django app using the docker cli
- Run both the web app and Redis using
docker-compose
The files for the backend section are in docker-workshop/backend
During the frontend portion of the workshop you will:
- Write a
docker-compose.yml
file to configure the web app with postgres - Run the web app and postgres using
docker-compose
The files for the backend section are in docker-workshop/frontend
There are no tasks to be done in this section outside of minor url changes in specified python files. This section is primarily to show one way to configure nginx to act as a reverse proxy and/or load balancer for the web services running in docker.
The files for the backend section are in docker-workshop/nginx
Its standard practice after implementing an API to test and/or document it. Unfortunately, documentation has a tendency to fall out of sync with the code..
Swagger provides two powerful capabilities that help prevent this from happening:
- Expressive and descriptive language for specifying the schema for JSON APIs
- Based on that schema it can generate client API libraries in a variety of languages.
To insure that documentation, tests, and code are in sync, you can write Swagger docs, generate API clients, then use that exclusively for writing integration tests. This enforces that the three are always in sync.
In this section the goal is to:
- Install Docker, docker-compose, and boot2docker.
Lets get some terminology out of the way
- Docker daemon: background service which manages running containers
- Docker host: the machine running the Docker daemon
- Docker client: the machine/process executing docker commands (eg build, run)
Docker does not run natively on OSX. To work around that, boot2docker seamlessly sets up a VirtualBox machine running a distribution of Linux which runs Docker. It also correctly maps settings so that it can be accessed seamlessly from your OSX machine.
Useful tips:
- By default, the docker host is reachable by the ip address of the VirtualBox VM. This can get
annoying. You can improve this by using
echo $DOCKER_HOST
, extracting the ip address, then putting an entry in/etc/hosts
that looks like192.168.59.103 drydock
. This will map the hostdrydock
to the VirtualBox VM, making testing things such as web applications in the browser nicer - Useful commands: boot2docker up, boot2docker down, boot2docker ip. These can run/stop the VM and print its ip address
Now that there is a Docker host/daemon running, you need to install the client application so you can manage/use it. Installation instructions can be found at docker.io.
The final thing you need to install is docker-compose. This is a tool which uses configuration file to coordinate running ordinary Docker commands. Everything docker-compose does, can be done with regular Docker commands, but it makes it much easier.
NOTE: if you run into permissions issues, download the binary using curl to your downloads
directory, then rename it to docker-compose, chmod it, then move it to /usr/local/bin
.
Before getting started, you will need to signup for the google API console.
- Enable the Google Places API for Webservices (APIs & auth -> APIs), click see more.
- Next you will need to generate credentials for reaching the API (APIs & auth -> credentials). Use the "Public API Access" option to create a "server key".
- Test your key by going to https://maps.googleapis.com/maps/api/place/search/json?location=-33.88471,151.218237&radius=100&sensor=true&key=MYKEYHERE, make sure to replace
MYKEYHERE
with your actual key
Next, browse to /docker-workshop/backend/environment
, copy secrets.txt.template
to
secrets.txt
, then input your API key. This won't get used in the Dockerfile
section, but will
get used in the docker-compose part of the backend section.
Run boot2docker halt; boot2docker up
. This should output three environment variables which you
need to place in your ~/.bashrc
and then source by running source ~/.bashrc
. These variables
tell the docker client running on your machine (assuming you run mac) where the docker daemon
(running in VirtualBox) lives.
Double check that in the Google API console you have clicked enable
on the Google Places API.
Before starting work on the API, it is helpful to know several docker commands. Below is a list of commonly used commands and flags, followed by several examples:
- build: builds a new image based on a Dockerfile.
- exec: run a command within an already running container. This is extremely helpful for debugging.
In practice you would use
docker exec -it image_name bash
. This tells docker to execute bash, interactively (-i) with pseudo-TTY allowing you to run arbitrary bash commands interactively. - run: run an already built image as a new container
- kill: stop a running container
- rm: remove a stopped container
- logs: prints a containers logs. This is useful for debugging problems when running containers in the background
Lets first use docker run
. docker run
takes at least one argument, the image to run as a new
container. Arguments after that are optional and can override the command the container runs
Run: docker run hello-world
You should see that first docker looks if the image named hello-world
exists locally, since it
doesn't it checks if Docker Hub has a matching image. Since there is one, it will
download the image, then run it as a new container. Since the process bound to the container exits,
the container will also stop.
You can verify this by running docker ps
.
You can see the stopped container by running docker ps -a
. You will see several columns:
- Container ID: identifier for container assigned randomly
- Image: image the container was built from
- Command: entrypoint command that the container is executing
- Created: Creation date
- Status: running status
- Ports: ports the container exposes (eg 8000/tcp) and if a host port is bound to it (eg 0.0.0.0:8000 -> 8000/tcp)
- Names: name of container, assigned randomly by default, assignable via the
--name
flag
Since most services run in docker are persistent, lets write a new version of hello world which runs indefinitely printing "Hello World" once every second.
Run: docker run ubuntu:14.10 /bin/sh -c "while true; do echo hello world; sleep 1; done"
Now the run command takes additional arguments which specify which command it should run instead of the default command for the container. Since the while loop is blocking, the container will now not exit. Lets suppose there was some urgent production issue on this container and we needed to "login" and inspect its environment variables. You can do this by running
- Docker Docs
- FROM: specifies which image should be used as a base
- ENV: sets environment variables
- ADD: copy file from the host to the container
- WORKDIR: change the current directory
- RUN: execute the given command
- EXPOSE: expose port on container to outside world
- VOLUME: add a volume which the host can mount onto
- ENTRYPOINT: set the container's entrypoint command
In this section you will
- Write the
Dockerfile
for the web app - Build and run an image/container for the web app
- Run the web app and Redis using docker-compose
- Dockerfile: define application build
- compose-common.yml: sourced by compose-development.yml and compose-production.yml
- docker-entrypoint.sh: executable file run by the container built by
Dockerfile
- manage.py: entrypoint for various django administration commands
- requirements.txt: list of python requirements for project
- environment/secrets.txt: copied from secrets.txt.template and not in version control
- backend: contains django project. Particular attention should be paid to
backend/settings
since you may need to configure variables here soon. - places: django app with code for fetching google places data. You shouldn't need to change anything in here
The Dockerfile
is responsible for configuring our application and turning it into an image. You
will write the Dockerfile
knowing that:
- You should use the official python 2 image as a base
- Set the environment variable "PYTHONUNBUFFERED" to 1
- The contents of
backend
(at root of repo) should end up in/web
in the container. - The current working directory should end up being
/web
- The python requirements in
requirements.txt
need to be installed viapip install -r requirements.txt
- Port 8000 should be exposed
- Add a volume at
/web
, this will be explained later - Set the entrypoint of the container to be
docker-entrypoint.sh
Next up, lets try to build and run the application container. It is highly recommended to use the
--help
flag on the docker cli. It is a simple and concise way
to learn what commands and options are available. Try doing docker --help
now to see what
commands there are.
There are quite a few, below are the most commonly used and most helpful:
Now, lets try building and running the application. Keep in mind we need to:
- Pass the API secret key to
docker run
- Set
DJANGO_MODE
to development - Bind the host port 8000 to the container exposed port 8000
You may find the following flags helpful for building:
- -t: tag the image with a name
You may find the following flags helpful for running:
- -e: set environment variables, such as your API key (we will learn soon how to set it in a better way)
- --name: name the container. By default Docker assigns a random name
- -p: configure exposed ports
After running the correct command, browse to
http://drydock:8000/places?location=san%20francisco&keywords=climbing
to check if the web app is running. You may need to replace drydock
with your VMs IP address.
For the backend, the compose files have already been written for you. This should provide a good
example of a docker-compose
configuration file and allow you to learn the docker-compose
cli.
In the portion covering the frontend, you will write your own docker-compose
configuration file.
In general, docker-compose
is "simply" a wrapper around the docker
cli. Anything you can do in
docker-compose
you can do with sufficient effort in docker
.
The default name for a docker-compose
configuration file is docker-compose.yml
. If the file is
named something different, you will have to inform the cli of this difference. The configuration is
also in yaml format. Open compose-common.yml
, compose-development.yml
, and
compose-production.yml
which configure django
to work with redis
in production and development
configurations.
It will also be helpful to open the docker-compose reference
docker-compose
supports limited, but useful inheritance functionality. In this file we define
the basic things about the web
container and redis
container that will be common across both
development and production configurations.
- build: specifies to build
web
withDockerfile
in the current directory - image: specifies to pull the official
redis
image - ports: bind the host port 8000 to the container port 8000
- links: allow the given container connect to the other one seamlessly using the link name as the host
- environment: supply environment variables
- env_file: supply environment variables from a file
- volumes: mount the current directory as a volume in the container. This is helpful for live coding without having to rebuild the container (django picks up changes and relaunches as well)
Now it is time to launch our application. Before we launched our app with docker
, but that didn't
launch it with redis
as well. Lets launch it once in each of development and production modes by
using compose-development.yml
and compose-production.yml
respectively.
As before docker-compose --help
is very useful. The below commands are useful:
- build: build any requisite images
- up: start the set of containers
- kill: kill containers running in background
- rm: remove containers, useful to try if you are seeing odd behavior
- -d: run in daemon mode
- -f: tell
docker-compose
which configuration file to use
Using the above and documentation, launch the set of services in development and then in production, killing/removing the containers in between. For each one
- Browse to http://drydock:8000/places?keywords=climbing&location=oakland
- Notice the print message on the first request, then on a subsequent repeated request.
- Now try requesting the same url, except add another random query string parameter such as
hi=1
. This should cause the request cache to fail in production, but it should not issue an api request.
To see how redis was configured in the django application, browse to backend/settings/production.py
.
Within here, find the CACHES
statement. In particular "LOCATION": "redis://redis:6379/1"
configures
django to communicate with the host named redis
, which is the hostname given to the redis
container
since the link name was redis
(much redis, much redis...).
In this section you should have:
- Learned how to create a
Dockerfile
- Learned how to use the
docker
cli - Leanred how to use the
docker-compose
cli - Seen an example of
docker-compose
configuration files that can be used in development and production.
Before diving into what you will do now, here is a summary of the API:
/api/view
: Providelocation
andkeywords
param and request is forwarded to backend api/api/save
: Same as view, but will save the locations fetched. Normally mutating behavior with a GET is a nono, but it made my life easier.../api/show
: Show a list of all saved locations and dates they were saved
In this section the frontend api to call the backend api has been written for you. The application
has the appropriate python configuration files and a Dockerfile
but is missing docker-compose
configuration. For the moment, we won't worry about separate configuration files for production
and development, and will hardcode environment variables setting the application mode. To get the
app running, you will need to:
- Create a web container building the
frontend
directoryDockerfile
- Expose port 8001 on the host binding to the container port 8000. Binding to the host port 8000 would cause a collision with the backend configured port.
- Create a postgres container. Note the postgres password and username need to be configured via
environment variables using
POSTGRES_PASSWORD
andPOSTGRES_USER
as described below. - Create a link from the web container to the database container
It will be helpful to know
- The hostname of the database container should be
db
- The username of the database should be
trulia
- The password of the database should be taken from
secrets.txt
and passed usingPOSTGRES_PASSWORD
Unfortunately, the application still won't work because there is nothing telling it about how
to reach the backend api. Within frontend/api/views.py
you will notice a variable BACKEND_API_URL
.
This configures where the application should look for the API using knowledge about linked services.
of the container (eg backend_web_1
).
Now the service should be fully running. Browse to http://drydock:8001/api/show or one of the other urls to test the application.
It turns out, there is a better and more general way (albeit more complex) to allow APIs to communicate with each other.
There are two issues with the prior approach:
- The requests are not being load balanced
- The above method does not extend to services which are interpendent. To make links, the container being linked to must already be running and by definition will not be linked to anything. This would not play well with for example a user service and a blog service. Links only work for services with a dependency graph that forms a directed acyclic graph (DAG).
The easiest work around to this problem is to allow the containers making API calls to know what is the ip address of the host machine, then bind a Nginx instance to reverse proxy/load balance requests.
You can do this using a piece of code from the Docker documentation:
HOST_IP=`ip route show 0.0.0.0/0 | grep -Eo 'via \S+' | awk '{ print \$2 }'` gunicorn -b 0.0.0.0:8000 frontend.wsgi
The code above will do a lookup of the host ip, then pass it along as HOST_IP
to the container.
This is the first piece of what we need to get Nginx running and is already included in the
docker-entrypoint.sh
in the frontend service. You will also need to go into frontend/api/views.py
and switch the commented lines for BACKEND_API_URL
. You should be able to retest this and see that
it works since it is still reaching the same VirtualBox VM that docker is running at on the same
port. Now remove the external_link
from your compose configuraiton. The next step is to configure Nginx.
Within the nginx
folder there are all the required files in order to launch the reverse proxy
fully configured. Unfortunately nginx
does not allow access to environment variables in its
configuration so the strategy used is to:
- Create a template nginx configuration file, with
DOCKER_IP
as a placeholder for the true ip address - Run a python script which has the
HOST_IP
passed in like before, and interpolates the value into the nginx template file to create the actual configuration file. - Run nginx with the generated configuration file.
Now go back to frontend/api/views.py
and change the port for BACKEND_API_URL
to port 80
. This
will point the requests to hit the nginx
server, which will forwarded/load balance them to
the backend server.
Lastly, run the nginx container with:
cd docker-workshop/nginx
docker build -t workshop-nginx .
docker run --name workshop-nginx -p 80:80 workshop-nginx
Now you should have the full application running: backend, frontend, and nginx tying them together.
WIP/TBD soon.
This workshop, including code and documentation is licensed under the Creative Commons Attribution ShareAlike 4.0 International License and the MIT license. Details for each license are below and in LICENSE.
The MIT License (MIT)
Copyright (c) 2015 Pedro Rodriguez
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
This work is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License.