跳转至正文内容

独立技术知识论坛 主机玩家与运维交流社区 • 与德国 netcup GmbH 无官方隶属关系。

NETCUP GUIDE
知识论坛 · 实时优惠码池
运维教程 难度:中级进阶 耗时:40 分钟 // 阅读约 19 分钟 • 核验于

如何在 Netcup 上使用 Docker、Caddy v2 与 GitHub Actions 部署零停机生产级网站

在 Netcup VPS 或 Root Server 上部署具备生产级可靠性的 Web 应用程序,绝非简单地将单个容器的 80 端口暴露到公网。一套高可用的现代 Web 架构需要在边缘反向代理层卸载 TLS 1.3 与 HTTP/3 (QUIC) 加密流量,将 PostgreSQL 与 Redis 严格隔离在无公网路由的气隙内网中,针对高速 QUIC 传输调优 Linux 内核 UDP 套接字缓冲区,并在代码合并至 main 分支时执行零请求丢失的滚动更新。本端到端工程指南将结合 Docker Compose v2、Caddy v2、针对 Netcup NVMe 存储深度调优的 PostgreSQL 16、Redis 7 以及 GitHub Actions 自动化 SSH 部署流水线,带您完整构建这一生产级栈。

楼主 (OP) // 实测发起人 Marcus Vance 首席云基础设施架构师(欧洲 KVM 与网络工程)
版主复核 // 共同维护 Elena Rostova 高级 Linux 系统与安全审计工程师
帖子状态 // 最后修订 社区核查规范 →
版主按语 // 实测环境与社区透明说明 已在位于纽伦堡机房(AS197540)的双栈 Netcup RS 1000 G12.5 实例上,基于 Caddy 2.8+、PostgreSQL 16.4、Redis 7.4 与 Docker Compose v2 完成全链路验证。

环境要求 // 前置条件清单

检查清单
  • ✓ 一台已安装 Docker Compose v2、运行 Ubuntu 24.04 LTS 或 Debian 12 的 Netcup VPS(如配备 8 GB 内存的 VPS 1000 G12.5)或 Root Server(如配备 8 GB ECC 内存的 RS 1000 G12.5)
  • ✓ 一个已注册的域名,且其 DNS A(IPv4)与 AAAA(IPv6)记录已正确解析至您的 Netcup 服务器
  • ✓ 已在宿主机 UFW 防火墙与 Netcup SCP 云防火墙中同时放行 80/tcp、443/tcp 以及 443/udp 端口

核心技术结论 (Key Takeaways)

核心摘要
  • 01 仅在 Caddy 边缘网关容器上发布 80/tcp、443/tcp 与 443/udp 端口;将 Web 应用、PostgreSQL 与 Redis 容器隐藏在内部网桥中,绝不绑定到宿主机公网网卡。
  • 02 通过 sysctl 调大 Linux 内核 UDP 接收与发送缓冲区上限(net.core.rmem_max = 7500000),使 Caddy 底层的 quic-go 协议栈能够跑满 Netcup 1–2.5 Gbit/s 端口线速而不发生 UDP 丢包。
  • 03 在 Caddyfile 中配置主动上游健康检查(health_uri /healthz)与故障重试窗口(lb_try_duration 10s),在容器镜像滚动更新期间实现真正的零中断切换。
  • 04 针对 Netcup 本地 RAID10 企业级 NVMe 存储子系统,精细调优 PostgreSQL 16 核心参数(shared_buffers、effective_cache_size、random_page_cost=1.1)。
  • 05 使用遵循最小权限原则的 deploy 专用账户配合 GitHub Actions 实现自动化部署,通过蓝绿扩容脚本确保新容器未通过健康探针前绝不下线旧容器。
📑 本文目录导航(共 5 节) ▼
§ 01

第 1 步:验证双栈 DNS 解析并调优 Linux 内核 HTTP/3 QUIC 缓冲区

在启动 Caddy v2 或 Traefik v3 等支持 ACME 自动证书签发的反向代理之前,您必须首先确认域名的 A 记录(IPv4)与 AAAA 记录(IPv6)均已准确无误地解析到您的 Netcup 服务器。Let’s Encrypt 与 ZeroSSL 等证书颁发机构(CA)在执行域名所有权验证时遵循“IPv6 优先”原则:只要您的域名存在 AAAA 记录,验证服务器就会优先通过 IPv6 发起 HTTP-01(TCP 80 端口)或 TLS-ALPN-01(TCP 443 端口)质询。如果您的 AAAA 记录填写了错误的地址,或者宿主机 Netplan 的 fe80::1 IPv6 网关配置有误,即使 IPv4 完全正常,证书签发也会因连接超时而失败。

