2021-02-05

2020-10-13

技術ノート

eyecatch
minbase.io 構築シリーズのインフラ編その4です。前回までで VPC、EC2 インスタンスの構築が終わったので、EC2 インスタンス上に Docker で Django を動かすためのコンテナ環境を構築します。

はじめに

ずっと、自分の技術ブログを作るなら Docker で構築してやるぞって思ってました。インフラを AWS で構築してコンテナ運用するのなら、きっと ECS や EKS を使って Fargate で運用するのが今風だし、そうしたほうが管理工数も下げることができるでしょう。しかし minbase.io はしがない個人運営ブログです。AWS 基盤を使うにせよ、必要最低限をコンセプトにやっています。そもそもスケールしなければならないほど minbase.io がバズるなんてことは直近では考えづらく、ELB を介しての負荷分散まで考える必要がないのは明白です。それでも Docker だけは導入したかったので、EC2 に直接入れたという形になります。もしバズったら設計を見直すかもしれませんが。

構成図

Docker コンテナの構成は以下のようになっています。

Docker 構成図

かいつまんで説明します。まず Nginx コンテナは、http できたリクエストを https に飛ばしたり、www. を省いたりという一般的なリダイレクト処理をしています。また、Let's Encrypt を使ってドメイン単位で TLS 暗号化しています。CSS や JS 等の静的ファイルは Django の機能で Nginx に配信し、Nginx から直接レスポンスを返しています。Nginx コンテナの Dockerfile や設定ファイル周りは後述します。Django は、minbase.io の主役です。技術ブログのフロントエンドからバックエンドまでを担っています。また、この構成だと Nginx 側で管理サイトへの IP 制限を実装するのが厳しかったので、Django 側で実現しています。あとは、別記事で書くと思いますが、各種レポーティング API を自前実装したので、その実行も担っています。で、いちばん後ろにいるのが PostgreSQL です。Model 構成もそのうち書くと思います。

フォルダ構成

Build する際のフォルダ構成です。


minbase.io
├── build
│     ├── django
│     │     ├── Dockerfile
│     │     └── requirements.txt
│     └── nginx
│           └── Dockerfile
├── django
│     └── uwsgi.ini
├── nginx
│     ├── conf.d
│     ├── templates
│     │     └── default.conf.template
│     └── uwsgi_params
├── .env
└── docker-compose.yml

Docker Compose

全体像です。

docker-compose.yml

version: "3.5"
services:
  django:
    build: ./build/django
    container_name: django.application
    restart: always
    volumes:
      - ./django:/django
      - socket_app:/var/run/uwsgi
      - socket_db:/var/run/postgresql
      - static:/static
      - media:/media
    depends_on:
      - postgres
    environment:
      TZ: ${TZ}
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
  nginx:
    build: ./build/nginx
    container_name: django.webserver
    restart: always
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf
      - ./nginx/uwsgi_params:/etc/nginx/uwsgi_params
      - ./nginx/templates:/etc/nginx/templates
      - ./nginx/conf.d:/etc/nginx/conf.d
      - ./nginx/letsencrypt:/etc/letsencrypt
      - socket_app:/var/run/uwsgi
      - static:/static
      - media:/media
    depends_on:
      - django
    environment:
      TZ: ${TZ}
      DOMAIN: ${DOMAIN}
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
  postgres:
    image: postgres:${POSTGRES_VERSION}
    container_name: django.database
    restart: always
    volumes:
      - data:/var/lib/postgresql/data
      - socket_db:/var/run/postgresql
    environment:
      TZ: ${TZ}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
volumes:
  data:
    driver: local
  socket_app:
    driver: local
  socket_db:
    driver: local
  static:
    driver: local
  media:
    driver: local

いくつかポイントがあります。

共有ボリューム : Unix ドメインソケット

コンテナ間の通信でよりオーバーヘッドの少ない方法を模索すると、Unix ドメインソケット通信に行きつきます。同一ホスト上で、ファイルへの読み書きを通じてプロセス間通信をするというものらしく、カーネル内部で完結するため高速で、ポート番号を払い出す必要がないところが利点です。Unix ドメインソケットについては私もさほど詳しくないので、詳細はページ下部のリンクをご覧ください。Docker におけるコンテナ間通信でこれを行うためには以下のように設定します。

  1. コンテナ間で共有するボリュームを定義する
  2. コンテナ A で、共有ボリュームに対して Socket ファイルを配置するディレクトリを指定する
  3. コンテナ B で、共有ボリュームに対して Socket ファイルを配置するディレクトリを指定する

# 関係のあるところだけ抜粋
version: "3.5"
services:
  django:
    container_name: django.application
    volumes:
      - socket_app:/var/run/uwsgi #---2
  nginx:
    container_name: django.webserver
    volumes:
      - socket_app:/var/run/uwsgi #---3
