A Beginner‘s Guide to Docker — How to Create a Client/Server Application with Docker Compose

Docker has revolutionized the way applications are developed and deployed. By packaging code and dependencies into containers, Docker enables applications to run consistently across environments and simplifies the development process. Docker Compose further improves the developer experience by making it easy to define and run multi-container applications.

In this comprehensive beginner‘s guide, we‘ll dive into the core concepts of Docker and explore how to leverage Docker Compose to create a client-server application step-by-step. Whether you‘re new to containerization or looking to level up your skills, this tutorial will equip you with a solid foundation to start Dockerizing your own applications. Let‘s get started!

Understanding Docker Fundamentals

Before we jump into building our application, let‘s establish a strong understanding of the key concepts in the Docker ecosystem.

Images and Containers

The two fundamental units in Docker are images and containers:

  • An image is a read-only template that contains the application code, libraries, tools, and a base operating system. Images are defined using a Dockerfile and can be stored in registries like Docker Hub for sharing. Some common base images include Alpine Linux, Ubuntu, and language-specific images like python or node.

  • A container is a runnable instance of an image. Containers are lightweight and isolated, with their own filesystems and networks. They can be started, stopped, and deleted independently. Containers are the basic unit of deployment in Docker.

To create a container, Docker reads the instructions in the image‘s Dockerfile, builds the image, and then runs a container based on that image.

Networking

By default, Docker creates a network for each application where containers can communicate with each other by their service names. Docker provides several networking drivers:

  • bridge – the default network driver. Containers on the same bridge network can communicate with each other, but are isolated from external networks.
  • host – removes network isolation and uses the host‘s networking directly.
  • overlay – enables communication between containers across multiple Docker hosts.

You can also define your own networks to control which services can communicate.

Volumes

Containers are ephemeral by nature, so any data stored inside the container is lost when the container is removed. Volumes provide a way to persist data outside the container‘s lifecycle.

There are two main types of volumes:

  • Host volumes mount a directory from the host machine into the container. This is useful for sharing code for development.
  • Named volumes are managed by Docker and stored in a directory on the host. They can be shared between containers.

Volumes are defined in the Dockerfile or Compose file and mounted into containers at runtime.

With these core concepts in mind, let‘s look at some Docker adoption statistics before diving into our application.

Docker Adoption Statistics

Docker‘s popularity has exploded since its initial release in 2013. Here are some key statistics that showcase its widespread adoption:

  • There were over 318 billion total Docker container downloads as of January 2023 (source)
  • Docker adoption increased to 35% of all organizations in 2022, up from 29% in 2021 (source)
  • 68% of developers use containers in development and/or production (source)
  • Docker Hub has over 9 million registered users and hosts over 8 million container images (source)

These numbers demonstrate that Docker has become a mainstream tool for modern software development. Its benefits of portability, isolation, and reproducibility have made it an essential skill for developers across the stack.

Creating a Client-Server Application with Docker Compose

Now that we have a solid foundation, let‘s walk through creating a simple client-server application using Python and Docker Compose.

Project Structure

First, let‘s set up our project structure:

.
├── docker-compose.yml
├── server
│   ├── Dockerfile
│   └── server.py
└── client
    ├── Dockerfile 
    └── client.py

The key files are:

  • docker-compose.yml defines our application‘s services
  • server/Dockerfile defines how to build the server‘s container image
  • server/server.py contains the Python server code
  • client/Dockerfile defines the client container image
  • client/client.py contains the Python client code

Server Code

Here‘s the code for a basic HTTP server using Python‘s http.server module in server/server.py:

from http.server import HTTPServer, SimpleHTTPRequestHandler

