Part 3: Building your project as a Docker image
Containers are isolated environments that can be used to run applications. You can run a container on any machine that has docker installed. We will cover how to port what we built in part 1 to a docker container.
This is not a docker workshop
We'll see some things about docker but we will not dive into them very much. If you are not familiar with docker, please read the official documentation. For a thorough introduction to docker, see this tutorial and Jérôme Petazzoni's training materials here
Running docker on Windows
Running Docker on Windows has its nuances. Make sure you have administrator rights and you have Docker Desktop installed. You most likely will need WSL2 to run docker. You can follow this tutorial to get started.
Building a vocabulary around Docker
Docker, Dockerfile
, docker-compose.yml
, containers, images, etc. What's that all about? Let's start by defining some terms.
- Docker is the containerization technology. It allows you to run applications in isolated environments called containers. Some other alternatives exist, such as podman, rkt, lxc, lxd, etc.
- A
Dockerfile
is a file that contains instructions to build a docker image. - A docker image is a snapshot of a container. It can be used to create containers.
- A docker container is a running instance of an image. It can be started, stopped, paused, etc.
- A docker image is built using layers, where each layers maps from a line from the
Dockerfile
- A docker container has network, storage, and other resources allocated to it. It can be configured to expose ports, mount volumes, etc.
- A docker container is designed to be stateless. It can be destroyed and recreated at any time. That means that if you want to persist data, you need to mount volumes to the container.
- docker compose is a docker tool that allows you to define and run multi-container applications. It is configured using a
docker-compose.yml
file. - a
docker-compose.yml
is a yaml file that describes many services and how they interact with each other. Each service is a container and can be configured to expose ports, mount volumes, etc. Docker compose helps in orchestrating the containers under a common namespace.
flowchart TD
Docker[Docker] -->|has| Dockerfile(Dockerfiles)
Dockerfile --> |has|Line[Lines]
Line --> |maps to| Layer[Layers]
Layer --> |builds| Image[Images]
Image --> |runs| Container[Containers]
Container --> |has| Network[Networks]
Container --> |has| Storage[Storage]
Storage --> |managed by| Volume[Volumes]
Container --> |has| Other[Other resources]
Docker --> |has| Compose[Docker Compose]
Compose --> |has a| docker-compose.yml[docker-compose.yml]
docker-compose.yml --> |describes| Services[Services]
Services --> |are| Container[Containers]
Your first container
The most basic container is one that runs a single command. Let's start by creating a Dockerfile
that runs echo "Hello world"
.
FROM ubuntu:latest
RUN apt update && apt install -y cowsay
ENV PATH="/usr/games:${PATH}"
CMD ["cowsay", "somenergia.coop"]
Save this file as services/part-3/baby-steps/Dockerfile
. Let's now build the image. From the root of the repository, run:
docker build -t poetry-ws-part-3-baby-steps:latest -f services/part-3/baby-steps/Dockerfile .
And run it:
$ docker run --rm poetry-ws-part-3-baby-steps
_________________
< somenergia.coop >
-----------------
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||
What's happening here? Let's break it down:
The Dockerfile
follows its own syntax:
- It starts from a base image, in this case
ubuntu:latest
- It runs a command, in this case
apt update && apt install -y cowsay
. You can issue as manyRUN
commands as you want. Each one will create a new layer. - It sets an environment variable, in this case
PATH
. This is only needed in this case for cowsay to be found. See more about this here. - We build the image and tag it as
poetry-ws-part-3-baby-steps:latest
. The-f
flag tells docker to use theDockerfile
at the given path. - We run the image. The
--rm
flag tells docker to remove the container after it has finished running. Thepoetry-ws-part-3-baby-steps
is the name of the image we built. TheCMD
line tells docker to runcowsay somenergia.coop
when the container starts.
Try it yourself: Talk with a different animal!
cowsay
accepts many arguments. Check cowsay -l
for a list of available cows. Try to modify the CMD
line to run cowsay
with a different animal.
_______
< hello >
-------
\ . .
\ / `. .' "
\ .---. < > < > .---.
\ | \ \ - ~ ~ - / / |
_____ ..-~ ~-..-~
| | \~~~\.' `./~~~/
--------- \__/ \__/
.' O \ / / \ "
(_____, `._.' | } \/~~~/
`----. / } | / \__/
`-. | / | / `. ,~~|
~-.__| /_ - ~ ^| /- _ `..-'
| / | / ~-. `-. _ _ _
|_____| |_____| ~ - . _ _ _ _ _>
See https://itsfoss.com/cowsay/ if you need help!
Try it yourself: Have a fortune cowkie
fortune
is another CLI tool that will tell you your fortune when prompted. Try to modify the Dockerfile
line to run cowsay
along fortune
.
____________________________________
/ You will pioneer the first Martian \
\ colony. /
------------------------------------
\ ^__^
\ (oo)\_______
(__)\ )\/\
||----w |
|| ||
See https://itsfoss.com/cowsay/ if you need help!
Using docker compose
Docker compose allows you to define and run multi-container applications. It is configured using a docker-compose.yml
file. Let's create one for our baby-steps
service.
version: "3"
services:
baby-steps:
image: poetry-ws-part-3-baby-steps:latest
build: services/part-3/baby-steps
Save this file as docker-compose.part-3.yml
. Let's now build the image. From the root of the repository, run:
docker compose -f docker-compose.part-3.yml build
And run it:
docker compose -f docker-compose.part-3.yml run baby-steps
You should see the same output as before. What's happening here? Let's break it down:
The docker-compose.yml
follows its own syntax:
- It defines a
version
. We are using3
here. See the documentation for more information. - It defines a service, with its own name,
baby-steps
. This services describes the image needed to run this service, and how it can be built from aDockerfile
. In this case, we are using the image we built in the previous section. - We use docker compose to build the image. The
-f
flag tells docker compose to use a customdocker-compose.yml
at the given path. - We use docker compose to run the image. The
run
command tells docker compose to run the image. Thebaby-steps
argument tells docker compose to run thebaby-steps
service.
docker compose is not needed to run containers
docker compose relies on docker, but it is not needed to run containers. You can run containers using docker run
as we did in the previous section. However, docker compose is very useful to orchestrate multiple containers together.
Getting inside a container
You may want to get inside the container to debug something, or to develop applications from inside. We'll see how to do that here.
Getting inside a running container
You can get inside a running container using docker run
and replace the command issued at the Dockerfile by something else. Let's try it out. Using plain docker, run:
$ docker run -it --rm poetry-ws-part-3-baby-steps bash
root@e5e06508458d:/#
Running as root
You are now running as root
inside the container. Be careful! This has security implications. See this guide for more information.
Try typing cowsay something
from inside the container now.
Exiting the container
You can exit the container by typing exit
or pressing Ctrl+D
.
Using docker compose, run:
docker compose -f docker-compose.part-3.yml run -it --rm baby-steps bash
Using visual studio code to edit files inside a container
You can also use visual studio code to edit files inside a container. This is very useful when you want to develop applications inside a container. You will need the Remote Explorer
extension. See this guide for more information.
devcontainers
Visual studio has devcontainers feature. See this guide for more information. We will not cover this here to keep it simple.
- Open visual studio code
- Open the remote explorer and look for the image with the name
poetry-ws-part-3-baby-steps:latest
. Right click on it and selectAttach in current window
.
- Visual studio code will now run from inside the container. You can now edit files and run commands from the terminal. Check the left bottom corner to confirm it!
Building a python image
Let's now build a python image. We'll use the python:3.9-slim
image as a base. Save this file as services/part-3/basic-python/Dockerfile
.
FROM python:3.9-slim
COPY requirements.txt requirements.txt
RUN pip install -r requirements.txt
CMD ["cowsay", "-t", "somenergia.coop"]
Add a new service to the docker-compose.part-3.yml
file:
version: "3"
services:
baby-steps:
image: poetry-ws-part-3-baby-steps:latest
build: services/part-3/baby-steps
basic-python: # new
image: poetry-ws-part-3-basic-python:latest # new
build: services/part-3/basic-python # new
build the image and run it:
docker compose -f docker-compose.part-3.yml run --build basic-python
Recreating project with docker
Let's now recreate part 1 with docker. We'll use the python:3.9-slim
image as a base. Since this image starts already with python installed, we don't need pyenv
to install it. But we will create, this time, a virtual environment managed by Poetry.
Managing virtual environments inside docker
People tend to argue over such scenario. Is isolation within isolation necessary? Is a virtual environment needed inside a docker container? See
- https://github.com/python-poetry/poetry/discussions/1879#discussioncomment-346113
- https://github.com/python-poetry/poetry/pull/3209#issuecomment-710678083
Answer is “it depends”, but it gives more control over dependencies and their state. For this workshop, we recommend it along docker multistage builds.
Creating the project greeter
You will need to create the following files:
mkdir -p services/part-3/python-poetry
cd services/part-3/python-poetry
mkdir -p src/greeter
touch src/greeter/__init__.py
touch src/greeter/cli.py
touch README.md
touch pyproject.toml
touch Dockerfile
Generate a lock file with poetry:
cd services/part-3/python-poetry
poetry lock
The whole project for this part should look like this:
$ tree services/part-3/python-poetry
services/part-3/python-poetry/
├── Dockerfile
├── poetry.lock
├── pyproject.toml
├── README.md
└── src
└── greeter
├── cli.py
└── __init__.py
2 directories, 6 files
And add the following to the files
# service/part-3/python-poetry/pyproject.toml
[tool.poetry]
name = "greeter" # new
version = "0.1.0"
description = ""
authors = ["Your Name <you@mail.co>"]
readme = "README.md"
packages = [ # new
{ include = "greeter", from = "." }, # new
] # new
[tool.poetry.scripts]
greeter-cli = "greeter.cli:cli" # new
[tool.poetry.dependencies]
python = "^3.10"
cowsay = "^6.1"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
# service/part-3/python-poetry/src/part_3/cli.py
import cowsay
def cli():
return cowsay.trex('Greetings from somenergia.coop!') # new
if __name__ == "__main__":
cli()
# service/part-3/python-poetry/src/part_3/__init__.py
<!-- service/part-3/python-poetry/README.md -->
# Part 3
Creating the Dockerfile
A dense Dockerfile ahead
This Dockerfile
is more involved than the previous ones. We'll break it down in the next sections.
# service/part-3/python-poetry/Dockerfile
# ---------------------------------------------------------------------------- #
# global build arguments #
# ---------------------------------------------------------------------------- #
# Global ARG, available to all stages (if renewed)
ARG WORKDIR="/app"
# global username
ARG USERNAME=somenergia
ARG USER_UID=1000
ARG USER_GID=1000
# tag used in all images
ARG PYTHON_VERSION=3.10.9
# ---------------------------------------------------------------------------- #
# build stage #
# ---------------------------------------------------------------------------- #
FROM python:${PYTHON_VERSION}-slim AS builder
# Renew args
ARG WORKDIR
ARG USERNAME
ARG USER_UID
ARG USER_GID
# Poetry version
ARG POETRY_VERSION=1.5.1
# Pipx version
ARG PIPX_VERSION=1.2.0
# prepare the $PATH
ENV PATH=/opt/pipx/bin:${WORKDIR}/.venv/bin:$PATH \
PIPX_BIN_DIR=/opt/pipx/bin \
PIPX_HOME=/opt/pipx/home \
PIPX_VERSION=$PIPX_VERSION \
POETRY_VERSION=$POETRY_VERSION \
PYTHONPATH=${WORKDIR} \
# Don't buffer `stdout`
PYTHONUNBUFFERED=1 \
# Don't create `.pyc` files:
PYTHONDONTWRITEBYTECODE=1 \
# make poetry create a .venv folder in the project
POETRY_VIRTUALENVS_IN_PROJECT=true
# ------------------------------ add user ----------------------------- #
RUN groupadd --gid $USER_GID "${USERNAME}" \
&& useradd --uid $USER_UID --gid $USER_GID -m "${USERNAME}"
# -------------------------- add python dependencies ------------------------- #
# Install Pipx using pip
RUN python -m pip install --no-cache-dir --upgrade pip pipx==${PIPX_VERSION}
RUN pipx ensurepath && pipx --version
# Install Poetry using pipx
RUN pipx install --force poetry==${POETRY_VERSION}
# ---------------------------- add code specifics ---------------------------- #
# Copy everything to the container
# we filter out what we don't need using .dockerignore
WORKDIR ${WORKDIR}
# make sure the user owns /app
RUN chown -R ${USER_UID}:${USER_GID} ${WORKDIR}
# Copy only the files needed for installing dependencies
COPY --chown=${USER_UID}:${USER_GID} pyproject.toml poetry.lock README.md src ${WORKDIR}/
RUN poetry install
USER ${USERNAME}
CMD [ "greeter-cli" ]
Breaking down the Dockerfile
There's a lot going on in this Dockerfile
. Let's break it down.
- We initialize some global variables meant to be available to all stages. We use the
ARG
keyword to define them. - We start by defining the
builder
stage from a slim python image as a base image. We use theAS
keyword to name the stage. - To use the global arguments defined at the beginning, we renew them after a new
FROM
line has been defined. We renew them by simply using the same ARG statement. - We define some additional variables such the versions of
poetry
andpipx
that we want to use. We do this because we want to pin versions for stable builds. - We define the
$PATH
variable to control and configurepython
,poetry
andpipx
installations beforehand - We configure
pipx
installation so that installer uses custom directories. - We also add the virtual environment directory to the path so that we can run the project from anywhere.
- We define the
PYTHONPATH
variable to point to the project directory at/app
so that we can import modules from the project. - We configure poetry using the
POETRY_VIRTUALENVS_IN_PROJECT
variable to make poetry create a.venv
folder in the project, at/app/.venv
. - We add a user and a group to avoid running as root.
- We install
pipx
usingpip
and later we installpoetry
usingpipx
. - We define our working directory at
/app
. This directory needs to be writable by the user that we created earlier. We also copy thepyproject.toml
andpoetry.lock
files to the container, alongREADME.md
and thesrc
directory. - We seal the stage by switching to the user that we created earlier. From this point on, all commands will be run as this user unless we switch to another user.
Add a new service to the docker-compose.part-3.yml
file:
version: "3"
services:
baby-steps:
image: poetry-ws-part-3-baby-steps:latest
build: services/part-3/baby-steps
basic-python:
image: poetry-ws-part-3-basic-python:latest
build: services/part-3/basic-python
python-poetry: # new
image: poetry-ws-part-3-python-poetry:latest # new
build: services/part-3/python-poetry # new
build the image and run it:
docker compose -f docker-compose.part-3.yml run --build python-poetry
Using docker compose to publish the image
You will need a docker hub account
You will need a docker hub account to publish the image. You can create one here. You can then login to your account using docker login
.
We can use docker compose to publish the image to docker hub. To do this, you must have a docker hub account and be logged in. You can push
the image to docker hub using:
docker compose -f docker-compose.part-3.yml push python-poetry
Don't put secrets in your Dockerfile
You should not put secrets in your Dockerfile
. Anyone can retrieve a password if you put it at some point in your Dockerfile
. You should use environment variables or better, use docker secrets to manage secrets. See this guide for more information.