Skip to content

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 安装。

python
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()
text
# Python 3.13 standard library only; no third-party packages.
dockerfile
# 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"]
text
*
!app.py
!requirements.txt
!Dockerfile
!Dockerfile.secret

完整 Compose 配置 ​

Docker Compose 完整配置 以 name 固定项目归属,store 只有内部网络地址,gateway 只发布回环端口;两个 healthcheck 使用镜像自带 Python,未假设 curl 存在。根文件系统只读,卷单独写入;内存、CPU、PID 与日志轮转值仅作示意,尚未经过运行测量。

yaml
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 改成 localhoststore 健康,gateway /ready 503修复服务名 store 和容器端口 8080
store 运行后停止gateway /live 200,/ready 和 / 为 503检查 store 状态和日志,恢复依赖,不靠重启 gateway 掩盖
宿主 18080 已占用gateway 端口发布失败核对占用者或更改宿主端口,不改容器监听
删除 named volume计数归零只能从事先验证的备份恢复,重拉镜像无效

以上是根据代码和配置推导的预期,不是实测输出。

读者自跑、停止与清理 ​

完整自跑说明保存在同目录 README;仅在读者自己确认环境后使用。

markdown
# 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。未运行构建或检查历史,不能声称已验证无泄漏。

dockerfile
# 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 引用,但本课不增加发布系统。

继续阅读