Skip to content

ソフトウェアを構成する12の要素

2021-05-09


ソフトウェアについての理解を深めるためにまとめる。
この12の要素のアプリケーションが全てではないし首を傾げる点もちょこちょこあった(多分僕の理解が足りてないだけだからこの記事を投稿し終わった後も勉強する)

ソフトウェアの基礎

現代ではソフトウェアはサービスとして提供されていて、Web アプリケーションや SaaS と呼ばれる。
及川卓也氏ソフトウェアファースト という書籍が個人的に現代のソフトウェアを表していてとても刺さった。

ソフトウェアには以下のことが必要不可欠である。

  • セットアップ自動化のために宣言的なフォーマットを使うことで新規で参画した人間への導入コストを減らす(弊社には Makefile 職人が多数いる)
  • 下層のOSへの依存関係を明確化し、実行環境間での移植性を最大化する。
  • クラウドプラットフォームを使用した CD に優れており、サーバー管理やシステム管理を不要なものにする。
  • デベロップ環境とプロダクション環境の差異を最低限にしつつ、CI の構築を適切に行う。
  • 環境を大きく変更することなくスケーリングすることができる

このような概念はどのプログラミング言語/プラットフォームを使用しても共通のことと言える。また、どのようなバックエンドサービス(データベース、メッセージキュー、メモリキャッシュなど)の組み合わせを使っていても適用できる。

コードベース

しっかりコードを管理しろという話。git が一般的かもしれない。
git を使用して github でコードを管理してる人・企業・組織は多いように感じる。
最近は自作してる人も見かける。RDBMS やら OS やら git はもはや作ってないだけの時代、怖い。

コードベースの考え方は単一のリポジトリに対して1人以上の人間がコミットをしていく状態である。
コードベースとアプリケーションの間には 1対1の関係が成り立っている。

  • もし複数のコードベースがある場合それはアプリケーションではない
    • それは分散システムである。
    • 分散システムのそれぞれのコンポーネントがアプリケーションであり、個別に12の要素に適合することができる。
  • 同じコードを共有する複数のアプリケーションは、ここで出てくる12の要素に違反している。
    • その場合の解決策は、共通のコードをライブラリに分解し、そのライブラリを依存関係管理ツールで組み込むようにすることである。

アプリケーションごとにただ1つのコードベースが存在するが、アプリケーションのデプロイ先は複数存在する。   デプロイはアプリケーションの実行中のインスタンスである。常に1つのプロダクション環境と、1つ以上のステージング環境が存在していて、さらにすべての開発者はローカル開発環境で動作するアプリケーションのコピーを持っており、それらもデプロイと見なせる。
デプロイごとに異なるバージョンがアクティブであるかもしれないが、コードベースはすべてのデプロイを通して同一である。例えば、開発者はステージング環境にまだデプロイされていないコミットを抱えているし、ステージング環境にはプロダクション環境にデプロイされていないコミットが含まれている。
しかし、これらのデプロイはすべて同一のコードベースを共有しているため、同一のアプリケーションの異なるデプロイであると言える。

依存関係

これはフロントエンドがお好きなみなさんなら特に大好きなやつ。native esm もここら辺が解決されれば一気にプロダクション環境導入に近づく(module 管理とかどうするの?ってなりそうだけど)と思う、知らんけど。
大体のプログラミング言語はライブラリを配布するためのあれこれが準備されている。npm だったり pip だったり。go は go get コマンドで github のやつをまんま持ってくる。あれも管理システムの一種か。

ここで示している12の要素を適切に取り入れているアプリケーションは、システム全体にインストールされるパッケージが暗黙的に存在することに決して依存しない。
そこに依存関係があるなら厳密に明示的に定義をする。さらに、実行時には依存関係分離ツールを使って、取り囲んでいるシステムから暗黙の依存関係が 漏れ出ない ことを保証する。
これらはデベロップ環境とプロダクション環境の両方で厳密に定義をする。

これらを明示的に宣言する理由の1つとして、アプリケーションに新しい開発者が加わった際のセットアップを単純化できるというものがある。環境構築、マジでだるい、ただでさえだるいものをさらにだるくしないためにやっておくべきである。
コードを読むのもだるい、全体像を理解するのにも時間がかかる。なのにそこでごたつきたくない。
JS/TS なら npm i で全て解決したいし、python なら requiements.txt を読み込んで環境を構築したい。そういうことである。時にはシェルスクリプトや Makefile を書くこともあるだろう。なるべく楽にしよう。

設定

