跳到主要内容

使用 Traefik 作为 Docker 的反向代理

· 阅读需 16 分钟
Skyone

本文主要介绍如何使用 Traefik 作为 Docker 的反向代理,以及如何使用 Traefik 配置自动 HTTPS。但是,在写这篇文章时有感而发,先聊聊我对建网站,或者说自学 linux 的一些历程吧。想直达重点的点这里跳转。

还记得最开始使用云服务器的时候,租了个阿里云的学生机,当时就想建个博客玩玩,一顿搜索,发现网上全在推荐使用 WordPress。但是当时还是小白啊,只会 ls cat 的那种,安装 PHP 和 Apache 几乎是不可能的,再加上网上一堆教程动不动就手动编译安装,我当时连 yum 都不会用,怎么可能编译安成果嘛。

所以我就取了个巧,直接拿阿里云镜像社区的别人装好了 WordPress 的系统(基于CentOS 8)。能用是能用了,随后又花了一个月备案。但这是我想到一个问题:一个服务器只有一个 443 端口,难道只能建一个网站吗?于是我就第一次听说了反向代理,以及著名的 Nginx

然而,Nginx 的配置文件显然也不是当时的我能看懂的。经过近半年的折腾,我会用 Nginx 了,可这时问题又来了,我的 SSL 证书过期了……当时陆陆续续搞了3个网站,结果换域名太麻烦了。难道不能自动化完成这些吗?难道不能通过图形化的界面生成 Nginx 的配置文件吗?这一次进入我视野的是 Nginx Proxy Manager,简称 NPM,但是人家教程里的安装方式当时只有 Docker 和使用 npm 安装,可我当时还不会用 Docker,也不会用 npm

不会怎么办?学呗!这么一想我当时还真离谱……于是折腾 Docker,用上了 Let's Encrypt + Nginx Proxy Manager。

随着我也会写了点小程序,我需要将演示站挂到网上。但 Nginx Proxy Manager 的配置任然麻烦,需要配置一堆 Docker 容器的 endpoint。在一次逛 GitHub 时,我发现了 Traefik,也就是今天的主角。它彻底解决了我上述的所有需求!

  • 稳定
  • 配置一次,以后全自动
  • 图形化面板
  • 基于容器的 label 配置

下面正文开始。

Traefik 安装与配置

因为使用 Docker 安装,所以安装过程不再赘述,直接上 docker-compose.yml 文件(需要先创建一个名为 proxy 的网络)

version: "3.8"

services:
traefik:
container_name: proxy
image: traefik:v2.9
environment: # 我使用了阿里云的 DNS 服务,所以需要配置阿里云的 AccessKey
ALICLOUD_ACCESS_KEY: ""
ALICLOUD_SECRET_KEY: ""
ALICLOUD_REGION_ID: "cn-hangzhou"
ports:
- "80:80"
- "443:443"
volumes:
- /etc/localtime:/etc/localtime:ro # 使用宿主机的时区
- /var/run/docker.sock:/var/run/docker.sock:ro # traefik 需要监听容器的启动和停止, 只读即可
- ./config:/etc/traefik
labels:
- "traefik.enable=true"
- "traefik.http.routers.traefik.entryPoints=websecure"
- "traefik.http.routers.traefik.rule=Host(`proxy.example.com`)" # 用于访问 Traefik 面板的域名, 本身也由 Traefik 管理
- "traefik.http.routers.traefik.middlewares=user-auth@file" # 简单的 HTTP Basic Auth
- "traefik.http.routers.traefik.service=api@internal"
networks:
- proxy

networks:
proxy:
external: true

但是仅仅这样还不够,还需要一些配置文件。假设以上 docker-compose.yml 文件在 ${APP} 目录下。

Traefik 的配置文件分为两种,一种是 static,一种是 dynamicstatic 配置文件是不会自动加载的,需要重启 Traefik 容器,而 dynamic 配置文件会自动加载。此外,我们需要在 static 配置文件中配置 dynamic 配置文件的路径。

静态配置文件

${APP}/config/traefik.yml
api:
dashboard: true

providers:
docker:
endpoint: "unix:///var/run/docker.sock"
exposedByDefault: false
file:
filename: /etc/traefik/dynamic.yml

entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
websecure:
address: ":443"
http:
middlewares:
- secureHeaders@file
- compressConfig@file
tls:
certResolver: letsencrypt

certificatesResolvers:
letsencrypt:
acme:
email: demo@example.com # 一个用于申请 let's encrypt 证书的邮箱
storage: /etc/traefik/acme.json
dnsChallenge:
provider: alidns # 阿里云的 AccessKey 来自 `docker-compose.yml` 文件中配置的环境变量
# AccessKey 需要有 DNS 权限

下面解释一下这个静态配置文件的内容:

  • api.dashboard

    用于开启 Traefik 面板

  • providers.docker

    开启了 Docker 集成,将容器的 label 作为动态配置文件

  • providers.file

    指定将 /etc/traefik/dynamic.yml 作为额外的动态配置文件(等下配置)

  • entryPoints

    配置入口点,这里配置了两个入口点,一个是 web,一个是 websecure,分别监听 80 和 443 端口

  • entryPoints.web.http.redirections.entryPoint.to

    配置了 web 入口点的重定向,即将所有 http 请求重定向到 websecure 入口点

    因为我为我的所有域名开启了 HSTS Preload,重定向规则已经被写进浏览器源代码了,所以这里配不配都差不多,大家可以酌情修改

  • entryPoints.websecure.http.middlewares

    配置了 websecure 入口点的中间件,这里配置了两个中间件,一个是 secureHeaders,一个是 compressConfig,分别用于配置安全头和压缩,这两个中间件的具体内容在下面的动态配置文件中,方便我们随时修改

  • entryPoints.websecure.http.tls

    配置了 websecure 入口点的 TLS 证书,这里使用了 letsencrypt 证书,下一行配置指定了 letsencrypt 证书的获取方式

  • certificatesResolvers.letsencrypt.acme

    配置了 letsencrypt 证书的获取方式,这里使用了 dnsChallenge,即通过 DNS 验证域名所有权。其实使用 HTTP 验证也可以,但是有两个原因使我不得不使用 DNS 验证。原因在后面的 FAQ 中会提到

动态配置文件

接下来是动态配置文件:

${APP}/config/dynamic.yml
http:
# services:
# demo:
# loadBalancer:
# servers:
# - url: http://web:80
# routers:
# demo:
# rule: Host(`demo.com`)
# entryPoints: [websecure]
# middlewares:
# - balala@file
# service: demo@file
middlewares:
nofloc:
headers:
customResponseHeaders:
Permissions-Policy: "interest-cohort=()"
secureHeaders:
headers:
sslRedirect: true
forceSTSHeader: true
stsIncludeSubdomains: true
stsPreload: true
stsSeconds: 63072000
compressConfig:
compress:
minResponseBodyBytes: 1024
excludedContentTypes: []
cacheHeaders:
headers:
customResponseHeaders:
Cache-Control: "public, max-age=604800"
user-auth:
basicAuth:
users:
- "" # 一个用户名和密码,使用 htpasswd 生成

同样的,下面解释一下这个动态配置文件的内容:

  • http.middlewares

    配置了一些中间件,这些中间件可以在任何地方使用,包括静态配置或 Docker 容器的 label 中。

    • secureHeaders

      配置了安全头,这里配置了 HSTS,即强制使用 HTTPS,以及 HSTS Preload,即将域名加入浏览器的 HSTS Preload 列表中,这样浏览器就不会发起 HTTP 请求了,有效防止第一次访问时的中间人攻击。

    • compressConfig

      配置了压缩,这里配置了最小压缩字节数为 1024,即只有大于 1024 字节的响应才会被压缩,这样可以避免小文件被压缩后反而变大。如果有一些文件不想被压缩,可以在 excludedContentTypes 中添加 MIME 类型。

    • cacheHeaders

      配置了缓存,这里配置了缓存时间为 7 天,即 604800 秒。注意,并不是所有文件都应该缓存!!!而且缓存头应该由服务器返回,而不是由反向代理,因为反向代理并不知道文件是否被修改过。这里只是一个示例,如果不会用忽略即可。

    • user-auth

      一个最简单的 HTTP Basic Auth 实现,可以保护一些本身不带身份验证的服务,比如 Traefik 面板。使用 htpasswd 生成用户名和密码,然后将生成的内容复制到 dynamic.yml 中即可。网上也有在线生成的网站,自行搜索。

    • nofloc

      配置了 Permissions-Policy,即禁用 FLoC,这是 Google Chrome 的一个新特性,用于替代第三方 Cookie,但是这个特性有很多问题,比如会泄露用户的隐私,所以我禁用了它。

