Developing with Docker

A Lego Docker logo, with a penguin figurine next to a Lego man in a parka.
Working on Docker (https://www.flickr.com/photos/134416355@N07/31518965950)

In my last post about Docker, I went over container theory and some basic Docker commands. In this post, I will cover using Docker in an application development workflow. We’ll look at developing a Python/Flask API in a container using Docker.

Setting up a Development Directory

So, let’s have a look at a directory structure for our application.

user@host$ tree

├── Dockerfile
├── Pipfile
├── Pipfile.lock
└── src
└── index.py

In our application’s root directory (which will be our container’s working directory) we store our Dockerfile. The Dockerfile is used to create an image that will be run inside of our container.

For an overview of what containers, images, and Docker are; see my post about the basics here.

For now, we’ll just create an empty Dockerfile. Later, we’ll cover the Docker image creation process.

user@host$ touch Dockerfile

We’ll also create a src directory that will hold our application code. Inside that directory, I’ll put an empty index.py file that will eventually become the entry point for our program.

user@host$ mkdir src
user@host$ touch src/index.py

We also have two files: Pipfile and Pipfile.lock. We’re going to use pipenv, the latest and greatest packaging tool for Python. pipenv uses these files to produce deterministic builds. This means that when we create our image on another host, the Python interpreter version and packages used will be exactly the same.

Creating Pipfile and Pipfile.lock Files

To generate the Pipfile and Pipfile.lock files, we’ll use pipenv to install our required python packages. In our case, that’s only Flask. First, make sure pipenv is installed.

user@host$ pip3 install pipenv

Then we’ll use pipenv to install Flask.

user@host$ pipenv install Flask
Creating a Pipfile for this project…
Installing Flask…
Collecting Flask
Using cached https://files.pythonhosted.org/packages/7f/e7/08578774ed4536d3242b14dacb4696386634607af824ea997202cd0edb4b/Flask-1.0.2-py2.py3-none-any.whl
Collecting Jinja2>=2.10 (from Flask)
Using cached
... output omitted ...
Installing collected packages: MarkupSafe, Jinja2, click, itsdangerous, Werkzeug, Flask
Successfully installed Flask-1.0.2 Jinja2-2.10 MarkupSafe-1.1.0 Werkzeug-0.14.1 click-7.0 itsdangerous-1.1.0

Adding Flask to Pipfile's [packages]…
Pipfile.lock not found, creating…
Locking [dev-packages] dependencies…
Locking [packages] dependencies…
Updated Pipfile.lock (e239e5)!
Installing dependencies from Pipfile.lock (e239e5)…
🐍 ▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉▉ 6/6 — 00:00:03
To activate this project's virtualenv, run the following:
$ pipenv shell

Now we have our Pipfile and Pipfile.lock files. As I wrote earlier, we will use those files to ensure that the code running in our container uses consistent versions of software.

cat Pipfile
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true

[requires]
python_version = "3.5"

[packages]
flask = "*"

[dev-packages]

I didn’t instruct pipenv to use a particular version of Python, so it used the version installed on my host system (3.5). So, when we build our container image, we’ll start with a Python 3.5 base.

Build Image and Container

Files named Dockerfile are used to build images that go into containers. According to the Docker documentation:

An image is an executable package that includes everything needed to run an application–the code, a runtime, libraries, environment variables, and configuration files.

https://docs.docker.com/get-started/#docker-concepts

So, for our Python/Flask application we’ll start with a base image that contains the Python interpreter, and all the dependencies for running Python programs and scripts.

If you go to Docker Hub and search for “Python”, the top result is the official Python image. That image comes in many versions. I’ve chosen to use the version that’s based off of Alpine Linux and has Python 3.5. Alpine Linux is a very lightweight Linux distribution (~5MB), so it’s a good option for keeping containers small while still having everything needed to run programs.

To find the particular version of the image, click on the tags tab on the image’s page.

Python Docker image tags.

The image I chose to use was python, the official Python image with the 3.5-alpine tag. This image has what I said I wanted: an Alpine base with the Python interpreter version 3.5.

Create a Dockerfile

Here’s my Dockerfile.

FROM python:3.5-alpine
EXPOSE 5000
RUN pip install pipenv
RUN mkdir -p /app/src
WORKDIR /app
ADD Pipfile /app
ADD Pipfile.lock /app
RUN pipenv install --system --deploy --ignore-pipfile
ADD src/index.py /app/src
CMD ["python", "/app/src/index.py"]

The order of the statements in this file are important. Each one represents a layer. From the top, if a layer has not changed it’s statement will not be executed. For example, after the initial build of the image I probably won’t change which base image I’m using. Therefore, when I change a lower layer – perhaps the Pipfile.lock change – the base image will not be reloaded.

This layering approach is one of the many advantages of using Docker. Without needing to do any of the set up yourself, if you have multiple containers using the same base image, Docker efficiently uses the same downloaded base image for each container – instead of downloading and using multiple copies of the same image.

It’s important to layer a Dockerfile so that rebuilds are as efficient as possible. If a command on one layer is executed, lines on subsequent layers are too. So, layer your Dockerfile with the most frequent changes on the bottom.

Exposing Ports

The next line in the Dockerfile is:

Expose 5000

Containers have their own internal networking. The expose line is instructing Docker to allow access to the container’s network on port 5000. Port 5000 is the default port Flask uses.

Exposing ports only enables connecting to that port within the internal Docker network. Later, when we publish that port making it available to the host system.

Setting up Image Environment

The next few lines/commands in the Dockerfile are for building on to the base python:3.5-alpine image. First, on line 3, we make sure pipenv is installed. Then, on line 4, we make a directory named app in the root of the file-system, and inside that directory another directory named src.

The app directory will be the location within our newly created image where we’ll install our application code. This is reflected on line 5 where we set our working directory to /app.

Adding in Our Application Code

With the groundwork laid for out application, we now need to move in our code. First, on lines 6 and 7 of the Dockerfile, we add our Pipfile and Pipfile.lock files. On line 8, we issue a RUN command to install the dependencies enumerated in these files.

pipenv in Docker

In a non-Docker/container environment, pipenv creates a virtual environment – typically in the user’s home directory – and a mapping is made to the directory that constitutes that virtual environment. When I tried using pipenv with this virtual environment, the image kept rebuilding at this layer. Since we’re using a specific Docker image – with a definite version of Python, we don’t need this virtual environment. I added the following flags to the pipenv installation command

  • --system – instructs pipenv to use the system’s Python installation and not a virtual environment.
  • --deploy – The install command will fail if the Pipfile.lock is out-of-date, or Python version is wrong.
  • --ignore-pipfile – Tells pipenv to only use Pipfile.lock for required packages and versions.

Now that we have everything we need for our application to function, on line 9, we add in our source code. Notice that where we’ve used the ADD command. If all we wanted to do was copy files into the image, we could have used the COPY command. ADD was used so that when the added files are changed, they will be updated inside of the image – when the image is rebuilt.

The last line in the Dockerfile is the command (CMD) to start the application.

Build the Image

With our Dockerfile complete, we can now use it to build the image that will be used in our application’s container. The form of the Docker command that builds images is as follows:

docker image build [OPTIONS] PATH | URL | -

To use our Dockerfile to build the image, we issue the following command.

user@host$ sudo docker image build -t myapp.v1 .

This tells Docker to build the image and tag it (-t) with the myapp.v1 tag, and to use the Dockerfile located in the current directory (. is an alias for current directory). The tag will help us identify and reference our image.

Create the Container

We want to create a container with the image we just built inside of it. The form of the command to do that is:

docker container create [OPTIONS] IMAGE [COMMAND] [ARG…]

To build our container there are the options we use.

user@host$ sudo docker container create --name=myapp -p 5000:5000 -v $(pwd)/src:/app/src myapp.v1

Here’s the break down:

  • --name=[NAME] – give the container a name that can easily be referenced
  • -p [HOST_PORT:CONTAINER_PORT] – Publish port 5000 to the host machine on port 5000 of the host machine
  • -v [HOST_DIR:CONTAINER_DIR] – mount a container’s directory to a directory on the host machine. We do this so we can edit our source code (develop our application) within the container.

Now that the container has been created, we can start it and start developing our application.

user@host$ sudo docker container start myapp

To make this simpler, we can create and start a container with one command using docker container run.

docker container run [OPTIONS] IMAGE [COMMAND] [ARG…]
user@host$ sudo docker container run -d --name=myapp -p 5000:5000 -v $(pwd)/src:/app/src myapp.v1

The -d flag instructs Docker to run the container detached. This means that the container runs in the background. It will not receive input or display output.

Live Editing within the Container

Now that we have our image created and running inside of a container, we’re going to want to do some active development. As our index.py file is empty, our application actually fails and the container ceases to function. So let’s add some hello world code to our index.py file.

 from flask import Flask
app = Flask(__name__)

@app.route("/")
def hello():
return "Hello World"

if __name__ == "__main__":
app.run(host='0.0.0.0')

Now when we start our container, it won’t error out and the Flask server will start running. If/when we edit our file(s), we’ll need to restart the Flask server for those changes to be reflected. Since the command that starts our container is the same as the command that starts the Flask server, we can restart our container when we update our application code.

First, let’s alter our hello world code. We’ll use Flask’s Response object to return a more HTTP response.

 from flask import Flask
from flask import Response
app = Flask(__name__)

@app.route("/")
def hello():
res = Response("hello world")
res.headers['Content-Type'] = 'text/plain'
return res

if __name__ == "__main__":
app.run(host='0.0.0.0')

And we restart our container, and consequently Flask, to have the changes take affect.

user@host$ sudo docker container restart myapp

If you encounter issues. Another helpful command is docker logs. It will show you the output that’s logged to standard out and standard err.

user@host$ sudo docker container logs myapp

Leave a Reply

Your email address will not be published. Required fields are marked *