ずっと、自分の技術ブログを作るなら Docker で構築してやるぞって思ってました。インフラを AWS で構築してコンテナ運用するのなら、きっと ECS や EKS を使って Fargate で運用するのが今風だし、そうしたほうが管理工数も下げることができるでしょう。しかし minbase.io はしがない個人運営ブログです。AWS 基盤を使うにせよ、必要最低限をコンセプトにやっています。そもそもスケールしなければならないほど minbase.io がバズるなんてことは直近では考えづらく、ELB を介しての負荷分散まで考える必要がないのは明白です。それでも Docker だけは導入したかったので、EC2 に直接入れたという形になります。もしバズったら設計を見直すかもしれませんが。
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
全体像です。
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 ドメインソケットについては私もさほど詳しくないので、詳細はページ下部のリンクをご覧ください。Docker におけるコンテナ間通信でこれを行うためには以下のように設定します。
# 関係のあるところだけ抜粋
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 ドメインソケットです。現時点では安定して動作しています。
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 コンテナを 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"]
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]
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 コンテナのビルド内容です。
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 コンテナは 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 における技術ブログ開発の内容に踏み込んでいきます。どういった構成で書くか等、まったくのノープランですが、、、なんとか続けていきたいと思います。