Docker时代的自建服务体验

我的Docker初体验,以及简单记录如何使用Traefik+Portainer来轻松管理自托管服务。

自从用上了云服务器之后,出于对一些现有的服务不满意,同时也是为了满足自己的折腾欲,我就很喜欢研究自建各种服务。什么网盘、笔记、图床系统都折腾过,然而,每次都是搭建成功的时候很开心,却并未真正全面使用这些服务。究其原因,主要是我对我的服务器环境似乎是有洁癖,服务器用久了,我就会觉得它好像“脏”了一样,就会忍不住想重装系统。而每次重装,我都懒得备份这些数据。服务器上最重要的就是自己的博客了,至于重装系统后其他的服务还装不装,就完全看心情了。

从另一个角度来讲,当时我并不知道还有Docker这样的神器,所有的服务自然都是搭建在宿主机上的,一般都是对着官方文档来搭建,不同服务的各种配置文件、数据散落在不同的地方,对于当时的我来说,想要完整地备份并还原还是挺麻烦的。后来,我索性就不折腾自建服务了,博客也改用静态生成器来构建,转到了Vercel上,评论系统换成了基于Leancloud数据库的Valine,至此,我对服务器也就没有硬性依赖了,只是偶尔开一下Minecraft服务器。

Docker初体验 #

我第一次尝到Docker的甜头就是部署Isso评论系统。Isso是用Python写的,众所周知,很多Linux发行版会自带Python库,但是库可能不全或者版本不满足要求,强行安装可能会把系统搞崩。说实话,看完文档中的这一段我已经被劝退:Interludium: Python is not PHP,所以我只能逼自己学习一下Docker。然后我就发现,只用一条命令,Isso就跑了起来。这种感觉真的很奇妙,我知道Docker是基于虚拟化技术,一个Docker容器其实也是个虚拟机,但是这个虚拟机的启动速度跟传统的虚拟机简直是天差地别,Docker的所有操作都跟人一种非常快的感觉。对此我只能直呼真香,同时也为以前折腾服务器浪费的时间和掉的头发而感到惋惜。

Docker的出现,让搭建各种服务变得极其boring。copy一个启动容器的命令,改一改,然后就运行起来了。或者copy下示例的docker-compose.yml文件,改一改,数个容器分分钟就全部跑起来了。更重要的是,Docker删除服务也超级简单,而且完全不用担心会有任何残留。启动容器的时候甚至可以加--rm选项来实现用后即焚,这使得体验各种自建服务变得极其方便,也让洁癖们没有心理负担。我们想要比较各种同类型的自建服务时候,往往会部署几个做对比,这些软件可能是用不同的语言写的,需要不同的运行环境,想把他们都运行起来还是很费工夫的。而现在,大多数的软件都会给一个Docker的部署方案,也就是创建几个容器的事,分分钟就能跑起来。

GitHub上有一个Awesome-Selfhosted的项目,上面收集了上千个自托管服务,种类非常齐全,可以说市面上各种商业服务,几乎都可以在这里找到替代品。我现在最大的乐趣就是体验各种自建服务了,我的服务器上已经运行了数个自建服务。与博客相关的有Isso评论系统、Umami访问统计,其他的还有File Browser文件列表程序、RSS阅读器、n8n自动化平台、Gotify通知服务等。

随着服务变多,我发现我在搭建这些服务的操作手法各有不同,有些是直接用docker run命令来启动,有些又是用的docker-compose。持久化数据有些是直接挂载宿主机中的文件夹到容器中,有一些又用的是docker volume。用docker run启动的容器,我不得不专门找一个地方把启动命令记录下来。我迫切需要规范一下各种操作,这样备份、迁移才会更轻松。另外,每次操作Docker都需要在命令行下,如果能找到一个GUI的工具就好了,不说高效,至少会比较直观。然后我就发现了Portainer这个可视化工具,体验了下来还是非常方便的。

另外,折腾自建服务的一个高频操作就是设置反向代理。我用的web server是Caddy,主要看中了它简单的配置文件以及自动https功能,非常省心。不过每次部署完一个服务还是免不了要手动编辑Caddyfile以及手动reload。虽说也不麻烦,而且几乎是一劳永逸,但总是觉得不优雅。于是我找到了三个优化方案:

  • caddy-docker-proxy:Caddy的一个插件,通过Docker的API,可以实现Docker服务的自动发现。只需要在启动docker容器的时候用label来设定host name和反代的端口。
  • Nginx Proxy Manager:提供一个Web UI,可以非常方便且直观地改nginx配置,以及可以自动配置https。不过没有自动服务发现。
  • Traefik Proxy:一个专门的反代服务器,支持服务自动发现,支持Docker Swarm以及K8s等主流的集群(然而我用不上),支持自动https,还自带一个dashboard。跟caddy-docker-proxy类似,也是需要在启动容器的时候指定label

