前言

最近,在做一个好玩的,以后有可能的话,说不定可以发展成自己的品牌 HackTheWhat

但是,无论如何,这不是重点,重点是想为这个品牌做一个有趣的网站。

于是乎,想到了最近用的很方便的Docker,我也算是时代的眼泪了,这么久才开始接触Docker。

正文

1. 文件目录

我们这里会用Docker起 3个服务:

  1. web:Python应用(FastAPI + Gunicorn

  2. nginx:反向代理(80 + 443)

  3. cerbot:使用webroot 模式签发/续签证书

Nginx负责TLS终止,并且把流量反向代理到web:8000 ;Let's Encrypt 使用cron 定时跑 certbot renew,完成后 reload Nginx。

我们先来展示一下我们的文件目录:

web-example/  
├─ docker-compose.yml  
├─ app/  
│ ├─ Dockerfile  
│ ├─ requirements.txt  
│ ├─ gunicorn.conf.py  
│ └─ app.py  
└─ nginx/  
  └─ conf.d/  
     ├─ site\_http.conf  
     ├─ site\_https.conf.disabled  
     └─ ssl\_params.conf

接下来是我们的文件内容,我们需要分别创建这些文件

1.1 docker-compose.yml

其中container_name 字段请 自行更改。

version: "3.8"

services:
  web:
    build: ./app
    container_name: example-web
    command: gunicorn -k uvicorn.workers.UvicornWorker -c gunicorn.conf.py app:app
    volumes:
      - ./app:/app
    expose:
      - "8000"
    restart: unless-stopped

  nginx:
    image: nginx:1.27-alpine
    container_name: example-nginx
    depends_on:
      - web
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d
      - ./nginx/certbot:/var/www/certbot
      - ./letsencrypt:/etc/letsencrypt
    restart: unless-stopped

  certbot:
    image: certbot/certbot:latest
    container_name: example-certbot
    volumes:
      - ./letsencrypt:/etc/letsencrypt
      - ./nginx/certbot:/var/www/certbot

1.2 app/Dockerfile

FROM python:3.11-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
    PYTHONUNBUFFERED=1 \
    PIP_NO_CACHE_DIR=1

WORKDIR /app

COPY requirements.txt .
RUN pip install --upgrade pip && pip install -r requirements.txt

COPY . .

EXPOSE 8000

CMD ["gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-c", "gunicorn.conf.py", "app:app"]

1.3 app/requirements.txt

fastapi>=0.111,<1
uvicorn[standard]>=0.30,<1
gunicorn>=21,<22

1.4 app/gunicorn.conf.py

import multiprocessing

bind = "0.0.0.0:8000"
workers = max(2, multiprocessing.cpu_count() * 2 + 1)
worker_class = "uvicorn.workers.UvicornWorker"
timeout = 60
keepalive = 5
accesslog = "-"
errorlog = "-"

1.5 app/app.py

其中title,还有html页面中的example 请自行更改。

from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse

app = FastAPI(title="example")

@app.get("/healthz")
async def healthz():
    return {"status": "ok"}

@app.get("/", response_class=HTMLResponse)
async def home(request: Request):
    return '''<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Example</title>
  <style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:2rem;max-width:820px}code{background:#f4f4f8;padding:.15rem .35rem;border-radius:.35rem}</style>
</head>
<body>
  <h1>Example</h1>
  <p>It works 🎉 — served by <strong>FastAPI + Gunicorn</strong> behind <strong>Nginx</strong> in Docker.</p>
  <p>Edit <code>app/app.py</code> to customize your homepage.</p>
  <p>Interactive docs: <a href="/docs">/docs</a></p>
</body>
</html>'''

1.6 nginx/conf.d/site_http.conf

其中 server 中的 server_name 字段请自行更改

# Initial HTTP-only config (use this before you obtain certificates)
server {
    listen 80;
    server_name example.com www.example.com;

    # ACME challenge location for certbot
    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    # Proxy all traffic to the Python app
    location / {
        proxy_http_version 1.1;
        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_pass http://web:8000;
    }
}

1.7 nginx/conf.d/site_https.conf.disabled

其中 server 中的 server_name ssl_certificatessl_certificate_key 字段 请自行更改。

# Enable HTTPS by renaming this file to: site_https.conf
# (and remove/rename site_http.conf), then reload nginx.
# This expects certs at /etc/letsencrypt/live/example.com/...

# Keep port 80 for ACME + redirect to HTTPS
server {
    listen 80;
    server_name example.com www.example.com;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    include /etc/nginx/conf.d/ssl_params.conf;

    location / {
        proxy_http_version 1.1;
        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_pass http://web:8000;
    }
}

1.8 nginx/conf.d/ssl_params.conf

# Reasonable modern TLS settings
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305';

# Enable HSTS (preload is optional; enable only when you're sure)
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;

2. 启动HTTP(给Certbot验证使用)

进入到 docker目录 /path/to/example-web,输入以下命令:

docker compose up -d web nginx
# 打开 http://example.com 看是否能访问,这里的example 是自己的域名

3. 申请证书(webroot模式)

启动HTTP后,我们申请证书:

EMAIL=you@example.com  # 换成你的邮箱
# 下面example.com请替换成自己的域名
docker compose run --rm certbot certonly \
  --webroot -w /var/www/certbot \
  -d example.com -d www.example.com \ 
  --email "$EMAIL" --agree-tos --no-eff-email

4. 切换到HTTPS的Nginx config 并且热重载Nginx

HTTPS证书申请到后,我们方可切换至HTTPS的 Nginx config,否则有小概率可能报错:

mv nginx/conf.d/site_https.conf.disabled nginx/conf.d/site_https.conf
rm -f nginx/conf.d/site_http.conf
docker compose exec nginx nginx -t && docker compose exec nginx nginx -s reload
# 访问 https://example.com

5. 自动续期Let's Encrypt证书(宿主机cron

设置自动续费:

crontab -e
# 每周一凌晨 3 点续期并重载 Nginx(/path/to/example-web改成你的路径)
0 3 * * 1 cd /path/to/example-web && \
  docker compose run --rm certbot renew && \
  docker compose exec nginx nginx -s reload

注意:如果遇到Permission Denied的情况,我们切换至su(root权限)即可。

6. 后续更新相关

如果后续想要简短的更新Docker服务,我们的更新分为三种:

  1. 只改页面/路由代码(不加新依赖)

  2. 新增第三方库(需要修改requirements.txt

  3. 添加静态文件或模板(*.png, *.html

6.1 只改页面/路由代码(不加新依赖)

  1. 修改 app/app.py

  2. 让应用重载,输入 docker compose restart web

6.2 新增第三方库(需要修改requirements.txt

  1. 编辑 app/requirements.txt(比如加上 jinja2 等)

  2. 重新构建并启动:

docker compose build web
docker compose up -d web

6.3 添加静态文件或模板(*.png, *.html

我们可以直接使用Nginx,来直接服务静态。

  1. 新建静态目录:app/static

  2. nginx/conf.d/site_https.conf里加入:

location /static/ {
    alias /var/www/static/;
    access_log off;
    expires 7d;
}
  1. 把静态目录挂载给 Nginx 容器(在 docker-compose.ymlnginx 服务里加一行):

services:
  nginx:
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d
      - ./nginx/certbot:/var/www/certbot
      - ./letsencrypt:/etc/letsencrypt
      - ./app/static:/var/www/static:ro   # ← 新增
  1. 重新加载 Nginx:

docker compose up -d nginx
docker compose exec -T nginx nginx -t && sudo docker compose exec -T nginx nginx -s reload

6.4 什么时候需要动Nginx?

  • 只改 Python 页面/路由:不需要动 Nginx。

  • 改了 Nginx 配置或证书:需要 nginx -t && nginx -s reload(见上面命令)。

总结

这个时代,有了GPT,学习起来,真方便呀!

参考

[1] ChatGPT

立志做一个有趣的碳水化合物。