Session 4: Orchestration and Reproducible Environments¶
In our last session, we mastered Docker by containerizing a simple Python application. We learned how a single Dockerfile can build a reusable image and how to run a container from it.
But what if your application isn't a single unit? What if it needs a database, a web server, or another service to function? That's where Docker Compose comes in.
Where did my storage go?
If you find that you are running out of room after all of the previous docker lessons, try running docker system prune --volumes --all. This will clear all non-running containers, volumes, and caches. This does mean your next builds will take longer.
The Need for Orchestration: Why Docker Compose?¶
Running a single container with docker run is great, but real-world applications often involve multiple interconnected services. Manually managing each container and its network settings would be cumbersome and prone to error.
Docker Compose is a tool for defining and running multi-container Docker applications. With a single YAML file, you can configure your application's services, networks, and volumes, and then launch everything with a single command. It's the "orchestra conductor" for your containers.
Here's a diagram illustrating the relationship:
graph TD
A[compose.yml] --> B(Docker Compose)
B --> C{"Service 1 (e.g., Jupyter)"}
B --> D{"Service 2 (e.g., Redis)"}
C --> E[Container 1]
D --> F[Container 2]
subgraph Host Machine
G[Local Files]
end
G <--> C
G <--> D
E <--> F
In this model, the compose.yml file is the blueprint for your entire application stack, defining each service and how they should be configured.
The Live Demonstration: Setting Up Our Environment¶
Let's put this into practice by building a powerful, reproducible development environment. We'll start with a single Jupyter container and then add a Redis service to demonstrate the power of Compose.
Single-Container Environment¶
First, let's create a development environment with just Jupyter. This shows that you can use Compose even for a single service to manage its configuration.
Open the examples/docker-compose-jupyter folder. Inside, you'll find a requirements.txt and a Dockerfile. The Dockerfile simply installs the requirements.txt on top of the base Jupyter image. The main file is compose.yml. Let's look at it.
services: This is where we define our containers. In this case, we have a single service namedjupyter.build: Instead of pulling a pre-built image, we are building our custom image from theDockerfilein the current directory (.).ports: The8888:8888line maps port8888on our local machine to port8888inside the container, allowing us to access the Jupyter server in our browser.volumes: This is a crucial line for development. It creates a volume mount that synchronizes our local project directory (.) with the/home/jovyan/appdirectory inside the container. This means any changes we make to our local files will be immediately visible to the container, and vice versa.working_dir: Sets the default directory inside the container to/home/jovyan/app.environment: Set environment variables for the container. Alternatively, or in tandem, you can specifyenv_fileif you have environment variables stored in a file, i.e..env.command: We are overriding the default startup command to run Jupyter without requiring a token, making access easier for our local development.
Open the integrated terminal in VS Code, navigate to the docker-compose-jupyter folder, and run:
This will build the image and start the container. You'll see the logs, and after a moment, you can access Jupyter Lab at http://localhost:8888.
To stop the services, simply ctrl+c to hault the services, then run:
Change my docker environment
If you make changes to your docker environment, i.e. changes to the Dockerfile, requirements, etc. Your changes will not reflect automatically. Much like the previous Docker lesson, you must rebuild. Which you can do with docker compose build.
Multi-Container Environment¶
Now, let's add a second service to this setup. Most applications need a database or some kind of data store. Instead of a full database, we'll add Redis, an in-memory key-value store, which is perfect for this example.
Open the examples/docker-compose-jupyter-redis folder. In this folder, you will find a similar Dockerfile and requirements.txt, but this time the requirements.txt includes the redis Python library. Now, let's examine the compose.yml file.
redisservice: We've added a new service. Theimageis set toredis:6.0-alpine, a lightweight Redis image from Docker Hub.networks: This is the most important new section. When Docker Compose runs, it creates a virtual network by default, and services can find each other using their service names as hostnames. By explicitly defining ourmy-networkand assigning both services to it, we ensure that they can communicate with each other. This is how the Jupyter container will be able to find and talk to the Redis container.
Now, open the terminal, navigate to the docker-compose-jupyter-redis folder, and run:
Information Overload!
As you increase the number of containers, or add particularly expressive ones, you may notice that your terminal gets flooded with logging statements. You can instead run docker compose up -d which runs the containers detached (in the background). This means you also don't have to ctrl+c to hault the containers, you can just proceed to docker compose down.
Docker Compose will now build the Jupyter image and pull the Redis image, and then start both containers at the same time. You will see the logs for both services in the same terminal.
Access Jupyter Lab at http://localhost:8888. Open a new notebook and try to connect to the Redis container using the following code:
If you see messages printed, then it means your jupyter and redis services are correctly communicating with each other on the Docker network. This is a powerful demonstration of multi-container orchestration.
To localhost or not to localhost
Although you may access redis from your local computer at http://localhost:6379, this is not the case for the jupyter container. Notice that the hostname for the redis service is redis, this means from the Jupyter service that it's trying to access redis at http://redis:6379, NOT on the localhost. If you were to specify http://localhost:6379, it will try to resolve something running on 6379 WITHIN the jupyter container. It's okay if this is "uncomfortable" - it'll make a lot more sense when we learn web application development.
Recommended Practice Exercises:¶
Docker Compose CLI¶
Read through some of the other docker compose options running docker compose --help in your terminal. Also read through some of the documentation.
Investigate and get comfortable with the following commands and their options:
updownbuildrunexec
Don't Boil the Ocean
Setting up docker compose projects from scratch takes some practice. But know, when it comes down to using docker compose, it really boils down to 3 commands: docker compose build, docker compose up, docker compose down. You can even cut that down to two if you combine the first two using docker compose up --build.
Explore and Experiment with Your Compose File:¶
- Change a port: In
compose.yml, change theportsmapping for thejupyterservice to something else, like"8000:8888". Rerundocker compose upand access Jupyter at the new port (http://localhost:8000). - Add a container name: Add the
container_nameproperty to yourjupyterservice (e.g.,container_name: my-jupyter-instance). Rerun Compose and check Docker Desktop to see the new name. - Set an environment variable: Add a new environment variable to the
jupyterservice (e.g.,TZ: 'America/New_York'). In your Jupyter notebook, runimport os; os.environ.get('TZ')to see if the variable was correctly set. - Change the Redis image: In the
redisservice, change theimageto a different version, like"redis:7.2-alpine". Rerun Compose to see the new image being pulled and used.
Simple Producer/Consumer with Redis:¶
This exercise demonstrates inter-container communication in a more complete way. You'll create one container to act as a "producer" and use your existing Jupyter container as a "consumer."
First, set up some new files:
- Inside the examples/docker-compose-jupyter-redis folder, create a new subfolder called producer.
- In this new folder, create a Dockerfile, a requirements.txt file, and a producer.py script.
- A sample python script which that will write to Redis every few seconds. Mocking a data transmitter.
| producer.py | |
|---|---|
FROM python:3.12-slim-bookworm.
-
Update the
docker-compose-jupyter-redis/compose.ymlfile to include this producer as a new service. Make sure to pay attention to the network settings. -
In your terminal, navigate to the
producerfolder and rundocker compose up. This will start the producer container, which will continuously send messages to Redis. -
Consume the signal: Go back to the jupyterlab service in your browser, create a new notebook with the following code.
| consumer.ipynb | |
|---|---|
This demonstrates that three separate applications can communicate on the same Docker network (Jupyter (Consumer) -> Redis <-- Producer).
graph LR
A[Redis] <-- Consume Signals --> B[Jupyter]
C[Producer] -- Produce Signal --> A
Suggested Readings & Resources:¶
- Docker Compose Official Documentation: Overview of Docker Compose - Docker's official guide to Compose.
- Redis-Py Documentation: Quickstart - Learn more about the Python library for Redis.
- Jupyter Docker Stacks: Using the Docker Stacks - Official documentation for the Jupyter base images.