[docker][nginx] nginxで簡易ALBを作る

開発、テスト用に複数のアプリを立ち上げたかったので、簡単な ALBツールを作ってみました。(環境はAWS)
ツールを作ることにしたのは、以下のような制約があったからです。

制約:
- サーバ外へ任意のポート公開ができない (80/443のみ)
- ALB 立ち上げ禁止 (本来ならコレ)

アプローチとしては、docker を使っているので nginx のデフォルトのコンテナを使って実現することを考えます。
元々やりたいのはホスト上で複数のアプリを並行稼働させることなので、それぞれ別のポートを割り振って立ち上げておいて、nginx でホスト名によって振分けする形を目指します。

![simple-alb.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/558772/1a8b3c72-370f-a0ad-4e94-df04c2593d04.png)


振分け先は同じホストで動いている他のコンテナなので、nginx からみるとコンテナ内からホストへ接続することになります。

![simple-alb-network.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/558772/48b5211a-698f-9e1c-08c1-29fb9f2aa425.png)


なお、ツールとしての簡易版なので、docker イメージを新たに作ったり、プラグイン追加はしないという範囲で進めます。

作成したツールは GitHub に置きました。
https://github.com/batatch/simple-alb


## 概要

簡易版とはいいつつ、設定内容は AWS ALB のものを模して定義ファイルを用意して、そこから nginx の設定を生成→docker 実行のような構成を目指します。

手順:

```
$ make build    # 定義ファイルから nginx 設定ファイルを生成
$ make up       # docker-compose で設定ファイルを割り当てて nginx のコンテナを起動
$ make down     # docker-compose で nginx のコンテナを停止
```

定義ファイル:

```alb.yml
---
http:
  listen: 80                       # リスナー、今回は HTTPのみ
  rules:
    - if:                          # ALB の IF 条件
        host: app01.example.com    # ホスト名マッチング
        pathes: [ "/" ]            # パスマッチング
      then:                        # ALB の THEN 文
        forward:                   # 転送設定
          name: tg-app01
          targets:                 # 転送先(複数)、ターゲットグループのようなイメージ
            - target: http://docker0:21080
              weight: 30
            - target: http://docker0:22080
          stickiness: true
    :
```

手順は Makefile にまとめます。
好みですが、これが最も手軽で分かりやすいと思います。
それから、上記の定義ファイルを元に、以下のような nginx の設定ファイルを作成します。

```nginx/conf.d/default.conf
upstream target1 {
    server http://docker0:21080;
    server http://docker0:22080;
}
server {
    listen 80;
    server_name app01.example.com;
       :
    location / {
        proxy_pass http://target1;
    }
}
```

枠組みはこんな感じ。


## テンプレートエンジン

設定ファイルを自動生成するので、なんらかのテンプレートエンジンが欲しいと思って、Ansible などで使われている Python の Jinja2 を使ってみます。

以下のような簡単な Python スクリプトで、テンプレートファイルと、設定ファイルの YAML ファイルから変換結果を得られるようにしました。

```j2.py
import sys
import yaml
from jinja2 import Template, Environment, FileSystemLoader

def _j2(templateFile, configFile):
    env = Environment(loader=FileSystemLoader('.', encoding='utf_8'))
    tpl = env.get_template(templateFile)

    with open(configFile) as f:
        conf = yaml.load(f)

    ret = tpl.render(conf)
    print(ret)

if __name__ == '__main__':
    if (len(sys.argv) <= 2):
        print("Usage: j2.pl <template file> <config file>")
        sys.exit(-1)
    _j2(sys.argv[1], sys.argv[2])
```

コマンドラインはこんな感じ。

```
$ python j2.pl template.conf.j2 param.yml > output.conf
```

## dockerコンテナからホストへの通信

これがなかなか面倒で、Windowsや Macの Docker環境であれば host.docker.internal で、コンテナ内→ホストへの接続ができるようですが、Linux ではそのような方法が用意されていません。

Linux では docker0 というインターフェースでホスト/コンテナ間をつないでいるらしいので、ホスト側で docker0 に振られた IPアドレスを取得し、環境変数化して docker 起動時に渡すようにしました。

```
$ env DOCKER0_ADDRESS=$( ip route | awk '/docker0/ {print $9}' ) \
  docker-compose up -d
```

```docker-compose.yml
version: '3'
services:
  alb:
    image: nginx:stable
      :
    extra_hosts:
      - "docker0:${DOCKER0_ADDRESS}"
```

docker-compose.yml にて extra_hosts にマッピングを書いておくと、コンテナ起動時にコンテナ内の /etc/hosts にホスト名とIPアドレスのマッピングが追記されるので、nginx の設定で名前参照できるようです。

```
/etc/hosts
----
172.17.0.1      docker0
```

## nginx でロードバランサ

nginx でロードバランサを設定するには http/upstream でターゲットのグループを定義して、http/server/location の proxy_pass で upstream 名を指定する。。想定だったのですが、エラーで起動しません。

どうやら nginx の無償版では upstream 内に書かれたホスト名の DNS解決(resolver) が使えないようです。
(nginx に有償版/無償版があるのを、今回初めて知りました。。)

この件についてまとめられた記事です。
下の Qiita の記事で UNIX ソケットを使う方法が紹介されています。
プラグイン使ったり、docker イメージ作成しないという制約に合っていたので、今回はこちらを採用しました。(設定ファイルが長くなりますが)

Nginxの名前解決についてまとめ
https://ktrysmt.github.io/blog/name-specification-of-nginx/
nginxのupstreamコンテキストで有償のresolveオプションを使わずに動的にDNS解決する
https://qiita.com/minamijoyo/items/183e51a28a3a9d79182f