当证书签发成功后,Caddy 会将私钥、X.509 证书链以及 OCSP Stapling 响应持久化保存在容器内的 /data/caddy/certificates 目录下(映射为命名卷 caddy_data),并在证书剩余有效期不足 30 天时自动在后台静默续期。因此,生产运维中必须确保 caddy_data 卷的持久化安全,切勿在日常部署脚本中使用 docker compose down -v 误删该卷,否则反复申请新证书会迅速触发 Let’s Encrypt 每个注册域名每周 5 张重复证书的速率限制。

与此同时,现代 HTTP/3 协议完全构建在基于 UDP 443 端口的 QUIC 传输层之上。标准的 Ubuntu 与 Debian 内核默认分配的 UDP 套接字接收缓冲区非常狭小(rmem_max = 212992 字节,仅约 208 KB)。当您的网站在 Netcup 高速 1 Gbit/s 或 2.5 Gbit/s 网卡上承受高并发 HTTP/3 请求时,过小的内核 UDP 缓冲区会迅速溢出,导致 Caddy 日志频繁刷出 failed to sufficiently increase receive buffer size 警告并被迫回退到较小的拥塞窗口。通过 sysctl 将 UDP 缓冲区上限提升至约 7.5 MB 并开启 TCP BBR 拥塞控制算法,是充分发挥 Netcup 高带宽网卡性能的关键前置步骤。

核验双栈 DNS 解析记录并应用 HTTP/3 QUIC 与 TCP BBR 内核调优参数
bash
# 1. 启动 Caddy 前,向公共解析器核验域名的 A 与 AAAA 记录是否已正确解析
dig +short A app.example.com @1.1.1.1
dig +short AAAA app.example.com @1.1.1.1

# 2. 调大 HTTP/3 (QUIC) 所需的 UDP 套接字缓冲区并启用 TCP BBR 拥塞控制算法
sudo tee /etc/sysctl.d/99-netcup-web-tuning.conf > /dev/null <<'EOF'
# 将 UDP 接收与发送缓冲区上限提升至约 7.5 MB,保障高吞吐 HTTP/3 QUIC 传输
net.core.rmem_max = 7500000
net.core.wmem_max = 7500000
net.core.rmem_default = 1048576
net.core.wmem_default = 1048576

# 开启 TCP BBR 拥塞控制并调高突发 Web 连接队列上限
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
net.core.somaxconn = 8192
net.ipv4.tcp_max_syn_backlog = 8192
EOF

sudo sysctl --system

# 3. 在 UFW 防火墙中放行 HTTP、HTTPS (TCP) 以及 HTTP/3 QUIC (UDP 443)
sudo ufw allow 80/tcp comment 'HTTP ACME & Redirect'
sudo ufw allow 443/tcp comment 'HTTPS TLS 1.3'
sudo ufw allow 443/udp comment 'HTTP/3 QUIC'
sudo ufw status numbered

命令与参数详解: 向公共递归解析器验证公网 IPv4/IPv6 DNS 解析生效情况,写入并加载针对高速 HTTP/3 传输的内核 UDP 缓冲区与 TCP BBR 调优配置,并在 UFW 防火墙中放行 TCP 80/443 与 UDP 443 端口。

预期终端输出
* Applying /etc/sysctl.d/99-netcup-web-tuning.conf ...
net.core.rmem_max = 7500000
net.ipv4.tcp_congestion_control = bbr
在单节点 Netcup 服务器上选择 Caddy v2 还是 Traefik v3?

虽然 Traefik v3 在多租户集群的动态 Docker Label 服务发现方面表现出色,但在单节点 Netcup VPS 与 Root Server 生产部署中,架构师往往更青睐 Caddy v2:它的常驻内存占用仅约 35 MB,Caddyfile 语法极其直观,原生支持动态 Zstandard (zstd) 压缩与零配置 OCSP Stapling,而且最重要的一点是——Caddy 完全不需要挂载具有宿主机 root 等效特权的 /var/run/docker.sock 套接字即可完成反向代理与负载均衡。

