Traefik与Docker swarm堆站攻略

技术狗 Jan 14, 2021

我新弄了一个博客,把之前的wordpress换成了ghost,我不确定会不会再导入原来的数据,其实只是文字还在,附件被我弄丢了~~

这一篇,是这个ghost的第一篇。ghost的体验确实是非常轻,wordpress时至今日已经是太重了,对于个人站来说,有太多的功能没啥用,虽然我如果说我把前几年的懒惰甩锅给wordpress就是贱人矫情。毕竟,输出才会进步吧。

这个ghost是跑在docker swarm上的。这在今天已经是少有人走的路,因为swarm可以认为已经凉了,或者更进一步说,docker本身的统治地位已经不在了。在2016年,我在360做游戏部门的服务容器化的时候,在把玩对比一段时间之后放弃了当时高速发展高频迭代以至于特性不稳定文档不完整的k8s,选择了更简单的swarm。其实在简单场合,swarm是足够用也足够稳定的,而且性能也没有什么问题,并不存在k8s大,swarm小这种说法,而且,国内的大部分为了k8s而k8s的集群也超不出5台物理机去。当然趋势就是趋势。

本篇是攻略,不是研讨,也就是在用传统nginx+应用之外的一种部署策略,对于个人、开发测试环境来说,还是比较合适的。首先,场景如下:

  • 一个虚拟机,是的,只有一个
  • 容器化的应用
  • 没啥访问压力

选择系统这种事,在云主机上不叫事,用来玩的机器,可以追求一下新,所以我挑了debian sid。sid是很简单的,把apt配置改一下,full-upgrade就好了。至于sid是啥,不想解释。-_- [解释]

deb http://ftp.us.debian.org/debian/ sid main contrib non-free
deb-src http://ftp.us.debian.org/debian/ sid main contrib non-free

deb http://security.debian.org/debian-security bullseye-security/updates main contrib non-free
deb-src http://security.debian.org/debian-security bullseye-security/updates main contrib non-free

# stretch-updates, previously known as 'volatile'
deb http://ftp.us.debian.org/debian/ bullseye-updates main contrib non-free
deb-src http://ftp.us.debian.org/debian/ bullseye-updates main contrib non-free

然后,装个docker,按docker官网上的[介绍],装个ce就行了,不要用debian自身源上的,装不上的,换个姿势试试。装好后记得把自己的登陆用户加到docker组里:

usermod -a -G docker [me]

当然,如果咱就直接root上去干,当我没说。

然后,创建swarm集群。即使咱就一个节点,也叫集群,1 manager 0 worker,孤单点了而已:

docker swarm init

输出一坨东西,主要是里面的token,如果咱有其它的机器需要加入这个集群,那么就需要这个token。一个swarm集群,加入千把个节点是没啥问题的!

然后,我们创建一个overlay网络:

docker network create --driver=overlay --attachable [起个名]

这里并不想解释swarm的网络本质和overlay,有兴趣可以去看[文档],创建一个就好。其实这里可以先不创建,而留在后面启动应用的时候自动创建。

下面来规划一下咱们的stack。实验环境,咱们弄俩就行了,第一个是环境,其中我们起一个traefik,一个portainer,一个whoami就可以了。如果大哥还想弄点类似grafana,prometheus啥的,也行。看看下面的stackfile(其实就是compose的yaml):[Reference]