折腾了一番,最后还是决定用Traefik,主要是看介绍觉得挺有意思的,想体验一下我不太熟悉的东西。

部署Traefik+Portainer #

最近正好服务器搬家,就想着把我运行着的一些服务好好整理一下,规范一下操作手法。我准备把Traefik+Portainer作为我的服务器的基础服务。Portainer的使用非常简单,然而这个Traefik就比较让人头疼了。文档感觉有点混乱,经常是需要从一个页面跳到另一个页面。而且文档上面列出的样例,让人完全搞不明白这个配置应该放在哪里。好在网上有些教程写得比较好,提供了大量的参考,比如这个博客。

Traefik与Portainer这两个服务的部署,我主要参考了这篇文章:Docker container management with Traefik v2 and Portainer (rafrasenberg.com),这篇文章讲得非常详细,作者甚至还做了个boilerplate放在了GitHub上,可以直接拿来用。这篇文章我就简单贴一些自己的配置,不过这篇文章的重点是分享我的体验,就不会写详细的部署步骤了。我的文件夹结构如下:

core
├── docker-compose.yml
├── portainer_data
└── traefik_data
    ├── acme.json        #存放SSL证书
    ├── configs
    │   └── dynamic.yml  #动态配置
    └── traefik.yml      #静态配置

docker-compose.yml内容如下:

version: "3.3"
services:
  traefik:
    image: traefik:latest
    container_name: traefik
    restart: always
    network_mode: host
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik_data/traefik.yml:/traefik.yml:ro
      - ./traefik_data/acme.json:/acme.json
      - ./traefik_data/configs:/configs
    labels:
      - "traefik.http.routers.traefik.entrypoints=websecure"
      - "traefik.http.routers.traefik.rule=Host(`traefik.example.com`)"
      - "traefik.http.routers.traefik.service=api@internal"
      - "traefik.http.routers.traefik.middlewares=user-auth@file"
      - "traefik.http.services.dummy-svc.loadbalancer.server.port=9999"
    environment:
      - "VERCEL_API_TOKEN=<YOUR_API_TOKEN>"
  
  portainer:
    image: portainer/portainer-ce:latest
    container_name: portainer
    restart: always
    network_mode: bridge
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./portainer_data:/data
    labels:
      - "traefik.http.routers.portainer.entrypoints=websecure"
      - "traefik.http.routers.portainer.rule=Host(`portainer.example.com`)"
      - "traefik.http.services.portainer.loadbalancer.server.port=9000"

我的traefik容器是运行在host网络之下的。这样有一个好处,就是后续的服务不用手动指定跟Traefik相同的虚拟网络,Traefik直接可以联通其他网络下的容器。不过这里有一个天大的坑,问题当Traefik工作在host网络或者Docker Swarm模式下就会出现,表现就是Traefik的其他的功能都正常,就dashboard界面404。这个问题把我整郁闷了,晚上我躺在床上也睡不着,一直在搜索解决方案,最后终于在GitHub上找到了“解决办法”,就是在label中加一行traefik.http.services.dummy-svc.loadbalancer.server.port=9999。最诡异的是这里的service name(这里叫dummy-svc)以及端口,竟然是随便指定就行。加上这一行,一切就都正常了。也不知道这究竟是bug还是特性,反正看样子官方是不打算修的,只在文档中加了个Docker Swarm模式下的特殊配置。是的,文档里只写了Docker Swarm模式要这么操作,并没有说host网络也需要,所以预计还会有更多人会被坑。

以下就是traefik.yml具体内容了,这个是Traefik的静态配置文件,修改这里的配置要重启Traefik才会生效。

api:
  dashboard: true

entryPoints:
  web:
    address: :80
    http:
      redirections:
        entryPoint:
          to: websecure
          permanent: true
  websecure:
    address: :443
    http:
      middlewares:
        - secureHeaders@file
      tls:
        certResolver: letsencrypt
        domains:
          - main: "*.example.com"
            sans:
              - "example.com"

providers:
  docker: true
  file:
    filename: /configs/dynamic.yml

certificatesResolvers:
  letsencrypt:
    acme:
      email: <your email address>
      storage: acme.json
      keyType: EC384
      dnschallenge:
        provider: vercel

