Future Tech Blog
フューチャー技術ブログ

「2020年代のコンテナ時代のPythonアーキテクチャ&デプロイ」というテーマでPyCon.jp 2020で発表してきました


初のオンライン&Python 2サポートが終了したあとのPyCon.jpという節目のイベントで発表してきました。

発表資料はこちらになります。

日英表記にした関係で表現をだいぶシンプルに削ることになったりしたので、口頭での説明のみ行ったこととか、その後のTwitterの感想を見て思ったことなどを軽く補足します。

コンテナの時代

コンテナのカバレッジが広がっている事例としてはDensoのMisakiとか戦闘機にKubernetesを載せてみました、とかもあります。

なお、この説明ではDockerをベースにしていますが、世の中にはランタイムもビルドツールも、Docker以外もたくさんあります。ビルドのレイヤーをどうするのかの問題とかはDockerの設計によって発生した新しい問題な気もしますし、Debianとかのベースイメージも外部でビルドした内容をただファイルコピーしているだけだったりするので、Dockerfileのみのイミュータブルインフラという考え方も別に天地ができたころからのルールというわけではなく、今後は外部ツールとのハイブリッドとかが出てこないともいえないし、ここに書いているベストプラクティスが今後いつまでも続くわけではなく、そのうち知識のアップデートが必要になる可能性もあります。

コンテナの仕組み

ここの図はいくつか簡略化しています。

  • ここでは仮想化の図はVirtualBoxとかVMWareとかを使った完全仮想化を想定した図になっています。準仮想化であっても、ホストOSの中の対象アプリを直接見れるわけではない、という点ではこの説明の範疇ではあります。
  • コンテナも、Linuxを使ってLinuxイメージを動かした想定の図です。WindowsやmacOSの場合は、ホストOSのカーネルとは別にLinuxカーネルを動作させて、その中でDockerを動かしますので、やや事情が異なります。

macOSのDockerの場合はまず、この仮想PCのつかうCPUコア数やメモリ、ストレージを設定します。ストレージは可変長ストレージなので細かいことは気にしなくても良いのですが、macでコンテナが遅い場合はメモリやCPUの調整が必要です。

Windowsは以前はmacOSと同じでしたが、WSL2をつかうバックエンドにすると、LinuxからオンデマンドでWindowsの持っているCPUやメモリを自由に割当できるようになり、macより柔軟になりました。Windowsの使えるコアのすべてと、メモリの80%がLinuxから利用可能なメモリに見え、必要に応じてLinux側にメモリとCPUを渡します。実装としてはVMの中で動いているのですが、使い勝手としてはLinuxに近くなりました。もちろん、使いすぎないようにあらかじめMaxのCPUとメモリを制限しておくこともできます。

しかし、現時点では使い終わったメモリがうまくWindowsに返らず、対症療法としてメモリ制限が50%にされる変更があったりもろもろ混乱は続いています。早く解決して欲しい。

https://qiita.com/yoichiwo7/items/e3e13b6fe2f32c4c6120

なお、Pythonの発表なので説明はしませんでしたが、Java 8の場合はDockerが用意する壁を突き抜けてメモリを確保しにいってしまうというバグ?があるらしいので、もしJavaを使っている人はJava 8 update 212(ライセンスが変わったあとのバージョンなので注意)か、Java 11以降をつかうようにしましょう。

コンテナの利用法

ここではいろいろなコンテナの開発での利用方法について紹介しました。まず注意点として、ホストのフォルダをマウントする方式の場合、Linuxのようなただ同一ファイルシステムを別の視点で見せている場合は問題ありませんが、macOSやWindowsのような仮想PCが絡む場合は、ゲストのOS側のファイルシステムにファイルを転送する必要があります。そのため、転送量が大きくなるとパフォーマンスが著しく落ちる可能性があります。macでは新しいマウント方式が入って改善されようとしていたりしますが、Mutagenが入るぞ、やっぱりやめるぞとか現在進行形でいろいろ話がされています。

ゲスト環境の中に潜り込んで編集してくれるような開発環境があれば解決しますが、現時点ではVisual Studio Codeのみがこれに対応しています。PyCharmなどのJetBrains製のツールなどはこれに対応しておらず、Linux版のアプリを使ってホストのX ServerにGUIを飛ばすとかしなければいけません。VSCodeを使うか、マウントの速度ペナルティを負うか、画面を飛ばすか、処理系を動かすのを諦める変わりにツールの自由を得るか、などいろいろトレードオフが現時点ではあります。JetBrainsの方でもissueとしては上がっています。今後の開発ツールは仮想環境内部のバックエンドと、開発ツールのUIのフロントエンドを分離して仮想環境に対応していく、ということで解決されてくると思います。