version: "3.8"
services:
  traefik:
    image: "traefik:latest"
    command:
      - "--api"
      - "--api.dashboard=true"
      - "--providers.docker=true"
      - "--providers.docker.swarmMode=true"
      - "--providers.docker.exposedbydefault=false"
      - "--providers.docker.network=54np-net"
      - "--providers.docker.watch"
      - "--entrypoints.web.address=:80"
      - "--entrypoints.websecure.address=:443"
      - "--certificatesresolvers.le.acme.email=conan.np@gmail.com"
      - "--certificatesresolvers.le.acme.storage=/le/acme.json"
      - "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"
      - "--accesslog=true"
      - "--accesslog.filepath=/log/access.log"
      - "--accesslog.bufferingsize=128"
      - "--metrics.prometheus=true"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /opt/54np.net/cert:/le
      - /opt/54np.net/log:/log
    networks:
      - 54np-net
    ports:
      - 80:80
      - 443:443
      - 8080:8080
    deploy:
      mode: global
      placement:
        constraints:
          - node.role == manager
      update_config:
        parallelism: 1
        delay: 10s
      restart_policy:
        condition: on-failure
      labels:
        - "traefik.enable=true"
        - "traefik.http.services.traefik.loadbalancer.server.port=80"
        - "traefik.http.routers.traefik.rule=Host(`traefik.env.54np.net`)"
        - "traefik.http.routers.traefik.service=api@internal"
        - "traefik.http.routers.traefik.entrypoints=websecure"
        - "traefik.http.routers.traefik.tls.certresolver=le"
        - "traefik.http.routers.traefik.middlewares=traefik-auth"
        - "traefik.http.middlewares.traefik-auth.basicauth.users=[BLAHBLAH]"

  whoami:
    image: "traefik/whoami:latest"
    depends_on:
      - traefik
    networks:
      - 54np-net
    deploy:
      mode: global
      update_config:
        parallelism: 1
        delay: 10s
      restart_policy:
        condition: on-failure
      labels:
        - "traefik.enable=true"
        - "traefik.http.routers.whoami.rule=Host(`whoami.env.54np.net`)"
        - "traefik.http.routers.whoami.entrypoints=web"
        - "traefik.http.services.whoami.loadbalancer.server.port=80"

  portainer:
    image: "portainer/portainer:latest"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /opt/54np.net/data/portainer:/data
    networks:
      - 54np-net
    ports:
      - target: 9000
        published: 39000
        mode: host
    deploy:
      mode: global
      placement:
        constraints:
          - node.role == manager
      update_config:
        parallelism: 1
        delay: 10s
      restart_policy:
        condition: on-failure
      labels:
        - "traefik.enable=true"
        - "traefik.http.services.portainer.loadbalancer.server.port=9000"
        - "traefik.http.routers.portainer.rule=Host(`portainer.env.54np.net`)"
        - "traefik.http.routers.portainer.entrypoints=web,websecure"
        - "traefik.http.routers.portainer.tls.certresolver=le"
        - "traefik.http.routers.portainer.middlewares=portainer-redirectscheme"
        - "traefik.http.middlewares.portainer-redirectscheme.redirectscheme.scheme=https"
        - "traefik.http.middlewares.portainer-redirectscheme.redirectscheme.permanent=true"

networks:
  54np-net:
    external: true

上面这一大堆东西,是本章的核心。我为啥要写本章,就是因为,想写明白这么个stackfile,需要踩一大堆的坑。当然,stackfile本身并不难写,恶心的是traefik这东西,由于版本的跳跃和文档的简陋,虽然用的人不少,但是网上找到的文章尤其是中文资料,基本都是漏洞百出的,对于一些参数的解释并不到位。

Traefik的自身定义,叫做“云原生边缘路由”。其实我们当它是API网关也好,当成是SLB也好,当成是反向代理也好,其实是为了更自动化替代之前nginx的一些工作。虽然纯go开发的traefik在极限性能上要弱于nginx一些,其实在大部分情况下也无伤大局,反正我是从来没有遇到瓶颈在这一层的现实场景的。关于traefik,可以看[官网]。

首先来看看traefik服务中,command和deploy.labels中的一些关键部分:

# 打开traefik的dashboard,以安全方式(internal)访问
- "--api"
- "--api.dashboard=true"

# 定义traefik的运行环境(发现机制)为docker swarm。traefik支持一大堆玩法,像k8s啥的。其实它已经是k8s官准的ingress实现了。
- "--providers.docker=true"
- "--providers.docker.swarmMode=true"
- "--providers.docker.exposedbydefault=false"

# 选择网络,就是前面我们建立的那个overlay
- "--providers.docker.network=54np-net"
- "--providers.docker.watch"

# 定义俩入口,一个叫web,监听80端口,一个叫websecure,监听443,其实就是http和https。在通常的场景里,我都不太会直接把traefik暴露,而是只做一层http,然后外面再来一层nginx。不过这一次,我不打算引入nginx,traefik完全是有能力直接对外的。
- "--entrypoints.web.address=:80"
- "--entrypoints.websecure.address=:443"