§ 02

第 2 步:构建多层生产级 compose.yaml(Caddy + Web 应用 + Postgres 16 + Redis 7)

接下来我们在 /opt/stacks/prod-web 目录下构建完整的生产级 Web 应用栈。该架构在网络层将流量严格隔离为两个网桥:边缘接入网桥 edge_net(仅连接 Caddy 反向代理与 Web 应用容器)以及气隙数据网桥 db_net(设置 internal: true,仅用于连接 Web 应用容器与后端的 PostgreSQL 16 及 Redis 7)。

当容器同时接入多个自定义网桥时,Docker 守护进程内置的嵌入式 DNS 服务器(127.0.0.11)会根据请求来源所处的网桥作用域进行智能解析:在 edge_net 网桥上,caddy 可以通过服务名 app 解析到 Web 应用容器的 IP,但无法解析或路由到处于 db_net 上的 postgres 与 redis;而同时挂载了 edge_net 与 db_net 双网卡的 app 容器,则可以无缝连接后端的 postgres:5432 与 redis:6379。

请特别注意:compose.yaml 中的 app 服务既没有声明固定的 container_name,也没有绑定任何宿主机 ports:(仅使用 expose: ["3000"] 向内部网桥声明端口)。省略 container_name 与宿主机端口映射是一项关键设计——它允许我们在执行零停机更新时,利用 docker compose up -d --scale app=2 瞬时横向扩容出第二个应用容器而不会发生容器重名或端口占用冲突,并借助 Docker 内置 DNS 的多 A 记录轮询(Round-Robin DNS)让 Caddy 自动发现新旧两个实例。

初始化 /opt/stacks/prod-web 目录结构并创建 .env 环境配置
bash
sudo mkdir -p /opt/stacks/prod-web/{caddy,postgres,secrets,logs}
sudo chmod 0700 /opt/stacks/prod-web/secrets
openssl rand -base64 32 | tr -d '\n' | sudo tee /opt/stacks/prod-web/secrets/db_password.txt > /dev/null
sudo chmod 0400 /opt/stacks/prod-web/secrets/db_password.txt

sudo tee /opt/stacks/prod-web/.env > /dev/null <<'EOF'
COMPOSE_PROJECT_NAME=prod_web
DOMAIN_NAME=app.example.com
ACME_EMAIL=ops@example.com
APP_IMAGE=ghcr.io/your-org/production-web:2026.11.1
POSTGRES_DB=prod_app
POSTGRES_USER=prod_user
EOF
sudo chmod 0600 /opt/stacks/prod-web/.env

命令与参数详解: 创建生产栈所需的子目录结构,生成 256 位随机数据库密钥文件并锁定为 0400 只读权限,同时在 .env 中配置站点域名、ACME 证书通知邮箱与初始应用镜像版本。

/opt/stacks/prod-web/compose.yaml
yaml
name: ${COMPOSE_PROJECT_NAME:-prod_web}

x-logging: &default-logging
  driver: "json-file"
  options:
    max-size: "20m"
    max-file: "5"
    compress: "true"

