Docker:Compose 双服务部署与持久数据
本篇以Docker 双服务计数项目为例;前置是Context、store/gateway、项目名与 counter 卷。源码与输入与下文配置共用同一份文件,示例未执行。
项目与前置
Docker 项目 示例未执行。examples/m5-docker 独立于本站 Dockerfile,由 store 与 gateway 两个服务组成。store 把计数存到 SQLite named volume;gateway 只代理 HTTP 请求,以服务名访问 store。无第三方 Python 包,无额外初始化 SQL 或未提供配置。未来自行运行需要 Linux Engine 或 Windows Desktop Linux 容器模式、支持本文件字段的现代 Compose、可信 Python 3.13 镜像源和空闲 18080 端口。
这是学习容器边界的本地回环演示。GET / 修改计数仅为把状态变化变得可见,不符合生产 HTTP 的安全方法约定;没有身份认证、TLS、并发压测或多副本写入设计,不能公开发布。
完整源码与构建文件
app.py 使用标准库 HTTPServer、sqlite3 和 urllib;每次请求开独立连接,通过事务提交计数。/ready 读数据库不增量;gateway 超时只返回 503,避免把一次增量重试成两次。requirements.txt 是依赖声明,不需要 pip 安装。
import json
import os
import signal
import sqlite3
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.request import urlopen
ROLE = os.environ.get("ROLE", "store")
DATA = Path(os.environ.get("DATA_DIR", "/data"))
if ROLE == "store":
DATA.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(DATA / "counter.db") as db:
db.execute("CREATE TABLE IF NOT EXISTS counter (id INTEGER PRIMARY KEY, value INTEGER NOT NULL)")
db.execute("INSERT OR IGNORE INTO counter VALUES (1, 0)")
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
try:
if self.path == "/live":
payload = {"live": True}
elif self.path not in ("/", "/ready"):
self.send_error(404)
return
elif ROLE == "gateway":
with urlopen(os.environ["UPSTREAM"] + self.path, timeout=2) as response:
payload = json.load(response)
else:
with sqlite3.connect(DATA / "counter.db", timeout=2) as db:
if self.path == "/":
db.execute("UPDATE counter SET value = value + 1 WHERE id = 1")
value = db.execute("SELECT value FROM counter WHERE id = 1").fetchone()[0]
payload = {"count": value, "role": ROLE}
body = json.dumps(payload).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
except Exception as error:
self.log_error("request failed: %s", type(error).__name__)
self.send_error(503, "dependency unavailable")
server = HTTPServer(("0.0.0.0", 8080), Handler)
def stop(signum, frame):
threading.Thread(target=server.shutdown, daemon=True).start()
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
try:
server.serve_forever(poll_interval=0.2)
finally:
server.server_close()# Python 3.13 standard library only; no third-party packages.# syntax=docker/dockerfile:1
FROM python:3.13-slim
WORKDIR /app
RUN mkdir /data && chown 10001:10001 /data
COPY --chown=10001:10001 app.py requirements.txt ./
USER 10001:10001
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
EXPOSE 8080
ENTRYPOINT ["python", "/app/app.py"]*
!app.py
!requirements.txt
!Dockerfile
!Dockerfile.secret完整 Compose 配置
Docker Compose 完整配置 以 name 固定项目归属,store 只有内部网络地址,gateway 只发布回环端口;两个 healthcheck 使用镜像自带 Python,未假设 curl 存在。根文件系统只读,卷单独写入;内存、CPU、PID 与日志轮转值仅作示意,尚未经过运行测量。
name: m5-docker
services:
store:
build: .
image: m5-docker-counter:theory
environment:
ROLE: store
volumes:
- counter:/data
networks: [backend]
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
mem_limit: 128m
cpus: 0.5
pids_limit: 64
stop_grace_period: 10s
healthcheck:
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/ready', timeout=2)"]
interval: 5s
timeout: 3s
retries: 5
start_period: 5s
logging:
driver: local
options:
max-size: 10m
max-file: "3"
gateway:
build: .
image: m5-docker-counter:theory
environment:
ROLE: gateway
UPSTREAM: http://store:8080
depends_on:
store:
condition: service_healthy
ports: ["127.0.0.1:18080:8080"]
networks: [backend]
read_only: true
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
mem_limit: 128m
cpus: 0.5
pids_limit: 64
stop_grace_period: 10s
healthcheck:
test: [CMD, python, -c, "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/ready', timeout=2)"]
interval: 5s
timeout: 3s
retries: 5
logging:
driver: local
options:
max-size: 10m
max-file: "3"
networks:
backend:
driver: bridge
volumes:
counter: {}正常结果与失败推演
Docker 计数 持久化 的预期是第一次访问 gateway 的 / 返回 count: 1,后续递增;健康检查只请求 /ready,因此不消耗计数。停止再启动同一项目应读取原卷数据。删除容器后重新创建,若仍使用同一 counter 卷也应保留;换项目名则得到另一个卷,不能误判为数据丢失。
| 改动或故障 | 预期症状 | 定位与恢复 |
|---|---|---|
| store 启动时 /data 不可写 | 进程启动失败,gateway 等不到 healthy | 检查卷 UID/GID 与只读挂载;修正数据权限后重建 |
| gateway 把 UPSTREAM 改成 localhost | store 健康,gateway /ready 503 | 修复服务名 store 和容器端口 8080 |
| store 运行后停止 | gateway /live 200,/ready 和 / 为 503 | 检查 store 状态和日志,恢复依赖,不靠重启 gateway 掩盖 |
| 宿主 18080 已占用 | gateway 端口发布失败 | 核对占用者或更改宿主端口,不改容器监听 |
| 删除 named volume | 计数归零 | 只能从事先验证的备份恢复,重拉镜像无效 |
以上是根据代码和配置推导的预期,不是实测输出。
读者自跑、停止与清理
完整自跑说明保存在同目录 README;仅在读者自己确认环境后使用。
# m5-docker:读者自行运行说明
示例未执行。本轮禁止启动服务、构建/启动容器或执行示例;下面仅供读者以后在自己的隔离环境使用。
前置:Linux Engine 或 Windows Docker Desktop 的 Linux 容器模式、现代 Compose Specification、可信 Python 3.13-slim 镜像、空闲回环端口18080、可创建named volume的权限。Python应用仅标准库,无pip步骤。tag未锁digest,发布前应自行选择核验过的digest和平台。此服务无认证,仅本地回环教学。
在此目录执行(不要使用仓库根Dockerfile):
```bash
docker compose up --build -d
docker compose ps
curl http://127.0.0.1:18080/ready
curl http://127.0.0.1:18080/
curl http://127.0.0.1:18080/
```
预期:两个容器healthy;ready返回200但不增量,后两次业务请求count依次为1、2(全新卷且此前无业务请求时)。停止store后gateway仍存活但ready和业务请求503。不要把这些预期写成已实测。
```bash
docker compose stop store
curl -i http://127.0.0.1:18080/ready
docker compose start store
docker compose down
docker compose up -d
```
相同项目名和卷应保留计数。down保留named volume;只有确定数据可丢弃并已有所需备份后,读者才可使用以下清理:
```bash
docker compose down --volumes --rmi local
```
此命令删除本项目卷,计数不可恢复;不运行全局prune。镜像可能因仍被其他容器引用而不能删除。
独立构建秘密示范:读者自行创建一个只含虚构字符串的非空临时文件token.txt,并保持它不进入Git;.dockerignore会排除它。Dockerfile.secret不参与主项目:
```bash
docker build -f Dockerfile.secret --secret id=build_token,src=token.txt -t m5-secret:theory .
docker image rm m5-secret:theory
```
缺少或空Secret应构建失败;有效Secret只作非空检查,不打印或复制值。普通构建缓存仍可能复用该步骤,Secret内容变化不保证缓存失效。实验后由读者删除token.txt;本仓库不提供真实凭据,也未执行构建验证。独立构建秘密实验
本文件不参与主 Compose 构建。读者自己创建不含真实凭据的临时 token 文件,再指定 Dockerfile.secret 与 --secret。缺少文件或空文件应失败;有效文件只改变构建是否允许继续,最终镜像不应含 token。未运行构建或检查历史,不能声称已验证无泄漏。
# syntax=docker/dockerfile:1
FROM python:3.13-slim AS check
# Teaching-only mount: tests presence, never prints or persists the value.
RUN --mount=type=secret,id=build_token,required=true test -s /run/secrets/build_token && touch /build-check
FROM python:3.13-slim
COPY --from=check /build-check /build-check
CMD ["python", "--version"]应用练习与解答
问题:健康检查也会增加 count 吗?答案:不会,只有 / 分支执行 UPDATE,/ready 只有 SELECT。健康端点若有业务副作用,会使监控本身改变业务状态。
问题:为什么两个服务都写 build: .?答案:它们用相同源码和构建上下文,区别由 ROLE 环境变量决定;没有隐藏的第二镜像源。未来也可先构建再按 digest 引用,但本课不增加发布系统。
继续阅读
- 知识专题
- 命令
- 命令