# 这一坨,是定义ACME,也就是自动申请更新SSL证书。
# 首先是邮箱,这个并不需要提前去认证什么的,写一个就好了。
# 这里的le,就是Let's Encrypt。也就是最著名的白嫖三个月SSL证书的那个活雷锋机构。虽然三个月很短,traefik自动会处理更新问题,在到期一个月的时候会去尝试更新。之前在nginx上虽然可以依靠acme脚本来做,也是需要nginx稍稍地reload一下的。
- "--certificatesresolvers.le.acme.email=conan.np@gmail.com"
# 证书存在哪里。这个路径建议mount到容器外面,不然每次重启traefik就会再申请一次,而Let's Encrypt是有请求频率限制的。
- "--certificatesresolvers.le.acme.storage=/le/acme.json"
# Challenge放在那里,也就是.well-known的访问点。ACME会在申请证书的时候,尝试回访一个地址,用来确认你是这个域名的主人。为了达到这个目的,可以有几种不同的做法,比如可以添加域名的TXT记录啥的,这里traefik使用了challenge。放在“web”这个entrypoint下,也就是放在80端口。
- "--certificatesresolvers.le.acme.httpchallenge.entrypoint=web"

# 打开Accesslog。没有了nginx,我们是需要access log的。而traefik默认是不记录access log的。
- "--accesslog=true"
# Accesslog记在哪里。当然,这个地址也是建议mount到外面。Traefik的log机制其实并不完善,因为这不是它的主要关注点,因为毕竟它不是一个WebServer。日志其实可以导出到一些专业的日志服务上去。
- "--accesslog.filepath=/log/access.log"
# Accesslog的写入缓冲大小。如果一直实时地写文件,会很辛苦的,所以通过缓冲来做就会好一些,当然这有丢日志的风险。这个参数的单位是“条”。
- "--accesslog.bufferingsize=128"

# 最后,打开Prometheus的监控exporter。虽然这次我并没有部署prometheus,不过,万一哪天高兴呢。这个东西默认也是关闭的,需要通过这个来打开。
- "--metrics.prometheus=true"
# 首先,把一个服务对traefik开放。其实这行是可有可无的。
- "traefik.enable=true"

# 定义一个服务。服务是Traefik的一个发现单位。不过下面的服务比较怪异,因为它是traefik自身的dashboard,它并不是一个部署在swarm集群中的标准应用,当然也不是通过容器的某个端口来访问的,所以下面这个80端口其实无所谓,写什么都行。
- "traefik.http.services.traefik.loadbalancer.server.port=80"

# 定义traefik服务的路由条件,这里我们用了Host匹配。路由条件其实的来源都是HTTP request header,条件可以组合也可以与或。这在nginx中是要区分对待server_name和一大堆location的,traefik定义在一起了。
- "traefik.http.routers.traefik.rule=Host(`traefik.env.54np.net`)"

# 这一个是关键的部分。我们以“安全方式”来访问dashboard,所以忽略了上面的80端口,而使用了api@internal。也就是上面command部分中第一组,我们开放的api.dashboard。至于它为啥叫internal,我也不知道。
- "traefik.http.routers.traefik.service=api@internal"

# 下面是定义这个路由的入口,我们把它放在websecure上,也就是443端口。
- "traefik.http.routers.traefik.entrypoints=websecure"
# 我们需要TLS,让le自动去给它弄证书。
- "traefik.http.routers.traefik.tls.certresolver=le"

# 对于这个dashboard,我们不想随便谁都上来看,所以需要简单加个basic auth,这个是traefik内置的middleware。定义一下,这个路由需要加载的middlewares是一个叫traefik-auth的规则(这里可以写多个规则)。
- "traefik.http.routers.traefik.middlewares=traefik-auth"
# 这个叫traefik-auth的规则,定义了basicauth的行为,它接受users和userfile,也就是我们通常在webserver中定义的basicauth的用户名和密码,输对了就让你看,不对就401.
# BLAHBLAH是一组用户名和密码,这个可以通过apache的htpasswd来生成。
# echo $(htpasswd -nb user password) | sed -e s/\\$/\\$\\$/g
- "traefik.http.middlewares.traefik-auth.basicauth.users=[BLAHBLAH]"

好了,折腾一顿。deploy中的其它部分,都是swarm的通用定义,我不想在这地方去写它了,知道咋回事就好。我们来启动这个stack:

docker stack deploy --compose-file env.yaml env

如果一切都正常,我们可以通过上面定义在路由规则里的域名来访问traefik的dashboard。当然这个域名需要正确解析,或者hosts过去:

输入了正确的用户名密码,可以看见traefik的一些运行时参数,有多少个entrypoint,有多少个service,有多少个middleware,HTTP / TCP / UDP分别是生效的规则(traefik不仅是可以做HTTP代理的,TCP和UDP也可以)。