这里我配置了Traefik的API为开启状态,也就是dashboard页面是打开的。然后指定了两个entryPoint,分别对应了http与https的端口。再往下就是provider的设置以及与自动签发SSL证书相关的配置。provider顾名思义其实就是指的是动态配置的提供者。这里设置了两个provider,一个就是docker,Traefik通过访问Docker的api,来实现自动服务发现并自动配置接入。另外一个provider就是file,在这里就是/configs/dynamic.yml这个文件,其内容如下:

# Dynamic configuration
http:
  middlewares:
    secureHeaders:
      headers:
        sslRedirect: true
        forceSTSHeader: true
        stsPreload: true
        stsSeconds: 31536000
    user-auth:
      basicAuth:
        users:
          - "admin:$apr1$6dkazkhc$cm/JoRpuJEeD/fvjWhYeh0"

tls:
  options:
    default:
      minVersion: VersionTLS12

修改动态配置是不需要重启Traefik的,所以各种middleware的配置放在这里会比较方便。比如可以设置http basicAuth的用户名和密码。这里的admin:$apr1$6dkazkhc$cm/JoRpuJEeD/fvjWhYeh0其实就是用户名admin,密码admin。这个字符串可以用htpasswd来生成,操作如下:

$ htpasswd -n <username>
New password:
Re-type new password:
admin:$apr1$6dkazkhc$cm/JoRpuJEeD/fvjWhYeh0

有了以上这些配置,只需运行docker-compose up -d,Traefik与Portainer就全部跑起来了。打开traefik.example.com就能访问Traefik的dashboard,打开portainer.example.com就能看到Portainer的界面,连https都全部配置好了。

Traefik dashboard

Traefik dashboard

Traefik的这个dashboard颜值还是比较高的,不过这个界面只能展示各种服务以及配置,并不能在这里修改任何配置。无论如何,有这样一个直观的界面还是非常有益的,出了问题更容易找原因。以下展示的是我的rss阅读器yarr的路由明细,可以看到请求是从443端口进来,经过路由匹配,再经过headersbasicauth两个中间件的处理,最后转到对应Docker容器。

yarr配置

yarr配置

Portainer Web UI

Portainer Web UI

自建服务新姿势 #

有了上面的Traefik+Portainer的基础环境,下面来分享一下这段时间以来我自己觉得比较舒服的服务搭建姿势。

在Portainer的界面中,我们既可以直接通过新建容器这样的方式来搭建服务,类似于使用docker run命令,也可以使用stack的方式来搭建,类似于使用docker-compose。最近迁移服务器的时候我发现,导出Portainer的数据再导入到新机器中,手动建的容器不会导入进来,容器的各种启动参数都要重新设置了。而通过stack创建的服务,docker-compose配置都在,只要再点点鼠标,各种服务就原封不动地重新运行起来。于是,后面我的部署全部改用stack了。下面还是以部署yarr为例,简单说一下Traefik+Portainer这套组合怎么使用。

打开Portainer的stack界面,新建一个stack。然后输入名称以及docker-compose配置。我的配置如下,可以看到这个配置其实跟正常配置几乎没有什么不同,唯有以下区别:

  • 多了一些labels配置。这是为了告诉Traefik如何配置路由,以及为服务设置各种中间件。
  • 容器不用映射端口出来了。因为Traefik会自己完成流量的转发,所以容器甚至不用映射任何端口到主机。不过这里有一个要注意的地方,这里的yarr容器中只有一个活动端口,Traefik可以直接选择到这个没有问题。而如果容器中活动端口不止一个,traefik选择的端口可能不是我们需要的,这时就要手动指定,比如前面portainer的部署,容器有三个端口,而只有9000端口是我们需要的,这时就要再加一个label:traefik.http.services.portainer.loadbalancer.server.port=9000
version: "3"
services:
  yarr:
    image: arsfeld/yarr:latest
    container_name: yarr
    restart: always
    network_mode: bridge
    volumes:
      - data:/data
    labels:
      - "traefik.http.routers.yarr.entrypoints=websecure"
      - "traefik.http.routers.yarr.rule=Host(`rss.example.com`)"
      - "traefik.http.routers.yarr.middlewares=user-auth@file"
volumes:
  data:

编辑完docker-compose配置,直接点击Deploy the stack,等几秒钟的工夫,部署成功,然后浏览器直接打开rss.example.com,everything works!这是真我从未有过的愉悦体验。

有时候不得不感叹科技的进步。之前迁移服务器,经常要折腾一下午,而现在完整迁移所有的数据,竟然只要十几分钟。考虑新服务器的软件更新、配置、安装docker的时间,整体上连一个小时都用不到。现在只后悔没有早一点开始用Docker。😶‍🌫️