traefikでwebsocketを細かく制御する

# 前置き
元がLT用なので布教形式になっています。

# traefikの布教
正式には`traefik proxy`なので注意してください。
## メリット
- traefik proxyといい、oss
- dockerやk8sなどにネイティブ対応し、ラベルで設定を書ける
- 一つのポート開放でwebsocketをすべてのdockerに振り分けられる
- tcp・udpをサーバーに振り分けられる（ロードバランサ）
- httpとwebsocketを簡単にhttpsとwssにできる
- ミドルウェアを使うことでパスの書き換えなどができる
- ダッシュボードでデバッグや現在の状況が見れる
- モダン
※tcpやudpはサーバーを一個しか使わないなら普通にdockerから解放でいいと思う。よって、traefikではポートを80,443,8080,wssの四つを解放でいいと思う。

## デメリット
- 知名度がないので記事がほぼ公式のみ
- .cert .pemファイルなどができない。acme.jsonにまとめられる。変換してくれるサードパーティソフトがあるので問題はない（必要になったことないけど）[解説（別の人）](https://www.zdyn.net/docker/2022/02/04/acme-certificate.html)
- http3に対応しているがまだ実験的な機能（なおかなり早くなるので私はオンにしている）

# 簡単なチュートリアル
自分の丸パクでいいです。コードの解説を下に書きます。
## 注意点
- auth用のパスなどは`htpasswd`コマンドを使います。
`sudo dnf install -y httpd-tools`などでinstallしてください。
- dockerを使えるようにしておいてください
- 先に`docker network create traefik-net`を実行しコンテナを横断するネットを作成します。
- docker compose のタグを更新した際は、`docker compose restart`ではなく`docker compose down && docker compose up -d`としましょう。設定が更新されません。
- https関係の設定を弄るときは本家を読みながらしてください
- `oligami.ml`は私のドメインです。全て自前のドメインに置き換えてください。
- faviconはrootなnginxが担当しています。あなたの環境にはありません。
- しばらくはurlを公開しておきます。

[traefik_example](https://oligami.ml/link/7iMsf0)

# 解説

https://github.com/oligami-0424/traefik_example#Traefik-example

上記のgithubを見ながら読んでください。
## treafik (reverse-proxy)
```yml:dynamic_conf.yml
# Dynamic Configuration
http:
  routers:
    dashboard:
      rule: Host(`oligami.ml`)&&(PathPrefix(`/api`)||PathPrefix(`/dashboard`))
      tls:
        certResolver: myresolver
      service: api@internal
      middlewares:
        - auth

  middlewares:
    auth:
      basicAuth:
        usersFile: /etc/traefik/.htpasswd
```
これはtraefikの状況やエラーを簡単に見れたりする、ダッシュボードの設定です。簡単なログインを通して見られなくしています。ファイルが無ければ`htpasswd -c .htpasswd user`で、あれば`-c`なしで実行します。このgithubのはtest:testPasswordだったはずです。
こんな感じのファイルで設定せずに全部dockerのラベルで済ませられるのがtraefikのいいところです（dashboardは無理）。
なお、このファイルが変更されると即時反映されます。

私のダッシュボードはこんなんです。![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/890310/9595db4e-23a0-8a6e-718d-3c7dc068f923.png)
ダッシュボードはルートに/dashboard/で飛べます。

```yml:traefik.yml
# Providers config
# log:
#   level: DEBUG
```
これでログのレベルを変更したりします。

```yml:traefik.yml
providers:
  docker: # Enable tells treafik to listen to docker
    exposedByDefault: false
    network: traefik-net

  file:
    directory: /etc/traefik/dynamic
```
これでdockerを使いますよ宣言してます（k8sとかも行けるので）。
あと、さっきのファイル（dynamic_conf.yml）があるフォルダ（/etc/traefik/dynamic）を登録します。
あと、`exposedByDefault: false`で、dockerは明示的に有効にしないとtraefikに取り込まれないようにします。

```yml:traefik.yml
# API/Dashboard config
api:
  insecure: true # Enable the Web UI
```
ダッシュボードを有効に

```yml:traefik.yml
experimental:
  http3: true
```
http3を有効に

```yml:traefik.yml
entryPoints:
  web:
    address: :80
    http:
      redirections:
        entryPoint:
          to: web-secure
          scheme: https

  web-secure:
    address: :443
    http3: {}

  tls8081:
    address: :8081

  tls8082:
    address: :8082
```
エントリポイント（使うポート）を登録します。web(http)はweb-secure(http3)に飛ばすようになっています。httpかhttpsはdockerごとに個別に設定します（確か）。
```yml:traefik.yml
certificatesResolvers:
  myresolver:
    acme:
      email: hogehoge@gmail.com
      storage: /letsencrypt/default/acme.json
      tlsChallenge: {}
      # caserver: 'https://acme-staging-v02.api.letsencrypt.org/directory'
      # httpChallenge:
      #   entryPoint: web
```
Let's encryptでてきとーにhttpsにします。自動更新です。独自の設定するときは頑張ってください。[公式](https://doc.traefik.io/traefik/https/acme/#certificate-resolvers)


```json:acme.json
```
証明書の情報とかが入っています。githubのは`#`で黒塗りしてあります。

```yml:docker-compose.yml
version: "3"

services:
  traefik:
    image: traefik:latest
    container_name: traefik
    ports:
      - 80:80
      - 443:443/tcp
      - 443:443/udp
      - 8080:8080
      - 8081:8081
      - 8082:8082
    restart: always
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./config:/etc/traefik:ro
      - ./letsencrypt:/letsencrypt
    networks:
      - traefik-net

networks:
  traefik-net:
    # docker network create traefik-net
    external: true
```
dockerを横断するネットワークである`traefik-net`は事前に作っておく必要があります。
ウェブ用である80ポート、https用である443ポート、http3用である443/udpポート、ダッシュボード用の8080ポート、websocket用の8081・8082を解放しています。
本来websocket用は一つでいいのですが、今回は一つのドッカーの二つのポートを同時にセキュアにできるという点の証明のため二つあります。これが一つでいいのがtraefikの利点です。
マウント（volumes）では、docker.sockを監視のため内部に取り込みます。あとはコンフィグファイルとacme.jsonの保存場所ですね。
networksでは`traefik-net`を指定しています。externalで外部から取り込んでいます。これによりdockerから該当のネットを引き込んでセキュアにします。

## 見本のページ（traefik_example）
### web server
htmlは説明することはないです。
```javascript:index.js
//ボタン登録 ---------------------------------------------------------
document.addEventListener("DOMContentLoaded", function () {
    const text_str = document.getElementById("text_str");
    const received_text = document.getElementById("received_text");
    var send_array = [];

    const websocket = new WebSocket("wss://oligami.ml:8081/wss/traefik_example/");
    const websocket2 = new WebSocket("wss://oligami.ml:8082/wss/traefik_example/");
    const websocket3 = new WebSocket("wss://oligami.ml:8082/wss/traefik_example2/");

    websocket.onmessage = (e) => {
        message = e.data;
        console.log(message);

        var li = document.createElement('li');
        if (send_array.includes(message))
            li.style.color = 'blue';
        var text = document.createTextNode(message);
        li.appendChild(text);
        received_text.appendChild(li);
    };

    document.getElementById('writeBtn').addEventListener("click", () => {
        websocket.send(text_str.value);
        send_array.push(text_str.value);
    })

    const websocket4 = new WebSocket("wss://oligami.ml:8081/wss/");
    const websocket5 = new WebSocket("ws://oligami.ml:53005");
})
```
send_arrayには今まで送信した単語が全て入っています。ここに単語が存在していたら色を変えるようになっています。
```javascript
const websocket = new WebSocket("wss://oligami.ml:8081/wss/traefik_example/");
```
ここを見るとわかるのですが、このようにwebsocketにはパスを設定することができます。
そして、traefikではこのパスごとに違うdockerに割り当てることができるので、websocket用のポートを一つしか開放しなくてよくなります。
この周りの三つのnew WebSocketは成功しますが、websocket4はパスがあっていないため何にも接続できず、websocket5はtraefikを介さずに接続するため、wssにならず、ブラウザから拒否られます。


websocket.onmessageでは、websocketで受け取ったメッセージを下にどんどん表示します。送ったことがあったら色を変えます。

logフォルダはそのままnginxのログが表示されます。
```conf:default.conf
server {
  server_name  oligami.ml;

  listen 80;
  listen 443;

  location /traefik_example/ {
    root   /var/www/html;
    index  index.html;
  }

  location /link/7iMsf0/ {
      return 308 https://oligami.ml/traefik_example/;
  }
}
```
パスによって振り分けます。/link/7iMsf0/は短縮リンクです。

### wss
```Dockerfile:Dockerfile
FROM python:3.11.2-slim

ENV APP_HOME=/home/wss

WORKDIR $APP_HOME

COPY requirements.txt $APP_HOME/

RUN pip install --upgrade pip \
    pip install --no-cache-dir -r $APP_HOME/requirements.txt
```
```txt:requirements.txt
###### Requirements without Version Specifiers ######`

# asyncio
# websockets

###### Requirements with Version Specifiers ######`

websockets==10.4
asyncio==3.4.3
```
よくあるpythonのdockerfileとrequirements.txtです。pip freezeしてあります。

```python:main2.py
import websockets
import asyncio
CLIENTS = set()

async def received(websocket):
    global CLIENTS
    CLIENTS.add(websocket)
    async for msg in websocket:
        websockets.broadcast(CLIENTS, msg)

async def main():
    async with websockets.serve(received, "", 8082):
        await asyncio.Future()  # run forever

asyncio.run(main())
```
ローカルホストの8082にwebsocketのサーバをたてて、受け取ったメッセージを全員に送り返すだけのプログラムです。

```python:main.py
import websockets
import asyncio
CLIENTS = set()
siritori_array = []

async def received(websocket):
    global CLIENTS
    global siritori_array
    CLIENTS.add(websocket)
    if len(siritori_array) != 0:
        for item in siritori_array:
            await websocket.send(item)
    async for msg in websocket:
        if len(siritori_array) == 0:
            websockets.broadcast(CLIENTS, msg)
            siritori_array.append(msg)
        elif msg in siritori_array:
            await websocket.send("It's already")
        elif siritori_array[-1][-1] != msg[0]:
            await websocket.send("not equal")
        else:
            websockets.broadcast(CLIENTS, msg)
            siritori_array.append(msg)

async def main():
    async with websockets.serve(received, "", 8081):
        await asyncio.Future()  # run forever

asyncio.run(main())
```
siritori_arrayにしりとりの文字列を保存します。後は接続してきたら今までのを送る。
最初の文字列なら無条件、もう言われてたら`It's already`と返す。
最後の一文字と最初の一文字が同じでなかったら`not equale`と返す。
って感じです。listenするポートは8081。
なお、`ん`で終わったら終了する処理はありません。

この下のが最後のオオトリですね。
#### docker-compose.yml
```yml:docker-compose.yml
networks:
  traefik-net:
    external: true
```
でネットワークを引き込みます。

##### nginx
```yml:docker-compose.yml
    networks:
      - traefik-net
```
でネットワークを設定

```yml:docker-compose.yml
      - traefik.enable=true
```
でtraefikの対象に設定

```yml:docker-compose.yml
      - traefik.http.routers.traefik_example_webpage.entrypoints=web-secure
      - traefik.http.routers.traefik_example_webpage.rule=Host(`oligami.ml`)&&(PathPrefix(`/traefik_example/`)||Path(`/link/7iMsf0`))
      - traefik.http.routers.traefik_example_webpage.tls=true
      - traefik.http.routers.traefik_example_webpage.tls.certresolver=myresolver
```
traefik_example_webpageと名付けたものを作成し（なかったら勝手に作られる）、エントリポイントをweb-secureに。
名前がかぶると正常に動作しないので注意
```go
Host(`oligami.ml`)&&(PathPrefix(`/traefik_example/`)||Path(`/link/7iMsf0`))
```
で、`Host`でドメインを設定、`PathPrefix`で/traefik_example/からはじまるやつと短縮リンク用のやつをこちらに引き込むように設定します。
割り当ての優先度は長さによって決まっているのでとにかく長くするとこちらに来ます。最終的に行きつくnginxはpriorityを明示的に1にしておくといいでしょう。
traefikはgoでできているのでここら辺の書き方はgo準拠です。
```yml:docker-compose.yml
      - traefik.http.middlewares.pathsetup.replacepathregex.regex=^/(link)/([^/]*)
      - traefik.http.middlewares.pathsetup.replacepathregex.replacement=/$${1}/$${2}/
      - traefik.http.routers.traefik_example_webpage.middlewares=pathsetup@docker
```
これでpathsetupと名付けたミドルウェアを作成します。replacepathregexという公式が用意したmiddlewareを用いて、正規表現で、linkのあとのパスの最後が/ではなかったら勝手につけるようにしています。nginxでは最後が/でないと振り分けてくれないので。
そしてそれをtraefik_example_webpageのミドルウェアとして登録します。

ちなみに、dynamic_conf.ymlみたいにファイルで書いた場合の利点としては、ミドルウェアなどの設定をdocker内からリアルタイムで弄ることで、色々できると思います。

##### wss2

```yml:docker-compose.yml
    build:
      context: ./wss/
```
でDockerfileを指定します。

```yml:docker-compose.yml
    expose:
      - 8082
```
で、`traefik-net`に8082ポートを解放します。

```yml:docker-compose.yml
    ports:
      - 53005:8081
```
で、8081ポートをtraefikを介さずに外部に出します。wssでないとサイトは弾くということをわかりやすくするためです。

```yml:docker-compose.yml
    volumes:
      - ./wss:/home/wss:ro
```
で、書き換え不可でpythonのファイルをマウントします。

```yml:docker-compose.yml
    command: python main.py & && python main2.py
```
で、最後にmain.pyをバックグラウンドで、main2.pyをデーモンで実行します。

```yml:docker-compose.yml
      - traefik.http.routers.wss2.rule=Host(`oligami.ml`)&&(Path(`/wss/traefik_example2/`))
```
で、パスを正確に指定し、これ以外を全てシャットアウトします。

```yml:docker-compose.yml
      - traefik.http.routers.wss2.entrypoints=tls8082
```
セキュアにしたい8082ポートに繋がっているtls8082というエントリポイントを指定します。

```yml:docker-compose.yml
      - traefik.http.routers.wss2.tls=true
      - traefik.http.routers.wss2.tls.certresolver=myresolver
```
でセキュアにします。

##### wss

```yml:docker-compose.yml
    expose:
      - 8081
      - 8082
```
でtraefik-netに8081と8082を公開します。

```yml:docker-compose.yml
      - traefik.http.routers.wss.entrypoints=tls8081,tls8082
```
で、8081と8082のエントリポイントを指定します。

# 最後に
これで全部だと思います。
質問や修正がある方はコメントに。

traefikはまだ全然広まっていませんが、とても便利です。皆さんも使っていきましょう。
困ったときは公式ドキュメントです。