jwilder nginx-proxy + letsencrypt-nginx-proxy-companion + Flask + uwsgiでHTTPS対応のWebサービスを作り、更にQualysでA+を取る

[JQueryとPythonを勉強しながらGCP上でWebサービスを公開するまで](https://qiita.com/legacyworld/items/f3416c9dbca6503b135e)という記事から始めたのだが、このアーキテクチャを作るのもなかなかハードルが高かった。
NginxとFlaskの初期の設定から説明してみたい。

localhostとpublic domainの両方で試せるようにdocker-compose.ymlを分けて作ったものをGitHubに置いている。
https://github.com/legacyworld/nginx-proxy-test
![mobilesuica.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/629456/b98ed14e-fea3-abb9-c92f-7f1c750da923.png)

# 環境
まずはドメインのないローカルで始める。
Docker VM：CentOS 7
docker: 19.03
docker-compose: 1.25.5

# HTTPS無しで始める
いきなり全部作り始めるのはあまりにもハードルが高いので、HTTPS無しで始めて見る。
<img width="711" alt="8.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/629456/2bff1a99-53c4-8d5f-3266-a869cae089d2.png">

これなら単なるリバースプロキシなので情報はいくらでも転がっている。
やることは単純で
ユーザ => index.html読む => javascript実行 => REST APIでFlaskにデータ（ファイル）を投げる => Pythonで処理して返す => FlaskがJSONで返す => 表示する

これを実現するには以下の事をやればよい。

- http://ip address/api/v1というREST APIにアクセスが来たらFlaskにForward
- それ以外は全てNginxのローカルにあるstatic fileで返す

とりあえずリバースプロキシの設定の確認なので最も単純な状態にする。

```html:index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
  </head>
  <h1>これはindex.html</h1>
</html>
```
```python:server.py
from flask import Flask, jsonify, request, make_response,send_from_directory
app = Flask('test')
@app.route('/api/v1/test/',methods=['GET'])
def test():
    return "Flask OK",200
if __name__ == '__main__':
    app.run(host='0.0.0.0', port=8080)
```

今Docker VMのIP Addressが172.16.0.21なのでこうなる状態まで持っていく。

- http://172.16.0.1

<img width="271" alt="9.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/629456/8b4eabcb-4956-03b5-bb4f-bdee97377d59.png">

- http://172.16.0.1/api/v1/test/

<img width="329" alt="10.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/629456/422c4022-fab1-e4d5-b1e0-b31b5ba950e4.png">

# nginx.confを作る
追加が必要なのは`location /api/v1/`の部分だけである。
Dockerのコンテナ間通信はコンテナ名で行われるのでこのような書き方になっている。

```conf:nginx.conf
events {
  worker_connections  1024;
}
http{
  server{
    listen 80;
    server_name localhost;
    location / {
      root   /usr/share/nginx/html;
      index  index.html index.htm;
    }
    location /api/v1/ {
      proxy_request_buffering off;
      proxy_pass http://app:8080;
    }
  }
}
```

# Dockerfileを作る
まずはNginx用のDockerfile。nginx.confをコピーするだけ

```dockerfile:Dockerfile
FROM nginx:alpine
COPY ./nginx.conf /etc/nginx/nginx.conf
```

次にFlask用のDockerfile。

```dockerfile:Dockerfile
FROM python:slim
USER root
RUN pip3 install flask
WORKDIR /src
ENV PORT=80
ENV PYTHONUNBUFFERED=1
ENTRYPOINT ["python", "-u", "server.py"]
```

# docker-compose.ymlを作る
ディレクトリ構造とdocker-compose.yml

```
.
|-- app
|   |-- Dockerfile
|   `-- src
|       `-- server.py
|-- docker-compose.yml
`-- web
    |-- Dockerfile
    |-- html
    |   `-- index.html
    `-- nginx.conf
```


```yml:docker-compose.yml
version: '3.7'
services:
  web:
    build:
      context: ./web
      dockerfile: Dockerfile
    container_name: web
    ports: 
      - 80:80
    volumes:
      - ./web/html:/usr/share/nginx/html
  app:
    build:
      context: ./app
      dockerfile: Dockerfile
    container_name: app
    volumes:
      - ./app/src:/src
``` 

Nginx,Flaskのコンテナ両方ともvolumesでhtmlファイルとソースコードファイルをホストから直接読み込むようにしている。
これですべて揃ったので後はいつもの`docker-compose up --build -d`で終わり。
無事立ち上がった。 

#uwsgiへの対応
NginxとFlask間の通信をuwsgiにする。結構いろんなファイルへの変更が発生する。
まずはDockerfileから。

```dockerfile:Dockerfile
FROM python:alpine
USER root
RUN apk add gcc build-base linux-headers
RUN pip3 install flask uwsgi
WORKDIR /src
ENV PORT=80
ENV PYTHONUNBUFFERED=1
RUN adduser -S uwsgiusr
USER uwsgiusr
ENTRYPOINT ["uwsgi","--ini","/src/uwsgi.ini"]
```
ほぼ全部入れ替えになってしまった。

- FROM python:alpine
    - slimを使っていたが、apk addが使えなかったのでalpineに変更
- RUN apk add gcc build-base linux-headers
    - uwsgiのBuildにgccが必要なため追加インストール。これがないと下記のエラーが出る
    - `Exception: you need a C compiler to build uWSGI`
- RUN pip3 install flask uwsgi
    - これはuwsgiが増えただけ
- RUN adduser -S uwsgiusr
- USER uwsgiusr
    - uwsgiusrで実行するので、ユーザを追加してからユーザ変更
- CMD ["uwsgi","--ini","/src/uwsgi.ini"]
    - uwsgiの設定からflaskを呼び出す。
 
次にuwsgiの設定ファイルであるuwsgi.ini

```ini:uwsgi.ini
[uwsgi]
wsgi-file = server.py
callable = app
master = true
processes = 1
socket = :3031
chmod-socket = 666
vacuum = true
die-on-term = true
py-autoreload = 1
uid = uwsgiusr
gid = uwsgiusr
```
細かいことはわからないが、他の設定ファイル等に関係ある部分だけ抜粋
[※このページを参照](https://uwsgi-docs.readthedocs.io/en/latest/WSGIquickstart.html)

- wsgi-file
    - このPythonファイルをuwsgiから呼ぶ
- callable
    - uwsgiはflaskの`app = Flask('test')`を目印に呼ぶから
- socket
    - ポート番号(HTTP socketではないらしい)
- uid/gid
    - Dockerfileでこのユーザを追加する

次はnginx.conf

```conf:nginx.conf
# 省略
    location /api/v1/ {
      proxy_request_buffering off;
      include uwsgi_params;
      uwsgi_pass app:3031;
    }
  }
}
```

- include uwsgi_params
    - [これのことだろうか？](https://github.com/nginx/nginx/blob/master/conf/uwsgi_params)
- uwsgi_pass app:3031
    - これでflaskに投げてくれる
 
flaskの部分は1行だけ

```python:server.py
# 省略
if __name__ == '__main__':
    app.run()