可以注意到,访问地址是https的,之前我并没有去手动申请过这个域名的SSL证书,这个动作是traefik自动完成的。那么我们来访问一下http的80端口看看:

它404了。因为我们没有定义这个router在web这个entrypoint上可访问。

接着,看看下面的whoami。这是一个非常简单的web应用,是用来显示主机参数的,它在docker hub的traefik/whoami中。我们简单地部署它,这次换用web这个entrypoint,不用websecure:

# 老样子。
- "traefik.enable=true"

# 还是老样子,一个域名。
- "traefik.http.routers.whoami.rule=Host(`whoami.env.54np.net`)"

# 这次换在80端口,不用https。
- "traefik.http.routers.whoami.entrypoints=web"

# 注意,这是whoami服务本身expose的端口,也就是traefik代理需要寻找的目标,不是对外暴露的。
- "traefik.http.services.whoami.loadbalancer.server.port=80"

来了:

它返回了主机名、主机ip(其实是这个容器的)和HTTP请求头。如果是一个多节点的集群,部署了多个whoami,每次刷新都会有不一样的结果。Traefik会去自动处理负载均衡的事情。

最后一个portainer,没啥可说的了:

注意,portainer有自己的存储,需要mount到外面,里面有一组sqlite数据库。当第一次访问并设置用户名密码之前,portainer服务每隔五分钟会重启。所以部署完了之后记得尽快去初始化用户,不然它会很烦的。Portainer现在已经挺好用了,可以看看[官网]。

下面,是最后一件重要的事情。我们弄这一堆东西是为了堆站用的,不是为了玩。以当前这个ghost为例,它要跑在这个体系上。其实参考前面的whoami和portainer已经基本知道应该怎么去玩了,后面的事情实在是非常简单。我们重新创建一个新的stack:

version: "3.8"
services:
  blog:
    # 这里用了官方的镜像,没自己打,我懒。
    image: "ghost:latest"
    # 存储要mount出去,不然重启就白写。ghost默认是用sqlite的。
    volumes:
      - /opt/54np.net/data/ghost:/var/lib/ghost/content
    # 选择和traefik在一个网络里,不然traefik找不到它。
    networks:
      - 54np-net
    # 这个环境变量是ghost要求的。环境变量名字不区分大小写。
    environment:
      url: https://54np.com
    deploy:
      update_config:
        parallelism: 1
        delay: 10s
      restart_policy:
        condition: on-failure
      labels:
        - "traefik.enable=true"
        # 下面,玩了一手新的,我们定义了两组路由,一个跑在web上,一个跑在websecure上,虽然我们可以把http和https弄成一摸一样的,让它咋的都能访问,但是看看下面的。
        - "traefik.http.services.ghost.loadbalancer.server.port=2368"
        - "traefik.http.routers.ghost.rule=Host(`54np.com`) || Host(`www.54np.com`)"
        - "traefik.http.routers.ghost.entrypoints=websecure"
        - "traefik.http.routers.ghost.tls.certresolver=le"
        - "traefik.http.routers.ghost-http.rule=Host(`54np.com`) || Host(`www.54np.com`)"
        - "traefik.http.routers.ghost-http.entrypoints=web"
        # 搞了一个middleware规则
        - "traefik.http.routers.ghost-http.middlewares=ghost-https"
        # 这个规则里定义了一个前面我们没用过的middleware叫redirectscheme,这个是用来重定向http scheme的。这里我们把web(也就是http)上的路由重定向到了https。
        - "traefik.http.middlewares.ghost-https.redirectscheme.scheme=https"
        # 定向一点不神秘,其实就是这个。如果permanent是true,就会301,如果不是true,就会302。这点和nginx中玩强制https是一样的。return一个301 https://$host$request_uri就完事。
        - "traefik.http.middlewares.ghost-https.redirectscheme.permanent=true"

networks:
  54np-net:
    # 因为这个network是我们在stack外创建的,所以要定义成external,否则部署stack时候会去尝试创建,就会报重名错误。这个在很前头已经说过了。
    external: true

这样,我们来看看成果:

当然,这个ghost我已经打扮过了,打扮的意思就是把默认的几篇文章删了 -_-

Ghost有一个特点,它是一个node应用,它本身就自带HTTP服务,所以它部署起来就比较容易。如果我们自己做了一个站,尤其是静态的或者php啥的,是需要我们在容器中打一个webserver进去的。因为traefik并不是一个webserver,它不做文件级的请求解释,它只是一个代理。

好了,就这样。

标签

NP博士

我是奶叔