[Docker] flask/uWSGI+nginx+postgresを別コンテナで構築する

# はじめに
最近Docker上でアプリを動かしてみたいと思い、1～2週間ほど格闘してようやくタイトルにあるような構成でアプリを動かすことに成功しました。ほかの記事でも結構見かけるような構成ではあるのですが、コンテナ起動時のテーブル構築やデータの永続化といったテーマにも触れていきたいとおもいます。


# 環境
・Window10 Home 64bit 
・WSL2
・Docker Desktop for Windows


# ディレクトリ構成

```
flask/
    ├ app/
    │   ├ config/
    │   │   ├ requirements.txt
    │   │   └ uwsgi.ini
    │   ├ Dockerfile
    │   └ run.py
    ├ database/
    │   ├ data/
    │   ├ docker-entrypoint-initdb.d/
    │   │   └ 1_create_table.sql
    │   └ Dockerfile
    ├ nginx/
    │   ├ config/
    │   │   └ nginx.conf
    │   └ Dockerfile
    └ docker-compose.yml    

```

[Github](https://github.com/yoshi-kin/flask_example)はこちらです。

# app

まずDockerfileです。
ワークディレクトリは/var/www/appにしていますが、任意です。
アプリケーションに必要なライブラリはrequirements.txtで管理するのでこれをインストールしています。

```docker:Dockerfile
FROM python:3.9

# コンテナ内の作業ディレクトリを指定（以降のカレントディレクトリ）
WORKDIR /var/www/app

RUN apt-get update \
    && apt-get install -y vim \
    && apt-get autoremove -y \
    && rm -rf /var/lib/apt/lists/*

# ホストのカレントディレクトリをコンテナ内のカレントディレクトリにコピー
COPY . .

# ライブラリインストール
RUN pip install -U pip \
    && pip install --no-cache-dir -r /var/www/app/config/requirements.txt
```

ライブラリ管理は[requirements.txt](https://note.nkmk.me/python-pip-install-requirements/)で行っています。

``` requirements.txt
uwsgi
flask
psycopg2
```

uwsgi.iniはnginxからsocketで通信を受け取る設定にしておきます。

```ini:uwsgi.ini
[uwsgi]
master = True
socket = :3031
# http = :3031
chdir = /var/www/app/
wsgi-file = run.py
callable = app
logto = /var/log/uwsgi.log
```

アプリに関しては`Hello World`を返す簡単なものを作っていきます。

``` python:run.py
from flask import Flask, redirect, url_for, render_template
import psycopg2 
from psycopg2.extras import DictCursor
# from werkzeug.middleware.proxy_fix import ProxyFix

app = Flask(__name__)
# app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_host=1, x_port=1, x_proto=1)

def pg_conn():
    setting = {
        'host': 'flask_db', # dbコンテナ名を指定
        'port': '5432',
        'dbname': 'postgres',
        'user': 'postgres',
        'password': 'postgres'
    }
    return psycopg2.connect(**setting)

@app.get('/')
def index():
    return 'Hello World'

@app.get('/red')
def red():
    with pg_conn() as conn:
        with conn.cursor(cursor_factory=DictCursor) as cur:
            sql = "INSERT INTO users(name) values('jessy')"
            cur.execute(sql)
            conn.commit()
    return redirect(url_for('index'))


if __name__ == "__main__":
    app.run()
```


# database

Dockerfileです。イメージを取得するだけなのでdocker-compose.ymlで直接取得しても構わないのですが、あとからDockerfileに定義すべき事項が出てきたときに少しめんどくさいと思ったのでDockerfileを使っています。

```dockerfile:Dockerfile
FROM postgres:latest
```

次にコンテナ起動時に必要なテーブルが作成されるように.sqlファイルを作っておきます。コンテナ内の`/docker-entrypoint-initdb.d`に置いておくとコンテナ起動時に実行してくれます。コンテナの中に入ってデータベースやテーブルを作成するといった作業を省略できます。実行対象ファイルは「.sql」「.sh」「.sql.gz」のようです。また、ファイル名のイニシャルに数字をつけておくことでDockerが順番に実行してくれます。

```sql:1_create_table.sql
create table if not exists users (
  id serial not null
  , name character varying not null
  , constraint users_PKC primary key (id)
) ;
```

最後に`/data`という空のディレクトリを作成しておきます。これはこの後出てきますが、データを永続化するために使います。

# nginx

```Dockerfile:Dockerfile
FROM nginx:latest

# vimをインストールしています。必要がなければコメントアウトしてください。
RUN apt-get update \
    && apt-get install -y vim \
    && apt-get autoremove -y \
    && rm -rf /var/lib/apt/lists/*
```

## 設定
次にnginxの設定ファイルです。おもにnginx-uWSGI間の通信方法についてみていきますので、他の設定については言及しません。今回はuwsgiとHTTPプロトコルの2つを試してみました。

nginxとuWSGIを同一のコンテナで運用する場合unixソケットで通信することが一般的だと思いますが、今回は別々のコンテナで運用するのでunixソケットは使えません。

### uwsgi

以下はuwsgiプロトコルで通信する場合の設定例です。

```nginx:nginx.conf
user  nginx;
worker_processes  auto;
error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;

events {
    worker_connections  1024;
}

http {

    include       /etc/nginx/mime.types;
    default_type  text/html;
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
    access_log  /var/log/nginx/access.log  main;
    sendfile       on;
    tcp_nopush     on;
    tcp_nodelay    on;
    keepalive_timeout  65;

    upstream app_server {
　　　　# コンテナ名を使用
        server flask_app:3031;
    }

    # map $http_x_forwarded_proto $real_scheme {
    #         default $scheme;
    #         https "https";
    #     }

    # map $http_host $port {
    #     default $server_port;
    #     "~^[^\:]+:(?<p>\d+)$" $p;
    # }
    
    server {
        listen 80 default;
        charset utf-8;
        server_name _;

        # proxy_set_header X-Forwarded-For   $remote_addr;
        # proxy_set_header X-Forwarded-Host  $host;
        # proxy_set_header X-Forwarded-Port  $port;
        # proxy_set_header X-Forwarded-Proto $real_scheme;

        location / {
            client_max_body_size 1m;
            client_body_buffer_size 8k;

            include uwsgi_params;
            # uwsgi（TCP Socketを使用）
            uwsgi_pass app_server;
            # HTTP
            # proxy_pass http://app_server;
            # proxy_redirect off;
        }
        
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   /usr/share/nginx/html;
        }
    }
}

```

uwsgiを使う場合は、uwsgi_passディレクティブに通信先コンテナとポート（TCPソケット）を記載します。ここでは通信先コンテナにコンテナ名を利用しています(upstreamディレクティブ)。コンテナ名をコンテナのIPアドレスに変えても動くはずです。

### HTTP
uWSGIと通信する場合、uwsgiプロトコル（uwsgi_pass）を使うことが推奨されているのですが、HTTP（proxy_pass）で通信することもあるかもしれないのでこちらでも設定してみました。

以下のように設定を変更します。（httpモジュールのみ抜粋）

```nginx:nginx.conf

http {
    include       /etc/nginx/mime.types;
    default_type  text/html;
    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
    access_log  /var/log/nginx/access.log  main;
    sendfile       on;
    tcp_nopush     on;
    tcp_nodelay    on;
    keepalive_timeout  65;

    upstream app_server {
　　　　# コンテナ名を使用
        server flask_app:3031;
    }
    
    # 追加
    map $http_x_forwarded_proto $real_scheme {
        default $scheme;
        https "https";
    }

    # 追加
    map $http_host $port {
        default $server_port;
        "~^[^\:]+:(?<p>\d+)$" $p;
    }
    
    server {
        listen 80 default;
        charset utf-8;
        server_name _;

        # 追加
        proxy_set_header X-Forwarded-For   $remote_addr;
        proxy_set_header X-Forwarded-Host  $host;
        proxy_set_header X-Forwarded-Port  $port;
        proxy_set_header X-Forwarded-Proto $real_scheme;

        location / {
            client_max_body_size 1m;
            client_body_buffer_size 8k;

            include uwsgi_params;
            # TCP Socket
            # uwsgi_pass app_server;
            
            # HTTP通信
            proxy_pass http://app_server;
            proxy_redirect off;
        }
        
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   /usr/share/nginx/html;
        }
    }
}

```

またuwsgi.iniもHTTPにします。

```ini:uwsgi.ini
[uwsgi]
master = True
# socket = :3031
http = :3031
chdir = /var/www/app/
wsgi-file = run.py
callable = app
logto = /var/log/uwsgi.log
```

さらにアプリ側も設定が必要です。

```python:run.py
from flask import Flask, redirect, url_for, render_template
import psycopg2 
from psycopg2.extras import DictCursor
# 追加
from werkzeug.middleware.proxy_fix import ProxyFix

app = Flask(__name__)
# 追加
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_host=1, x_port=1, x_proto=1)

....
....
```

いろいろ設定が必要なのはこれらがないとflaskのredirect時にLocationヘッダURLがproxy_passディレクティブに設定されているURL（appコンテナ）になってしまいます。

そうならないように`werkzeug.middleware.proxy_fix`というモジュールを使っています。これはアプリケーションの前にあるプロキシ（ここではnginx）が設定するX-Forwarded-に基づいて、WSGI環境を調整するモジュールです。([ドキュメント](https://werkzeug.palletsprojects.com/en/2.0.x/middleware/proxy_fix/))

LocationヘッダにはnginxコンテナホストへのURLを設定したいので、X-Forwarded-にはnginxコンテナホストの情報を設定しています(nginx.conf)。

参考：[https://github.com/sjmf/reverse-proxy-minimal-example](https://github.com/sjmf/reverse-proxy-minimal-example)


# docker-compose.yml

ここまでのところがイメージの土台です。
ここからはイメージからコンテナを起動する際の設定について定義になります。

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

services:
  database:
    container_name: flask_db
    build: ./database
    expose:
      - "5432" 
    volumes:
      # 1_create_table.sqlをコンテナ内のdocker-entrypoint-initdb.dに置くため
      - ./database/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
      - ./database/data:/var/lib/postgresql/data
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES-DATABASE=postgres
      - DATABASE_HOST=localhost

  app:
    container_name: flask_app
    build: ./app
    expose:
      - "3031"　
    volumes:
      - ./app:/var/www/app
    depends_on:
      - database
    command: uwsgi --ini /var/www/app/config/uwsgi.ini

  nginx:
    container_name: flask_nginx
    build: ./nginx
    restart: always
    volumes:
      - ./nginx/config/nginx.conf:/etc/nginx/nginx.conf
    depends_on:
      - app
    ports:
      - "8080:80"
```

## データ永続化

ホストの`database/data`をコンテナ内の`/var/lib/postgresql/data`にマウントしていますが、これはデータベースの内容が保存される`/var/lib/postgresql/data`の内容をホスト上に同期するためです。

したがって、仮にコンテナを削除したとしても新たなコンテナの`/var/lib/postgresql/data`にホストの`database/data`をマウントさせることで以前のデータを再び同期させ、データを引き継ぐことができます。ちなみに今回のマウント方法を**バインドマウント**といいます。

Dockerにはもう一つ**ボリュームマウント**というマウント方法があります。こちらはバインドマウントのようにホストのディレクトリをマウントさせるのではなく、DockerEngine管理下にあるボリュームと呼ばれる記憶領域をコンテナのディレクトリにマウントするものです。

こちらの方法でもボリュームが存続する限りデータを維持できることになります。ボリュームマウントの方法はトップレベルに明示的にボリュームを作り、バインドマウントのようにコンテナのディレクトリにマウントさせるだけです。

今回の場合であれば、以下のように修正すればよいです。

```yml:docker-compose.yml
....

services:
  database:
    build: ./database
    container_name: flask_db
    expose:
      - "5432" 
    volumes:
      - ./database/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
      # 作成したボリュームをコンテナにマウント
      - volume_data:/var/lib/postgresql/data
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES-DATABASE=postgres
      - DATABASE_HOST=localhost

  .....
  .....

# 明示的にvolume_dataというボリューム（ボリューム名は任意）を作成
volumes:
  volume_data:
```

実はもう一つ**一時ファイルシステムマウント**と呼ばれるマウント機能があるようです。今回は試さなかったので書いてませんが興味のある方は調べてみてください。


# おわりに
これで一応タイトルのような構成でアプリを動かせます。[Github](https://github.com/yoshi-kin/flask_example)に上げていますのでぜひ参考にしてみてください。