volumes:
  socket_app: #---1
    driver: local

minbase.io では、Nginx/Django 間の通信も Unix ドメインソケット、Django/PostgreSQL 間の通信も Unix ドメインソケットです。現時点では安定して動作しています。

共有ボリューム : static/media

Django は静的ファイルを一か所に集めておいて配信する仕組みが備わっています。ここでいう静的ファイルとは CSS や JS 等、レンダリングとかせずに Web サーバーがそのままレスポンスを返していいファイルのことだと理解しています。特に本番環境では、この静的ファイルをフロントの Web サーバーにあらかじめ配信しておくことでアプリケーションへの負荷を軽減し、静的ファイルの読み込み効率を向上させることができます。単に静的ファイルをブラウザへ返すだけの処理をアプリケーションにやらせるのは無駄なので、Web サーバーから直接返すようにしましょうということですね。Docker 環境でこの仕組みを利用するためには、前述の共有ボリュームを利用します。Nginx コンテナと Django コンテナでボリュームを共有することで、static ファイルと media ファイルを Web サーバーから配信できるようにします。ちなみに用語の説明としては以下の通りです。

種類 説明
static ファイル CSS や JS 等、リクエストに応じて中⾝を変更したりせずにそのまま配信していいファイル
media ファイル 静的ファイルの中で、ユーザーがサイトを利⽤してアップロードしたファイルや画像

実際の設定は単純で、簡略化すると以下のようになります。


# 関係のあるところだけ抜粋
version: "3.5"
services:
  django:
    container_name: django.application
    volumes:
      - static:/static
      - media:/media
  nginx:
    container_name: django.webserver
    volumes:
      - static:/static
      - media:/media
volumes:
  static:
    driver: local
  media:
    driver: local

ロギング

Docker を本番環境で運用するに当たってポイントとなるのはやはりロギングかと思います。Docker には様々なロギングドライバが用意されており、コンテナ内部でログローテートを仕込まなくても docker-compose.yml の記述で簡単に設定できます。ドライバの種類について、公式ドキュメントから抜粋してみます(なお説明は簡略化しています)

種類 説明
none コンテナ用のロギング・ドライバを無効化します。
json-file デフォルト・ロギング・ドライバです。JSON メッセージをファイルに記録します。
syslog ログ・メッセージを syslog に記録します。
journald ログ・メッセージを journald に記録します。
gelf ログ・メッセージを Graylog のエンドポイントや Logstash に記録します。
fluentd ログ・メッセージを fluentd に記録します(forward input)。
awslogs ログ・メッセージを Amazon CloudWatch Logs に記録します。
splunk HTTP イベント・コレクタを使いログを splunk に書き込みます。
etwlogs ログメッセージを ETW イベントとして書き込みます。
gcplogs ログメッセージを Google Cloud Logging に書き込みます。

CloudWatch に飛ばせるのは便利ですが、認証設定等が面倒で管理できないだろうなという気がしたので、今回は見送りました。これが商用の環境だったりしたらその環境に応じたログ収集ソリューションの運用に乗せるべきだと思いますが、今回のように個人運営という前提のもとであれば、結局のところjson-fileが最強ということになるかと思います。docker-compose logsコマンドで気軽に確認できて、かつローテートできさえすればいいので。以下の通り、10MB のファイルを3ファイルまで保持する設定にしています。これで十分だと思います。


# 関係のあるところだけ抜粋
version: "3.5"
services:
  django:
    container_name: django.application
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
  nginx:
    container_name: django.webserver
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"
  postgres:
    container_name: django.database
    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

環境変数の隔離

パスワード等の表示したくない文字列は .env ファイルを作ってそちらに書くようにしています。コンテナのバージョンやタイムゾーンも .env に書くようにしています。あとは .env を .gitignore に書いておけば OK です。よくやるやつです。


・・・
environment:
  TZ: ${TZ}
  POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
・・・

Django コンテナ

Django コンテナのビルド内容です。

Dockerfile

Django コンテナを Build するための Dockerfile です。やっていることは難しくありません。ロケール設定、パッケージの最新化、Django で使うパッケージのインストールが中心です。


FROM python:3.8
ENV PYTHONUNBUFFERED 1
ENV PYTHONIOENCODING utf-8 
ENV DEBCONF_NOWARNINGS yes
ENV DEBIAN_FRONTEND noninteractive
ARG DIR=/django
RUN mkdir -p $DIR
WORKDIR $DIR
COPY requirements.txt $DIR/
RUN set -xe \
&& apt-get update -y \
&& apt-get install -y --no-install-recommends vim git curl locales postgresql-client \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* \
&& locale-gen ja_JP.UTF-8 \
&& localedef -f UTF-8 -i ja_JP ja_JP.utf8 \
&& pip install --no-cache-dir --upgrade pip \
&& pip install --no-cache-dir -r requirements.txt
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:jp
ENV LC_ALL ja_JP.UTF-8
ENV LC_CTYPE ja_JP.UTF-8
CMD ["uwsgi","--ini","/django/uwsgi.ini"]