アプリケーションの設定はそれぞれの環境(デベロップ、ステージング、プロダクション)で異なる唯一の項目である。
設定とは

  • データベース、Memcached、他のバックエンドサービスなどのリソースへのハンドル
  • 外部サービスの認証情報
  • ホスト名などのデプロイごとの固有の変数

がある。

アプリケーションで設定を定数として格納して使用するケースも見かけるがこれはやめたほうがいい。定数として使用するのも設定の一種ではあるがここでいう設定は環境ごとの設定の話であり、これを定数として表すのは環境全体の設定のことである。
逆にアプリケーション内部の設定はアプリケーション側で行うべきである。Rails なら config/routes.rb などで格納してあるあれ。

デプロイごとで変わらない値はアプリケーションで管理し、デプロイごとで変わる値は環境変数で管理するのが良い。(もちろんこれが全てではない、yml を使って ignore しておいて個々が設定というパターンもある)

バックエンド

バックエンドサービスとはアプリケーションの部分ではなくネットワーク越しに利用するサービス(IaaS や PaaS の類)のことを言う。
例として、データストア(MySQL、MariaDB)、メッセージキューイングシステム(RabbitMQ、Beanstalkd)、SMTP サービス(mailgunなど)、キャッシュシステム(Memcached)などがある。

従来のサービスでは開発者が全てのバックエンドサービスを管理していた。いわゆるオンプレミスというやつである。
今でもオンプレミスを使用しているところはあるが、近年ではクラウドが一般的になってきていると言える。
クラウドが有名になってきてはいるが、12の要素ではローカルのオンプレミスとサードパーティのクラウドを区別しない。アプリケーション側から見ればどちらもアタッチされたリソースであり、設定に格納された値を使用してアクセスすることができるからである。

これからのサービスやツールを使用したデプロイはアプリケーションの値を変更することなくローカルの DB とプロダクションの DB を切り替えることができる。いや、切り替えることができるべきである。
どちらも必要なのは上であげた設定の中の値である。

それぞれのバックエンドサービスはリソースである。例えばそこに MySQL サーバがあればそれはリソースである。2つあればそれは2つの異なるリソースということができる。
これはそれぞれのアタッチされたリソースは疎結合であることを表すことができ、バックエンドサービスのリソースは疎結合であるべきだということができる。

ビルド、リリース、実行

コードベースは3つのステージを経てステージング、またはプロダクション環境へデプロイされる。

  • ビルドステージは、コードをビルドと呼ばれる実行可能な状態へと変える変換である
    • デプロイする際のコミットを使用して、そこで指定されているバージョンを使用してバイナリやアセットファイルをコンパイルしてひとまとめにする
  • リリースステージ は、ビルドステージで生成されたビルドを受け取り、それをデプロイの現在の設定と結合する
    • 出来上がる リリースにはビルドと設定の両方が含まれる
    • 実行環境の中ですぐにでも実行できるよう準備が整う
  • 実行ステージ (ランタイムとも呼ばれる)は、選択されたリリースに対して、アプリケーションのいくつかのプロセスを起動することで、アプリケーションを実行環境の中で実行する

といった段階がある。これらのステージは厳密に分離する必要がある。
例えば、実行ステージにあるコードを変更してもその変更をビルドステージに伝える方法がないため、コードを実行中に変更することはあり得ない。

デプロイツールは通常、リリース管理ツールを提供する。中でも注目すべきは、以前のリリースにロールバックする機能である。(リバート大臣なので悲しくなってきた)

すべてのリリースは常に一意のリリースIDを持つべきである。リリースIDの例としては、リリースのタイムスタンプ(2021-05-09-10:02:17)や連番(v100)などがある。
リリースは履歴を残したりバグを発見するために非常に重要である。そのため一度作られたリリースは変更することができない。変更する場合は新しいリリースを作らなければならない。
リリースには常に固有のIDが振られていて、それを使用して遡ることが可能である。

プロセス

アプリケーションは実行環境の中で1つ以上のプロセスとして実行される。
最も単純な例だとコードは単体のスクリプトであり、実行環境は言語ランタイムがインストールされた開発者のローカルのラップトップであり、プロセスはコマンドラインから実行される。

// index.js
// コードは単体のスクリプト
const name = 'takurinton';
console.log(`hi, i am ${name}`);
1
2
3
4
// プロセスの実行
node index.js
1
2

のようなイメージである。
これは最も簡単な例なので実際は複雑に絡み合うアプリケーションを想定している。

また、12の要素からなるアプリケーションのプロセスはステートレスかつシェアードナッシングである。
永続化する必要のあるすべてのデータは、ステートフルなバックエンドサービス(典型的にはデータベース)に格納しなければならない。(それはそう感が)