我在注释部分还写了一个示例的 servicerouter,可以用于非 Docker 的服务。其实 docker 容器的 label 也是照着这个写的,只是格式不同而已。就是一个路由对应一个服务,中间添加中间件就行了。

示例: 使用 Traefik 反向代理 mediawiki

这里我使用一个具体的例子,即使用 Traefik 反向代理 mediawiki + Nginx,全部使用 Docker 安装。(其实这个例子适合任何 PHP 程序。这次任然使用 ${APP} 作为项目根目录,但注意,这里的 ${APP} 不是上面的 ${APP},而是一个新的目录

构建 mediawiki 镜像

首先准备一个 php-fpm 的镜像,Dockerfile 如下:

${APP}/build/Dockerfile
FROM php:fpm

ENV TZ=Asia/Shanghai
RUN apt-get update && apt-get install -y \
libzip-dev \
python3 python3-pip \
libfreetype6-dev \
libjpeg62-turbo-dev \
libpng-dev \
libicu-dev \
zlib1g \
git \
diffutils \
zlib1g-dev && \
apt-get clean && rm -rf /var/lib/apt/lists/*
RUN pecl install apcu && \
docker-php-ext-enable apcu && \
echo "extension=apcu.so" >> /usr/local/etc/php/php.ini && \
echo "apc.enable_cli=1" >> /usr/local/etc/php/php.ini && \
echo "apc.enable=1" >> /usr/local/etc/php/php.ini && \
docker-php-ext-configure gd --with-freetype --with-jpeg && \
docker-php-ext-install -j$(nproc) gd && \
docker-php-ext-install intl opcache

这个镜像安装了 apcuopcache 用于缓存,gd 用于处理图片,intl 用于处理多语言,用于缓存 PHP 代码。此外,还有 gitdiffutils 减少编辑冲突。Python 用于语法高亮插件。

你可能会问,mediawiki 源码哪去了?实际上,mediawiki 的插件是以文件的形式直接放到源代码同目录的,所以我们只需要将源代码挂载到容器中即可。这样做的好处是,我们可以直接修改源代码,而不需要重新构建镜像。

docker-compose.yml 文件

然后是 docker-compose.yml 文件:

version: "3.8"

services:
nginx:
container_name: mediawiki-nginx
image: nginx:latest
restart: unless-stopped
networks:
- proxy
- mediawiki
volumes:
- "./config:/etc/nginx/conf.d"
- "/etc/localtime:/etc/localtime:ro"
- "./html:/var/www/html"
labels:
- "traefik.enable=true"
- "traefik.http.routers.mediawiki.Rule=Host(`www.wiki.com`)"
- "traefik.http.routers.mediawiki.service=mediawiki"
- "traefik.http.services.mediawiki.loadBalancer.server.port=80"

mediawiki:
container_name: mediawiki
image: mediawiki-fpm:latest
restart: unless-stopped
build:
context: build
networks:
- mediawiki
volumes:
- "/etc/localtime:/etc/localtime:ro"
- "./html:/var/www/html"
- "./data:/var/www/data"

networks:
proxy:
external: true
mediawiki:
name: mediawiki

从上面的配置文件可以看出,${APP}/html 目录是 NginxPHP 共享的目录,用于存放 PHP 代码。${APP}/data 目录是 PHP 专用的目录,用于存放 PHP 生成的文件,比如缓存文件、上传的文件等等。${APP}/config 目录是 Nginx 专用的目录,用于存放 Nginx 的配置文件。

配置 Nginx

下面是 Nginx 的配置文件:

${APP}/config/mediawiki.conf
server {
listen 80;
set $base /var/www/html;
set $data /var/www/data;
set $php_cgi "mediawiki:9000";
root $base;

resolver 127.0.0.11 ipv6=off; # Docker DNS 不支持 IPv6, 仅仅是 Docker 内部, 并不影响外部 IPv6 的访问

# security headers
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: ws: wss: data: blob: 'unsafe-inline' 'unsafe-eval'; frame-ancestors 'self';" always;
add_header Permissions-Policy "interest-cohort=()" always;

# . files
location ~ /\.(?!well-known) {
deny all;
}

# logging
access_log /var/log/nginx/access.log combined buffer=512k flush=1m;
error_log /var/log/nginx/error.log warn;

# index.php
index index.php;

# gzip
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;

# index.php fallback 下面有详细解释
location / {
try_files $uri $uri/ @mediawiki;
}

location @mediawiki {
rewrite ^/(.*)$ /index.php?title=$1 last;
}

# additional config
# favicon.ico
location = /favicon.ico {
log_not_found off;
}

# robots.txt
location = /robots.txt {
log_not_found off;
}

# assets, media
location ~* \.(?:css(\.map)?|js(\.map)?|jpe?g|png|gif|ico|cur|heic|webp|tiff?|mp3|m4a|aac|ogg|midi?|wav|mp4|mov|webm|mpe?g|avi|ogv|flv|wmv)$ {
expires 7d;
}

# svg, fonts
location ~* \.(?:svgz?|ttf|ttc|otf|eot|woff2?)$ {
add_header Access-Control-Allow-Origin "*";
expires 7d;
}

# handle .php
location ~ \.php$ {
fastcgi_pass $php_cgi;
# 404
try_files $fastcgi_script_name =404;

# default fastcgi_params
include fastcgi_params;

# fastcgi settings
fastcgi_index index.php;
fastcgi_buffers 8 16k;
fastcgi_buffer_size 32k;

# fastcgi params
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param PHP_ADMIN_VALUE "open_basedir=$base/:$data/:/usr/bin/:/usr/lib/php/:/tmp/";
}
}

这配置我认为已经几乎完美了,安全、缓存、压缩、Wiki链接重写全都实现了。下面解释一下这个配置文件的内容:

首先是最重要的 location / 块,这里配置了 index.php 的 fallback,即当访问的文件不存在时,尝试访问 index.php,这样就可以实现 index.php 的伪静态。但是,这里还有一个问题,即当访问的文件是一个目录时,也会尝试访问 index.php,这样就会导致 mediawikiindex.php 也会被重写,导致无法访问。所以,我们需要在 location / 块中添加一个 try_files 指令,即当访问的文件不存在时,尝试访问目录,如果目录也不存在,就跳转到 @mediawiki 块,这样就可以实现 index.php 的伪静态,而且不会影响 mediawikiindex.php

然后是 location @mediawiki 块,这里重写了 index.php,即将 / 重写为 /index.php?title=,这样就可以实现 mediawikiindex.php 的伪静态。也就是说原本 /index.php?title=Main_Page 会这种丑陋的链接被重写为 /Main_Page,而 /index.php?title=Special:RecentChanges 会被重写为 /Special:RecentChanges。当然,mediawiki里也需要相应的配置,下面会提到。

还有一点要注意,由于 mediawiki 很有写年头了,代码难免很老旧,用到了不安全的 eval 函数,所以 Content-Security-Policy 中不能禁用 unsafe-eval,否则会导致网页 JavaScript 完全不可用(但页面可以正常显示)。不相信的话看看 console 里的报错就知道了。

其他的就是缓存、压缩、安全头、日志等等,不再赘述。

然后把 mediawiki 的代码放到 ${APP}/html 目录下,然后启动 docker-compose 即可。

mediawiki 伪静态

最后是 mediawiki 的配置文件,由于 mediawiki 的配置文件主要由可视化的安装程序生成,我只提一个要点:

${APP}/html/LocalSettings.php
$wgScriptPath = "";

$wgArticlePath = "/$1";

$wgResourceBasePath = $wgScriptPath;

结语

不知不觉,又是一篇长文,估计没有多少人会真正看完吧? 不过没关系,我并不在乎有没有人看,我只是想记录一下自己的学习历程,以及一些心得体会。

如果你看到这里,不如交换一下邮箱吧~毕竟非科班的业余爱好者还是很难找到有相同爱好的人的。我的联系方式可以在 关于我 页面找到。