feat: 添加 Docker 容器化部署支持

支持两种部署模式,兼容新建 MySQL 和现有 MySQL:

- Dockerfile:Python 3.12-slim 两阶段构建,非 root 运行
- docker-compose.yml:全栈模式(含 MySQL 8.0 + 可选 Nginx)
- docker-compose.external-db.yml:接入现有 MySQL 模式
- docker/entrypoint.sh:自动等待 DB 就绪 → 初始化表 → 启动 Gunicorn
- docker/nginx.conf:反向代理 + 静态文件直出 + 安全响应头
- .env.docker.example / .env.external-db.example:各模式配置示例
- .gitattributes:确保 entrypoint.sh 在 Windows 上保持 LF 换行

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 00:38:14 +09:00
parent 7c156813c5
commit 3ad430e3e3
11 changed files with 583 additions and 0 deletions

44
.dockerignore Normal file
View File

@@ -0,0 +1,44 @@
# ── Python 缓存 ──────────────────────────────────────────────
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
# ── 虚拟环境 ─────────────────────────────────────────────────
.venv/
venv/
env/
ENV/
# ── 环境变量(不打包进镜像!)────────────────────────────────
.env
.env.docker
.env.external-db
# ── 上传文件(挂载卷提供,不打包)───────────────────────────
app/static/uploads/
# ── 数据库迁移临时文件 ───────────────────────────────────────
migrations/
# ── 版本控制 ─────────────────────────────────────────────────
.git/
.gitignore
# ── IDE/编辑器 ───────────────────────────────────────────────
.idea/
.vscode/
*.swp
*.swo
.DS_Store
Thumbs.db
# ── 测试/文档 ────────────────────────────────────────────────
tests/
*.md
docs/
# ── 日志 ─────────────────────────────────────────────────────
*.log
logs/

40
.env.docker.example Normal file
View File

@@ -0,0 +1,40 @@
# ═══════════════════════════════════════════════════════════════
# 模式一:新建 MySQL 全栈部署
# 使用方式cp .env.docker.example .env.docker
# 编辑后执行docker compose --env-file .env.docker up -d
# ═══════════════════════════════════════════════════════════════
# ── 应用安全 ────────────────────────────────────────────────────
# 必须修改!使用随机长字符串
SECRET_KEY=change-this-to-a-very-long-random-string-in-production
# ── MySQL新建数据库配置──────────────────────────────────────
MYSQL_ROOT_PASSWORD=StrongRootPass@2026
MYSQL_DATABASE=resource_library
MYSQL_USER=resource_library
MYSQL_PASSWORD=StrongUserPass@2026
# 是否对外暴露 MySQL 端口(生产环境建议保持 127.0.0.1 限制)
# 仅本机访问(推荐): MYSQL_EXPOSE_PORT=127.0.0.1:3306
# 所有网络接口(开发调试): MYSQL_EXPOSE_PORT=3306
MYSQL_EXPOSE_PORT=127.0.0.1:3306
# ── 管理员账号(首次启动自动创建)────────────────────────────────
ADMIN_USERNAME=admin
ADMIN_PASSWORD=Admin@123456
ADMIN_EMAIL=admin@example.com
# ── 应用配置 ────────────────────────────────────────────────────
FLASK_ENV=production
APP_PORT=5000
MAX_UPLOAD_SIZE_MB=500
LOG_LEVEL=info
# ── Gunicorn ────────────────────────────────────────────────────
# 建议CPU 核心数 × 2 + 1
GUNICORN_WORKERS=4
GUNICORN_TIMEOUT=120
# ── Nginx--profile nginx 时生效)──────────────────────────────
NGINX_HTTP_PORT=80
NGINX_HTTPS_PORT=443

45
.env.external-db.example Normal file
View File