services:
  caddy:
    image: caddy:2.8-alpine
    container_name: prod_web_caddy
    restart: unless-stopped
    ports:
      - "80:80/tcp"
      - "443:443/tcp"
      - "443:443/udp"
    environment:
      DOMAIN_NAME: ${DOMAIN_NAME}
      ACME_EMAIL: ${ACME_EMAIL}
    volumes:
      - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
      - ./logs:/var/log/caddy
    networks:
      - edge_net
    depends_on:
      app:
        condition: service_healthy
    deploy:
      resources:
        limits:
          cpus: "0.75"
          memory: 384M
    security_opt:
      - no-new-privileges:true
    logging: *default-logging

  app:
    image: ${APP_IMAGE}
    restart: unless-stopped
    expose:
      - "3000"
    environment:
      NODE_ENV: production
      PORT: "3000"
      DB_HOST: postgres
      DB_PORT: "5432"
      DB_NAME: ${POSTGRES_DB}
      DB_USER: ${POSTGRES_USER}
      DB_PASSWORD_FILE: /run/secrets/db_password
      REDIS_URL: redis://redis:6379/0
    secrets:
      - db_password
    networks:
      - edge_net
      - db_net
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/healthz > /dev/null || exit 1"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 15s
    deploy:
      resources:
        limits:
          cpus: "1.50"
          memory: 1536M
    security_opt:
      - no-new-privileges:true
    logging: *default-logging

  postgres:
    image: postgres:16.4-alpine3.20
    container_name: prod_web_postgres
    restart: unless-stopped
    shm_size: 256mb
    environment:
      POSTGRES_DB: ${POSTGRES_DB}
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - db_password
    volumes:
      - pg_data:/var/lib/postgresql/data
      - ./postgres/postgresql.conf:/etc/postgresql/postgresql.conf:ro
    command: ["postgres", "-c", "config_file=/etc/postgresql/postgresql.conf"]
    networks:
      - db_net
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB} -h 127.0.0.1"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 20s
    deploy:
      resources:
        limits:
          cpus: "1.50"
          memory: 3072M
    security_opt:
      - no-new-privileges:true
    logging: *default-logging

  redis:
    image: redis:7.4-alpine
    container_name: prod_web_redis
    restart: unless-stopped
    command:
      - "redis-server"
      - "--appendonly"
      - "yes"
      - "--maxmemory"
      - "512mb"
      - "--maxmemory-policy"
      - "allkeys-lru"
    volumes:
      - redis_data:/data
    networks:
      - db_net
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5
    deploy:
      resources:
        limits:
          cpus: "0.50"
          memory: 768M
    security_opt:
      - no-new-privileges:true
    logging: *default-logging

secrets:
  db_password:
    file: ./secrets/db_password.txt

volumes:
  caddy_data:
  caddy_config:
  pg_data:
  redis_data:

networks:
  edge_net:
    driver: bridge
  db_net:
    driver: bridge
    internal: true

命令与参数详解: 完整的生产环境 Compose 编排文件:将 Postgres 16 与 Redis 7 严格隔离在 db_net 内网中,同时允许 app 服务在 edge_net 上位于 caddy 后端进行水平无状态扩缩容。

安全与生产运维提示: 在日常部署维护期间,切勿随意删除 caddy_data 命名卷;该卷持久化存储了已签发的 ACME TLS 证书与私钥。如果反复删除重建该卷,会迅速触发 Let’s Encrypt 每 168 小时最多签发 5 张重复证书的速率限制(Rate Limit)。

§ 03

第 3 步:配置支持主动健康检查、HTTP/3 与安全响应头的 Caddyfile

为了确保在生产部署期间不丢弃任何一个正在处理的客户端 HTTP 请求,我们需要在 Caddy 的 reverse_proxy 指令块中同时启用主动健康检查(health_uri /healthz)与负载均衡重试窗口(lb_try_duration 10s)。当我们在滚动更新期间拉起新的 app 容器并准备停止旧容器时,Caddy 会每 5 秒探测一次所有上游容器的 /healthz 端点;一旦某个实例未能返回 HTTP 200 状态码,或者连接发生瞬时中断,lb_try_duration 10s 会将当前挂起的客户端请求自动重放至另一个健康的容器实例,对终端用户完全透明。

此外,以下 Caddyfile 还配置了从 www. 到裸域名的永久 308 重定向、原生 Zstandard (zstd) 与 Gzip 动态压缩、符合 OWASP 安全基准的响应头(包括 HSTS 预加载、禁止 MIME 嗅探、同源 Frame 限制以及移除暴露服务端特征的 Server 标头),并将访问日志以结构化 JSON 格式进行自动轮转存储。

/opt/stacks/prod-web/caddy/Caddyfile
text
{
    email {$ACME_EMAIL}
    servers {
        protocols h1 h2 h3
        trusted_proxies static private_ranges
    }
}

www.{$DOMAIN_NAME} {
    redir https://{$DOMAIN_NAME}{uri} permanent
}

{$DOMAIN_NAME} {
    encode zstd gzip

    header {
        Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "SAMEORIGIN"
        Referrer-Policy "strict-origin-when-cross-origin"
        Permissions-Policy "accelerometer=(), camera=(), geolocation=(), microphone=(), payment=(), usb=()"
        -Server
    }

    reverse_proxy app:3000 {
        lb_policy round_robin
        lb_try_duration 10s
        lb_try_interval 250ms

        health_uri /healthz
        health_interval 5s
        health_timeout 2s
        health_status 200

        fail_duration 15s
        max_fails 2

        header_up X-Real-IP {remote_host}
    }

    log {
        output file /var/log/caddy/access.log {
            roll_size 50MiB
            roll_keep 5
            roll_keep_for 720h
        }
        format json
    }
}