```nginx/conf.d/default.conf
upstream tg-app01 {
    server unix:/var/run/nginx_tg-app01_1;  # (2-1) tg-app01の 1つめのターゲット
    server unix:/var/run/nginx_tg-app01_2;  # (2-2) tg-app01の 2つめのターゲット
}
server {
    listen 80;
    server_name app01.example.com;
        :
    location / {
        proxy_pass http://tg-app01;  # (1) upstream tg-app01 を参照
    }
}
server {
    listen unix:/var/run/nginx_tg-app01_1;  # (2-1) 1つめのターゲットの参照先
    server_name app01.example.com;
        :
    location / {
        proxy_pass http://docker0:21080;
    }
}
server {
    listen unix:/var/run/nginx_tg-app01_2;  # (2-2) 2つめのターゲットの参照先
    server_name app01.example.com;
        :
    location / {
        proxy_pass http://docker0:22080;
    }
}
```


## Websocket の疎通

今回 Websocket を通す必要があったので、以下の設定を行います。
各 server ブロックに必要なようです。

```nginx/conf.d/default.conf
# これと
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
    :
server {
    listen 80;
    server_name app02.example.com;

    # ここから
    proxy_set_header Host               $host;
    proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Host   $host;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Real-IP          $remote_addr;

    proxy_http_version          1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    # ここまで

    location / {
        proxy_pass http://tg-app02;
    }
}
```

## nginx 設定ファイルのテンプレート

ここまでの内容を踏まえて、テンプレート定義は以下のようになりました。
細かいですが、+α でデフォルトパターンの指定や固定レスポンスの設定も入っています。

```src/default.conf.j2
## http listener settings
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}
{% for rule in http.rules %}
{%- if rule.then.forward %}
upstream {{ rule.then.forward.name }} {
    {%- if rule.then.forward.stickiness %}
    ip_hash;
    {%- endif %}
    {%- for tg in rule.then.forward.targets %}
    server {{ 'unix:/var/run/nginx_%s_%d' % (rule.then.forward.name, loop.index) }}{{ ' weight=%d' % tg.weight if tg.weight else '' }};
    {%- endfor %}
}
{%- endif %}
server {
    listen {{ http.listen }}{{ '  default_server' if rule.if.default_server }};
    server_name {{ rule.if.host }};
    {% if rule.then.forward %}
    proxy_set_header Host               $host;
    proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Host   $host;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Real-IP          $remote_addr;

    proxy_http_version          1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    {% endif %}
    {%- for path in rule.if.pathes %}
    location {{ path }} {
        {%- if rule.if.headers %}
        {%- for header in rule.if.headers %}
        if ($http_{{ header|replace('-','_')|lower() }} = "{{ rule.if.headers[header] }}") {
            proxy_pass http://{{ rule.then.forward.name }};
            break;
        }
        {%- endfor %}
        {%- else %}
        proxy_pass http://{{ rule.then.forward.name }};
        {%- endif %}
    }
    {%- endfor %}
    {%- if rule.then.response %}
    location / {
       {%- if rule.then.response.content_type %}
       default_type {{ rule.then.response.content_type }};
       {%- endif %}
       return {{ rule.then.response.code }}{{ ' \'%s\'' % rule.then.response.message if rule.then.response.message }};
    }
    {%- endif %}
}
{%- if rule.then.forward %}
{%- for tg in rule.then.forward.targets %}
server {
    listen {{ 'unix:/var/run/nginx_%s_%d' % (rule.then.forward.name, loop.index) }};
    server_name {{ rule.if.host }};
    {% if rule.then.forward %}
    proxy_set_header Host               $host;
    proxy_set_header X-Forwarded-For    $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Host   $host;
    proxy_set_header X-Forwarded-Server $host;
    proxy_set_header X-Real-IP          $remote_addr;

    proxy_http_version          1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    {% endif %}
    location / {
        proxy_pass {{ tg.target }};
    }
}
{%- endfor %}
{%- endif %}
{% endfor %}
```

docker-compose 設定は以下のとおり。
nginx の設定フォルダを外部ボリュームにマウントして、設定を反映させます。

```docker-compose.yml
version: '3'
services:
  alb:
    image: nginx:stable
    ports:
      - "80:80"
    volumes:
      - ./conf.d:/etc/nginx/conf.d
    extra_hosts:
      - "docker0:${DOCKER0_ADDRESS}"
    restart: always
```

動作確認は以下のとおり。

```
$ make build    # 設定ファイル変換
$ make up       # nginx コンテナ起動

$ curl http://localhost:80 -i -H "Host:app01.example.com"  # ホスト名を設定してアクセス
HTTP/1.1 200 OK
Server: nginx/1.18.0
Date: Sat, 29 Aug 2020 17:26:23 GMT
Content-Type: text/html
Content-Length: 1863
Connection: keep-alive
Last-Modified: Wed, 11 Mar 2020 05:22:13 GMT
ETag: "747-5a08d6b34ab40"
Accept-Ranges: bytes

<!DOCTYPE html>
<html>
    :
```


## まとめ

AWS ALB っぽい構成要素を YAML 定義に書いて、複雑な nginx の設定を書かなくていいので、管理もしやすくなると思います。
ビルドやプラグインのインストールも不要なので、DockerHub から nginx のイメージの取得さえできれば簡単に ALB 気分になれます。

nginx の設定の勉強にもなりました。(無償版/有償版も知らなった。。)

もっと調べ込んで頑張れば、ALB 本家の設定パターンを実現できるかもしれないですね。(今回はできるだけ手軽に使えるレベルにしたかったので、作り込みはしないようにしました)