@@ -0,0 +1,45 @@
# ═══════════════════════════════════════════════════════════════
# 模式二:使用现有 MySQL外部数据库
# 使用方式cp .env.external-db.example .env.external-db
# 编辑后执行:
# docker compose -f docker-compose.external-db.yml \
# --env-file .env.external-db up -d
# ═══════════════════════════════════════════════════════════════
# ── 应用安全 ────────────────────────────────────────────────────
SECRET_KEY=change-this-to-a-very-long-random-string-in-production
# ── 外部数据库连接(修改为实际连接信息)─────────────────────────
# 格式mysql+pymysql://用户名:密码@主机:端口/数据库名
#
# 示例 1 - 本项目现有数据库:
# DATABASE_URL=mysql+pymysql://resource_library:BWRVzzCwzuuP_1Hj@pma.hty1024.com:31000/resource_library
#
# 示例 2 - 宿主机 MySQL容器访问宿主机使用 host.docker.internal
# DATABASE_URL=mysql+pymysql://resource_library:password@host.docker.internal:3306/resource_library
#
# 示例 3 - 标准端口的云数据库:
# DATABASE_URL=mysql+pymysql://user:password@your-rds.amazonaws.com:3306/resource_library
DATABASE_URL=mysql+pymysql://resource_library:your_password@your_db_host:3306/resource_library
# 等待数据库就绪的超时时间(秒)
DB_WAIT_SECONDS=30
# ── 管理员账号(首次启动自动创建,已存在则跳过)────────────────
ADMIN_USERNAME=admin
ADMIN_PASSWORD=Admin@123456
ADMIN_EMAIL=admin@example.com
# ── 应用配置 ────────────────────────────────────────────────────
FLASK_ENV=production
APP_PORT=5000
MAX_UPLOAD_SIZE_MB=500
LOG_LEVEL=info
# ── Gunicorn ────────────────────────────────────────────────────
GUNICORN_WORKERS=4
GUNICORN_TIMEOUT=120
# ── Nginx--profile nginx 时生效)──────────────────────────────
NGINX_HTTP_PORT=80
NGINX_HTTPS_PORT=443

14
.gitattributes vendored Normal file
View File

@@ -0,0 +1,14 @@
# 强制 Shell 脚本使用 LF在 Windows 上构建 Linux 镜像时必须)
docker/entrypoint.sh text eol=lf
*.sh text eol=lf
# 普通文件使用系统默认换行
*.py text
*.html text
*.css text
*.js text
*.md text
*.yml text
*.yaml text
*.txt text
*.json text

67
Dockerfile Normal file
View File