```
app.run()のところだけ変えている。
最後にdocker-compose.yml

```yml:docker-compose.yml
# 省略
    ports:
      - 3031:3031
    volumes:
      - ./app/src:/src
```
portsだけ追加

# HTTPSに対応する
HTTPSとなるとドメインが必要になってくるため、環境をGCPへ移す。
GCPでの環境の作り方はこちらで記事にしている。
[※お名前.com + GCP + DockerでWebサーバの立ち上げ](https://qiita.com/legacyworld/items/c4c702c80eb7efe0a3db)

- 目標
    - [Qualys](https://www.ssllabs.com/index.html)でA+を取って安全なWebサーバを目指す。実はjwilder/nginx-proxyを何も考えずに使うとA+になるので、これを動かせば何とかなる。
- 証明書
    - これは無料のLet's Encrypt以外に選択肢はない
    - jwilder/nginx-proxyにくっつけて使うcompanionがあるので何とかこれを動かす
- ドメイン
    - お名前.comで1円
- 環境
    - GCPのCotainer-Optimized-OS / f1-micro
        - docker: 19.03.6
        - docker-compose: 1.25.5
    
Let's encryptが一回目に行うchallengeをどう乗り切れば良いのかがわからなくてかなり悩んだ。  
一応完成版はこのような構成。
変更したファイルはdocker-compose.ymlとnginx.conf

```yml:docker-compose.yml
version: '3.7'
services:
  nginx-proxy:
    image: jwilder/nginx-proxy:alpine
    container_name: nginx-proxy
    ports:
      - 80:80
      - 443:443
    volumes:
      - /var/run/docker.sock:/tmp/docker.sock:ro
      - cert:/etc/nginx/certs
      - html:/usr/share/nginx/html
    labels:
      com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy: "true"

  letsencrypt-nginx:
    image: jrcs/letsencrypt-nginx-proxy-companion
    container_name: lets
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - cert:/etc/nginx/certs:rw
      - html:/usr/share/nginx/html
      - vhost:/etc/nginx/vhost.d
    depends_on:
      - nginx-proxy

  web:
    build:
      context: ./web
      dockerfile: Dockerfile
    container_name: web
    environment:
      - VIRTUAL_HOST=www.example.com
      - LETSENCRYPT_HOST=www.example.com
      - LETSENCRYPT_EMAIL=xxx@gmail.com
    volumes:
      - html:/usr/share/nginx/challenge
      - ./web/html:/usr/share/nginx/html
    depends_on:
      - letsencrypt-nginx

  app:
    build:
      context: ./app
      dockerfile: Dockerfile
    container_name: app
    ports:
      - 3031:3031
    volumes:
      - ./app/src:/src