命令与参数详解: 配置 Caddy v2 同时开启 HTTP/1.1、HTTP/2 与 HTTP/3 协议,实现规范化 www 永久重定向、Zstandard/Gzip 压缩、加固的 OWASP 安全标头、针对 /healthz 的主动负载均衡探测以及自动轮转的 JSON 访问日志。

§ 04

第 4 步:针对 Netcup RAID10 NVMe 存储深度调优 PostgreSQL 16 (postgresql.conf)

在默认出厂配置下,PostgreSQL 16 假定自己运行在一块老旧的机械硬盘和微型内存机器上(默认 random_page_cost = 4.0、shared_buffers = 128MB、effective_io_concurrency = 1)。而在配备了企业级 RAID10 NVMe 固态存储与 8 GB 内存的 Netcup VPS 1000 G12.5 或 RS 1000 G12.5 上,这些保守的默认参数会导致查询规划器(Query Planner)错误地高估随机磁盘读取成本,从而放弃高效的 B-Tree 索引扫描并退化为缓慢的全表顺序扫描(Sequential Scan)。

同时,我们在 compose.yaml 中为 postgres 容器显式声明了 shm_size: 256mb。Docker 默认为容器分配的 /dev/shm POSIX 共享内存段仅有 64 MB;当 PostgreSQL 16 启用并行查询工作进程(Parallel Workers)执行多表哈希连接或大表 VACUUM 时,一旦动态共享内存(DSM)超过 64 MB,查询就会立即中断并报错 ERROR: could not resize shared memory segment: No space left on device。将 shm_size 扩大至 256 MB 并配合下方挂载的 /opt/stacks/prod-web/postgres/postgresql.conf,能够充分释放 Netcup NVMe 存储的并发 IOPS 性能:

不同 Netcup 服务器规格的 PostgreSQL 16 核心调优参数对照表
参数名称Postgres 默认出厂值VPS 1000 G12.5 / RS 1000 G12.5 (8 GB 内存)RS 2000 G12.5 (16 GB 内存)
shared_buffers128MB768MB (占 3 GB 容器内存的 25%)1536MB (占 6 GB 容器内存的 25%)
effective_cache_size4GB2048MB4096MB
work_mem4MB16MB32MB
maintenance_work_mem64MB192MB384MB
random_page_cost4.0 (机械硬盘假设)1.1 (企业级 NVMe 固态)1.1 (企业级 NVMe 固态)
effective_io_concurrency1200200
/opt/stacks/prod-web/postgres/postgresql.conf
ini
# 连接与 SCRAM-SHA-256 认证配置
listen_addresses = '*'
max_connections = 100
password_encryption = scram-sha-256

# 内存分配(针对 8 GB Netcup 节点上的 3 GB Postgres 容器调优)
shared_buffers = 768MB
effective_cache_size = 2048MB
work_mem = 16MB
maintenance_work_mem = 192MB
huge_pages = try

# Netcup 企业级 NVMe I/O 与查询规划器成本调优
random_page_cost = 1.1
seq_page_cost = 1.0
effective_io_concurrency = 200

# 预写式日志 (WAL) 与检查点平滑刷盘
wal_level = replica
max_wal_size = 2GB
min_wal_size = 512MB
checkpoint_completion_target = 0.9
wal_compression = zstd

# 可观测性与慢查询日志记录
logging_collector = off
log_min_duration_statement = 200
log_checkpoints = on
log_lock_waits = on

命令与参数详解: 针对 Netcup 企业级 NVMe 存储全面优化 PostgreSQL 16:强制启用 SCRAM-SHA-256 密码哈希认证、开启 Zstd WAL 日志压缩、将随机页读取成本设为 random_page_cost = 1.1,并自动记录执行耗时超过 200ms 的慢查询。

§ 05

第 5 步:编写零停机滚动发布脚本与 GitHub Actions SSH 部署流水线

如果您通过直接执行 docker compose down && docker compose up -d 来发布网站更新,那么在容器停止与新容器完成初始化之间的数秒乃至十余秒内,所有访问网站的用户都会看到 502 Bad Gateway 错误。

