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.
Say we have our API defined in a file called main.py, and our local filesystem looks like this:
│ ├── Dockerfile
│ └── main.py
Our (very simple)
api/Dockerfile might look like this:
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:
command: python main.py
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.
command tag in the
api service definition will overwrite the
CMD value in the Dockerfile being built.
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
localhost, which won’t work when both the
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.
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.