Docker Compose 实践¶
从单容器到容器编排¶
在前面的课程中,我们学习了如何使用 Docker 容器来运行单个服务。
通过 docker run 命令,我们可以快速启动一个数据库、一个 Web 服务器或者一个缓存服务。
这种方式在开发简单应用时非常有效。然而,随着应用架构的演进,微服务的理念逐渐流行,一个应用可能由多个相互依赖的服务组成。
如果继续使用单容器管理方式,我们需要手动管理容器间的网络连接、存储卷映射、环境变量配置等,这不仅增加了运维的复杂度,还容易因手动操作而出错。
这就是为什么我们需要一个更高层次的工具来管理多容器应用。 Docker Compose 应运而生,它通过一个声明式的 YAML 配置文件,帮助我们定义和管理多容器应用。 通过 Docker Compose,我们可以用一个命令就完成整个应用的部署,而不需要手动管理每个容器。
Docker Compose 核心概念¶
Docker Compose 是一个用于定义和运行多容器 Docker 应用程序的工具。使用 Compose,你可以通过一个 YAML 文件来配置应用程序的所有服务,然后使用一个命令来创建和启动所有服务。
主要概念¶
- 服务 (Services):容器的定义,包括使用哪个镜像、端口映射、环境变量等
- 网络 (Networks):定义容器之间如何通信
- 卷 (Volumes):定义数据的持久化存储
- 依赖关系 (Dependencies):定义服务之间的启动顺序
- 环境变量 (Environment Variables):管理不同环境的配置
核心命令¶
docker compose up:创建和启动所有服务docker compose down:停止和删除所有服务docker compose ps:查看服务状态docker compose logs:查看服务日志
实践项目:使用 docker compose 构建 Todo 应用¶
在本章节中,我们通过一个最小可用的 Todo 应用来实战 Docker Compose 编排。示例完全基于官方镜像,并在容器启动时用命令动态生成所需配置与代码,不需要在本机创建除本文外的任何文件。
目标组件¶
- Nginx:统一入口与反向代理 (对外 8080)
- 前端:CDN 版 React 静态页,由 Nginx 托管
- 后端:Node.js Express API (容器内 3001)
- 数据库:MongoDB (容器内 27017)
项目结构 (示意)¶
3.3 架构图¶
┌─────────────┐
│ Nginx │
│ :8080 │
└─────┬───────┘
│
┌────────┴────────┐
│ │
┌───────▼─────┐ ┌──────▼──────┐
│ Frontend │ │ Backend │
│ (React) │ │ (Node.js) │
│ :80 │ │ :3001 │
└─────────────┘ └──────┬───────┘
│
┌───────▼───────┐
│ MongoDB │
│ Database │
│ :27017 │
└───────────────┘
Docker Compose 配置¶
将下列 docker-compose.yml 内容复制到你的工程中使用 (该 compose 通过容器内命令动态生成 nginx.conf、前端 index.html 与后端 server.js):
version: "3.9"
name: todo-app
services:
# 统一入口网关:反向代理到 frontend 与 backend
nginx:
image: nginx:1.25-alpine
container_name: todo_nginx
ports:
- "8080:80"
depends_on:
- frontend
- backend
command: >-
sh -c '
cat > /etc/nginx/nginx.conf <<"EOF"
user nginx;
worker_processes auto;
events { worker_connections 1024; }
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
upstream frontend { server frontend:80; }
upstream backend { server backend:3001; }
server {
listen 80;
location / { proxy_pass http://frontend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
location /api/ { rewrite ^/api/?(.*)$ /$1 break; proxy_pass http://backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }
}
}
EOF
&& nginx -g "daemon off;"'
# 前端:无构建版 React(CDN 加载),由 nginx 直接静态托管
frontend:
image: nginx:1.25-alpine
container_name: todo_frontend
command: >-
sh -c '
cat > /usr/share/nginx/html/index.html <<"EOF"
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Todo App</title>
<style>
body { font-family: ui-sans-serif, system-ui; max-width: 680px; margin: 24px auto; }
li { display: flex; gap: 8px; align-items: center; padding: 6px 0; }
button { cursor: pointer; }
</style>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
</head>
<body>
<h1>Todo App</h1>
<div id="root"></div>
<script>
const e = React.createElement;
const API_BASE = '/api';
function App(){
const [todos, setTodos] = React.useState([]);
const [text, setText] = React.useState('');
async function load(){
const res = await fetch(`${API_BASE}/todos`);
setTodos(await res.json());
}
async function add(){
if(!text.trim()) return;
await fetch(`${API_BASE}/todos`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ title: text })});
setText('');
load();
}
async function toggle(id, completed){
await fetch(`${API_BASE}/todos/${id}`, { method:'PATCH', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ completed: !completed })});
load();
}
async function remove(id){ await fetch(`${API_BASE}/todos/${id}`, { method:'DELETE' }); load(); }
React.useEffect(()=>{ load(); },[]);
return e('div', null,
e('div', { style:{ display:'flex', gap:8 } },
e('input', { value:text, onChange:ev=>setText(ev.target.value), placeholder:'What to do?', style:{ flex:1, padding:8 } }),
e('button', { onClick:add }, 'Add')
),
e('ul', null, todos.map(t => e('li', { key:t._id },
e('input', { type:'checkbox', checked:!!t.completed, onChange:()=>toggle(t._id, !!t.completed) }),
e('span', { style:{ textDecoration: t.completed ? 'line-through' : 'none' } }, t.title),
e('button', { style:{ marginLeft:'auto' }, onClick:()=>remove(t._id) }, 'Delete')
)))
);
}
ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(App));
</script>
</body>
</html>
EOF
&& nginx -g "daemon off;"'
# 后端:Node.js(运行时安装依赖并生成 server.js)
backend:
image: node:18-alpine
container_name: todo_backend
environment:
- MONGODB_URI=mongodb://mongodb:27017/todos
- PORT=3001
depends_on:
- mongodb
command: >-
sh -c '
cat > server.js <<"EOF"
import express from "express";
import cors from "cors";
import { MongoClient, ObjectId } from "mongodb";
const app = express();
const port = process.env.PORT || 3001;
const mongoUri = process.env.MONGODB_URI || "mongodb://localhost:27017/todos";
app.use(cors());
app.use(express.json());
const client = new MongoClient(mongoUri);
let collection;
async function init(){
await client.connect();
const db = client.db();
collection = db.collection("todos");
}
app.get("/health", (_req, res) => res.json({ ok: true }));
app.get("/todos", async (_req, res) => {
const items = await collection.find({}).sort({ _id: -1 }).toArray();
res.json(items);
});
app.post("/todos", async (req, res) => {
const doc = { title: String(req.body?.title ?? ""), completed: false };
const r = await collection.insertOne(doc);
res.status(201).json({ _id: r.insertedId, ...doc });
});
app.patch("/todos/:id", async (req, res) => {
const id = req.params.id; const body = req.body || {};
await collection.updateOne({ _id: new ObjectId(id) }, { $set: body });
const updated = await collection.findOne({ _id: new ObjectId(id) });
if(!updated) return res.status(404).json({ message:"Not Found" });
res.json(updated);
});
app.delete("/todos/:id", async (req, res) => {
const id = req.params.id;
await collection.deleteOne({ _id: new ObjectId(id) });
res.status(204).end();
});
init().then(()=> app.listen(port, () => console.log(`API listening on ${port}`)))
.catch(err => { console.error("Mongo connect error", err); process.exit(1); });
EOF
&& npm init -y >/dev/null 2>&1 \
&& npm i express@4 cors@2 mongodb@6 --silent \
&& node server.js'
# 数据库:官方 MongoDB
mongodb:
image: mongo:7
container_name: todo_mongodb
volumes:
- mongodb_data:/data/db
volumes:
mongodb_data:
配置与代码说明¶
- 统一入口
nginx:容器启动时写入nginx.conf并前台运行 - 前端
frontend:容器启动时生成index.html,通过 CDN 加载 React,无需构建工具 - 后端
backend:容器启动时写入server.js,随后安装依赖并运行 - 数据库
mongodb:官方镜像,使用命名卷mongodb_data持久化数据
服务解析¶
- nginx 服务:使用官方
nginx:alpine镜像,对外暴露8080:80,将/转发到frontend,/api转发到backend - frontend 服务:使用官方
nginx:alpine,启动时写入带 React CDN 的index.html - backend 服务:使用官方
node:18-alpine,生成server.js,安装依赖后运行,连接mongodb - mongodb 服务:使用官方
mongo:7镜像,使用命名卷mongodb_data持久化
网络与数据¶
- 网络:默认 bridge 网络,服务间通过服务名互访 (
nginx、frontend、backend、mongodb) - 数据:使用命名卷
mongodb_data持久化 MongoDB 数据
使用说明¶
- 在后台启动服务:
- 查看服务状态:
- 查看服务日志:
docker compose logs nginx
docker compose logs frontend
docker compose logs backend
docker compose logs mongodb
- 停止所有服务:
- 重新拉取镜像并重建容器:
- 重启单个服务:
访问应用¶
本地开发 (如 VS Code) 直接在浏览器打开 http://localhost:8080 即可访问 Todo 应用。
- 使用 VS Code Dev Containers/Remote - Containers 时,
8080端口通常会自动转发;也可在 Ports 面板手动添加端口转发 - 若端口被占用,可在
docker-compose.yml中将8080:80改为其他可用端口 (如30080:80),然后重新启动:docker compose up -d