requirements.txt

Django で使うパッケージは慣例に倣って requirements.txt にまとめています。django-cloudinary-storage が使いたいので、Django は2系にしています。Python3.8 をサポートしているのは 2.2.8 からです。また、Nginx と通信するための WSGI モジュールは uWSGI を使っています。

※2020/10/20 脆弱性対応のためアップデートしました。 2.2.8 => 2.2.16


Django==2.2.16
django-bootstrap4==2.2.0
django-cleanup==5.0.0
django-cloudinary-storage==0.2.3
django-debug-toolbar==2.2
django-environ==0.4.5
django-summernote==0.8.11.6
djangorestframework==3.11.1
google-api-python-client==1.10.0
Janome==0.3.10
oauth2client==4.1.3
Pillow==7.2.0
psycopg2==2.8.5
psycopg2-binary==2.8.5
pyOpenSSL==19.1.0
uWSGI==2.0.19.1

Django の機能を拡張するための各種モジュール、Google Analytics Reporting API を使うための各種モジュール、DB アクセスのための psycopg2、Web サーバと Django の間のインターフェースを提供する uWSGI、コンテンツの日本語形態素解析を行うための Janome 等、用途に応じて幅広くインストールしています。

uwsgi.ini

uWSGI を起動する際、ini ファイルを指定しています。パラメータが大量にあるため最適な設定になっているかイマイチ自信がないのですが、現状は以下のような感じです。チューニングの余地アリです。


[uwsgi]
wsgi-file = /django/config/wsgi.py
pidfile = /var/run/uwsgi/uwsgi.pid
socket = /var/run/uwsgi/uwsgi.sock
chmod-socket = 777
processes = 16
threads = 1
master = true
max-requests = 6000
max-requests-delta = 300
buffer-size = 32768
thunder-lock = true
die-on-term = true
vacuum = true
py-autoreload = true

パラメータ設定についてはこちらを参考にしました。また、Unix ドメインソケットで通信する際のソケットファイルのパスもここで設定しています。chmod-socket = 777のところとか改善したいです。今後の課題ですね。。

Nginx コンテナ

Nginx コンテナのビルド内容です。

Dockerfile

Django の場合と同じく、ロケール設定、パッケージの最新化、必要なモジュールのインストールといったオーソドックスな内容です。TLS 暗号化するので certbot は必須かと思います。ほかはお好みで。