プロセスのメモリ空間やファイルシステムは、短い単一のトランザクション内でのキャッシュとして利用してもよい。例えば、大きなファイルをダウンロードし、そのファイルを処理し、結果をデータベースに格納するという一連の処理において、ファイルシステムをキャッシュとして利用できる。
しかし、キャッシュに頼り切ることはしてはいけない。メモリやディスクにキャッシュされたものが将来のリクエストやジョブにおいて利用されることを決して仮定しない。
注意点として、プロセスが再起動すると、すべての局所的な状態(メモリやファイルシステムなど)が消えてしまうことがある。プロセスを再起動する際は注意しなければならない(デプロイや設定の変更など)

Webシステムの中には、スティッキーセッションに頼るものがあるが、キャッシュに頼り切ることはしてはいけないのでこれを利用するよりはセッション状態のデータの格納先は有効期限を持つデータストアに格納するべきである。

ポートバインディング

アプリケーションはサーバーコンテナの内部で実行されることがある。例えば、PHP アプリケーションは Apache の内部のモジュールとして実行されるかもしれないし、Java アプリケーションは Tomcat の内部で実行されるかもしれない。
12の要素のアプリケーションは完全に自己完結している。
Web アプリケーションは ポートにバインドすることで HTTP をサービスとして公開し、そのポートにリクエストが来るのを待つ。

ローカルの開発環境では、開発者はローカルの開発環境にアクセスするために http://localhost:5000/ のようなURLにアクセスする。
プロダクション環境ではルーティング層が、外向きのホスト名からポートにバインドされたプロセスへとリクエストをルーティングする。

これは一般に依存関係宣言を使ってサーバーライブラリをアプリケーションに追加することで実装される。
サーバーライブラリは Python でいう Tornado、JVM ベースの言語でいう Jettyなどがある。
これはアプリケーションのコード内で完結する。リクエストを処理するための実行環境を認識させるためにポートをバインドする。

ポートをバインドするのは HTTP だけではなく、MySQL の 3306 やその他もろもろがある。

並行性

すべてのコンピュータープログラムは一度実行されると1つ以上のプロセスとして表される。
アプリケーションでは様々なプロセス実行形態がとられてきた。例えば、PHP のプロセスは Apache の子プロセスとして実行され(知らなかった)、リクエスト量に応じて起動される。
Java プロセスは PHP のプロセスとは逆で、JVMが1つの巨大な親プロセスを提供し、起動時にシステムリソース(CPUやメモリ)の大きなブロックを確保し、スレッドを使って内部的に並行性を管理する。(へーーーーー!!!)
しかし、どちらの場合でも、実行されるプロセスを開発者が意識することはほとんどない。

12の要素を兼ね備えたアプリケーションでプロセスは第一級市民である。
このプロセスの考え方は、サービスのデーモンを実行するための UNIX プロセスモデルから大きなヒントを得ている。
このモデルを使い、個々のワークロードの種類をプロセスタイプに割り当てることで、開発者はアプリケーションが多様なワークロードを扱えるように設計することができる。
例えば、HTTP リクエストを受ける部分は Web プロセスによって処理し、実行に時間のかかるバックグラウンドタスクはワーカープロセスによって処理することができる。
このモデルは非同期の処理を否定するわけではない、それぞれの層の処理ごとに分割することができるからただスケーリングしやすいよみたいな話である。

廃棄容易性

12の要素を持っているアプリケーションは廃棄容易性に優れている。つまり即座に起動して終了することができる。
この性質が、素早く柔軟なスケールと、コードや設定に対する変更の素早いデプロイを可能にし、プロダクション環境へのデプロイの堅牢性を高める。

プロセスは、 起動時間を最小化するよう努力するべきである。
理想的には、1つのプロセスは起動コマンドが実行されてから数秒間でリクエストやジョブを受け取れるようになるべきである。
起動時間が短いと、リリース作業やスケールアップのアジリティが高くなる。さらに、プロセスマネージャーが必要に応じてプロセスを新しい物理マシンに簡単に移動できるようになるため、堅牢性も高くなる。

プロセスは、プロセスマネージャーから SIGTERM シグナル(終了のシグナルだがプロセスはこれを受信後も動くことはできる)を受け取ったときにグレースフルにシャットダウンする。
Web プロセスの場合、グレースフルシャットダウンは、サービスポートのリッスンを中止し、処理中のリクエストが終了するまで待ち、シャットダウンすることで実現される。
ワーカープロセスの場合、グレースフルシャットダウンは、処理中のジョブをワーカーキューに戻すことで実現される。

