把一个 FastAPI 记账接口和 PostgreSQL 数据库打包成可以直接上线的 Docker 应用:多阶段构建镜像、非 root 用户运行、健康检查、Compose 编排、密码放在 .env 里,再加一个自动备份和恢复数据库的脚本。
先看清成品
成品是一个项目文件夹,在任何装了 Docker 的机器上执行
docker compose up -d --build,就会启动 API 和数据库两个容器,API 等数据库健康后才启动。本文全部步骤已在 Linux 上实际运行:两个容器均显示 healthy,接口正确返回记账汇总,镜像大小 182 MB,容器内用户为appuser,备份后删除数据卷、再恢复,数据完整回来。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| 多阶段 Dockerfile | 构建依赖不进入最终镜像 |
| Compose 编排 | API + PostgreSQL 17,带健康检查和启动顺序 |
| 密钥管理 | 数据库密码放 .env,不进代码仓库 |
| 记账 API | 新增支出、查询汇总、健康检查 |
| 备份脚本 | 导出压缩备份,保留最近 7 份 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Docker Engine 或 Docker Desktop | 支持 docker compose |
docker compose version 能输出版本 |
| 系统 | Linux、macOS、Windows 均可 | 上线服务器推荐 Linux |
| 端口 | 8000 空闲 | 被占用时改 Compose 的端口映射 |
第一步:项目结构是什么样的?
ledger/
├── app/
│ ├── __init__.py
│ └── main.py
├── requirements.txt
├── Dockerfile
├── .dockerignore
├── compose.yaml
├── .env.example # 提交到仓库的模板
├── .env # 真实密码,不提交
└── backup.sh
1. 让 AI 生成初稿(可选)
帮我把一个 FastAPI + PostgreSQL 项目容器化:
- Dockerfile 用多阶段构建,基础镜像 python:3.12-slim
- 最终镜像以非 root 用户运行,带 HEALTHCHECK 调用 /healthz
- compose.yaml 包含 api 和 db(postgres:17),db 带 pg_isready 健康检查,api 等 db 健康后启动
- 数据库密码从 .env 读取
- 再写一个 pg_dump 备份脚本,保留最近 7 份
第二步:应用代码怎么写?
1. app/main.py
import os
from contextlib import asynccontextmanager
import psycopg
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
DB = os.environ["DATABASE_URL"]
def conn():
return psycopg.connect(DB, autocommit=True)
@asynccontextmanager
async def lifespan(app):
with conn() as c:
c.execute("""CREATE TABLE IF NOT EXISTS expense (
id serial PRIMARY KEY, item text NOT NULL, amount numeric(10,2) NOT NULL,
created_at timestamptz DEFAULT now())""")
yield
app = FastAPI(title="记账 API", lifespan=lifespan)
class ExpenseIn(BaseModel):
item: str
amount: float
@app.get("/healthz")
def healthz():
try:
with conn() as c:
c.execute("SELECT 1")
except psycopg.OperationalError:
raise HTTPException(503, "database unavailable")
return {"ok": True}
@app.post("/expenses", status_code=201)
def add(e: ExpenseIn):
with conn() as c:
r = c.execute("INSERT INTO expense (item, amount) VALUES (%s, %s) RETURNING id", (e.item, e.amount)).fetchone()
return {"id": r[0]}
@app.get("/expenses/summary")
def summary():
with conn() as c:
n, total = c.execute("SELECT count(*), coalesce(sum(amount), 0) FROM expense").fetchone()
return {"count": n, "total": float(total)}
/healthz 会真的查询一次数据库,数据库挂了就返回 503,这样健康检查才有意义。
2. requirements.txt
fastapi==0.156.4
uvicorn[standard]==0.54.0
psycopg[binary]==3.3.6
版本号写死,保证每次构建结果一致。
第三步:Dockerfile 怎么写?
1. Dockerfile
# syntax=docker/dockerfile:1
# ---------- 第一阶段:安装依赖 ----------
FROM python:3.12-slim AS builder
WORKDIR /build
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# ---------- 第二阶段:运行 ----------
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
RUN useradd --create-home --uid 10001 appuser
WORKDIR /app
COPY --from=builder /install /usr/local
COPY app ./app
USER appuser
EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/healthz').status == 200 else 1)"
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
| 写法 | 原因 |
|---|---|
两个 FROM |
第一阶段装依赖,第二阶段只复制安装结果 |
先 COPY requirements.txt 再装依赖 |
改代码时依赖层可以复用缓存,构建更快 |
useradd + USER appuser |
容器被攻破时权限更小 |
HEALTHCHECK 用 Python 标准库 |
slim 镜像里没有 curl,不用额外安装 |
PYTHONUNBUFFERED=1 |
日志实时输出,docker logs 立刻能看到 |
2. .dockerignore
.git
.venv
__pycache__
*.pyc
.env
.env 写进 .dockerignore,防止密码被打包进镜像。
第四步:Compose 怎么编排?
1. compose.yaml
services:
api:
build: .
image: ledger-api:1.0
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql://ledger:${DB_PASSWORD}@db:5432/ledger
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17
environment:
POSTGRES_USER: ledger
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ledger
volumes:
- dbdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ledger -d ledger"]
interval: 5s
timeout: 3s
retries: 10
restart: unless-stopped
volumes:
dbdata:
2. 生成密码
cp .env.example .env
echo "DB_PASSWORD=$(openssl rand -hex 16)" > .env
注意:把
.env加进.gitignore,只提交.env.example。
3. 构建并启动
docker compose up -d --build
docker compose ps
本文实际状态:
api Up 20 seconds (healthy)
db Up 25 seconds (healthy)
4. 调用接口
curl -s -X POST localhost:8000/expenses -H 'content-type: application/json' -d '{"item":"午饭","amount":32.5}'
curl -s -X POST localhost:8000/expenses -H 'content-type: application/json' -d '{"item":"地铁","amount":4}'
curl -s localhost:8000/expenses/summary
本文实际输出:
{"id":1}{"id":2}
{"count":2,"total":36.5}
5. 检查结果
| 检查项 | 命令 | 本文结果 |
|---|---|---|
| 镜像大小 | docker images ledger-api:1.0 |
182 MB |
| 运行用户 | docker compose exec api id |
uid=10001(appuser) |
| 接口文档 | 浏览器打开 localhost:8000/docs |
自动生成的 Swagger 页面 |
第五步:怎样备份和恢复数据库?
1. backup.sh
#!/usr/bin/env bash
# 每天备份一次数据库,保留最近 7 份
set -euo pipefail
cd "$(dirname "$0")"
mkdir -p backups
f="backups/ledger-$(date +%Y%m%d-%H%M%S).sql.gz"
docker compose exec -T db pg_dump -U ledger ledger | gzip > "$f"
ls -1t backups/*.sql.gz | tail -n +8 | xargs -r rm --
echo "已备份到 $f"
./backup.sh
本文实际输出:
已备份到 backups/ledger-20261021-131036.sql.gz
2. 定时备份
crontab -e
加入一行,每天凌晨 3 点备份:
0 3 * * * /home/deploy/ledger/backup.sh >> /home/deploy/ledger/backups/backup.log 2>&1
3. 恢复演练
不演练过的备份不算备份。本文实际做了一次:删除数据卷后重建,再导入备份。
docker compose down -v
docker compose up -d db
zcat backups/ledger-20261021-131036.sql.gz | docker compose exec -T db psql -U ledger ledger
docker compose up -d
curl -s localhost:8000/expenses/summary
恢复后汇总重新显示 {"count":2,"total":36.5}。
先只启动 db 再导入,可以避免 API 启动时抢先建表,导入时报“表已存在”。
注意:
down -v会删除数据卷里的全部数据,只在演练或确认有备份时使用。备份文件要复制到另一台机器或云存储,和服务器放在一起等于没备份。
出问题时该怎么调?
1. api 一直是 starting 或 unhealthy
docker compose logs api --tail 50
docker inspect --format '{{json .State.Health}}' $(docker compose ps -q api)
第二条命令能看到健康检查每次的输出。常见原因是 DATABASE_URL 密码和数据库不一致。
2. 改了 .env 里的密码后连不上
PostgreSQL 只在数据卷为空时使用 POSTGRES_PASSWORD 初始化。已有数据时,要么进数据库修改密码,要么备份后删除数据卷重建。
3. 构建时 pip 下载失败
报错含 Temporary failure in name resolution 时,是构建容器无法联网。检查 Docker 的 DNS 和代理设置,可以先用 docker run --rm busybox nslookup pypi.org 测试。
4. KeyError: 'DATABASE_URL'
直接在本机运行了 uvicorn,而没有设置环境变量。本地调试时先设置:
export DATABASE_URL=postgresql://ledger:密码@localhost:5432/ledger
并在 Compose 里给 db 临时加上端口映射 5432:5432。
常见问题怎么处理?
| 现象 | 原因 | 解决方法 |
|---|---|---|
port is already allocated |
8000 被占用 | 改成 "8080:8000" |
| 改代码后没变化 | 没重新构建 | docker compose up -d --build |
| 镜像很大 | 用了完整版 python 镜像 | 用 slim 并保持多阶段 |
| 备份文件为 0 字节 | db 容器没运行 | 先 docker compose ps 确认 |
permission denied 写文件 |
非 root 用户无写权限 | 只给需要写的目录授权,不要改回 root |
| 服务器重启后服务没起来 | Docker 未设开机启动 | sudo systemctl enable docker |
项目资源
| 用途 | 地址 |
|---|---|
| Dockerfile 参考 | https://docs.docker.com/reference/dockerfile/ |
| 多阶段构建 | https://docs.docker.com/build/building/multi-stage/ |
| Compose 文件参考 | https://docs.docker.com/reference/compose-file/ |
| Compose 启动顺序 | https://docs.docker.com/compose/how-tos/startup-order/ |
| PostgreSQL 镜像 | https://hub.docker.com/_/postgres |
| pg_dump 文档 | https://www.postgresql.org/docs/current/app-pgdump.html |
| FastAPI 部署到 Docker | https://fastapi.tiangolo.com/deployment/docker/ |
| Psycopg 3 文档 | https://www.psycopg.org/psycopg3/docs/ |
建议先只做到第四步,在自己电脑上看到两个容器都是 healthy,再用 curl 调一次接口。
然后务必做一次第五步的恢复演练,确认备份真的能恢复,再把项目部署到服务器上。