そんな感じで、VSCodeが現状は自由度が高いので、コンテナ活用の絵はVSCodeにしていたのでした。僕はこの絵の一番右のスタイルでやることが多いです。Python、Go、Node.jsのどの言語もマルチプラットフォームはきちんとしているので、現在進行形で編集しているコードを動かすものはOSネイティブで動かし、JetBrains系のIDEを使っています。

これと同じような問題は、WindowsとWSL2間でもあったりします。あっちの方がさらに複雑だったりしますので、そのうち別のエントリーを書くかもしれません。

コンテナ時代のアーキテクチャ

古きよきUNIX世界のアーキテクチャに先祖帰りしているぞ、という話をしました

  • 1つのプロセスが1つのタスクを
  • 環境変数で設定を変更
  • TCP/UDPのソケットを使って外部へのサービスの提供(HTTPサーバー含む)
  • ファイルなどの保存もTCP/UDPのソケットを使ってリクエスト
  • ログは標準出力へ
  • シグナルでプロセスの終了

シグナルの話はとくに発表では触れませんでしたが、Pythonでは余計なことをしなければ自然と対応できる気がします。

オンプレで動かしていたサービスをとりあえずコンテナ化してしまうとやりがちなのが、systemdとかを入れて、サービスモリモリのコンテナを作ってしまうというものがあります。systemdなどを使わずに、デーモンなども作らずに、コンテナ中でフォアグラウンドプロセスとしてすべて起動するようにします。そのコンテナを複数束ねてサービスを実現するようにします。お子様ランチのようなワンプレートで全部乗せではなく、1品1品別皿に盛られて、量を増やしたい場合は同一種の皿を増やす回転寿司スタイルがコンテナです。

ログ出力はサイドカーよりも標準出力で、というのはコンテナのログドライバーとかでそういうものができるようになってきた、という段階で、まだ100%そうだ、とは言えないとは思います。ですが、アプリケーションにログの出力先を教えてあげないといけない、というのは設計思想的にはコンテナっぽくはないので、ログドライバー経由が今後は増えてくるでしょう。

ちなみに、OpenCensus/OpenTelemetryでぐぐると僕の書いた弊社の技術ブログの記事が上位に出てきます。Chromeのシークレットウインドウで検索をすると日本語では1位、英語でも10位以内でした。自慢です。

コンテナ時代のPythonアーキテクチャ

前節の話をPythonで実現するには、といった解説です。Dockerのベストプラクティスなどによると、プロセスマネージャなどは利用せずにアプリケーションを直接起動するように書いています。プロセスマネージャというと、PythonではWSGIのgunicorn、uwsgiですね。Rubyだとrackのunicorn、puma、Node.jsだとpm2とかが該当します。ただ、Pythonの場合はFlaskやDjangoなども、組み込みのウェブサーバーはデバッグ用で、そもそもシングルスレッドで1つの処理でブロッキングしたりと性能をまったく
重視しない環境だったりします。各フレームワークもgunicornなどの利用を進めていたりしますので、Pythonの場合はgunicornに最低限のワーカーをぶら下げたものをコンテナとするのが良いでしょう。

アプリケーションフレームワークとパフォーマンス

僕の周りのPythonistaの中でちょっと話題になるのがこのプレゼンで紹介したStarletteです。あとは、このStarletteの上に構築されたAPIサーバーのためのフレームワークのfastapiです。せっかくPython2がなくなったので、Python3の性能を活用するこれらのフレームワークの利用が増えると面白く仕事ができるんじゃないかな、と思います。フューチャー社内でPython案件で何か質問とかあれば僕に問い合わせてもらいたいのですが、これらのフレームワークを使うヘルプであればさらに大歓迎です。

Flaskで作ってみたアプリケーションは以下の通りです。たいてい、外部のサービスに問い合わせてその結果をマッシュアップして返すということで、urllibを使ってマシン内部に起動したnginxのコンテナにリクエストを投げ、その結果をそのまま返すというコードです。

app.py
1
2
3
4
5
6
7
8
9
10
11
import os
import json
import urllib.request

from flask import Flask, jsonify
app = Flask(__name__)

@app.route('/')
def hello_world():
j = json.load(urllib.request.urlopen("http://nginx/index.json"))
return jsonify(j)