为了在单台 Netcup 服务器上实现零停机滚动更新(Zero-Downtime Rolling Update),我们编写一个专用发布脚本 /opt/stacks/prod-web/rollout.sh。该脚本的工作流分为四步:首先拉取最新镜像;随后通过 --scale app=2 --no-recreate 在保持旧容器继续服务的同时启动第二个新版本容器;接着循环轮询 Docker 健康状态,直到新容器通过 /healthz 检查并被加入 Caddy 的负载均衡池;最后向旧容器发送 SIGTERM 信号执行 15 秒优雅连接排空(Connection Draining),将其安全移除并恢复为单实例运行。

/opt/stacks/prod-web/rollout.sh (单机蓝绿零停机滚动更新脚本)
bash
#!/usr/bin/env bash
set -euo pipefail

STACK_DIR="/opt/stacks/prod-web"
NEW_IMAGE="${1:-}"

cd "$STACK_DIR"

if [[ -n "$NEW_IMAGE" ]]; then
  sed -i "s|^APP_IMAGE=.*|APP_IMAGE=${NEW_IMAGE}|" .env
fi

echo "==> [1/4] 正在拉取最新应用容器镜像..."
docker compose pull app

echo "==> [2/4] 正在将 app 临时扩容至 2 个实例并等待 /healthz 就绪..."
docker compose up -d --no-deps --scale app=2 --no-recreate app

# 最多等待 30 秒,确保新旧两个 app 容器均报告 healthy 状态
for i in {1..15}; do
  UNHEALTHY=$(docker compose ps app --format json | grep -v '"Health":"healthy"' || true)
  if [[ -z "$UNHEALTHY" ]]; then
    echo "==> 新旧两个 app 实例均已通过健康检查!"
    break
  fi
  sleep 2
done

echo "==> [3/4] 正在优雅排空旧容器连接并缩容回单实例..."
OLD_CONTAINER=$(docker compose ps -q app | head -n 1)
docker stop -t 15 "$OLD_CONTAINER"
docker rm "$OLD_CONTAINER"
docker compose up -d --no-deps --scale app=1 --no-recreate app

echo "==> [4/4] 正在清理悬空旧镜像..."
docker image prune -f
echo "==> 零停机滚动发布完成!"

命令与参数详解: 利用 Docker Compose 内部 DNS 轮询与 Caddy 主动健康检查机制,在单台 Netcup 宿主机上执行零请求丢失的蓝绿滚动更新,并在更新后自动清理悬空旧镜像。

.github/workflows/deploy-netcup.yml (GitHub Actions 自动化 CI/CD 工作流)
yaml
name: Build & Deploy to Netcup Production

on:
  push:
    branches: ["main"]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4

      - name: Log in to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Build and Push Immutable SHA Image
        uses: docker/build-push-action@v6
        with:
          context: .
          push: true
          tags: |
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

      - name: Trigger Zero-Downtime Rollout on Netcup Server
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.NETCUP_SSH_HOST }}
          port: ${{ secrets.NETCUP_SSH_PORT }}
          username: ${{ secrets.NETCUP_SSH_USER }}
          key: ${{ secrets.NETCUP_SSH_PRIVATE_KEY }}
          script: |
            sudo /opt/stacks/prod-web/rollout.sh "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}"

命令与参数详解: 当代码推送到 main 分支时,自动构建带有不可变 Git Commit SHA 标签的容器镜像,推送至 GitHub Container Registry (GHCR),并通过 SSH 触发 Netcup 服务器上的 /opt/stacks/prod-web/rollout.sh 脚本完成零停机发布。

安全与生产运维提示: 请遵循最小权限原则:为 CI/CD 专用的 SSH 账户(如 deploy)在 /etc/sudoers.d/deploy 中仅授予免密执行该发布脚本的权限(deploy ALL=(root) NOPASSWD: /opt/stacks/prod-web/rollout.sh),切勿授予无限制的完整 root 权限。

常见故障排查方案

排障手册

[故障现象] Caddy 签发 ACME TLS 证书失败,日志报错 Timeout during connect (likely firewall problem)

根本原因: 宿主机 UFW 防火墙或 Netcup SCP 云防火墙拦截了 80/tcp 或 443/tcp 端口,或者您的域名配置了指向错误/未启用 IPv6 地址的 AAAA 记录。

解决方案: 从外部网络机器分别运行 curl -4 -I http://app.example.com 与 curl -6 -I http://app.example.com 测试双栈连通性。修复宿主机的 IPv6 网关路由,或暂时移除错误的 AAAA 记录。

