Getting Started with Docker-Compose
Docker-Compose fills the gap between running single Docker containers and enterprise-grade container orchestration engines like Kubernetes. With it, you can run multiple containers as services on a default network, so the services can communicate. Learn the basics of this powerful tool in this blog post.
Containers are becoming ever more prevalent in the world of software development, but cloud-hosted container orchestration engines like Kubernetes can be overkill for smaller projects, or early development. So, there is something of a gap between running single containers, and running a series of containerized services in a complex orchestration engine.
This is where Docker-Compose shines. It is a relatively simple way to get multiple containers to talk to each other on a default network. Today, we’ll go over some basics to get you started using Docker-Compose locally.
If you aren’t familiar with Dockerfiles, you’ll want to brush up. This blog post may be helpful.
Local and Remote Images
Just as when you work with single Docker containers, the images that define containers in Docker-Compose can either be built locally or pulled from a remote container registry. By default, Docker-Compose tries to pull images from DockerHub, just like Docker does.
Oftentimes, your project will include both locally built images and those pulled from a remote registry. For example, say you have an API that needs to interact with a database. Odds are that you will need to build your API from local code (so that it reflects local changes made during development) but the image defining your database will probably not change, so it’s a great candidate for storing in a remote registry, and simply having Docker-Compose pull it down as needed.
Concrete Example
Say we have our API defined in a file called main.py, and our local filesystem looks like this:
root
├── api
│ ├── Dockerfile
│ └── main.py
└── docker-compose.yml
Our (very simple) api/Dockerfile
might look like this:
FROM python:latest
COPY . .
CMD python main.py
Because we’re copying our local code into the image when it builds, we can easily update the local main.py
file and reflect those changes in our container when we start it.
That is, if we ran the following from within the api
directory, we would have a locally built image:
docker build -t example-image .
And we could then run the container based on this image by running:
docker run example-image
But what if we need to include that database we were talking about? Where does that get defined? As it turns out, it lives in the docker-compose.yml
file that is listed above as well.
Let’s say that our API needs a Postgresql database to store stuff in. We can make that available to our running API on a default network by including it as a service
in Docker-Compose YAML file, like so:
---
version: '3'
services:
db:
image: postgres:latest
ports:
- 5432:5432
environment:
- POSTGRES_USER=admin
- POSTGRES_PASSWORD=admin
- POSTGRES_DB=example_db
api:
restart: always
build: ./api/
ports:
- 5000:5000
command: python main.py
depends_on:
- db
Here, we have two services defined – our locally built api
image, and the publicly available postgres:latest
image that will be pulled from Dockerhub. We can tell the difference, because the db service defines an image
to be pulled. Conversely, the api
service has substituted that tag for a build
tag which takes a filepath to the directory in which the Dockerfile lives that defines the image we want to include.
We can also see that the api
service can’t run until the db
service has started, because of the depends_on
tag included in the service definition. This tells Docker-Compose what order to spin up the containers in.
NOTE: the command
tag in the api
service definition will overwrite the CMD
value in the Dockerfile being built.
Port Mapping
We have also mapped ports from the processes running inside of containers to the containers themselves, so other services outside of a given container can talk to those inside of it. For example, Postgres listens on port 5432
by default, so we map that port to the container, so it will forward any requests to that address on the network to the running service.
Notice that we’ve done the same with our locally built api
service – it is listening on port 5000
and that is mapped to the same port on the container it is running in.
For example, our API will need to have a connection string defined in order to connect to the Postgres instance. If we were running the whole thing directly on our local development machine, the string might look like this:
db = PostgresqlDatabase('example_db', user='admin', password='admin', host='0.0.0.0', port=5432)
But this is pointing to 0.0.0.0
or localhost
, which won’t work when both the api
and db
are containerized. Thankfully, Docker-Compose has made this simple to update to allow the services to communicate, like so:
db = PostgresqlDatabase('example_db', user='admin', password='admin', host='db', port=5432)
Notice that we changed the IP address for the service to the service name as it is defined in the docker-compose.yml
file. Now the api
service will be able to connect to the database.
TL;DR
Docker-Compose fills the gap between running single Docker containers and enterprise-grade container orchestration engines like Kubernetes. With it, you can run multiple containers as services on a default network, so the services can communicate. It is defined with a YAML file, and can include both locally built images and images pulled from a remote container registry like DockerHub, as shown above.