@@ -0,0 +1,67 @@
# ═══════════════════════════════════════════════════════════════
# 个人资料库 — Dockerfile
# 构建方式docker build -t resource-library .
# ═══════════════════════════════════════════════════════════════
# ── 阶段 1依赖构建独立层仅在 requirements.txt 变更时重建)──
FROM python:3.12-slim AS builder
WORKDIR /build
# 安装编译依赖cryptography 等需要 gcc
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libffi-dev libssl-dev default-libmysqlclient-dev pkg-config \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
# 编译到 wheel 缓存目录,下一阶段直接 pip install --no-index
RUN pip wheel --no-cache-dir --wheel-dir /build/wheels -r requirements.txt
# ── 阶段 2运行镜像精简不含编译工具──────────────────────
FROM python:3.12-slim AS runtime
# 运行时系统依赖libmagic 用于文件类型识别)
RUN apt-get update && apt-get install -y --no-install-recommends \
libmagic1 curl \
&& rm -rf /var/lib/apt/lists/*
# 创建非 root 运行用户
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
# 从 builder 安装预编译 wheels离线无需网络
COPY --from=builder /build/wheels /tmp/wheels
COPY requirements.txt .
RUN pip install --no-cache-dir --no-index --find-links /tmp/wheels -r requirements.txt \
&& rm -rf /tmp/wheels
# 单独安装 gunicornWSGI 服务器)
RUN pip install --no-cache-dir gunicorn==23.0.0
# 复制应用代码
COPY . .
# 创建上传目录并设置权限
RUN mkdir -p app/static/uploads/{text,image,audio,video,temp} \
&& chown -R appuser:appuser /app
# 复制并授权启动脚本
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# 切换到非 root 用户
USER appuser
# 声明上传目录为卷
VOLUME ["/app/app/static/uploads"]
EXPOSE 5000
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:5000/auth/login || exit 1
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -0,0 +1,89 @@
# ═══════════════════════════════════════════════════════════════
# docker-compose.external-db.yml — 模式二:使用现有 MySQL
#
# 适用场景:
# - 已有云数据库(如 RDS、本项目使用的 pma.hty1024.com:31000
# - 不想在容器内运行 MySQL
#
# 用法:
# cp .env.external-db.example .env.external-db
# # 编辑 .env.external-db 填写实际数据库连接信息
# docker compose -f docker-compose.external-db.yml \
# --env-file .env.external-db up -d
#
# 包含服务:
# app — Flask + Gunicorn
# nginx — Nginx 反向代理(可选,使用 --profile nginx 启用)
# ═══════════════════════════════════════════════════════════════
services:
# ── Flask 应用 ──────────────────────────────────────────────
app:
build:
context: .
dockerfile: Dockerfile
image: resource-library:latest
container_name: resource_library_app
restart: unless-stopped
environment:
FLASK_ENV: ${FLASK_ENV:-production}
SECRET_KEY: ${SECRET_KEY}
DATABASE_URL: ${DATABASE_URL} # 完整连接串,见 .env.external-db.example
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-Admin@123456}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com}
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-4}
GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-120}
MAX_UPLOAD_SIZE_MB: ${MAX_UPLOAD_SIZE_MB:-500}
LOG_LEVEL: ${LOG_LEVEL:-info}
# 外部数据库等待超时(秒)
DB_WAIT_SECONDS: ${DB_WAIT_SECONDS:-30}
volumes:
- uploads_data:/app/app/static/uploads
ports:
- "${APP_PORT:-5000}:5000"
extra_hosts:
# 将外部数据库主机名加入容器 hosts若使用域名访问外部 DB
# 格式:- "db-host:实际IP"
# 若主机名可正常解析则不需要此配置
- "host-gateway:host-gateway"
networks:
- frontend
logging:
driver: json-file
options:
max-size: "20m"
max-file: "5"
# ── Nginx 反向代理(可选)──────────────────────────────────
nginx:
image: nginx:1.27-alpine
container_name: resource_library_nginx
restart: unless-stopped
profiles: ["nginx"]
depends_on:
- app
ports:
- "${NGINX_HTTP_PORT:-80}:80"
- "${NGINX_HTTPS_PORT:-443}:443"
volumes:
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- uploads_data:/app/app/static/uploads:ro
networks:
- frontend
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# ── 持久化卷 ────────────────────────────────────────────────────
volumes:
uploads_data:
name: resource_library_uploads
# ── 网络 ────────────────────────────────────────────────────────
networks:
frontend:
name: resource_library_frontend

118
docker-compose.yml Normal file
View File

@@ -0,0 +1,118 @@
# ═══════════════════════════════════════════════════════════════
# docker-compose.yml — 模式一:新建 MySQL全栈部署
#
# 用法:
# cp .env.docker.example .env.docker
# # 编辑 .env.docker 修改密码和 SECRET_KEY
# docker compose --env-file .env.docker up -d
#
# 包含服务:
# db — MySQL 8.0
# app — Flask + Gunicorn
# nginx — Nginx 反向代理(可选,使用 --profile nginx 启用)
# ═══════════════════════════════════════════════════════════════
services:
# ── MySQL 数据库 ────────────────────────────────────────────
db:
image: mysql:8.0
container_name: resource_library_db
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MYSQL_DATABASE: ${MYSQL_DATABASE:-resource_library}
MYSQL_USER: ${MYSQL_USER:-resource_library}
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
volumes:
- mysql_data:/var/lib/mysql
- ./docker/mysql-init:/docker-entrypoint-initdb.d:ro # 可放自定义初始化 SQL
command: >
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
--default-authentication-plugin=mysql_native_password
--innodb-buffer-pool-size=256M
ports:
- "${MYSQL_EXPOSE_PORT:-127.0.0.1:3306}:3306" # 默认仅本机可访问
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost",
"-u", "${MYSQL_USER:-resource_library}",
"-p${MYSQL_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
networks:
- backend
# ── Flask 应用 ──────────────────────────────────────────────
app:
build:
context: .
dockerfile: Dockerfile
container_name: resource_library_app
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
FLASK_ENV: ${FLASK_ENV:-production}
SECRET_KEY: ${SECRET_KEY}
DATABASE_URL: mysql+pymysql://${MYSQL_USER:-resource_library}:${MYSQL_PASSWORD}@db:3306/${MYSQL_DATABASE:-resource_library}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-Admin@123456}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com}
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-4}
GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-120}
MAX_UPLOAD_SIZE_MB: ${MAX_UPLOAD_SIZE_MB:-500}
LOG_LEVEL: ${LOG_LEVEL:-info}
volumes:
- uploads_data:/app/app/static/uploads
ports:
- "${APP_PORT:-5000}:5000" # 使用 Nginx 时可去掉此行
networks:
- backend
- frontend
logging:
driver: json-file
options:
max-size: "20m"
max-file: "5"
# ── Nginx 反向代理(可选,加 --profile nginx 启用)──────────
nginx:
image: nginx:1.27-alpine
container_name: resource_library_nginx
restart: unless-stopped
profiles: ["nginx"]
depends_on:
- app
ports:
- "${NGINX_HTTP_PORT:-80}:80"
- "${NGINX_HTTPS_PORT:-443}:443"
volumes:
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- uploads_data:/app/app/static/uploads:ro # 让 Nginx 直接服务静态文件
# - ./docker/ssl:/etc/nginx/ssl:ro # HTTPS 证书目录(取消注释后使用)
networks:
- frontend
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
# ── 持久化卷 ────────────────────────────────────────────────────
volumes:
mysql_data:
name: resource_library_mysql
uploads_data:
name: resource_library_uploads
# ── 网络 ────────────────────────────────────────────────────────
networks:
backend:
name: resource_library_backend
internal: true # db 不暴露给外部网络
frontend:
name: resource_library_frontend

91
docker/entrypoint.sh Normal file
View File

@@ -0,0 +1,91 @@
#!/bin/sh
# ═══════════════════════════════════════════════════════════════
# 容器启动脚本
# 职责:等待数据库就绪 → 初始化表结构 → 启动 Gunicorn
# ═══════════════════════════════════════════════════════════════
set -e
# ── 颜色输出 ──────────────────────────────────────────────────
log() { echo "[entrypoint] $*"; }
info() { echo "\033[0;36m[entrypoint] $*\033[0m"; }
ok() { echo "\033[0;32m[entrypoint] ✓ $*\033[0m"; }
warn() { echo "\033[0;33m[entrypoint] ⚠ $*\033[0m"; }
err() { echo "\033[0;31m[entrypoint] ✗ $*\033[0m" >&2; }
# ── 等待数据库就绪(最多 60 秒)────────────────────────────────
wait_for_db() {
info "等待数据库连接就绪…"
MAX_WAIT=${DB_WAIT_SECONDS:-60}
elapsed=0
until python - <<'PYEOF'
import os, sys, pymysql
url = os.environ.get('DATABASE_URL', '')
# 从 DATABASE_URL 解析连接参数
# 格式: mysql+pymysql://user:pass@host:port/db
import re
m = re.match(r'mysql\+pymysql://([^:]+):([^@]+)@([^:/]+):?(\d+)?/(\S+)', url)
if not m:
sys.exit(1)
user, pwd, host, port, db = m.groups()
port = int(port or 3306)
try:
conn = pymysql.connect(host=host, port=port, user=user,
password=pwd, database=db, connect_timeout=3)
conn.close()
sys.exit(0)
except Exception as e:
print(f" 未就绪: {e}", file=sys.stderr)
sys.exit(1)
PYEOF
do
if [ "$elapsed" -ge "$MAX_WAIT" ]; then
err "数据库在 ${MAX_WAIT}s 内未就绪,退出"
exit 1
fi
elapsed=$((elapsed + 3))
sleep 3
done
ok "数据库连接成功"
}
# ── 数据库初始化(幂等)────────────────────────────────────────
init_db() {
info "执行数据库初始化…"
python init_db.py \
--admin-user "${ADMIN_USERNAME:-admin}" \
--admin-pass "${ADMIN_PASSWORD:-Admin@123456}" \
--admin-email "${ADMIN_EMAIL:-admin@example.com}"
ok "数据库初始化完成"
}
# ── 创建上传目录 ───────────────────────────────────────────────
prepare_dirs() {
mkdir -p app/static/uploads/{text,image,audio,video,temp}
ok "上传目录就绪"
}
# ── 主流程 ────────────────────────────────────────────────────
info "启动个人资料库…"
info "Python: $(python --version)"
info "Flask env: ${FLASK_ENV:-development}"
prepare_dirs
wait_for_db
init_db
# ── 启动 Gunicorn ─────────────────────────────────────────────
WORKERS=${GUNICORN_WORKERS:-4}
TIMEOUT=${GUNICORN_TIMEOUT:-120}
BIND=${GUNICORN_BIND:-0.0.0.0:5000}
ok "启动 Gunicorn (workers=${WORKERS}, bind=${BIND})"
exec gunicorn \
--workers "$WORKERS" \
--worker-class sync \
--timeout "$TIMEOUT" \
--bind "$BIND" \
--access-logfile - \
--error-logfile - \
--log-level "${LOG_LEVEL:-info}" \
"run:app"

View File

73
docker/nginx.conf Normal file
View File

@@ -0,0 +1,73 @@
# ═══════════════════════════════════════════════════════════════
# Nginx 反向代理配置 — 个人资料库
# ═══════════════════════════════════════════════════════════════
upstream flask_app {
server app:5000;
keepalive 32;
}
# HTTP → HTTPS 重定向(生产环境启用)
# server {
# listen 80;
# server_name your-domain.com;
# return 301 https://$host$request_uri;
# }
server {
listen 80;
server_name _; # 替换为实际域名,如 resource.example.com
client_max_body_size 512M; # 需与 MAX_UPLOAD_SIZE_MB 一致
# ── 安全响应头 ──────────────────────────────────────────────
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# ── 静态文件直接由 Nginx 服务(绕过 Gunicorn性能更好──────
location /static/ {
alias /app/app/static/;
expires 7d;
add_header Cache-Control "public, immutable";
access_log off;
# 上传的用户文件:禁止目录列表
location /static/uploads/ {
alias /app/app/static/uploads/;
autoindex off;
expires 1d;
}
}
# ── 代理到 Flask ────────────────────────────────────────────
location / {
proxy_pass http://flask_app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 10s;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
# 大文件上传支持
proxy_request_buffering off;
}
# ── HTTPS 配置(取消注释并配置证书后启用)────────────────────
# listen 443 ssl http2;
# ssl_certificate /etc/nginx/ssl/cert.pem;
# ssl_certificate_key /etc/nginx/ssl/key.pem;
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_session_cache shared:SSL:10m;
# ── 健康检查端点 ────────────────────────────────────────────
location /health {
access_log off;
proxy_pass http://flask_app/auth/login;
proxy_set_header Host $host;
}
}

View File

@@ -12,3 +12,5 @@ requests==2.32.3
python-dotenv==1.0.1
python-magic-bin==0.4.14; sys_platform == 'win32'
python-magic==0.4.27; sys_platform != 'win32'
# WSGI 服务器(容器生产环境)
gunicorn==23.0.0