XF

服务部署 Step 1. Docker + Traefik Proxy

History


假定我们已经购买了一台云服务器,并且已经装好了系统镜像,可以开机、可以通过 SSH 登入进去。

下面的操作和配置皆以 Debian 系统为例。除了偶尔使用 apt 命令安装之外,我们没有太多 Debian only 的操作(在其他系统上,使用系统自带的包管理器安装软件即可)。尤其是装了 Docker 之后,大多数操作实际上在 Debian 或是其他 Linux 发行版上都没区别。

现在我的服务都在 Docker 里运行,用 Traefik 作为统一的 Reverse Proxy 来处理所有 HTTPS 请求,根据域名或路径分发给不同应用的容器。目前看来数据备份、迁移还算方便:例如有一天我想将某个服务、或全部服务从一台服务器迁移至另一台服务器,只需要将相应的 docker-compose 配置文件夹及数据文件夹 rsync 到目标服务器,在目标服务器上运行 docker-compose up -d 就可以了,整个迁移过程相对省时省力。

域名

本页面及后续的服务搭建页面,均要求您拥有一个自己的域名以方便行事 —— 尤其是申请 HTTPS 证书。现在我假定您已经购买了一个域名,并能自己处理 DNS 解析。对于个人用户,我推荐把域名 DNS 解析 nameserver 设置成 Cloudflare,甚至把域名本身也直接迁移到 Cloudflare。此处仅作推荐,不是必须

注 1本页面及后续服务搭建页面,一般会以 my-example.com 域名作为例子进行说明。实际操作时请将代码里所有的该域名替换成您自己购买的域名,并记得在 DNS 服务商处将相应的域名指向您的服务器地址。

注 2我喜欢给自己的每个服务创建一个二级域名,例如 FreshRSS 服务器就是 rss.my-example.com,密码管理器就是 password.my-example.com 等。当然这些二级域名都可以指向同一台服务器 —— 如果您只有一台服务器的话。

Server Initial Setup

初次开机之后,我通常会做如下初步设置:

  1. 编辑 /etc/ssh/sshd_config 修改 SSH 连接信息,包括修改非 22 端口、禁用 Root 登录、禁用密码认证等;
  2. 更新软件包;
  3. 安装常用工具如 git, gpg, rsync, python3-pip 等,此处不一一列举,需要的时候自行安装就是了;
  4. 如果内存较小,可能需要开启 swap 空间
  5. 配置自己顺手的终端。我将自己常用的 Zsh 配置(包括 Zinit 和 Powerlevel10k)备份到了 GitHub,通常新装服务器之后 clone 下来就可以用了。
  6. 安装 ufwfail2ban,并设置相应规则。注意设置 ufw 时首先应允许当前的 SSH 端口,然后再 enable,否则会把自己也拦在外面。如果云服务商本身就提供一个外部防火墙,而且我们不需要精细控制防火墙规则,那么可能不需要自己开防火墙,服务商提供的就够用了;
    • ufw - Uncomplicated Firewall,顾名思义,是一个用户友好的、简易的防火墙程序。可以认为它只是一个前端操作工具,其背后原理实际上还是在修改 iptables 的规则。其 ‘Uncomplicated’ 就体现在,你只需使用 ufw allow 443 这样简单的语法就可以开放一个端口,而不必去手写涉及到了各种 CHAIN 和 INTERFACES 的 iptables 命令。
    • 需要注意的是,如果你使用 Docker 开放了某个容器的某个端口,并希望使用 ufw 来限制这个端口,例如仅允许部分 IP,这是做不到的:因为 ufw 实际上是个用简易语法操作 iptables 的前端,而 Docker 本身在开放每个容器的端口时也会在 iptables添加相应的准入规则,而且 Docker 的规则优先级还在 ufw 添加的规则之前。要想限制 Docker 的端口,您需要手动编辑 iptables 里的 DOCKER-USER chain,或者在其之前插入你自己的 rule chain。
  7. 安装 DockerDocker-Compose.

Setup Traefik

Traefik 是一个反向代理工具,简单来说,假如你的服务器上运行着多个服务(容器),分别有不同功能及对应不同的域名或路径:以我为例,我的服务器上运行着 FreshRSS 服务、RSSHub 服务、Bitwarden 密码管理服务,我希望用户在访问 password.my-example.com 时,流量被导向 Bitwarden 密码管理器对应的容器;而用户在访问 rss.my-example.com 时,流量自动导向 FreshRSS 对应的容器。Traefik 就是一个非常理想的完成这项任务的工具,并且它附带了自动管理 HTTPS 证书、负载均衡等功能。

Traefik

本节我们启动一个 Traefik 容器,同时启动一个简单的 whoami 容器(用户访问它的时候,会回显用户的 HTTP 请求头信息),来确认我们的 Traefik 反向代理服务一切工作正常。下一篇文章再开始介绍具体的 Bitwarden、FreshRSS 等应用容器的搭建。后面那些具体应用,都是由本节启动的这个 Traefik 负责代理的