volumes:
  cert:
  html:
　vhost:
```

```conf:nginx.conf(webの)
events {
  worker_connections  1024;
}
http{
  server{
    listen 80;
    server_name www.example.com;
    location / {
      root   /usr/share/nginx/html;
      index  index.html index.htm;
    }
    location /api/v1/ {
      proxy_request_buffering off;
      include uwsgi_params;
      uwsgi_pass app:3031;
    }
    location ^~ /.well-known/acme-challenge/ {
      root /usr/share/nginx/challenge;
    }
  }
}
```

docker-compose.ymlにはコンテナが2つ追加してある
 
- nginx-proxy
    - [jwilder/nginx-proxy](https://github.com/nginx-proxy/nginx-proxy)
        - 設定はいろいろ出来るがそのままなにも設定せずに使う
- letencrypt-nginx
    - [docker-letsencrypt-nginx-proxy-companion](https://github.com/nginx-proxy/docker-letsencrypt-nginx-proxy-companion)
    - これもそのまま使う
- environment
    - VIRTUAL_HOST
        - このドメイン名に来たアクセスは、このコンテナに転送されてくる
    - LETSENCRYPT_HOST / LETSENCRYPT_EMAIL
        - Let's Encryptがこの情報で証明書を作る
- volumes
    - /var/run/docker.sock:/tmp/docker.sock:ro
        - nginx-proxyがDockerと通信するためのもの。
    - cert
        - 証明書を置くための場所。
    - html
        - チャレンジファイルを置くための場所
    - vhost
        - 書いておかないとエラーが出てLet's Encrypt Companionが立ち上がらない

        ```
