Logo
“使用 Traefik 作为 Docker 的反向代理”的封面

使用 Traefik 作为 Docker 的反向代理

Avatar

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 配置文件的路径。

静态配置文件

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: [email protected] # 一个用于申请 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 中会提到

动态配置文件

接下来是动态配置文件:

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 如下:

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 的配置文件:

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 的配置文件主要由可视化的安装程序生成,我只提一个要点:

$wgScriptPath = "";

$wgArticlePath = "/$1";

$wgResourceBasePath = $wgScriptPath;

结语

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

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


隐私政策

Copyright © Skyone 2025