假定我们已经购买了一个域名 my-example.com,并且其 DNS 解析服务托管在 Cloudflare 上。如果你使用的不是 Cloudflare,则请按照这个链接里面的信息对下面的部分变量名进行调整。

我的习惯是,将所有容器的配置文件放在一个名为 site 的文件夹中,将所有的数据卷(volumes)放在一个叫做 volumes 的文件夹中。以后服务器备份、迁移的时候,也直接复制这两个文件夹即可。

Traefik 配置

首先为 Traefik 创建一个项目文件夹,例如:

mkdir -p ~/site/traefik
cd ~/site/traefik

然后创建一个 docker-compose.yml 文件,内容如下:

# docker-compose.yml
version: "3.5"

services:
  traefik:
    # The official v2.0 Traefik docker image
    image: traefik
    container_name: traefik
    restart: always
    ports:
      # The HTTP port
      # - "80:80"
      # The HTTPS port
      - "443:443"
      # The Web UI (enabled by --api.insecure=true)
      - "8080:8080"
    env_file:
      # DNS Provider credentials
      - dns_provider.env
    volumes:
      # So that Traefik can listen to the Docker events
      - "${HOME}/volumes/traefik-letsencrypt:/letsencrypt"
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "./traefik.toml:/etc/traefik/traefik.toml"
      - "./dyn.toml:/dyn.toml"
    networks:
      - traefik
    logging:
      options:
        max-size: "10m"
        max-file: "2"

  whoami:
    # A container that exposes an API to show its IP address
    image: containous/whoami
    restart: always
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.whoami.rule=Host(`whoami.my-example.com`)"
      - "traefik.http.routers.whoami.entrypoints=websecure"
      - "traefik.http.routers.whoami.tls.certresolver=mydnschallenge"
      - "traefik.http.middlewares.whoami.headers.stsSeconds=311040000"
      - "traefik.http.middlewares.whoami.headers.stsIncludeSubdomains=true"
      - "traefik.http.middlewares.whoami.headers.stsPreload=true"
    networks:
      - traefik

networks:
  traefik:
    name: traefik

注意到这里面包含了 2 个容器,即 Traefik,以及 Traefik 反向代理背后的其中一个 demo 容器 whoami。如上文所说,在后续文章里,Bitwarden、FreshRSS 这些具体的应用,都将是 Traefik 反向代理背后的具体业务容器。

这个 docker-compose.yml 文件引用了数个外部文件,这里一一道来:

dns_provider.env: 我们希望网站是以 HTTPS 加密协议对外服务的,这就要求我们需要处理申请 HTTPS 证书事宜。好在 Traefik 提供了几个自动处理证书申请的方式,其共同点就是需要验证你对申请的证书所指示的域名的所有权。这里我们采用 DNS Challenge 的方式,也就是说,如果能验证你有权修改域名的 DNS 记录,那么就说明这个域名确实是你的,就可以给你颁发对应的 HTTPS 证书。我们的域名 DNS 是托管在 Cloudflare 上的,所以我们获取 Cloudflare 的 API Key,交给 Traefik,便可完成此验证。只需要 CF_API_EMAILCF_API_KEY 这两项就可以了。具体内容如下:

# dns_provider.env
# See docs at https://docs.traefik.io/https/acme/#providers
CF_API_EMAIL=[email protected]
CF_API_KEY=dfhkladhfakeyiuw764293ih4xq7x

${HOME}/volumes/traefik-letsencrypt:存放证书的地方,这个会自动创建。

traefik.toml:Traefik 的配置文件。

# traefik.toml

[log]
  level = "DEBUG"

[providers]
[providers.docker]
[providers.file]
  filename = "/dyn.toml"

[entryPoints]
  [entryPoints.web]
    address = ":80"

  [entryPoints.websecure]
    address = ":443"

    [entryPoints.websecure.forwardedHeaders]
      trustedIPs = ["173.245.48.0/20","103.21.244.0/22","103.22.200.0/22","103.31.4.0/22","141.101.64.0/18","108.162.192.0/18","190.93.240.0/20","188.114.96.0/20","197.234.240.0/22","198.41.128.0/17","162.158.0.0/15","104.16.0.0/13","104.24.0.0/14","172.64.0.0/13","131.0.72.0/22","2400:cb00::/32","2606:4700::/32","2803:f800::/32","2405:b500::/32","2405:8100::/32","2a06:98c0::/29","2c0f:f248::/32"]

[certificatesResolvers.mydnschallenge.acme]

  email = "[email protected]"

  storage = "/letsencrypt/acme.json"

[certificatesResolvers.mydnschallenge.acme.dnsChallenge]
  provider = "cloudflare"

这里面定义了一些 entryPoints 的名称,DNS Challenge 的名称等。在下文中将可以看到,我们会指定某个业务容器的 entrypointwebsecure,也就是上面定义的名称,代表访问这个容器的访客,需要从 websecure 这个 Entry Point 指定的外部端口进来,在这里也就是 443 端口,即 HTTPS 的默认端口。