Dockerfileはこんな感じです。仕事でPythonコンテナをデプロイする人向けのDockerfile (1): オールマイティ編で紹介したエッセンスがいろいろ入っています。今後はマルチステージビルド必須です。BuildKitといったビルド高速化のソリューションもマルチステージビルドをしなければほぼ意味がありません。Docker力を鍛えるにはまずマルチステージビルドです。

Dockerfile
1
2
3
4
5
6
7
8
9
10
11
FROM python:3.8-slim-buster

ENV APP_HOME /app
WORKDIR $APP_HOME
COPY requirements.txt .

RUN pip install -r requirements.txt
COPY . .
ENV PYTHONUNBUFFERED=TRUE

CMD gunicorn --bind :8000 --workers 1 app:app

Starletteのコードはこんな感じです。asyncioのセッションとかが出てくるのでちょっと冗長ですが、やっていることはそんなに難しくはないでしょう。routes配列を利用して、パスごとに別のハンドラを設定というのも当然できます。デコレータを使うAPIの方がお洒落に見えますが、まあこっちもそんなに大変ではないかと思います。

こちらも同じNginxにリクエストを投げていますが、asyncioを生かしたaiohttpを使ったリクエストを投げています。

main.py
1
2
3
4
5
6
7
8
9
10
from starlette.applications import Starlette
from starlette.responses import UJSONResponse
import aiohttp

async def app(scope, receive, send):
session = aiohttp.ClientSession()
resp = await session.get("http://nginx/index.json")
response = UJSONResponse(await resp.json())
await response(scope, receive, send)
await session.close()

Dockerfileは以下のような感じです。こちらは仕事でPythonコンテナをデプロイする人向けのDockerfile (2): distroless編で紹介したDistrolessを使っています。

Dockerfile
1
2
3
4
5
6
7
8
9
10
11
12
13
14
FROM python:3.7-slim-buster as builder
WORKDIR /opt/app
COPY requirements.lock /opt/app
RUN pip3 install -r requirements.lock

FROM gcr.io/distroless/python3-debian10 as runner
COPY --from=builder /usr/local/lib/python3.7/site-packages /root/.local/lib/python3.7/site-packages
COPY --from=builder /usr/local/bin/gunicorn /opt/app/gunicorn
COPY main.py /opt/app/main.py
WORKDIR /opt/app/

ENV PYTHONUNBUFFERED=TRUE
EXPOSE 8000
CMD ["./gunicorn", "-w", "1", "-k", "uvicorn.workers.UvicornWorker", "--log-level", "warning", "--bind", ":8000", "main:app"]

これらのコンテナを1コアのCPUに割当て、いろいろ属性を変えつつabで計測してみました。なんかMacBook Airは発熱してくると速度が落ちてくるのか安定はしないので具体的な数値を出して議論するような結果は得られませんでしたが、Starletteの方が倍以上高速でした。並列をかなり増加させると、Flaskの方はどんどんパフォーマンスが落ちてきてしまいましたが、Starletteは並列数によらず一定のリクエストを毎秒捌いていました。またワーカー数やスレッド数を1-3あたりで変更して計測しても、Starletteの速度の安定性は変わらずでした。Flaskはスレッドを2にすると少し上がるところもありましたが、ベストプラクティスの3(コア数x2 + 1)にするとかえって落ちたりと安定せずでした。

ただ、CPUバウンドかI/Oバウンドか、タスクの特性がどちらかにどの程度寄っているかはアプリケーションによって違います。オライリージャパンから出版されたばかりのEffective Python第2版の項目63あたりのasyncioのセクションではasyncioのイベントループの中でブロッキングする処理を入れてしまうと全体として応答性が下がってしまう話などが書かれています。時間がかかるがレスポンスを返したあとに処理しても構わないもの(バックグラウンドプロセスへのタスクの移譲など)の場合は項目64で紹介されているconcurrent.futuresを利用するなどした方が良いかもしれません。

非同期I/Oということで特性はNode.jsやGoに近くなると思いますが、チューニングポイントが減って、リソースをどれだけ増やせばどれだけ捌けるのかが見通しやすい非同期I/Oの特性は、インフラを担当する人にはフレンドリーで助かるんじゃないかと思います。

ログ

構造化ロギングについて紹介しました。構造化ロギングは次のリポジトリでPython/Java/Node.js/Go/PHPの実装方法が紹介されています。もしRubyとか他の言語のユーザーの人はPRを送ってみると良いと思います。

