Flaskプロジェクトのセットアップ(TDD/Docker/Postgresなど) - Part 3
著者:Leonardo Giordani - 07/07/2020 Updated on Feb 23, 2021
この連載では、TDD、Docker、Postgresを使って、効率性と整頓性を意識したセットアップで、Flaskプロジェクトの開発を探っています。
キャッチアップ
1回目と2回目の投稿では、整頓されたセットアップでFlaskプロジェクトを作成しました。開発環境とテストの実行にDockerを使用し、重要なコマンドを管理スクリプトにマッピングすることで、設定を1つのファイルにまとめ、システム全体を動かすことができます。
この記事では、シナリオを簡単に作成する方法を紹介します。シナリオとは、カスタムデータを使用してオンザフライで作成されたデータベースのことで、Flaskアプリケーションまたはコマンドラインを使用して、クエリを分離してテストすることができます。また、本番環境用の構成を定義する方法と、デプロイメントのヒントも紹介します。
ステップ1 - シナリオの作成
シナリオの考え方は簡単です。特定のユースケースのバグを調査したり、データベースクエリのパフォーマンスを向上させたりする必要があり、カスタマイズされたデータベースでこれを行う必要がある場合があります。これがシナリオで、データベースに特定のデータセットを投入するPythonファイルで、その上でアプリケーションやデータベースシェルを実行することができます。
多くの場合、開発用データベースは本番環境のコピーであり、個人情報の漏洩を防ぐために機密データは削除されているかもしれません。これは、クエリをテストするための現実的なケースを提供する一方で(例えば、100万行でクエリはどのように実行されるか)、何が起こっているかを正しく理解するためにすべてのデータを目の前に置く必要がある初期調査の際には役に立たないかもしれません。リレーショナルデータベースの結合方法を学んだ人は、私がここで言いたいことを理解しています。
原理的には、シナリオを作成するためには、空のデータベースを起動して、そのデータベースに対してシナリオコードを実行するだけです。実際には、それほど複雑ではありませんが、いくつかの小さな問題を解決する必要があります。
まず、開発用のデータベースとテスト用のデータベースをすでに運用しています。2つ目は一時的なものですが、開発用のデータベースが稼働している間にテストを実行できるようにプロジェクトをセットアップすることにしました。その方法は、開発用に5432ポート(Postgresの標準的なポート)、テスト用に5433ポートを使用するというものです。シナリオを作成すると、さらにデータベースが追加されます。もちろん、開発用データベースとテスト用データベースを稼働させながら、同時に5つのシナリオを実行することは想定していませんが、3回目に実行するときはすぐに一般的なものにするというルールを自分に課しています。
つまり、5434番ポートでシナリオ用のデータベースを作ることはせず、より汎用的なソリューションを探すことにしています。これは、Dockerのネットワークモデルが提供してくれるもので、コンテナのポートをホストにマッピングすることはできますが、宛先ポートを割り当てることはできず、Docker自身が権限のないものの中からランダムに選択します。つまり、5432番ポート(コンテナ内のポート)をマッピングしたPostgresコンテナを作成し、Dockerにホスト内の32838番ポートに接続させることができるのです(例)。アプリケーションがどのポートを使用するかを知っている限り、これは5432ポートを使用するのと全く同じです。
残念なことに、Dockerのインターフェースは情報を提供する際には非常にスクリプトフレンドリーではなく、私は出力を少し解析しなければなりません。実際には、コンテナをスピンアップした後、docker-compose port db 5432というコマンドを実行すると、0.0.0.0:32838のような文字列が返ってくるので、そこからポートを抽出しています。大したことではありませんが、これらは異なるシステムを一緒にオーケストレーションする際に直面する(時には多くの)問題です。
新しい管理スクリプトを次に示します。
code: manage.py
import os
import json
import signal
import subprocess
import time
import shutil
import click
import psycopg2
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
# Ensure an environment variable exists and has a value
def setenv(variable, default):
os.environvariable = os.getenv(variable, default) setenv("APPLICATION_CONFIG", "development")
APPLICATION_CONFIG_PATH = "config"
DOCKER_PATH = "docker"
def app_config_file(config):
return os.path.join(APPLICATION_CONFIG_PATH, f"{config}.json")
def docker_compose_file(config):
return os.path.join(DOCKER_PATH, f"{config}.yml")
def configure_app(config):
# Read configuration from the relative JSON file
with open(app_config_file(config)) as f:
config_data = json.load(f)
# Convert the config into a usable Python dictionary
for key, value in config_data.items():
setenv(key, value)
@click.group()
def cli():
pass
@cli.command(context_settings={"ignore_unknown_options": True})
@click.argument("subcommand", nargs=-1, type=click.Path())
def flask(subcommand):
configure_app(os.getenv("APPLICATION_CONFIG"))
cmdline = "flask" + list(subcommand) try:
p = subprocess.Popen(cmdline)
p.wait()
except KeyboardInterrupt:
p.send_signal(signal.SIGINT)
p.wait()
def docker_compose_cmdline(commands_string=None):
config = os.getenv("APPLICATION_CONFIG")
configure_app(config)
compose_file = docker_compose_file(config)
if not os.path.isfile(compose_file):
raise ValueError(f"The file {compose_file} does not exist")
command_line = [
"docker-compose",
"-p",
config,
"-f",
compose_file,
]
if commands_string:
command_line.extend(commands_string.split(" "))
return command_line
@cli.command(context_settings={"ignore_unknown_options": True})
@click.argument("subcommand", nargs=-1, type=click.Path())
def compose(subcommand):
cmdline = docker_compose_cmdline() + list(subcommand)
try:
p = subprocess.Popen(cmdline)
p.wait()
except KeyboardInterrupt:
p.send_signal(signal.SIGINT)
p.wait()
def run_sql(statements):
conn = psycopg2.connect(
dbname=os.getenv("POSTGRES_DB"),
user=os.getenv("POSTGRES_USER"),
password=os.getenv("POSTGRES_PASSWORD"),
host=os.getenv("POSTGRES_HOSTNAME"),
port=os.getenv("POSTGRES_PORT"),
)
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
cursor = conn.cursor()
for statement in statements:
cursor.execute(statement)
cursor.close()
conn.close()
def wait_for_logs(cmdline, message):
logs = subprocess.check_output(cmdline)
while message not in logs.decode("utf-8"):
time.sleep(0.1)
logs = subprocess.check_output(cmdline)
@cli.command()
def create_initial_db():
configure_app(os.getenv("APPLICATION_CONFIG"))
try:
except psycopg2.errors.DuplicateDatabase:
print(
f"The database {os.getenv('APPLICATION_DB')} already exists and will not be recreated"
)
@cli.command()
@click.argument("filenames", nargs=-1)
def test(filenames):
configure_app(os.getenv("APPLICATION_CONFIG"))
cmdline = docker_compose_cmdline("up -d")
subprocess.call(cmdline)
cmdline = docker_compose_cmdline("logs db")
wait_for_logs(cmdline, "ready to accept connections")
cmdline.extend(filenames)
subprocess.call(cmdline)
cmdline = docker_compose_cmdline("down")
subprocess.call(cmdline)
@cli.group()
def scenario():
pass
@scenario.command()
@click.argument("name")
def up(name): 1
config = os.getenv("APPLICATION_CONFIG")
scenario_config_source_file = app_config_file("scenario")
scenario_config_file = app_config_file(config)
if not os.path.isfile(scenario_config_source_file):
raise ValueError(f"File {scenario_config_source_file} doesn't exist")
shutil.copy(scenario_config_source_file, scenario_config_file) # Point 3
scenario_docker_source_file = docker_compose_file("scenario")
scenario_docker_file = docker_compose_file(config)
if not os.path.isfile(scenario_docker_source_file):
raise ValueError(f"File {scenario_docker_source_file} doesn't exist")
shutil.copy(docker_compose_file("scenario"), scenario_docker_file) # Point 4
configure_app(f"scenario_{name}")
cmdline = docker_compose_cmdline("up -d") # Point 5
subprocess.call(cmdline)
cmdline = docker_compose_cmdline("logs db")
wait_for_logs(cmdline, "ready to accept connections")
cmdline = docker_compose_cmdline("port db 5432") # Point 6
out = subprocess.check_output(cmdline)
port = out.decode("utf-8").replace("\n", "").split(":")1 scenario_module = f"scenarios.{name}"
scenario_file = os.path.join("scenarios", f"{name}.py")
if os.path.isfile(scenario_file): # Point 7
import importlib
scenario = importlib.import_module(scenario_module)
scenario.run()
cmdline = " ".join( # Point 8
docker_compose_cmdline(
"exec db psql -U {} -d {}".format(
os.getenv("POSTGRES_USER"), os.getenv("APPLICATION_DB")
)
)
)
print("Your scenario is ready. If you want to open a SQL shell run")
print(cmdline)
@scenario.command()
@click.argument("name")
def down(name): # Point 2
config = os.getenv("APPLICATION_CONFIG")
cmdline = docker_compose_cmdline("down")
subprocess.call(cmdline)
scenario_config_file = app_config_file(config)
os.remove(scenario_config_file)
scenario_docker_file = docker_compose_file(config)
os.remove(scenario_docker_file)
if __name__ == "__main__":
cli()
ここに scenario up(Point 1) と scenario down(Point 2) というコマンドを追加しました。 ご覧のとおり、up 機能はまず config/scenario.json(Point 3) と docker/scenario.yml`(Point 4)(まだ作成しなければなりません)をシナリオの名前を付けたファイルにコピーします。
その後、up -dというコマンド(Point 5)を実行して、テストのときと同様にデータベースの準備が整うのを待ちます。その後、非常に簡単なPythonの文字列処理(Point 6)でコンテナのポートを抽出し、正しい環境変数を初期化しています。
最後に、シナリオのコードを含むPythonファイル(Point 7)をインポートして実行し、新しく作成されたデータベースにPostgresシェルを入れるためにpsqlを実行するコマンドライン(Point 8)を含むフレンドリーなメッセージを表示します。
関数downは単純にコンテナを破壊し、シナリオの設定ファイルを削除します。
なくなった2つの設定ファイルはとてもシンプルです。docker composeの設定は
code: docker/scenario.yml
version: '3.4'
services:
db:
image: postgres
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "5432" # Point 1
web:
build:
context: ${PWD}
dockerfile: docker/Dockerfile
environment:
FLASK_ENV: ${FLASK_ENV}
FLASK_CONFIG: ${FLASK_CONFIG}
APPLICATION_DB: ${APPLICATION_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_HOSTNAME: "db"
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
command: flask run --host 0.0.0.0
volumes:
- ${PWD}:/opt/code
ports:
- "5000"
ここでは、データベースが一時的なものであること、ホストのポートが自動的に1に割り当てられていること、そしてアプリケーションもスピンアップしていることがわかります(開発用のポートとの衝突を避けるため、ランダムなポートにマッピングしています)。
設定ファイルは
code: config/scenario.json
[
{
"name": "FLASK_ENV",
"value": "development"
},
{
"name": "FLASK_CONFIG",
"value": "development"
},
{
"name": "POSTGRES_DB",
"value": "postgres"
},
{
"name": "POSTGRES_USER",
"value": "postgres"
},
{
"name": "POSTGRES_HOSTNAME",
"value": "localhost"
},
{
"name": "POSTGRES_PASSWORD",
"value": "postgres"
},
{
"name": "APPLICATION_DB",
"value": "application"
}
]
これは、開発やテストのためにすでに行っていることに何も新しいことを加えていません。
Gitコミット
このステップで行った変更は、このGitコミットで確認できるほか、ファイルを閲覧することもできます。
参考資料
psql: PostgreSQLの対話型ターミナル シナリオ例1
システムを理解するために、データベース上では何もしない非常にシンプルなシナリオを見てみましょう。このシナリオのコードは
code: scenarios/foo.py
import os
def run():
シナリオを実行すると、次のような出力が得られます。
code: bash
$ ./manage.py scenario up foo
Creating network "scenario_foo_default" with the default driver
Creating scenario_foo_db_1 ... done
Creating scenario_foo_web_1 ... done
HEY! This is scenario foo
Your scenario is ready. If you want to open a SQL shell run
docker-compose -p scenario_foo -f docker/scenario_foo.yml exec db psql -U postgres -d application
docker psコマンドを実行すると、私の開発環境がシナリオに沿って正常に動作していることがわかります。
code: bash
$ docker ps
CONTAINER ID IMAGE COMMAND ... PORTS NAMES 85258892a2df scenario_foo_web "flask run --host 0.…" ... 0.0.0.0:32826->5000/tcp scenario_foo_web_1 a031b6429e07 postgres "docker-entrypoint.s…" ... 0.0.0.0:32827->5432/tcp scenario_foo_db_1 1a449d23da01 development_web "flask run --host 0.…" ... 0.0.0.0:5000->5000/tcp development_web_1 28aa566321b5 postgres "docker-entrypoint.s…" ... 0.0.0.0:5432->5432/tcp development_db_1 また、 scenario up foo というコマンドの出力には、HEY! This is scenario foo that was printed by the file foo.py という文字列が含まれています。また、提案されたコマンドを実行することもできます。
code: bash
$ docker-compose -p scenario_foo -f docker/scenario_foo.yml exec db psql -U postgres -d application
psql (12.3 (Debian 12.3-1.pgdg100+1))
Type "help" for help.
application=# \l
List of databases
Name | Owner | Encoding | Collate | Ctype | Access privileges
-------------+----------+----------+------------+------------+-----------------------
application | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
postgres | postgres | UTF8 | en_US.utf8 | en_US.utf8 |
template0 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | | postgres=CTc/postgres
template1 | postgres | UTF8 | en_US.utf8 | en_US.utf8 | =c/postgres +
| | | | | postgres=CTc/postgres
(4 rows)
application=#
そして、データベースの中には、シナリオのために明示的に作成されたデータベース・アプリケーションが入っています(名前は config/scenario.json で指定されています)。psqlを知らない人は、Ctrl-d や\qで終了できます。
シナリオを削除する前に、config/scenario_foo.json と docker/scenario_foo.yml の2つのファイルを見てみましょう。これらのファイルは config/scenario.json と docker/scenario.yml の単なるコピーですが、これらを見ることで全体の動作を理解する助けになると思います。終わったら ./manage.py scenario down foo を実行しましょう。
Git コミット
このステップで行った変更は、このGit コミット で確認することもできますし、ファイルを参照 することもできます。 シナリオの例 2
もうちょっと面白いことをしてみましょう。新しいシナリオは scenario/users.py に含まれています。
code: scenarios/users.py
from application.app import create_app
from application.models import db, User
app = create_app("development") # Point 1
def run():
with app.app_context():
db.drop_all()
db.create_all()
# Administrator
admin = User(email="admin@server.com")
db.session.add(admin) # Point 2
# First user
user1 = User(email="user1@server.com")
db.session.add(user1)
# Second user
user2 = User(email="user2@server.com")
db.session.add(user2)
db.session.commit()
シナリオにはできるだけこだわらないことにしました。特定のものを作りすぎて、必要なものをテストするのに十分な柔軟性が得られなくなるのを避けるためです。つまり、この例のように、シナリオでは appを作成し(Point 1)、データベース・セッションを明示的に使用する(Point 2)必要があります。アプリケーションは "development "という設定で作成します。この設定は、application/config.py にあるFlaskの設定であり、config/development.json にあるものではないことを覚えておいてください。
これでシナリオを実行することができますね。
code: bash
$ ./manage.py scenario up users
そして、データベースに接続してユーザーを探します。
code: bash
$ docker-compose -p scenario_users -f docker/scenario_users.yml exec db psql -U postgres -d application
psql (12.3 (Debian 12.3-1.pgdg100+1))
Type "help" for help.
application=# \dt
List of relations
Schema | Name | Type | Owner
--------+-------+-------+----------
public | users | table | postgres
(1 row)
application=# select * from users;
id | email
----+------------------
1 | admin@server.com
2 | user1@server.com
3 | user2@server.com
(3 rows)
application=# \q
Gitコミット
このステップで行った変更は、このGitコミット で確認できますし、ファイルを参照 することもできます。 ステップ2 - 本番環境のシミュレーション
この連載の最初に述べたように、私の目標の一つは、本番環境で実行するのと同じデータベースを開発で実行することでした。このため、開発とテストの両方で Postgres コンテナを実行できるようにする設定手順を行いました。実際の本番シナリオでは、PostgresはおそらくAWSのRDSサービス上など、別のインスタンスで実行されるでしょうが、接続パラメータがある限り、設定は何も変わりません。
Dockerを使えば、実際に本番環境を簡単にシミュレーションすることができます。もし私たちのノートブックが24時間365日接続されているなら、本番環境を直接ホストした方がいいでしょう。最近ではお勧めしませんが、何年も前にクラウドコンピューティングがまだ登場していなかった頃、多くの重要な企業がこのように始めていました。LAMPスタックをインストールする代わりにコンテナを設定しますが、考え方は変わりません。
ここでは、本番環境をシミュレートした構成を作成し、これを適切な本番インフラに変換するためのヒントを紹介します。本番環境のWebアプリケーションのコンポーネントを明確に把握したい場合は、それらを1つずつ分析した次の記事お読みください。
ここで変更しなければならない最初のコンポーネントは、HTTPサーバーです。開発ではFlaskの開発サーバーを使用していますが、サーバーが表示する最初のメッセージは WARNING: This is a development server. Do not use it in a production deployment. 。やったぜ、Flask! 代わりになるのはGunicornなので、まずrequirementsに追加します。
code: requirements/production.txt
Flask
flask-sqlalchemy
psycopg2
flask-migrate
gunicorn
次に、本番用のdocker-compose構成を作成する必要があります。
code: docker/production.yml
version: '3.4'
services:
db:
image: postgres
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${POSTGRES_PORT}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
web:
build:
context: ${PWD}
dockerfile: docker/Dockerfile.production
environment:
FLASK_ENV: ${FLASK_ENV}
FLASK_CONFIG: ${FLASK_CONFIG}
APPLICATION_DB: ${APPLICATION_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_HOSTNAME: "db"
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_PORT: ${POSTGRES_PORT}
command: gunicorn -w 4 -b 0.0.0.0 wsgi:app 1
volumes:
- ${PWD}:/opt/code
ports:
- "8000:8000" # Point 2
volumes:
pgdata:
ここで見られるように、アプリケーションを実行するコマンドは、わずかに異なる1。コンテナのアドレス0.0.0.0に4つのプロセス(-w 4)を公開して、wsgi.pyファイル(wsgi:app)からappというオブジェクトを読み込みます。Gunicornはデフォルトで8000番ポートを公開しているので、その2つをホストの同じポートにマッピングしました。
次に、Webアプリケーションのプロダクションイメージを定義するDockerfile.productionファイルを作成しました。
code: docker/Dockerfile.production
FROM python:3
ENV PYTHONUNBUFFERED 1
RUN mkdir /opt/code
RUN mkdir /opt/requirements
WORKDIR /opt/code
ADD requirements /opt/requirements
RUN pip install -r /opt/requirements/production.txt
最後に必要なのは、設定ファイルです。
code: config/production.json
[
{
"name": "FLASK_ENV",
"value": "production"
},
{
"name": "FLASK_CONFIG",
"value": "production"
},
{
"name": "POSTGRES_DB",
"value": "postgres"
},
{
"name": "POSTGRES_USER",
"value": "postgres"
},
{
"name": "POSTGRES_HOSTNAME",
"value": "localhost"
},
{
"name": "POSTGRES_PORT",
"value": "5432"
},
{
"name": "POSTGRES_PASSWORD",
"value": "postgres"
},
{
"name": "APPLICATION_DB",
"value": "application"
}
]
FLASK_ENVとFLASK_CONFIGの値を変更しただけなので、開発版とあまり変わらないことがわかります。 明らかにPOSTGRES_PASSWORDという平文で書いてはいけないシークレットが含まれていますが、結局これは本番環境のシミュレーションです。実際の環境では、シークレットはAWS Secrets Managerのような暗号化されたマネージャに保管されるべきです。
FLASK_ENVはFlaskの内部設定を変更し、特にデバッガを無効にし、FLASK_CONFIG=productionはapplication/config.py からProductionConfigオブジェクトをロードすることを覚えておいてください。このオブジェクトは今のところ空ですが、本番サーバーの公開設定が含まれているかもしれません。
これで、以下の方法でイメージをビルドすることができます。
code: bash
$ APPLICATION_CONFIG="production" ./manage.py compose build web
Gitコミット
このステップで行われた変更は、このGitコミット で確認できますし、ファイルを参照 することもできます。 参考資料
ステップ3 - スケールアップ
コンテナのポートをホストにマッピングすることは、本番環境でコンテナを実行する最大のポイントである、より多くの負荷に対応するためのスケールアップやスケールダウンが不可能になるため、あまり良いアイデアではありません。これは、クラウドではいろいろな方法で解決できるかもしれません。例えば、AWSでは、AWS Fargateでコンテナを実行し、アプリケーションロードバランサーに登録することができます。単一のホストで行う別の方法は、HTTPサーバーの前にWebサーバーを実行することで、これはDocker Composeで簡単に実装できるかもしれません。
今回はnginxを追加して、そこからHTTPをサーブし、docker-composeのネットワークを介してアプリケーションコンテナをリバースプロキシすることにします。まず、docker-composeの新しい設定
code: docker/production.yml
version: '3.4'
services:
db:
image: postgres
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${POSTGRES_PORT}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
web:
build:
context: ${PWD}
dockerfile: docker/Dockerfile.production
environment:
FLASK_ENV: ${FLASK_ENV}
FLASK_CONFIG: ${FLASK_CONFIG}
APPLICATION_DB: ${APPLICATION_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_HOSTNAME: "db"
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_PORT: ${POSTGRES_PORT}
command: gunicorn -w 4 -b 0.0.0.0 wsgi:app
volumes:
- ${PWD}:/opt/code
nginx:
image: nginx
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- 8080:8080
volumes:
pgdata:
ご覧のように、デフォルトの Nginx イメージを実行するサービス nginx を追加し、これから作成するカスタム設定ファイルをマッピングしました。アプリケーションコンテナは、ホストから直接アクセスしないので、ポートマッピングは必要ありません。Nginxの設定ファイルは次になります。
code: docker/nginx/nginx.conf
worker_processes 1;
events { worker_connections 1024; }
http {
sendfile on;
upstream app { # Point 1
server web:8000;
}
server {
listen 8080; # Point 2
location / {
proxy_redirect off;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $server_name;
}
}
}
これはごく標準的な設定であり、実際の本番環境では他にも多くの設定値を追加することになるでしょう(特に、HTTPではなくHTTPSを提供することが重要です)。セクション upstream(Point 1)では、docker-composeのネットワーキングを活用してwebを参照しており、内部DNSでは同名のサービスのIPに直接マッピングされています。8000番台のポートは、先に述べたGunicornのデフォルトのポートを利用しています。私のノートブックではnginxコンテナをrootで実行しないので、HTTPの伝統的な80の代わりに8080 (Point 2)を公開していますが、これも実際の生産環境では異なるかもしれません。
この時点で、実行してみましょう。
code: bash
$ APPLICATION_CONFIG="production" ./manage.py compose up -d
Starting production_db_1 ... done
Starting production_nginx_1 ... done
Starting production_web_1 ... done
Nginxはデフォルトですべての受信リクエストを表示するので、nginxコンテナのログを見てみると面白いですよ。
code: bash
$ APPLICATION_CONFIG="production" ./manage.py compose logs -f nginx
Attaching to production_nginx_1
nginx_1 | 172.30.0.1 - - 05/Jul/2020:10:40:44 +0000 "GET / HTTP/1.1" 200 13 "-" "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0" 最後の行は、本番環境が稼働しているときにlocalhost:8080にアクセスしたときに表示されるものです。
サービスのスケールアップやスケールダウンが簡単にできるようになりました。
code: bash
$ APPLICATION_CONFIG="production" ./manage.py compose up -d --scale web=3
production_db_1 is up-to-date
Starting production_web_1 ...
Starting production_web_1 ... done
Creating production_web_2 ... done
Creating production_web_3 ... done
Gitコミット
このステップで行われた変更は、このGitコミット で確認できますし、ファイルを参照 することもできます。 参考資料
Nginx: HTTPおよびリバースプロキシサーバー(その他) ボーナスステップ - Docker networkingを詳しく見る
Docker Composeがサービス間の接続を作成し、nginxコンテナの設定に使用したと述べましたが、これが人によっては黒魔術のように見えるかもしれないことを理解しています。私はこれが実際に黒魔術であると信じていますが、少し調べることもできると思いますので、魔道書を開いてDockerネットワークの暗い秘密(の一部)を明らかにしましょう。
本番環境が動いている間にnginxコンテナに接続して、何が起こっているかをリアルタイムで見ることができるので、まずはnginxコンテナにbashシェルを実行します。
code: bash
$ APPLICATION_CONFIG="production" ./manage.py compose exec nginx bash
中に入ると、/etc/nginx/nginx.conf にある自分の設定ファイルが見えますが、これは変わっていません。Dockerネットワークはテンプレートエンジンとしてではなく、ローカルDNSとして動作していることを覚えておいてください。つまり、コンテナ内からWebを解決しようとすると、複数のIPが表示されるはずです。digコマンドはDNSを調査するのに適したツールですが、nginxコンテナにはプリインストールされていないので、以下を実行する必要があります。
code: bash
root@33cbaea369be:/# apt update && apt install dnsutils
この時点で、実行してみましょう。
code: bash
root@33cbaea369be:/# dig web
; <<>> DiG 9.11.5-P4-5.1+deb10u1-Debian <<>> web
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 30539
;; flags: qr rd ra; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;web. IN A
;; ANSWER SECTION:
web. 600 IN A 172.30.0.4
web. 600 IN A 172.30.0.6
web. 600 IN A 172.30.0.5
;; Query time: 0 msec
;; SERVER: 127.0.0.11#53(127.0.0.11)
;; WHEN: Sun Jul 05 10:58:18 UTC 2020
;; MSG SIZE rcvd: 78
root@33cbaea369be:/#
このコマンドを実行すると3つのIPが出力されますが、これらは現在実行しているサービスウェブの3つのコンテナに対応しています。コンテナの外から)スケールダウンすると
code: bash
$ APPLICATION_CONFIG="production" ./manage.py compose up -d --scale web=1
となると、digの出力は
code: bash
root@33cbaea369be:/# dig web
; <<>> DiG 9.11.5-P4-5.1+deb10u1-Debian <<>> web
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 13146
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;web. IN A
;; ANSWER SECTION:
web. 600 IN A 172.30.0.4
;; Query time: 0 msec
;; SERVER: 127.0.0.11#53(127.0.0.11)
;; WHEN: Sun Jul 05 11:01:46 UTC 2020
;; MSG SIZE rcvd: 40
root@33cbaea369be:/#
本番用インフラストラクチャの作成方法
これは非常に短いセクションになります。インフラストラクチャの作成と本番へのデプロイは複雑なトピックなので、研究を刺激するためのヒントだけを提供したいと思います。
AWS ECSは基本的にクラウド上のDockerであり、全体の構造はdocker-composeのセットアップにほぼ1対1で対応できるので、学ぶ価値があります。ECSは、あなたが管理する明示的なEC2インスタンスでも、Fargateでも動作します。つまり、コンテナを実行するEC2インスタンスは、AWS自身によって透過的に管理されています。
Terraformは、インフラを作成するのに適したツールです。多くの制限があり、そのほとんどがカスタムHCL言語に起因していますが、徐々に良くなってきています(例えば、バージョン0.13では、ようやくモジュール上でforループを実行できるようになりました)。欠点はあるものの、静的なインフラを構築するには最適なツールなので、ぜひ取り組んでみてください。
しかし、Terraformはコードのデプロイには適していません。システムとの動的なやりとりが必要になるため、優れた継続的インテグレーションシステムをセットアップする必要があります。Jenkinsは非常に有名なオープンソースのCIですが、個人的には大規模システム向けに設計されていないようなので、結局やめてしまいました。例えば、Jenkinsサーバーのデプロイを自動化するのは非常に複雑ですし、ダイナミックな大規模システムを作るには手動の介入を一切必要としません。いずれにしても、Jenkinsは最初に使うには良いツールですが、CircleCIやBuildkiteのような他の製品にも目を向けてみると良いでしょう。
デプロイパイプラインを作成する際には、イメージを作成してそれを実行するだけではなく、少なくとも実際のアプリケーションでは多くのことを行う必要があります。データベースマイグレーションを適用するタイミングを決めたり、Webフロントエンドがある場合は、JavaScriptアセットをコンパイルしてインストールする必要があります。デプロイ時にダウンタイムを発生させたくないので、ブルー/グリーンデプロイメントを検討する必要があります。一般的には、少なくとも短期間であれば、異なるバージョンのアプリケーションを同時に実行できる戦略が必要です。また、A/Bテストやゾーンデプロイを行う場合には、より長い期間の実行が必要です。
最後に
これがこの短いシリーズの最後の投稿です。何か役に立つことを学んでいただき、プロジェクトを適切にセットアップしたり、Dockerのような技術を調べたりするきっかけになっていれば幸いです。いつものように、フィードバックや質問をお気軽にお寄せください。また、私の投稿がお役に立った場合は、興味を持ってくれそうな人にシェアしてください。
Flask project setup series
日本語訳: Flaskプロジェクトのセットアップ(TDD/Docker/Postgresなど) - Part 3