class MyServer(SimpleHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-type", "text/html")
        self.end_headers()
        self.wfile.write(bytes("", "utf-8"))

if __name__ == "__main__":
    server_address = ("", 8000)
    httpd = HTTPServer(server_address, MyServer)
    print("Server running on port 8000")
    httpd.serve_forever()

This server listens on port 8000 and responds with a simple HTML message for GET requests.

Server Dockerfile

Next, we‘ll define how to package the server code into a Docker image in server/Dockerfile:

FROM python:3.9-slim

WORKDIR /app

COPY server.py .

EXPOSE 8000

CMD ["python", "server.py"]

This Dockerfile uses a slim Python base image, copies the server code into the image, exposes port 8000, and defines the startup command.

Client Code

The client code in client/client.py makes a request to the server:

import http.client

conn = http.client.HTTPConnection("server", 8000)

conn.request("GET", "/")

response = conn.getresponse()
print(f"Status: {response.status} {response.reason}")

data = response.read().decode()
print(data)

conn.close() 

The client connects to a host named "server" on port 8000 (which we‘ll define in the Compose file), makes a GET request, and prints the response.

Client Dockerfile

The client/Dockerfile is very similar to the server‘s:

FROM python:3.9-slim

WORKDIR /app

COPY client.py .

CMD ["python", "client.py"]

Defining Services with Docker Compose

With our components ready, we can define the application‘s services in docker-compose.yml:

version: "3.9"
services:
  server:
    build: ./server
    ports:
      - "8000:8000"
  client:
    build: ./client
    depends_on:
      - server      

This defines a "server" service that builds from the ./server directory and maps port 8000, and a "client" service that depends on the "server".

Some best practices to follow in your Compose files include:

  • Use version "3.x" for the latest features
  • Give your services descriptive names
  • Mount volumes for any non-ephemeral data
  • Use depends_on to control service startup order
  • Define environment variables for configuration

Building and Running the Application

To build and run the application, use these Docker Compose commands:

# Build the images
docker-compose build

# Start the containers 
docker-compose up

# Stop and remove the containers
docker-compose down

When you run docker-compose up, you should see the server start up, followed by the client connecting and printing the response:

Starting myapp_server_1 ... done
Starting myapp_client_1 ... done
Attaching to myapp_server_1, myapp_client_1
server_1  | Server running on port 8000
client_1  | Status: 200 OK
client_1  | 
myapp_client_1 exited with code 0

Congrats, you‘ve just orchestrated your first application with Docker Compose!

Additional Docker Compose Features

Here are a few more Compose features that are useful as your applications grow in complexity:

Profiles

Profiles allow you to define alternative configurations in the same Compose file. For example, you could have a "debug" profile that mounts your code as a volume for live reloading. Profiles are specified using the --profile flag when running Compose commands.

Overriding Commands

You can override the default command for a service at runtime using the command option in your Compose file or the --command flag. This is handy for running alternative start scripts or passing in different arguments.

Healthchecks

Compose can run healthchecks on your containers to ensure they‘re ready to handle traffic. You define the healthcheck command in your Dockerfile or Compose file, and Compose will wait until the healthcheck passes before considering the service "up".

Docker in the Development Ecosystem

Docker has become an integral part of the modern development stack. Its rise in popularity has coincided with the proliferation of microservices architectures, where applications are broken down into smaller, loosely coupled services that can be developed and deployed independently.

Docker enables teams to standardize their development environments and ensure consistency across the entire pipeline from dev to production. By packaging services into containers, developers can use the same images locally as will be deployed to production, reducing issues caused by environment drift.

Many popular development tools now integrate with Docker to streamline the development workflow:

  • IDEs like VS Code and JetBrains have extensions for managing Docker images and containers
  • CI/CD platforms like Jenkins, GitLab, and CircleCI support building and pushing Docker images
  • Kubernetes, the de facto standard for container orchestration, uses Docker as its default container runtime

As the containerization ecosystem continues to mature, the tooling and best practices around Docker are only getting better. Staying up-to-date with the latest Docker features and integrations is key to maximizing your productivity as a developer.

Conclusion

In this guide, we‘ve covered the essential concepts of Docker and walked through creating a development-ready client-server application using Docker Compose.

To recap, the key steps in Dockerizing an application are:

  1. Write a Dockerfile for each service that defines how to build its image
  2. Create a docker-compose.yml file that describes the application‘s services and their runtime configuration
  3. Use docker-compose build and docker-compose up to build and run the entire multi-container application with a single command

The benefits of using Docker Compose for development include:

  • Consistency – all developers use the same images and configuration
  • Portability – applications can be run on any machine that has Docker installed
  • Efficiency – images are built once and started as containers almost instantly
  • Isolation – services run in independent containers and can be built separately
  • Scalability – services can be scaled up or down by changing the number of container replicas

As a developer in today‘s cloud-native landscape, understanding how to leverage containers is essential. Docker and Docker Compose provide an accessible entrypoint to the world of containers and empower you to create reproducible, portable applications that can run anywhere.

Equipped with this knowledge, I encourage you to start incorporating Docker into your own development workflow. Containerize your side projects, experiment with microservices patterns, and collaborate with others using Compose files. The more you use containers, the more their benefits will become clear.

While this guide focused on a simple application, the same concepts and patterns apply as your systems grow in size and complexity. As you progress on your containerization journey, dive into more advanced topics like multi-stage builds, container networking, Kubernetes, and serverless platforms. The Docker ecosystem is vast and constantly evolving, so there‘s always more to learn.

Happy Dockerizing!

Similar Posts