The most surprising thing about containerizing every microservice is how little it actually changes your application’s core logic, yet how much it revolutionizes its deployment and scaling.
Imagine a simple two-service application: user-service and order-service.
# user_service.py
from flask import Flask, jsonify
app = Flask(__name__)
@app.route('/users/<int:user_id>')
def get_user(user_id):
# In a real app, this would hit a database
return jsonify({"id": user_id, "name": "Alice"})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
# order_service.py
from flask import Flask, jsonify
app = Flask(__name__)
@app.route('/orders/<int:user_id>')
def get_orders(user_id):
# In a real app, this would hit a database
return jsonify([{"order_id": 101, "item": "Laptop"}, {"order_id": 102, "item": "Mouse"}])
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
Without Docker, you’d install Python, Flask, and run these services directly on your server, managing their dependencies and processes manually.
Now, let’s containerize them. For user-service, create a Dockerfile:
# user-service/Dockerfile
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "user_service.py"]
And a requirements.txt:
Flask==2.0.2
Do the same for order-service. Then, build the images:
docker build -t user-service:v1 ./user-service
docker build -t order-service:v1 ./order-service
And run them as containers:
docker run -d --name user-service-container -p 5000:5000 user-service:v1
docker run -d --name order-service-container -p 5001:5000 order-service:v1
Notice we mapped container port 5000 to host ports 5000 and 5001 respectively. Now, user-service is accessible at http://localhost:5000/users/1 and order-service at http://localhost:5001/orders/1. The application logic is identical; only its execution environment has changed.
The core problem Docker solves here is environment inconsistency. Before Docker, "it worked on my machine" was a common lament. Each service, running in its own container, has its dependencies, libraries, and runtime version isolated. This means a user-service container built on your machine will behave identically on a staging server or production. It’s a self-contained, reproducible unit of deployment.
Internally, each Docker image is a layered filesystem. FROM python:3.9-slim is the base layer. RUN pip install... adds a layer with installed packages. COPY . . adds your application code as another layer. When you run a container, Docker creates a writable layer on top of these read-only image layers. This isolation means changes within a container don’t affect other containers or the host system.
You control the deployment via docker run commands or, more commonly, orchestration tools like Docker Compose or Kubernetes. For our example, docker run is the lever. You specify the image, container name, port mappings (-p), and whether to run in detached mode (-d).
A key aspect often overlooked is how Docker handles networking between containers. By default, containers on the same Docker network can communicate using their container names as hostnames. So, if order-service needed to call user-service, it wouldn’t use localhost:5000. Instead, it would use http://user-service:5000/users/1 (assuming they are on the same user-defined network). This abstraction allows services to find each other without needing to know their specific IP addresses or host port mappings.
The next logical step after containerizing individual services is orchestrating them, allowing them to discover and communicate with each other reliably at scale.