[故障现象] 响应头中已包含 Alt-Svc: h3=":443",但浏览器始终只使用 HTTP/2 而无法握手 HTTP/3 (QUIC)

根本原因: compose.yaml 中遗漏了 443:443/udp 端口映射,或者 UFW / Netcup SCP 防火墙仅放行了 TCP 443 而拦截了入站 UDP 443 流量。

解决方案: 执行 sudo ss -ulnp | grep 443 确认 Docker/Caddy 正在监听 UDP 443 端口,并检查 sudo ufw allow 443/udp 与 SCP 云防火墙入站规则是否已放行 UDP 443。

[故障现象] 后端 Web 应用程序日志中记录的客户端 IP 全部显示为内网地址 172.28.0.2,而非真实访客 IP

根本原因: 客户端请求经过 Caddy 反向代理通过 Docker 内部网桥转发至 app 容器,而后端 Web 框架未配置信任来自私有子网代理的 X-Forwarded-For / X-Real-IP 请求头。

解决方案: 在已配置 Caddy trusted_proxies static private_ranges 的基础上,在您的应用框架中开启代理头信任(例如在 Express/Fastify 中设置 app.set("trust proxy", true),或在 Uvicorn/Gunicorn 中启用 --proxy-headers)。

[故障现象] Caddy 启动日志中出现 failed to sufficiently increase receive buffer size (was: 208 kiB, wanted: 7168 kiB) 警告

根本原因: 宿主机 Linux 内核的 net.core.rmem_max 与 net.core.wmem_max 参数仍停留在 Ubuntu 默认的 212992 字节上限。

解决方案: 按照本文第 1 步应用 /etc/sysctl.d/99-netcup-web-tuning.conf 内核配置(执行 sudo sysctl --system),随后重启 Caddy 容器(sudo docker compose restart caddy)。

常见问题解答 (FAQ)

权威解答

我可以使用这套架构在单台 Netcup VPS 1000 G12.5 上同时托管多个独立的网站和域名吗?

完全可以。最佳实践是运行一个绑定宿主机 80 与 443 端口的全局共享 Caddy 边缘容器,并将其接入一个外部共享 Docker 网络(docker network create caddy_edge)。随后将每个独立网站的 compose.yaml 中的 app 服务接入 caddy_edge 网络,在 Caddyfile 中新增一个域名块(如 site2.example.com { reverse_proxy site2_app:3000 }),最后执行 docker exec prod_web_caddy caddy reload --config /etc/caddy/Caddyfile 即可实现零停机自动签发新站点证书。

在 Netcup 服务器前端是否应该开启 Cloudflare CDN 代理(小黄云 Orange Cloud)?

如果您的核心用户群位于欧洲(或靠近您所选的纽伦堡、维也纳、阿姆斯特丹、北美马纳萨斯或新加坡机房),直接由 Netcup 服务器通过 Caddy HTTP/3 响应流量可以省去一次额外的 CDN TLS 握手跳数,通常能将首字节时间(TTFB)降低 15–35 ms。如果您为了隐藏源站 IP 而开启了 Cloudflare 小黄云,请务必在 Cloudflare SSL/TLS 设置中开启 Full (Strict) 完全严格模式,并为 Caddy 配置 Cloudflare Origin CA 源站证书或 DNS-01 插件。

修改 Caddyfile 配置后,如何在不中断现有 WebSocket 或 HTTP/3 长连接的前提下平滑重载?

修改配置后切勿直接执行 docker restart prod_web_caddy。正确做法是在容器内部调用热重载命令:sudo docker compose exec caddy caddy reload --config /etc/caddy/Caddyfile。Caddy 会先校验语法,随后平滑派生新工作协程并优雅排空旧连接。

为什么我们要将 postgresql.conf 作为只读文件挂载,而不是直接修改 /var/lib/postgresql/data/postgresql.conf?

将 postgresql.conf 独立存放在 /opt/stacks/prod-web/postgres/postgresql.conf 并以 :ro 只读方式挂载,能够让您的数据库性能调优参数与 compose.yaml 一起纳入 Git 版本控制,实现基础设施即代码(IaC)并与底层的二进制数据卷解耦。

参考资料 // 官方技术文档与核验来源

延伸阅读 // 核心架构评测与买前必读

领取优惠码 →