また、下層のハードウェアの障害に関して言えば、プロセスは 突然の死に対して堅牢 であるべきである。
このような事態が起こることは、SIGTERM によるグレースフルシャットダウンに比べればずっと少ないが、起こる可能性は充分にある。
ここの対策は クラッシュオンリー設計 などが参考になりそう。

デベロップ/プロダクションの環境の一致

デベロップ環境とプロダクション環境は一致しておいた方がいい。
古き良きサーバでは以下の点で非常に大きなギャップがあった。

  • 時間のギャップ
    • プロダクション環境にコードが反映されるまで数日、数週間、時には数ヶ月かかることがある(は?)
  • 人材のギャップ
    • 開発者が書いたコードをインフラエンジニアがデプロイする
  • ツールのギャップ
    • プロダクション環境へのデプロイでは Apache、MySQL、Linux を使うのに、開発者が Nginx、SQLite、MacOS のようなスタックを使うことがある(これは最近だと Docker やリモート環境でなんとかなってる)

12の要素を持ったアプリケーションではデベロップ環境とプロダクション環境のギャップを小さく保つことが重要である。
そのために最近では github と連携して push したら自動でテストを回してデプロイする仕組みの構築が進んでいる。これを CI/CD(Continuous Integration/Continuous Delivery)と呼ぶ。
参考にした記事ではここを「数時間程度でデプロイをしてデプロイ作業は同じ人間が行う」としていたが、僕はここを CI/CD で表現しようと思う。
(なんか安易に CI/CD とか言ってると小並感が出るな)

そして、CI/CD をうまく回すためにデベロップ環境とプロダクション環境の差異をなるべく減らすことが重要になる。
上で挙げた「設定」の部分は個々の環境による、それ以外の部分はバックエンドのオンプレミス・サードパーティの部分まで含めて一致するべきである。(リソースは違うが使ってるものが同じなら良い)

ログ

ログは実行中のアプリケーションの挙動を可視化する。
サーバーベースの環境では、ログは一般的にディスク上のファイル(ログファイル)に書き込まれる。(一例だけど)

ログは、すべての実行中のプロセスとバックエンドサービスの出力ストリームから収集されたイベントが、集約されて時刻順に並べられたストリームである。(アクセスログだったら時刻とかUAとか...etc)
ログには固定の始まりと終わりはなく、アプリケーションが稼動している限り垂れ流しになり続けるべきである。

12の要素のアプリケーションではアプリケーションの出力ストリームの送り先やストレージについて一切関知しない。 アプリケーションはログファイルに書き込んだり管理しようとするべきではない。代わりに、それぞれの実行中のプロセスはイベントストリームを stdout にバッファリングせずに書きだす。ローカルでの開発中、開発者はこのストリームをターミナルのフォアグラウンドで見ることで、アプリケーションの挙動を観察する。

継続的にログを取得することで長期に渡ってアプリケーションの挙動を確認するための大きな力と柔軟性をもたらし、次のようなことができるようになる。

  • 過去の特定のイベントを見つける
  • 大きなスケールの傾向をグラフ化する(1分あたりのリクエスト数など)
  • ユーザー定義のヒューリスティクスに基づいて素早くアラートを出す(1分あたりのエラー数がある閾値を超えた場合にアラートを出すなど)

様々なログを出力することで多くの恩恵を得ることができ、アプリケーションの保守や運用、分析にもつながるため非常に重要である。
個人的に、アドテクなどの分野でのログを見てみたい感じがする(何を取得してるのか気になる)

管理プロセス

プロセスフォーメーションは、アプリケーションが実行されたときにアプリケーションの通常の役割(Webリクエストの処理など)に使われるプロセス群である。
それとは別に、開発者はしばしばアプリケーションのために1回限りの管理・メンテナンス用のタスクを実行したくなる。

例として以下のようなものがある。

  • データベースのマイグレーションを実行する。(Django でいう manage.py migrate、Rails でいう rake db:migrate
  • ちょっと DB 覗きたいときに cli からクライアントに入る
  • アプリケーションのリポジトリにコミットされた1回限りのスクリプトを実行する(php でいう scripts/fix_bad_records.php

1回限りの管理プロセスは、アプリケーションの通常のデーモンプロセスと全く同じ環境で実行されるべきである。
つまり、管理用のコードだろうがアプリケーションのコードと同時にデプロイされるべきである。また、ローカル環境と同じものを使用するべきである。

また、依存関係を解決するためのツールや手段もまた、全てのプロセスタイプに使用するべきである。
例えば Django を使用していて、サーバの起動に python manage.py runserver を使用しているならマイグレーションは python manage.py migrate を使用するべきである。

まとめ

とても勉強になりました。