DNS Challenge 这里定义了一个名为 mydnschallenge 的 Resolver,其中定义我们使用的 DNS 服务商是 Cloudflare。而针对这个服务商的具体 Token 设置,在前面的 dns_provider.env 文件中有写。下文中将看到,我们会指定某个业务容器的 certresolvermydnschallenge,意味着,对于该容器所对应的域名,Traefik 将调用 mydnschallenge 这个 Resolver 来为该域名申请 HTTPS 证书。

另外有一个 forwardedHeaders.trustedIPs。原因是,我的网站常常会在前面套一层 Cloudflare 的 CDN,当用户通过 Cloudflare 转发访问服务器时,Traefik 服务器会在 Cloudflare 转发过来的 HTTP 请求头中的 X-Forwarded-For 字段看到 2 个 IP:一个是访客访问 Cloudflare 时的真实 IP,例如 12.34.56.78,另一个是 Cloudflare 回源服务器的 IP,例如 104.20.9.218

默认配置下,Traefik 并不会信任这个 X-Forwarded-For 信息,因为隔着 Cloudflare,它无法验证访客访问 Cloudflare 时是不是真的是 12.34.56.78。Traefik 会直接丢弃这个字段,并且只相信眼见为实 —— Cloudflare 回源用来连接 Traefik 服务器所用的 IP 的确就是 104.20.9.218,这是可以验证的。所以 Traefik 会将后面这个 Cloudflare 服务器的 IP 作为 X-Forwarded-For 字段转发给后面的容器。这就造成一个问题:当世界各地的访客通过 Cloudflare 代理访问我们的网站时,我们的网站程序看到的全是来自 Cloudflare 的访问,根本无法区分访客的来源。这里的 forwardedHeaders.trustedIPs 设置,即是让 Traefik 服务器信任所有来自 Cloudflare 转发的 X-Forwarded-For 字段,并将这个字段原样转发给后面的应用容器。这样应用便可以隔着层层代理看到访客的真正 IP。

这些 Cloudflare 的 IP 段来自这里。这部分 forwardedHeaders.trustedIPs 的配置不是网站运行必须的,只是方便辨识访客的真正 IP。

dyn.toml:动态加载的配置,这个可有可无,我在这里设置了一些 TLS 参数,要求网站使用 TLS 1.2 或更新的标准,并且只使用被认为安全的加密算法。这个可以稍稍改善网站在 SSLLabs 测试的安全评级。

# dyn.toml

[tls.options]
  [tls.options.default]
    minVersion = "VersionTLS12"
    cipherSuites = [
      "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
      "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
      "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256"
    ]
    sniStrict = true
  [tls.options.mintls13]
    minVersion = "VersionTLS13"

whoami 容器示例

后面的内容就简单了,可以看到 whoami 容器通过 labels 设置了 Traefik 服务相关参数,这里再复制粘贴一下:

# docker-compose.yml

# ...

whoami:
  # A container that exposes an API to show its IP address
  image: containous/whoami
  restart: always
  labels:
    - "traefik.enable=true"
    # Replace with your own hostname
    - "traefik.http.routers.whoami.rule=Host(`whoami.my-example.com`)"
    - "traefik.http.routers.whoami.entrypoints=websecure"
    - "traefik.http.routers.whoami.tls.certresolver=mydnschallenge"
    - "traefik.http.middlewares.whoami.headers.stsSeconds=311040000"
    - "traefik.http.middlewares.whoami.headers.stsIncludeSubdomains=true"
    - "traefik.http.middlewares.whoami.headers.stsPreload=true"
  networks:
    - traefik
# ...

其中:

启动容器

接下来,在 docker-compose.yml 文件所在的目录下,运行:

docker-compose up -d

然后稍等一会儿容器启动。由于初次申请证书可能需要时间,可能还需要等几十秒到一两分钟,网站才能正常访问。在浏览器中键入 whoami.my-example.com (也就是 whoami 容器的 Host() 中的域名),应该会看到返回

Hostname: 7fb82276f82b
IP: 127.0.0.1
IP: 172.27.0.3
RemoteAddr: 172.27.0.4:47234
GET / HTTP/1.1
Host: whoami.my-example.com
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.16; rv:86.0) Gecko/20100101 Firefox/86.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.8,zh-HK;q=0.5,zh-CN;q=0.3
Cookie: __cfduid=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Dnt: 1
Te: trailers
Upgrade-Insecure-Requests: 1
X-Forwarded-For: x.x.x.x
X-Forwarded-Host: whoami.my-example.com
X-Forwarded-Port: 443
X-Forwarded-Proto: https
X-Forwarded-Server: eb2dc150ae10
X-Real-Ip: x.x.x.x

就算大功告成了。

Note: 本文中所有 Traefik 相关的配置如果意义不明,直接 Google 搜索 Traefik + 你想了解的配置项 通常可以搜到文档。