https://github.com/ymotongpoo/cloud-logging-configurations/blob/master/python/structlog/main.py

クラウドサービスにつながって便利になるものの、ローカルでは残念ながらそこまで利便性は高くはない、というか検索とかはgrepでやるなら特に普通のログと変わるところはないのですが、たいていどのロガーもカラフルにして出力する機能があったりします。ログの視認性はかなり上がるので、ローカルであっても使ってみると良いのではないでしょうか?これとは別にローカルのビューアーはあってもいいな、と思って少し作り始めて見たりもしています。

ちょうどPyCon直後に開催されたゲーム開発者のカンファレンスのCEDECでも、任天堂の方がリングフィットアドベンチャーの話をされていて、その中で構造化ロギングの話も出ていました。構造化ロギング来ています。

イメージ選定補足

イメージ選定においては、Debian系が無難ですよというのと、Debian系の変種のdistrolessについて紹介しました。

1点、手元で試したものの検証できなかったのがGPU対応です。Pythonでウェブサービスというと機械学習も関係してくることが多いです。で、Dockerイメージで機械学習のアプリのデプロイというのも事例として良く聞く気がします。

この辺りをみながら、手元のGeForce RTX 2060のノートでWSL2でDocker GPUというのにチャレンジしてみました。

https://docs.nvidia.com/cuda/wsl-user-guide/index.html#getting-started

一応、ベンチマークは実行できてDockerの中でGPUも認識したのですが、ここに出てくるnvidia-container-cli infoコマンドなどは実行できず・・・

https://blog.amedama.jp/entry/docker-nvidia-container-toolkit

ここの説明によると、--gpusオプションをつけるとコンテナ内部にホスト側にインストールされているコマンドやライブラリがマウントされてコンテナ内部からも使えるようになるらしいのですが手元では確認できませんでした。NVIDIA公式CUDAイメージはUbuntuとCentOSここで紹介されているのはなんの変哲もないUbuntuイメージで実行していました。原理原則からすればDebianでもいいと思われますが・・・ちょっと機械学習周りはそのうちしっかり調べたいです。

その他のお話

この発表では、ビルドイメージのサイズ以外に、ビルド時間も大切であると話をしました。その際にビルド時間にストレートに聞いてくる要素としてビルドのコンテキストについて紹介しました。どのファイルがDockerサーバーへの送信対象になっているかを確認するには以下のDockerfileをビルドして実行してみると分かります。

Dockerfile.ctx
1
2
3
4
5
6
FROM alpine

WORKDIR /context
ADD . ./

CMD ["du", "-h"]

何も設定しないと、.gitフォルダなども送信対象になってしまうので、.gitフォルダが数100MBとかあると、それだけで毎回ビルド時間が数10秒伸びてしまいます。

.gitignoreと同じフォーマットの.dockerignoreファイルを置くことでDockerに無視されるファイルを指定できます。.git以外に、Dockerの中でビルドし直すようなビルド結果のファイルなどは除外しておくべきです。基本的には.gitignoreのファイルをコピーして.gitを足すぐらいで良いかと思います。

.gitをコンテキストに含まなければならないケースが1つあり、現在のGitのハッシュ値をとってきてビルド結果にどのコミット断面でビルドしたかの情報を入れたい場合です。あるあるですね。Node.jsのパッケージではありますが、next-build-idみたいなツールが該当しますが、これを実行するためには.gitフォルダもコンテキストに含める必要があります。

これの高速化のテクととしては、リポジトリの全ファイル履歴が格納されている巨大な.git/objectsフォルダにファイルの実態がいなくてもgit rev-parse HEADコマンドは利用できます。ビルドの中でこのコマンドを使っているようなリポジトリの場合は、.git/objectsを.dockerignoreに追加することで、.gitフォルダを指定するのと99.9%同じ結果が得られます(他のフォルダは誤差)。ただし、このフォルダ自体は必要なので、.git/objectsをRUN mkdir .git/objectsで作るようにしましょう。

まとめ

Dockerコンテナがどういうものかを紹介しつつ、それにあったPythonのアーキテクチャの紹介をしてきました。口頭で説明したような内容はこのブログエントリーでだいぶ補完できたんじゃないかと思います。

技術ブログではGo色が強いのですが、フューチャーの中で一番人材豊富なのはJavaですし、個人的にはPythonも仕事でやりたいので、もし御用命あればお待ちしております。