Webスタック解体新書
著者: Leonardo Giordani - 16/02/2020 Updated on Oct 27, 2020
It was gross. They wanted me to dissect a frog. (Beetlejuice, 1988)
キモかった。カエルを解剖してくれと言われたよ。(ビートルジュース、1988年)
はじめに
最近、本番環境のインフラに初めて触れた若いWeb開発者たちと仕事をしていると、「Webサービス」のアーキテクチャに見られる様々なコンポーネントについて多くの質問を受けました。これらの質問は、Node.jsやPythonなどの高レベル言語でエンドポイントを作成する方法を理解しているものの、ユーザーのブラウザと選択したフレームワークの間で起こる複雑なことを知らなかった開発者の混乱(時にはフラストレーション)を明確に表していました。ほとんどの場合、そもそもフレームワーク自体がなぜ存在するのかを知りません。
私たちが(Pythonの)Web開発について議論するときに使う言葉を(ランダムに)列挙すれば、その課題は明らかです。HTTP、クッキー、Webサーバ、Websocket、FTP、マルチスレッド、リバースプロキシ、Django、nginx、静的ファイル、POST、証明書、フレームワーク、Flask、SSL、GET、WSGI、セッション管理、TLS、ロードバランシング、Apache...
この記事では、本番対応のWebサービスを一から構築しようとしている上述のすべての単語(およびさらにいくつかの単語)を確認したいと思います。これにより、若い開発者が全体像を把握し、私のような上級開発者が日常の会話の中で(時には間違いなく)落としがちなこれらの「曖昧な」名前を理解するのに役立つことを願っています。
この記事の焦点は、グローバルアーキテクチャと特定のコンポーネントが存在する理由なので、使用するサービスの例は基本的なHTML Webページです。参照言語はPythonですが、全体的な議論はどの言語やフレームワークにも当てはまります。
私のアプローチは、まず理由を述べ、次に可能な解決策を実行するというものです。その後、足りない部分や未解決の問題を指摘し、次のレイヤーに進みます。このプロセスの最後には、読者は各コンポーネントがなぜシステムに追加されたのかを明確に理解できるはずです。
完璧なアーキテクチャとは
システムアーキテクチャーの基本的な考え方として、「天才的な技術者が考えた完璧なソリューションは存在せず、応用すればよい」ということが重要です。残念ながら、デザインパターンをそのような「魔法のソリューション」と勘違いしている人がよくいます。しかし、『Design Patterns』の原書には、次のように書かれています。
デザインは、目の前の問題に特化したものであると同時に、将来の問題や要求に対応できるだけの汎用性を持つべきである。また、再設計は避けるか、少なくとも最小限にしたいものです。
そしてその後...
デザインパターンは、成功したデザインやアーキテクチャの再利用を容易にします。デザインパターンは、システムの再利用を可能にする設計案を選択し、再利用性を損なう設計案を回避するのに役立ちます。
この本の著者は、オブジェクト指向プログラミングについて論じていますが、これらの文章はどんなアーキテクチャにも適用できます。このように、「目の前の問題」と「設計上の選択肢」があるということは、現在と未来の両方の要求を理解することが最も重要であることを意味します。明確な要求を念頭に置いてこそ、効果的なソリューションを設計することができ、他の設計者がすでに考案した膨大な数のパターンを利用することができるのです。
最後に一言。ウェブスタックは複雑な構造をしており、異なるプログラマーが異なる目的のために開発した複数のコンポーネントやソフトウェアパッケージで構成されています。このようなコンポーネントには、ある程度の重ね合わせがあることは、十分に理解できます。理論上のレイヤー間の境界線は通常非常に明確ですが、実際にはその境界線はしばしば曖昧です。このことをよく理解しておけば、もうウェブスタックで迷うことはないでしょう。
いくつかの定義
Web スタックに関わる最も重要な概念であるプロトコルについて簡単に説明します。
TCP/IP
TCP/IP はネットワークプロトコルであり、2 台のコンピュータが物理的なネットワークを介して接続し、メッセージを交換するために従わなければならない確立されたルールのセットです。TCP/IPは、OSIスタックの2つの異なる層、すなわちトランスポート(TCP)とネットワーク(IP)をカバーする2つの異なるプロトコルで構成されています。TCP/IPは、イーサネットやワイヤレスなど、あらゆる物理インターフェース(データリンク層と物理OSI層)の上に実装することができます。TCP/IPネットワーク上のアクターは、IPアドレスとポート番号からなるタプルであるソケットによって識別されます。
しかし、私たちがWebサービスを開発する上で注意しなければならないのは、TCP/IPが信頼性の高いプロトコルであるということです。信頼性の高いプロトコルとは、通信において、パケットが失われたときにプロトコル自体が再送を行うことを意味します。つまり、通信速度は保証されませんが、一度送信したメッセージがエラーなく目的地に到達することが保証されているのです。
HTTP
TCP/IPは、コンピュータが送信する生のバイトが目的地に到達することを保証しますが、意味のある情報をどのように送信するかという問題は、まったく手つかずのままです。特に1989年にTim Barners-Leeが解決しようとした問題は、ネットワーク上のハイパーテキストリソースにどのようにして一意に名前を付け、どのようにしてアクセスするかということでした。
HTTPは、このような問題を解決するために考案されたプロトコルで、その後大きく進化しました。HTTPは、WebSocketなどの他のプロトコルの助けを借りて、リアルタイム通信やゲームなど、当初は不向きとされていた通信分野にも進出しました。
HTTPは、テキストのリクエストとそのレスポンスのフォーマットを規定したプロトコルである。1991年に公開された初期バージョン0.9では、URLの概念が定義され、特定のリソースを要求するGET操作のみが許可されていた。その後、HTTP 1.0と1.1では、ヘッダーやメソッドの追加、パフォーマンスの最適化など、重要な機能が追加されました。この記事を書いている時点では、HTTP/2の採用率は世界のウェブサイトの約45%で、HTTP/3はまだドラフト段階です。
開発者として覚えておかなければならないHTTPの最も重要な特徴は、ステートレスなプロトコルであるということです。つまり、このプロトコルでは、サーバーがリクエスト間の通信状態を把握する必要がなく、基本的にセッション管理はサービスの開発者自身に任されています。
最近では、ユーザーが認証情報を提供していくつかのプライベートデータにアクセスするようなサービスの前には、通常、認証層が必要となるため、セッション管理は非常に重要です。しかし、セッション管理は、ユーザーが作成した視覚的な好みや選択を、同じウェブサイトに後からアクセスする際に再利用するなど、他の文脈でも有用です。HTTP のセッション管理問題の典型的な解決策は、クッキーやセッション・トークンの使用です。
HTTPS
近年、セキュリティは非常に重要な言葉になっていますが、それには理由があります。私たちがインターネット上で交換したり、デジタル機器に保存したりする機密データの量は飛躍的に増加していますが、残念ながら悪意のある攻撃者の数や、彼らの行動によって引き起こされる損害のレベルも同様に増加しています。HTTPプロトコルは本質的に安全ではありません。
HTTPは本質的に安全ではありません。通常、インターネットのような完全に信頼できないネットワーク上で行われる2つのサーバー間のプレーンテキスト通信です。プロトコルが考案された当初は、セキュリティは問題ではありませんでしたが、今日では、個人のセキュリティやビジネスに不可欠な個人情報を交換する際に、最も重要な問題となっています。私たちは、情報を正しいサーバに送信していること、そして送信したデータが傍受されていないことを確認する必要があります。
HTTPSは、TLS(Transport Layer Security)プロトコルでHTTPを暗号化し、信頼できる機関から発行されたデジタル証明書の使用を強制することで、改ざんや盗聴の問題を解決します。本稿執筆時点では、Firefox で読み込まれる Web サイトの約 80% がデフォルトで HTTPS を使用しています。サーバーがHTTPS接続を受信し、それをHTTP接続に変換すると、通常、TLS(またはSSL、TLSの旧称)を終了すると言われています。
WebSocket
HTTPの大きな欠点は、通信が常にクライアントによって開始され、サーバーは明示的に要求された場合にのみデータを送信できることです。最初の解決策としてポーリングを実装することもできますが、サーバーとクライアントの間にチャネルが開かれていて、両者が要求されなくてもデータを送信できるような、適切な全二重通信の性能を保証することはできません。そのようなチャネルを提供するのが、WebSocketプロトコルです。
WebSocketは、オンラインゲーム、金融情報やスポーツニュースなどのリアルタイムフィード、会議や遠隔教育などのマルチメディア通信などのアプリケーションに欠かせない技術です。
WebSocketはHTTPではなく、HTTPがなくても存在することを理解しておくことが重要です。また、この新しいプロトコルは、既存のHTTP接続の上で使用されるように設計されているため、WebSocket通信は、もともとHTTPを使用して取得されたWebページの一部に見られることが多いのも事実です。
HTTPによるサービスの実装
さて、いよいよビット&バイトの話に入ります。ここでの出発点は、HTTP上のサービス、つまりHTTPのリクエストとレスポンスのやりとりがあることです。例として、HTTPメソッドの中でも最もシンプルなGETリクエストを考えてみましょう。
code: HTTP
GET / HTTP/1.1
Host: localhost
User-Agent: curl/7.65.3
Accept: */*
ご覧のように、クライアントはHTTPプロトコルで指定された形式で、純粋なテキストメッセージをサーバに送信しています。最初の行には、メソッド名(GET)、URL(/)、使用しているプロトコル(そのバージョン(HTTP/1.1)を含む)が含まれています。残りの行はヘッダーと呼ばれ、サーバーがリクエストを管理するのに役立つメタデータが含まれています。Hostヘッダーの完全な値は、この場合はlocalhost:80ですが、HTTPサービスの標準ポートは80なので、指定する必要はありません。
localhostがポート80でHTTPサービスを提供している場合(HTTPを理解するソフトウェアを実行している場合)、以下のようなレスポンスが得られます。
code: HTTP
HTTP/1.0 200 OK
Date: Mon, 10 Feb 2020 08:41:33 GMT
Content-type: text/html
Content-Length: 26889
Last-Modified: Mon, 10 Feb 2020 08:41:27 GMT
<!DOCTYPE HTML>
<html>
...
</html>
リクエストの場合と同様に、レスポンスは規格に沿ってフォーマットされたテキストメッセージです。最初の行には、プロトコルとリクエストのステータス(この場合は成功を意味する200)が記載されており、次の行にはさまざまなヘッダーに含まれるメタデータが記載されています。最後に、空の行の後、メッセージにはクライアントが要求したリソース、この場合はウェブサイトのベースURLのソースコードが含まれています。このHTMLページには、おそらくCSSやJS、画像などの他のリソースへの参照が含まれているので、ブラウザはユーザーにページを正しく表示するために必要なデータを集めるために、他にもいくつかのリクエストを送信します。
そのため、最初の問題は、このプロトコルを理解し、HTTPリクエストを受信したときに適切なレスポンスを送信するサーバーを実装することです。要求されたリソースの読み込みを試みて、それが見つかれば成功(HTTP200)、見つからなければ失敗(HTTP404)を返さなければなりません。
1 ソケットとパーサー
1.1 理由づけ
TCP/IPは、ソケットを使用するネットワークプロトコルです。ソケットとは、IPアドレス(ネットワーク内で一意のもの)とポート(特定のIPアドレスに対して一意のもの)のタプルであり、コンピュータが他の人と通信するために使用します。ソケットは、オペレーティングシステムにおけるファイルのようなオブジェクトであり、開いたり閉じたりすることができ、読み書きすることができます。ソケットプログラミングは、ネットワークに対するかなり低レベルなアプローチですが、ネットワークアクセスを提供するコンピュータのすべてのソフトウェアは、最終的にソケットを扱う必要があることを認識する必要があります(ほとんどの場合、何らかのライブラリを介して)。
私たちはゼロから物事を構築しているので、ソケット接続を開き、HTTPリクエストを受信し、HTTPレスポンスを送信する小さなPythonプログラムを実装してみましょう。ポート80は「ローポート」(1024より小さい数字)なので、通常はそこにソケットを開く権限はありませんので、ポート8080を使います。HTTPはどのポートでも提供できるので、今のところ問題はありません。
1.2 実装方法
server.pyというファイルを作成し、以下のコードを入力します。そう、タイプしてください。ただコピー&ペーストするのではなく、体で覚えないと何も学べません。
code: python
import socket
## Create a socket instance
## AF_INET: use IP protocol version 4
## SOCK_STREAM: full-duplex byte stream
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
## Allow reuse of addresses
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
## Bind the socket to any address, port 8080, and listen
s.bind(('', 8080))
s.listen()
## Serve forever
while True:
# Accept the connection
conn, addr = s.accept()
# Receive data from this socket using a buffer of 1024 bytes
data = conn.recv(1024)
# Print out the data
print(data.decode('utf-8'))
# Close the connection
conn.close()
この小さなプログラムは、ポート 8080 での接続を受け入れ、受信したデータをターミナルに表示します。このプログラムを実行した後、別のターミナルで curl localhost:8080 を実行してテストすることができます。次のようなものが表示されるはずです。
code: bash
$ python3 server.py
GET / HTTP/1.1
Host: localhost:8080
User-Agent: curl/7.65.3
Accept: */*
サーバーは while ループ内のコードを実行し続けているので、終了させたい場合は Ctrl+C で実行しなければなりません。ここまでは良いのですが、応答を送信していないので、これはまだHTTPサーバーではありません。実際には、curlから次のようなエラーメッセージを受け取る必要があります。(52) Empty reply from server.
標準的な応答を送り返すのは非常に簡単で、生のバイトを渡して conn.sendall を呼び出すだけで済みます。最小の HTTP レスポンスには、プロトコルとステータス、空の行、実際のコンテンツが含まれています。
code: HTTP
HTTP/1.1 200 OK
Hi there!
サーバーは次のようになります。
code: python
import socket
## Create a socket instance
## AF_INET: use IP protocol version 4
## SOCK_STREAM: full-duplex byte stream
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
## Allow reuse of addresses
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
## Bind the socket to any address, port 8080, and listen
s.bind(('', 8080))
s.listen()
## Serve forever
while True:
# Accept the connection
conn, addr = s.accept()
# Receive data from this socket using a buffer of 1024 bytes
data = conn.recv(1024)
# Print out the data
print(data.decode('utf-8'))
conn.sendall(bytes("HTTP/1.1 200 OK\n\nHi there!\n", 'utf-8'))
# Close the connection
conn.close()
しかし、この時点では、ユーザーのリクエストに実際に応答しているわけではありません。curl localhost:8080/index.html や curl localhost:8080/main.css のように異なるcurlコマンドラインを試してみても、常に同じレスポンスを受け取ることになります。私たちは、ユーザーが求めているリソースを見つけ出し、それをレスポンスの内容として送り返すようにすべきです。
このバージョンのHTTPサーバーは、リソースを適切に抽出し、カレントディレクトリからの読み込みを試み、成功または失敗のいずれかを返します。
code: python
import socket
import re
## Create a socket instance
## AF_INET: use IP protocol version 4
## SOCK_STREAM: full-duplex byte stream
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
## Allow reuse of addresses
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
## Bind the socket to any address, port 8080, and listen
s.bind(('', 8080))
s.listen()
HEAD_200 = "HTTP/1.1 200 OK\n\n"
HEAD_404 = "HTTP/1.1 404 Not Found\n\n"
## Serve forever
while True:
# Accept the connection
conn, addr = s.accept()
# Receive data from this socket using a buffer of 1024 bytes
data = conn.recv(1024)
request = data.decode('utf-8')
# Print out the data
print(request)
resource = re.match(r'GET /(.*) HTTP', request).group(1)
try:
with open(resource, 'r') as f:
content = HEAD_200 + f.read()
print('Resource {} correctly served'.format(resource))
except FileNotFoundError:
content = HEAD_404 + "Resource /{} cannot be found\n".format(resource)
print('Resource {} cannot be loaded'.format(resource))
print('--------------------')
conn.sendall(bytes(content, 'utf-8'))
# Close the connection
conn.close()
ご覧の通り、この実装は非常にシンプルです。index.html という名前のシンプルなローカルファイルを作成し、以下の内容を記述します。
code: HTML
<head>
<title>This is my page</title>
<link rel="stylesheet" href="main.css">
</head>
<html>
<p>Some random content</p>
</html>
と入力し、curl localhost:8080/index.html を実行すると、ファイルの内容が表示されます。この時点で、ブラウザを使って http://localhost:8080/index.html を開くこともでき、ページのタイトルとコンテンツが表示されます。Webブラウザは、HTTPリクエストを送信し、これがHTML(および画像や動画など多くのファイルタイプ)であればレスポンスの内容を解釈して、メッセージの内容をレンダリングすることができるソフトウェアです。レンダリングに必要な不足リソースを取得するのもブラウザの役割です。スタイルシートやJSスクリプトへのリンクをページのHTMLコード内の<link> や <script> タグで提供する場合、それらのファイルに対してもHTTP GETリクエストを送信するようにブラウザに指示していることになります。
http://localhost:8080/index.html にアクセスした際の server.py の出力は以下の通りです。
code: HTTP
GET /index.html HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:72.0) Gecko/20100101 Firefox/72.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Language: en-GB,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive
Upgrade-Insecure-Requests: 1
Pragma: no-cache
Cache-Control: no-cache
Resource index.html correctly served
--------------------
GET /main.css HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:72.0) Gecko/20100101 Firefox/72.0
Accept: text/css,*/*;q=0.1
Accept-Language: en-GB,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Resource main.css cannot be loaded
--------------------
GET /favicon.ico HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:72.0) Gecko/20100101 Firefox/72.0
Accept: image/webp,*/*
Accept-Language: en-GB,en;q=0.5
Accept-Encoding: gzip, deflate
Connection: keep-alive
Pragma: no-cache
Cache-Control: no-cache
Resource favicon.ico cannot be loaded
--------------------
ご覧のように、ブラウザは多くのヘッダを含むリッチなHTTPリクエストを送信し、HTMLコードに記載されているCSSファイルを自動的に要求し、ファビコン画像を自動的に取得しようとします。
1.3 参考資料
以下の資料は、このセクションで説明されているトピックについて、より詳細な情報を提供しています。
1.4 課題
ゼロから何かを作って、それが日常的に使っているブラウザのような本格的なソフトウェアとスムーズに動作することを発見すると、ある種の満足感が得られます。また、現在の世界を基本的に動かしているHTTPのような技術が、本質的には非常にシンプルなものであることを発見するのは、非常に興味深いことだと思います。
とはいえ、シンプルなソケットのプログラミングではカバーできなかった機能もたくさんあります。例えば、HTTP/1.0では、GET以外にもPOSTなどのメソッドが導入されています。POSTは、ユーザーがフォームを使ってサーバーに情報を送信し続ける今日のWebサイトにとって、最も重要なメソッドです。9つのHTTPメソッドをすべて実装するには、受信したリクエストを適切に解析し、コードに関連する関数を追加する必要があります。
しかし、この時点では、通常はビジネスの中心ではない、プロトコルの低レベルの詳細を多く扱っていることに気づくかもしれません。HTTP上のサービスを構築するとき、私たちは、他のウェブサイトの検索、本の購入、友人との写真の共有など、特定のプロセスを簡素化するコードを適切に実装するための知識を持っていると信じています。私たちは、TCP/IPソケットの微妙な仕組みを理解したり、リクエスト・レスポンスプロトコルのためのパーサーを書くことに時間を費やしたくありません。これらの技術がどのように動作するかを見るのはいいことですが、日常的にはもっと高いレベルのものに集中する必要があります。
私たちの小さなHTTPサーバーの状況は、HTTPがステートレスなプロトコルであるという事実によって悪化しているかもしれません。このプロトコルは、2つの連続したリクエストを接続する方法を提供していないため、現代のインターネットの基礎である、通信の状態を追跡することができません。私たちがウェブサイトで認証を行い、他のページにアクセスする際には、私たちが誰であるかをサーバーに記憶させる必要があります。これは、接続の状態を追跡することを意味します。
つまり、適切なHTTPサーバーとして動作させるためには、この時点でコードはすべてのHTTPメソッドとクッキーの管理を実装しなければなりません。また、Websocket のような他のプロトコルもサポートする必要があります。これらはすべて些細な作業なので、アプリケーションプロトコルの低レベルな詳細ではなく、ビジネスロジックに集中できるように、システム全体に何らかのコンポーネントを追加する必要があるのは間違いありません。
2 ウェブフレームワーク
2.1 理由づけ
Web フレームワークの登場です。
何度も説明しているように (クリーンなアーキテクチャに関する本や関連記事を参照してください)、Web フレームワークの役割は、HTTP リクエストを関数呼び出しに変換し、関数の戻り値を HTTP レスポンスに変換することです。フレームワークの本来の役割は、動作中のビジネスロジックを、HTTPおよび関連するプロトコルを介してWebに接続するためのレイヤーです。フレームワークは、セッション管理を代行し、URLを関数にマッピングしてくれるので、私たちはアプリケーションロジックに集中することができます。
HTTPサービスの全体像としては、これがフレームワークの役割です。DBやテンプレートエンジン、他のシステムとのインターフェースにアクセスするためのレイヤーなど、フレームワークがこの範囲外で提供するものは、プログラマーとしては便利な追加機能ですが、原則としてフレームワークをシステムに追加した理由の一部ではありません。フレームワークを追加した理由は、ビジネスロジックとHTTPの間のレイヤーとして機能するからです。
2.2 実装
Miguel Gringberg氏と彼の素晴らしいFlaskメガチュートリアル のおかげで、私は数秒でFlaskをセットアップすることができました。このチュートリアルはMiguelのWebサイトで見ることができるので、ここでは説明しません。Flaskメガチュートリアルの内容(23章もある!)だけを使って、極めてシンプルな「Hello, world」アプリケーションを作成します。 以下の例を実行するには、仮想環境が必要で、flaskをpipでインストールしなければなりません。詳細はMiguelのチュートリアルを参照してください。
app/__init__.py ファイルは、
code: python
from flask import Flask
application = Flask(__name__)
from app import routes
app/routes.py ファイルは、
code: python
from app import application
@application.route('/')
@application.route('/index')
def index():
return "Hello, world!"
フレームワークの力がここですでに発揮されています。3行のPythonでインデックス関数を定義し、2つの異なるURL(/と/index)に接続しました。これにより、ビジネスロジックに適切に取り組むための時間とエネルギーが残されました。このケースでは、今まで誰もこんなことしなかった革命的な「Hello, world! 」です
最後に、service.pyファイルは
code: python
from app import application
Flaskには、いわゆる開発用のWebサーバー(この言葉に聞き覚えはありませんか)が付属しており、ターミナル上で実行することができます。
code: bash
$ FLASK_APP=service.py flask run
* Serving Flask app "service.py"
* Environment: production
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
* Debug mode: off
これで、指定されたURLにブラウザでアクセスし、すべてが正しく動作することを確認できます。127.0.0.1は「このコンピュータ」を指す特別なIPアドレスであることを覚えておいてください。localhostという名前は通常、オペレーティングシステムによってその別名として作成されるので、この2つは交換可能です。ご覧のように、Flaskの開発サーバーの標準ポートは5000なので、それを明示的に指定する必要があります。そうしないと、ブラウザはポート80(デフォルトのHTTP)にアクセスしようとします。ブラウザで接続すると、HTTPリクエストに関するいくつかのログメッセージが表示されます。
code: CONSOLE
これらのリクエストは、前編で紹介した小さなサーバーで得られたものと同じなので、もうお分かりでしょう。
2.3. 参考資料
2.4 課題
どうやら、私たちはすべての問題を解決したようで、多くのプログラマーはここでやめてしまいます。彼らはフレームワークの使い方を学びますが(これは大きな成果です!)、すぐに発見するように、これは本番システムには十分ではありません。Flaskサーバーの出力をよく見てみましょう。ここには、特に以下のことが明記されています。
code: CONSOLE
WARNING: This is a development server. Do not use it in a production deployment.
Use a production WSGI server instead.
生産システムを扱う上での主な問題は、パフォーマンスに代表されます。JavaScriptでコードを最小化するときのことを考えてみてください。ファイルを小さくするために意識的にコードを難読化していますが、これはファイルをより速く取り出すことだけを目的としています。
HTTPサーバの場合も、話はあまり変わりません。Webフレームワークは通常、Flaskのように開発用のWebサーバを提供しており、HTTPを適切に実装していますが、非常に非効率な方法で実装されています。まず第一に、このフレームワークはブロッキングフレームワークです。つまり、私たちのリクエストが処理されるのに数秒かかる場合(例えば、エンドポイントが非常に遅いデータベースからデータを取得するため)、他のリクエストはキューで処理されるのを待たなければなりません。これでは最終的に、ユーザーはブラウザのタブにスピナーが表示され、「最新のウェブサイトを構築することはできない」と首をかしげることになります。他にも、メモリ管理やディスクキャッシュなどのパフォーマンスに関する問題がありますが、一般的には、このウェブサーバはあらゆる生産負荷(複数のユーザーが同時にウェブサイトにアクセスし、高いサービス品質を期待すること)を処理できないと言ってよいでしょう。
これは驚くことではありません。結局のところ、私たちはビジネスに集中するためにTCP/IP接続に対処したくなかったので、フレームワークを保守している他のコーダーにこれを委ねました。一方、フレームワークの作者は、ミドルウェアやルート、HTTPメソッドの適切な処理などに集中したいと考えています。彼らは「マルチユーザー」体験のパフォーマンスを最適化することに時間を使いたくないのです。これはPythonの世界では特に顕著です(Node.jsなどではあまり顕著ではありません)。Pythonはあまりコンカレント指向ではなく、プログラミングのスタイルもパフォーマンスも、高速でノンブロッキングなアプリケーションには向いていません。これは最近、asyncやインタプリタの改善によって変わりつつありますが、これについては別の記事に譲ります。
さて、本格的なHTTPサービスができたところで、ユーザーが自分のコンピュータでローカルに実行されていないことに気づかないほど高速にする必要があります。
3 コンカレンシーとファサード
3.1 理由付け
さて、パフォーマンスに問題があるときは、ただ単に同時実行を行うだけです。今、あなたは多くの問題を抱えています! (ここ を参照) 並行処理(concurrency)は多くの問題を解決しますが、それと同じくらい多くの問題の原因でもありますので、最も安全で複雑でない方法で使用する方法を見つける必要があります。基本的には、フレームワーク自体には何も変更を加えずに、フレームワークを何らかの方法で並行して実行するレイヤーを追加したいと考えています。
そして、異なるものを均質化しなければならないときには、間接的なレイヤーを作成します。これで1つ以外の問題は解決します。(ここを参照)
つまり、サービスを並行して実行する層を作る必要がありますが、それをサービスの具体的な実装から切り離して、使用しているフレームワークやライブラリに依存しないようにしたいのです。
3.2 実装
このケースでは、独立したサードパーティのコンポーネントが使用できるように、ウェブフレームワークが公開しなければならないAPIの仕様を提供するという解決策があります。Pythonの世界では、この一連のルールは WSGI(Web Server Gateway Interface)と呼ばれていますが、このようなインターフェースはJavaやRubyなど他の言語にも存在します。ここでいう「ゲートウェイ」とは、フレームワークの外側にあるシステムの部分で、ここではプロダクション・パフォーマンスを扱う部分を指しています。WSGIを通じて、私たちはフレームワークが共通のインターフェイスを公開する方法を定義しています。これにより、並行処理に興味のある人々は、何かを独立して自由に実装できるようになります。
フレームワークがゲートウェイのインターフェイスと互換性があれば、互換性レイヤーを介して、コンカレンシーを扱い、フレームワークを使用するソフトウェアを追加することができます。そのようなコンポーネントはプロダクションレディのHTTPサーバで、Pythonの世界ではGunicornとuWSGIの2つが一般的な選択肢です。
本番対応のHTTPサーバーとは、ソフトウェアが開発サーバーと同じようにHTTPを理解することを意味しますが、同時により大きなワークロードを維持するためにパフォーマンスを向上させることを意味し、先に述べたようにこれは並行処理によって行われます。
FlaskはWSGIと互換性があるので、Gunicornで動作させることができます。仮想環境にインストールするには、pip install gunicorn を実行し、次のような内容のwsgi.pyというファイルを作成して設定します。
code: python
from app import application
if __name__ == "__main__":
application.run()
Gunicornを動作させるには、同時接続可能なインスタンス数と外部ポートを指定します。
code: bash
$ gunicorn --workers 3 --bind 0.0.0.0:8000 wsgi
このように、Gunicornにはワーカーという概念があり、これは並行処理を表現する一般的な方法です。具体的には、Gunicornはpre-forkワーカーモデルを実装しています。つまり、各ワーカーに対して異なるUnixプロセスを(事前に)作成します。これを確認するには、ps コマンドを実行します。
code: bash
$ ps ax | grep gunicorn
14919 pts/1 S+ 0:00 ~/venv3/bin/python3 ~/venv3/bin/gunicorn --workers 3 --bind 0.0.0.0:8000 wsgi
14922 pts/1 S+ 0:00 ~/venv3/bin/python3 ~/venv3/bin/gunicorn --workers 3 --bind 0.0.0.0:8000 wsgi
14923 pts/1 S+ 0:00 ~/venv3/bin/python3 ~/venv3/bin/gunicorn --workers 3 --bind 0.0.0.0:8000 wsgi
14924 pts/1 S+ 0:00 ~/venv3/bin/python3 ~/venv3/bin/gunicorn --workers 3 --bind 0.0.0.0:8000 wsgi
プロセスを使用することは、Unix システムで同時実行を実装する 2 つの方法のうちの 1 つに過ぎません。それぞれの方法の利点と欠点については、この記事では説明しません。とりあえずは、入力されたリクエストを非同期に処理する複数のワーカーを扱っているということを覚えておいてください。その結果、複数の接続を受け入れることができるノンブロッキング・サーバが実装されます。
3.3 リソース
以下のリソースは、このセクションで説明したトピックに関するより詳細な情報を提供しています。
3.4 課題
Gunicornを使用することで、本番用のHTTPサーバーが完成し、必要なものはすべて実装されたようです。しかし、まだ多くの検討事項や足りない部分があります。
パフォーマンス(再び)
私たちの新しいキラーモバイルアプリケーションの負荷を支えるのに、3人のワーカーで十分でしょうか?毎分数千人の訪問者が予想されるので、もう少し増やした方がいいかもしれません。しかし、ワーカーの数を増やす一方で、私たちが使っているマシンのCPUパワーとメモリには限りがあることを念頭に置かなければなりません。アプリケーションを停止し、マシンをより高性能なものに交換し、サービスを再起動することなく、いかにワーカーを増やし続けることができるか。
変化を受け入れる
本番環境で直面する問題は、これだけではありません。テクノロジーの重要な側面は、新しい、そして(願わくば)より良いソリューションが普及するにつれて、時間とともに変化していくということです。私たちは通常、システムをできるだけ通信可能な層に分割して設計しますが、それは、層を自由に別のものと交換したいからです。繰り返しになりますが、私たちはウェブ・フレームワークの場合と同じように、同じインターフェイスを維持したまま基盤となるシステムを進化させることができるようにしたいのです。
HTTPS
このシステムに欠けているもうひとつの部分は HTTPS である。Gunicorn と uWSGI は HTTPS プロトコルを理解していないので、プロトコルの "S "の部分を処理し、"HTTP "の部分を内部のレイヤーに任せるようなものが必要です。
ロードバランサー
一般的にロードバランサーとは、ワーカーのプールに仕事を分配するシステムのコンポーネントのことです。Gunicornはすでにワーカー間で負荷を分散しているので、これは新しいコンセプトではありませんが、一般的にはより大きなレベルで、マシン間やシステム全体で行いたいと考えています。負荷分散は階層化され、多くのレベルで構成することができます。また、システムの一部のコンポーネントをより重要視し、より多くの負荷を受け入れる準備ができているとフラグを立てることもできます(たとえば、ハードウェアが優れているなど)。ロードバランサーはネットワークサービスにおいて非常に重要であり、負荷の定義はシステムごとに大きく異なります。一般的に、Webサービスでは接続数が負荷の標準的な尺度となります。
リバースプロキシ
ロードバランサーはフォワードプロキシであり、クライアントがプール内のどのサーバーにもコンタクトできるようにします。同時に、リバースプロキシは、クライアントが同じエントリーポイントを通じて、複数のシステムから生成されたデータを取得できるようにします。リバースプロキシは、異なる技術で実装されたサブシステムにHTTPリクエストをルーティングするのに最適な方法です。例えば、システムの一部をDjangoとPostgresを使ってPythonで実装し、別の一部をDynamoDBなどの非リレーショナルデータベースと接続されたGoで書かれたAWS Lambda関数で提供するといったことが考えられます。通常、HTTPサービスでは、この選択はURLに応じて行われます(例えば、/api/で始まるすべてのURLをルーティングするなど)。
ロジック
サービスとは関係のない単純なルールを管理するために、ある程度のロジックを実装できるレイヤーも必要です。典型的な例としては、HTTPリダイレクトがあります。ユーザーが https:// ではなく http:// というプレフィックスでサービスにアクセスした場合はどうなるでしょうか?正しい対処法はHTTP 301コードですが、このようなリクエストがフレームワークに到達して、このような単純なタスクのためにリソースを無駄にしたくはありません。
4 ウェブサーバについて
4.1 理由づけ
Webサーバーは、これまで説明してきたタスクを実行するソフトウェアに与えられる一般的なラベルです。システムのこの部分には、現在市場をリードしている2つのオープンソースプロジェクトであるnginxとApacheがよく使われます。技術的なアプローチは異なりますが、どちらも前のセクションで説明したすべての機能を実装しています(それ以上の機能もあります)。
4.2 実装
OS と格闘したり、たくさんのパッケージをインストールしたりすることなく nginx をテストするには、Docker を使用します。Dockerはマルチマシン環境をシミュレートするのに便利ですが、実際の本番環境でも利用できる技術かもしれません(例えばAWS ECSはDockerコンテナで動作します)。
これから実行する基本構成は非常にシンプルです。1つのコンテナにFlaskのコードを入れてGunicornでフレームワークを実行し、もう1つのコンテナでnginxを実行します。Gunicornは、Dockerでは公開されていないため、ブラウザからは到達できない内部ポート8000でHTTPを提供し、nignxは伝統的なHTTPポートであるポート80を公開します。
wsgi.pyファイルと同じディレクトリに、Dockerfileを作成します。
code: Dockerfile
FROM python:3.6
ADD app /app
ADD wsgi.py /
WORKDIR .
RUN pip install flask gunicorn
EXPOSE 8000
これはPythonのDockerイメージから起動し、appディレクトリとwsgi.pyファイルを追加し、Gunicornをインストールしています。同じディレクトリにnginx.confというファイルを作成し、nginxの設定を行います。
code: nginx.conf
server {
listen 80;
server_name localhost;
location / {
}
}
これにより、80番ポートでリッスンするサーバーを定義し、/で始まるすべてのURLを8000番ポートのapplicationと呼ばれるサーバーに接続します。これはGunicornを実行しているコンテナです。
最後に、コンテナの構成を記述するdocker-compose.ymlファイルを作成します。
code: docker-compose.yml
version: "3.7"
services:
application:
build:
context: .
dockerfile: Dockerfile
command: gunicorn --workers 3 --bind 0.0.0.0:8000 wsgi
expose:
- 8000
nginx:
image: nginx
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
ports:
- 8080:80
depends_on:
- application
ご覧の通り、nginxの設定ファイルに記載したapplicationという名前は魔法の文字列ではなく、Docker Composeの設定でGunicornコンテナに割り当てた名前です。
このインフラを作るためには、pip install docker-compos eでDocker Composeを仮想環境にインストールする必要があります。また、.env というファイルを作成して、COMPOSE_PROJECT_NAMEをセットします。
code: .env
COMPOSE_PROJECT_NAME=service
これで、docker-compose up -d で Docker Compose を実行できます。
code: bash
$ docker-compose up -d
Creating network "service_default" with the default driver
Creating service_application_1 ... done
Creating service_nginx_1 ... done
すべてが正常に動作していれば、ブラウザを開いてlocalhostにアクセスすると、Flaskが提供しているHTMLページが表示されるはずです。
docker-composeのログを見れば、サービスが何をしているか確認できます。アプリケーションというサービスのログにGunicornの出力が見られます。
code: bash
$ docker-compose logs application
Attaching to service_application_1
そこで、docker-compose logs -f nginx でリアルタイムにログを追ってみましょう。ブラウザでアクセスしたlocalhostのページを更新すると、コンテナから以下のような出力が得られるはずです。
code: bash
$ docker-compose logs -f nginx
Attaching to service_nginx_1
nginx_1 | 192.168.192.1 - - 14/Feb/2020:08:42:20 +0000 "GET / HTTP/1.1" 200 13 "-" "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:72.0) Gecko/20100101 Firefox/72.0" "-" このような、nginxは標準的なログ形式を採用しています。ここには、クライアントのIPアドレス(192.168.192.1)、接続タイムスタンプ、HTTPリクエストと応答ステータスコード(200)のほか、クライアント自身に関する情報が表示されます。
次に、サービスの数を増やして、負荷分散の仕組みを確認してみましょう。そのためには、まずnginxのログ形式を変更して、リクエストを処理したマシンのIPアドレスを表示するようにします。nginx.confファイルにlog_formatとaccess_logオプションを追加します。
code: nginx.conf
log_format upstreamlog '$time_local $host to: $upstream_addr: $request $status'; server {
listen 80;
server_name localhost;
location / {
}
access_log /var/log/nginx/access.log upstreamlog;
}
変数 $upstream_addr には、nginxによってプロキシされたサーバーのIPアドレスが格納されています。
次に、docker-compose down を実行してすべてのコンテナを停止し、docker-compose up -d -scale application=3 を実行して再びコンテナを起動します。
code: bash
$ docker-compose down
Stopping service_nginx_1 ... done
Stopping service_application_1 ... done
Removing service_nginx_1 ... done
Removing service_application_1 ... done
Removing network service_default
code: bash
$ docker-compose up -d --scale application=3
Creating network "service_default" with the default driver
Creating service_application_1 ... done
Creating service_application_2 ... done
Creating service_application_3 ... done
Creating service_nginx_1 ... done
As you can see, Docker Compose runs now 3 containers for the application service. If you open the logs stream and visit the page in the browser you will now see a slightly different output
$ docker-compose logs -f nginx
Attaching to service_nginx_1
これはnginxがロードバランシングを行っていることを示していますが、実のところ、これはDockerのDNSを通じて行われており、Webサーバが明示的に行っているわけではありません。これはnginxコンテナにアクセスしてdigアプリケーションを実行することで確認できます(digをインストールするには、apt update とapt install dnsutils を実行する必要があります)。(訳注:プラットフォームのOSがUbuntuの場合です)
code: bash
root@99c2f348140e:/# dig application
; <<>> DiG 9.11.5-P4-5.1-Debian <<>> application
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 7221
;; flags: qr rd ra; QUERY: 1, ANSWER: 3, AUTHORITY: 0, ADDITIONAL: 0
;; QUESTION SECTION:
;application. IN A
;; ANSWER SECTION:
application. 600 IN A 192.168.240.2
application. 600 IN A 192.168.240.4
application. 600 IN A 192.168.240.3
;; Query time: 1 msec
;; SERVER: 127.0.0.11#53(127.0.0.11)
;; WHEN: Fri Feb 14 09:57:24 UTC 2020
;; MSG SIZE rcvd: 110
nginxによるロードバランシングを確認するために、2つのサービスを明示的に定義して、それぞれに異なるウェイトを割り当てます。docker-compose down を実行し、nginxの設定を次のように変更します。
code: nginx.conf
upstream app {
server application1:8000 weight=3;
server application2:8000;
}
log_format upstreamlog '$time_local $host to: $upstream_addr: $request $status'; server {
listen 80;
server_name localhost;
location / {
}
access_log /var/log/nginx/access.log upstreamlog;
}
ここでは、application1 と application2 という 2 つの異なるサービスをリストアップするアップストリーム構造を定義し、1 つ目のサービスに 3 の重みを与えました。これで nginx は単に DNS に頼るだけではなく、意識的に 2 つの異なるサービスを選択しています。
それでは、Docker Composeの設定ファイルでサービスを定義してみましょう。
code: docker-compose.yml
version: "3"
services:
application1:
build:
context: .
dockerfile: Dockerfile
command: gunicorn --workers 6 --bind 0.0.0.0:8000 wsgi
expose:
- 8000
application2:
build:
context: .
dockerfile: Dockerfile
command: gunicorn --workers 3 --bind 0.0.0.0:8000 wsgi
expose:
- 8000
nginx:
image: nginx
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
ports:
- 80:80
depends_on:
- application1
- application2
私は基本的にアプリケーションの定義を重複させましたが、最初のサービスは、2つの間の可能な違いを示すために、現在6つのワーカーを実行しています。次に、docker-compose up -d と docker-compose logs -f nginx を実行します。ブラウザでページを何度も更新すると、次のようなものが表示されます。
code: bash
$ docker-compose logs -f nginx
Attaching to service_nginx_1
172.18.0.2(application1) と 172.18.0.3(application2) の間でロードバランシングが行われているのがよくわかります。
この記事が長くなるのを避けるため、リバース・プロキシやHTTPSの例はここでは紹介しません。これらのトピックに関するリソースは次のセクションでご紹介します。
4.3 リソース
以下のリソースは、このセクションで説明したトピックに関するより詳細な情報を提供します。
このサンプルのソースコードはGitHub で公開しています。 4.4 課題
さて、ようやく仕事が終わったと言えるでしょう。マルチスレッドのウェブフレームワークの前に本番用のウェブサーバができたので、HTTPヘッダを扱う代わりにPythonのコードを書くことに集中できるようになりました。
Webサーバを使うことで、サービスを中断することなく、新しいインスタンスを追加するだけでインフラを拡張することができます。HTTPコンカレントサーバは、私たちのフレームワークの複数のインスタンスを実行し、フレームワーク自体がHTTPを抽象化し、私たちの高級言語にマッピングします。
おまけ:クラウド・インフラ
インターネットの黎明期には、企業が自社のサーバーを保有し、システム管理者がOS上で直接スタック全体を動かしていました。言うまでもなく、これは複雑でコストがかかり、故障の原因にもなっていました。
今日では「クラウド」が主流となっています。そこで、このようなウェブスタックをAWS上で実行するのに役立つコンポーネントをいくつか簡単に紹介したいと思います。AWSは、私が最もよく知っているプラットフォームであり、この記事を書いている時点で世界で最も普及しているクラウドプロバイダーです。
Elastic Beanstalk
Elastic Beanstalk は、シンプルなアプリケーション向けのエントリーレベルのソリューションで、ロードバランシング、オートスケーリング、モニタリングを提供するマネージドインフラストラクチャです。複数のプログラミング言語(PythonやNode.jsなど)を使用でき、ApacheやnginxなどのWebサーバーを選択することができます。EBサービスのコンポーネントは隠されているわけではありませんが、それらに直接アクセスすることはできず、動作方法を変更するには設定ファイルに頼らなければなりません。シンプルなサービスには適したソリューションですが、すぐにもっとコントロールが必要になるでしょう。 Elastic Container Service (ECS)
Elastic Container Server(ECS) では、Dockerコンテナをクラスタにまとめて実行し、CloudWatchから送られてくるメトリクスに基づいてオートスケール・ポリシーを設定することができます。ECSは、EC2インスタンス(仮想マシン)上で実行するか、Fargateと呼ばれるサーバーレスインフラ上で実行するかを選択できます。ECSはDockerコンテナを実行しますが、DNSエントリーやロードバランサーは自分で作成する必要があります。また、EKS(Elastic Kubernetes Service)を使ってKubernetes上でコンテナを動かすという選択肢もあります。 Elastic Compute Cloud (EC2)
Elastic Compute Cloud (EC2) はAWSのベアメタルであり、スタンドアロンの仮想マシンやオートスケーリングされた仮想マシン群をスピンアップします。これらのインスタンスにSSHで接続し、ソフトウェアのインストールや設定を行うスクリプトを提供することができます。ここには、アプリケーション、Webサーバー、データベースなど、何でもインストールすることができます。クラウドコンピューティング時代の最初の頃は、この方法が主流でしたが、今はそうすべきではないと思います。クラウドプロバイダーは、ログや監視などの関連サービスや、パフォーマンスの面で提供してくれるものがたくさんありますから、それを使わない手はありません。EC2はまだ存在しており、その上でECSを運用する場合は、何ができて何ができないのかを知っておく必要があります。 Elastic Load Balancing
Network Load Balancer(NLB)が純粋なTCP/IP接続を管理するのに対し、Elastic Load BalancingはHTTPに特化したアプリケーションロードバランサーであり、我々が必要とするサービスの多くを実行することができます。最近改善されたルールによるリバースプロキシや、ACM(AWS Certificate Manager)で作成した証明書を使ってTLSを終了させることができます。このように、ALBはWebサーバの良い代替となります。しかし、ロードバランシングの最初のレイヤーとして使用することができ、その後ろにnginxやApacheなどの機能が必要な場合はそれらを使用することができます。 CloudFront
CloudFront は、コンテンツ・デリバリー・ネットワークであり、コンテンツへの高速アクセスを提供する地理的に分散されたキャッシュです。CDNはこの記事で説明したスタックの一部ではありませんが、静的コンテンツを高速化し、AWS Certificate Managerに接続してTLSを終了させることができるので、CFについて言及する価値があると思います。 まとめ
ご覧の通り、Webスタックは非常に豊富なコンポーネントのセットであり、その理由は多くの場合、パフォーマンスに関連しています。私たちが当たり前のように使っている技術や、幸いにも導入が容易になった技術はたくさんありますが、フルスタックエンジニアは、そのようなレイヤーの存在だけでなく、その目的や、少なくとも基本的な設定についても知っておくべきだと私は考えています。