Warning: '/etc/nginx/vhost.d' does not appear to be a mounted volume.
Error: can't access to '/etc/nginx/vhost.d' directory !
Check that '/etc/nginx/vhost.d' directory is declared as a writable volume.
```

- labels
    - よくはわからないが、これでLet's Encrypt Companionがどのnginx-proxyを使えばよいか判定するらしい
- depends_on
    - 無くても良いとは思うが、時々起動がうまくいかなかったのでnginx-proxy -> letsencrypt -> webの順で立ち上がるようにしている

nginx.confは一か所追加

- location ^~ /.well-known/acme-challenge
    - ここにアクセスが来た時（チャレンジファイル）だけ/use/share/nginx/challenge(=Dockerのhtml volume)を見る

ここのチャレンジの部分が最初よくわからずどうしても証明書を取ることが出来なかった。
上記の設定とチャレンジによる証明書の取得の流れを絵にしてみた（間違ってたらどなたかご指摘ください）
<img width="780" alt="12.PNG" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/629456/86006910-fc92-a2c5-2bc9-9fcbc638d502.png">

1. Let's Encrypt Companionが/usr/share/nginx/html(=Dockerのhtml volume)にチャレンジファイルを置く
2. Let's Encryptの外部サーバに対してACMEプロトコル通信で「ファイルが用意できました」と伝える
3. Let's Encryptの外部サーバからこのファイルを取りに来る（ログからすると4か所から取りに来ていた）
4. このGETはVIRTUAL_HOSTの設定によりWebサーバへ転送される
5. Webサーバはhtml volumeをマウントしているのでLet's Encrypt Companionが保存したファイルを読める
6. ファイルがLet's Encryptのサーバへ返る
7. 証明書が発行されて、Let's Encrypt Companionがcert volumeに保存する

上記の5.のステップがよくわからずこんなエラーが出ていた。
Let's Encrypt Companionのエラー

```
2020-05-09 09:01:21,734:ERROR:simp_le:1417: CA marked some of the authorizations as invalid, which likely means it could not access http://example.com/.well-known/acme-challenge/X. Did you set correct path in -d example.com:path or --default_root? Are all your domains accessible from the internet? Please check your domains' DNS entries, your host's network/firewall setup and your webserver config. If a domain's DNS entry has both A and AAAA fields set up, some CAs such as Let's Encrypt will perform the challenge validation over IPv6. If your DNS provider does not answer correctly to CAA records request, Let's Encrypt won't issue a certificate for your domain (see https://letsencrypt.org/docs/caa/). Failing authorizations: https://acme-v02.api.letsencrypt.org/acme/authz-v3/4467562815
```

Webサーバのログ


```
2020/05/09 09:01:20 [error] 6#6: *1 open() "/usr/share/nginx/html/.well-known/acme-challenge/sa_v8la8ZtiEBjC76rbJmu23r7AktikFmX1LXV1ow4w" failed (2: No such file or directory), client: 172.20.0.5, server: www.example.com, request: "GET /.well-known/acme-challenge/sa_v8la8ZtiEBjC76rbJmu23r7AktikFmX1LXV1ow4w HTTP/1.1", host: "www.example.com"
```

**http://www.example.com/.well-known/acme-challenge**へのアクセスがWebサーバの**/usr/share/nginx/html/.well-known/acme-challenge**に行っているが、Let's Encrypt Companionとvolumeを共有していなかったためチャレンジファイルを見ることが出来なかったのだ。
これがnginx.confを変更した理由である。

長かったがこれでようやくHTTPS化が終わりました。
この状態でQualysでチェックをかけるとA+を取れます。

# ドメインの追加
ここまで来ると新しいドメインでの追加は簡単です。
例えばwww.hogehoge.comを追加する場合はdocker-compose.ymlを以下のように記述すればよい。

```yml:docker-compose.yml
version: '3.7'
services:
  nginx-proxy:
    image: jwilder/nginx-proxy:alpine
    container_name: nginx-proxy
    ports:
      - 80:80
      - 443:443
    volumes:
      - /var/run/docker.sock:/tmp/docker.sock:ro
      - cert:/etc/nginx/certs
      - html:/usr/share/nginx/html
    labels:
      com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy:

  letsencrypt-nginx:
    image: jrcs/letsencrypt-nginx-proxy-companion
    container_name: lets
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - cert:/etc/nginx/certs:rw
      - html:/usr/share/nginx/html
      - vhost:/etc/nginx/vhost.d
    depends_on:
      - nginx-proxy

  web:
    build:
      context: ./web
      dockerfile: Dockerfile
    container_name: web
    environment:
      - VIRTUAL_HOST=www.example.com
      - LETSENCRYPT_HOST=www.example.com
      - LETSENCRYPT_EMAIL=xxx@gmail.com
    volumes:
      - html:/usr/share/nginx/challenge
      - ./web/html:/usr/share/nginx/html
    depends_on:
      - letsencrypt-nginx

  hoge:
    build:
      context: ./hoge
      dockerfile: Dockerfile
    container_name: hoge
    environment:
      - VIRTUAL_HOST=www.hogehoge.com
      - LETSENCRYPT_HOST=www.hogehoge.com
      - LETSENCRYPT_EMAIL=xxx@gmail.com
    volumes:
      - html:/usr/share/nginx/challenge
      - ./hoge/html:/usr/share/nginx/html
    depends_on:
      - letsencrypt-nginx

  app:
    build:
      context: ./app
      dockerfile: Dockerfile
    container_name: app
    ports:
      - 3031:3031
    volumes:
      - ./app/src:/src

volumes:
  cert:
  html:
　vhost:
```