FROM nginx:1.19
ENV DEBCONF_NOWARNINGS yes
ENV DEBIAN_FRONTEND noninteractive
RUN set -xe \
&& apt-get update -y \
&& apt-get install -y --no-install-recommends vim curl locales certbot \
&& apt-get clean -y \
&& rm -rf /var/lib/apt/lists/* \
&& locale-gen ja_JP.UTF-8 \
&& localedef -f UTF-8 -i ja_JP ja_JP.utf8
ENV LANG ja_JP.UTF-8
ENV LANGUAGE ja_JP:jp
ENV LC_ALL ja_JP.UTF-8
ENV LC_CTYPE ja_JP.UTF-8
#CMD ["/usr/sbin/nginx", "-g", "daemon off;"]

なお最後のコマンドをコメントアウトしていますが、Docker Hub 上の Nginx コンテナイメージの Dockerfile の記述にCMD ["nginx", "-g", "daemon off;"]とあるためです。これを残したままだと conf ファイルのテンプレート展開がうまくいきませんでした。

テンプレート

Nginx コンテナは、バージョン 1.19 からかなりうれしい機能が備わっています。templates フォルダに配置した拡張子 .template のファイルに対して、Build 時に自動的に docker-compose.yml の environment に記述した変数を展開して conf ファイルを生成し、conf.d に出力してくれます。この機能を利用して、以下のようにしています。


# dokcer-compose.yml
# 関係のあるところだけ抜粋
version: "3.5"
services:
  nginx:
    container_name: django.webserver
    volumes:
      - ./nginx/templates:/etc/nginx/templates
      - ./nginx/conf.d:/etc/nginx/conf.d
    environment:
      DOMAIN: ${DOMAIN}


# .env
# 関係のあるところだけ抜粋
DOMAIN=minbase.io


# default.conf.template
# HTTPS

upstream django {
    ip_hash;
    server unix:///var/run/uwsgi/uwsgi.sock;
}

server {
    listen 80;
    server_name ${DOMAIN};
    return 301 https://${DOMAIN}$request_uri;
}

server {
    listen 80;
    listen 443;
    server_name www.${DOMAIN};
    return 301 https://${DOMAIN}$request_uri;
}

server {
    listen 443 default_server ssl;
    server_name ${DOMAIN};
    charset utf-8;
    client_max_body_size 75M;

    ssl_certificate      /etc/letsencrypt/live/${DOMAIN}-0001/fullchain.pem;
    ssl_certificate_key  /etc/letsencrypt/live/${DOMAIN}-0001/privkey.pem;

    location / {
        uwsgi_pass django;
        include /etc/nginx/uwsgi_params;
    }

    location /static {
        alias /static;
    }

    location /media {
        alias /media;
    }
}

${DOMAIN}の中身は "minbase.io" ですが、この値は .env に書かれており、docker-compose.yml は .env を参照しています。Build した際、${DOMAIN}が変数展開され、文字列として minbase.io が入った状態で default.conf が生成され、conf.d に配置されます。今回はドメインを展開しているだけですが、IP アドレスでなんらかの制御を加えたい場合等はかなり重宝する機能かと思います。また、ソケットファイルのパスも忘れずに書いておきましょう。

その他のパラメータ

uwsgi で必要な uwsgi_params という設定ファイルも配置し、conf に書いておく必要があります。下記の通りですが、これはコピペでいいらしく、内容についてはノータッチです。


uwsgi_param  QUERY_STRING       $query_string;
uwsgi_param  REQUEST_METHOD     $request_method;
uwsgi_param  CONTENT_TYPE       $content_type;
uwsgi_param  CONTENT_LENGTH     $content_length;

uwsgi_param  REQUEST_URI        $request_uri;
uwsgi_param  PATH_INFO          $document_uri;
uwsgi_param  DOCUMENT_ROOT      $document_root;
uwsgi_param  SERVER_PROTOCOL    $server_protocol;
uwsgi_param  REQUEST_SCHEME     $scheme;
uwsgi_param  HTTPS              $https if_not_empty;

uwsgi_param  REMOTE_ADDR        $remote_addr;
uwsgi_param  REMOTE_PORT        $remote_port;
uwsgi_param  SERVER_PORT        $server_port;
uwsgi_param  SERVER_NAME        $server_name;

PostgreSQL コンテナ

PostgreSQL コンテナは Docker Hub 上にあるイメージをそのまま使います。Django との通信は Unix ドメインソケットなので、共有ボリュームの定義をしておけば OK です。


# 関係のあるところだけ抜粋
version: "3.5"
services:
  django:
    container_name: django.application
    volumes:
      - socket_db:/var/run/postgresql
  postgres:
    container_name: django.database
      - socket_db:/var/run/postgresql
volumes:
  socket_db:
    driver: local

ビルドしてみる

docker-compose.yml があるディレクトリで、以下コマンドを実行してしばらく待つと、前述の3コンテナが立ち上がります。


[test-user@web-01 minbase.io]$ docker-compose -up -d --build
・・・
Creating django.database ... done
Creating django.application ... done
Creating django.webserver   ... done

こうして苦労してこしらえた Docker 環境ですが、開発していく中で死ぬほどビルドアンドデストロイしながら、少しずつアプリケーションを作り上げていくことになります。Django の作りこみで「Model 設定をミスって変になってしまったのでやり直したい!」なんて場合でも dumpdata さえ取っておけばいくらでもカジュアルに作り直すことができます。これが Docker 最大の魅力です。おまけですが、私は Docker 環境を一気にクリーンアップするために以下のようなシェルスクリプトを作ってカジュアルに掃除しています。ごっそり消えるので、取り扱い注意ではありますが。


#!/bin/bash
RMC=$(docker ps -aq) && if [ -n "$RMC" ]; then docker stop $RMC && docker rm $RMC -f; fi
RMI=$(docker images -aq) && if [ -n "$RMI" ]; then docker rmi $RMI -f; fi
docker system prune -af
docker image prune -f
docker volume prune -f
docker network prune -f

おわりに

ご察しの通り、設定ファイル周りがまだまだ弱いかなーといった感じです。検証環境を用意しづらいところではあるので、できる範囲でチューニングしていきます。また、スクリプトの説明をするよりも設定ファイルの説明をするほうが大変でした。理解が足らず、記事に落とし込み切れていない感じがします。改善ポイントが眠っている部分だと思うので、今後見直すかもしれません。

本記事までで、minbase.io を構築した際のインフラ基盤を一通りトレースできたことになります。次からは Django における技術ブログ開発の内容に踏み込んでいきます。どういった構成で書くか等、まったくのノープランですが、、、なんとか続けていきたいと思います。