<-- mermaid -->

ジュニアエンジニアを脱却するための「コンテナ流儀」

こんにちは。ソーシャル経済メディア「NewsPicks」で検索システムを開発しております崔(ちぇ)です。 この記事は、 NewsPicks Advent Calendar 2023 の23日目の記事になります。

qiita.com

昨日ははぐっさんによる「SwiftUIのKeyframeAnimatorでちょっとしたカードアニメーション 〜猫の手を添えて〜」でした!

はじめに

今年の下半期からAmazon Elastic Compute Cloud(以下、EC2)の検索サーバーをAmazon Elastic Container Service(以下、ECS)に移行するプロジェクトを進めております。

ECSとは、AWSが提供するコンテナを管理するフルマネージドサービスです。コンテナ化されたサービスを容易にデプロイ、管理、スケールできます。

「コンテナ」はコンテナ型仮想化の技術を用いて構築された名前空間です。コンテナ仮想環境を提供する代表的なプラットフォームがDockerです。

FYI:仮想化方式

  • 仮想マシン(VM):ハイパーバイザー(ソフトウェア)がVMを作り、物理サーバーのコンピューティング・リソース(CPU、メモリなど)を割り当て、OSをインストールします。各VMは独立した実行環境を持つため、あるVMのOSがクラッシュしても他は影響を受けません。一つのVM内で複数のOSが利用でき、リソース活用が効率的です。ただ、VMが多いほどパフォーマンスが損なわれたり、ハードウェアのスペックが高い必要があるなどのデメリットがあります。
  • コンテナ:重い仮想環境を改善するためプロセスを隔離する技術が開発されました。実行環境は独立しておらず、ホストOSのカーネルを共有します。プロセッサやメモリの消費が少なく、ディスクをあまり使用しません。多くのコンテナが起動していもパフォーマンスに影響しません。ただ、一つのカーネルを共有しているため、ある一つのコンテナが攻撃されたら全てに影響するのでセキュリティ的にVMより脆弱だと言えます。

このDocker、ドキュメントさえ読めばDockerfileで簡単にコンテナが作れます。さらに、ベストプラクティスも紹介しているので、それ通りにすれば理想的なものが作れるはずです。が、どうしてそうすべきか、分からなくてもやもやしていませんか?

私はそうでした。ドキュメントは膨大で、理想的なコンテナの作り方やその背景を調べたくても何で検索すればいいかわからない。でも今すぐ理解できなくたって開発はできる。それでもやもやを無視していました。

そんな中、Dockerfileでイメージを作るPRを出したら、ちゃんと理解していなかったせいか、シニアエンジニアの方々に、「コンテナ流儀」に従ってコンテナを構築してほしい!とコメントをいただきました。コメントの意味を調べるうちにやっといろいろ分かるようになりました。

今日はその内容をもとに、ジュニアエンジニアを脱却することのできる「コンテナ流儀」について解いていこうと思います。もしもやもやしているのなら、ぜひ読んでいただけると嬉しいです!

コンテナ流儀: 必要最低限のものだけで運用する

先に結論を書きますが、「コンテナ流儀に従う」というのは「必要最低限のものだけで運用する」ことを意味します。

コンテナ仮想環境は、他の仮想環境より軽く早いのが特徴です。その良さを活かすには、ちゃんと必要最低限のリソースだけで軽いコンテナを構築する必要があります。

特に、コンテナは増えてもお互いの性能に干渉しないため、実行すべきプロセスが多い場合でも、最小単位のコンテナを複数個たてるのが理想的です。

Point1)レイヤーは少ないほどいい

Dockerは一つのイメージを一つのスナップショットで作成するのではなく、レイヤー(変更の差分)を積み重ねた多層のスナップショットで作成します。FROM LABEL COPY RUN CMD などのファイルシステムを変更する処理がレイヤーを生成します。

Dockerのドキュメントでは、できる限りレイヤーを小さくし必要最低限のものだけ残すことで、最終的なイメージが重くならないようにすることを推奨しています。


Minimize the number of layers

Keeping your layers small is a good first step, and the logical next step is to reduce the number of layers that you have. Fewer layers mean that you have less to rebuild, when something in your Dockerfile changes, so your build will complete faster.


Dockerfileで作るコンテナは、イメージのレイヤーを最小限にすることでビルド時間を短縮することができます。レイヤーが最小限になれば、コンテナ作成時のpull & pushの回数を減らすことができるからです。

仮に、以下のような仕組みで起動するアプリケーションが複数存在し、それらをコンテナ化するとしましょう。

  • アプリケーションはJava系の言語で実装されており、Gradleなどでビルドする
  • 作られたJARファイルと起動用のスクリプト、環境変数を渡すためのスクリプトがあり、ZIPファイルにして専用のサーバーに転送する
  • 専用のサーバーはAmazon Linuxを使用する
  • 専用のサーバーに転送されたZIPファイルを解凍し、起動用スクリプトを実行するとアプリケーションが動く

皆さんはどのようにイメージを定義しますか?

愚直に上記の説明通りアプリケーションが動く専用のサーバーを定義すると、以下のようなDockerfileになると思います。

FROM public.ecr.aws/amazoncorretto/amazoncorretto:21  # レイヤー

RUN yum -y update && \  # レイヤー
    yum -y install unzip, ...

ARG APP_NAME

RUN mkdir work_dir  # レイヤー
COPY ./${APP_NAME} work_dir  # レイヤー
WORKDIR work_dir

RUN unzip ./some-app-${app-name}.zip  # レイヤー
RUN source ./env.sh  # レイヤー
CMD ./start-api.sh ${app_name}  # レイヤー

しかし、これだとアプリケーションが動くには不要なZIPファイルが残り続けたりするので、ディスクを無駄に使ってしまいます。また、もう少し工夫すれば、レイヤーも減らせそうです。

つまり、Dockerfileで定義したイメージで起動したコンテナが専用サーバーになるので、起動に必要なものだけを入れればいいのです。そして、Dockerのドキュメント に書いてあるように、実行する必要があるプロセスを一つのスクリプトにまとめるといいです。


Use a wrapper script

Put all of your commands in a wrapper script, complete with testing and debugging information. Run the wrapper script as your CMD.


コンテナは COPY でローカルのディレクトリを持ってこれるので、そもそもZIPファイルを作る必要がありません。なので解凍処理を省略し、環境変数を設定する部分を start-api.sh に入れました。上記のDockerfileと比べたら、レイヤーが減ったことがわかります。

FROM public.ecr.aws/amazoncorretto/amazoncorretto:21  # レイヤー

RUN yum -y update && \  # レイヤー
    yum -y install ...

ARG APP_NAME

RUN mkdir work_dir  # レイヤー
COPY ./${APP_NAME} work_dir  # レイヤー
WORKDIR work_dir

CMD ./start-api.sh ${APP_NAME}  # レイヤー

FYI

Dockerイメージは、アプリケーションを正常に動作させるために必要なリソースと、環境変数の設定などを集めてパッケージにしたものです。必要最低限のものだけでパッケージを作れば、どのインフラ上でも動けるようになるので嬉しいです。

TIP:ベースイメージを作る

全てのアプリケーションを同じ仕組みで起動するなら、ベースイメージを作り共通化することができます。このケースだと、以下の処理が共通化できるでしょう。

  • Docker HubからAmazon Linuxのイメージをpullする
  • 必要なパッケージをインストールする
  • 作業用のディレクトリを作る

ベースイメージを用いて共通化することで、各アプリケーションのレイヤーがさらに減り、ビルドの速度が向上します。

さらに、Docker Hubからイメージをpullする処理がベースイメージに一元化されれば、気づいたら一部だけ別のバージョンのものを使ってた!みたいなバグは発生しなくなります。

# ベースイメージ
FROM public.ecr.aws/amazoncorretto/amazoncorretto:21  # レイヤー

RUN yum -y update && \  # レイヤー
    yum -y install ...

RUN mkdir work_dir  # レイヤー

これで各アプリケーションのイメージのレイヤーが3つに減りました!

FROM base-image:latest  # ECRなどからpull  # レイヤー

WORKDIR work_dir

ARG APP_NAME

COPY ./${APP_NAME} work_dir  # レイヤー
CMD ./start-api.sh ${APP_NAME}  # レイヤー

Point2)不要なパッケージをインストールしない

コンテナが重いと、無駄にビルド時間が伸びたりパフォーマンスが低下します。

コンテナを軽くするためには、そもそも不要なパッケージをインストールしないようにしましょう。この点は、Dockerのドキュメントにも記載されています。


Don’t install unnecessary packages

Avoid installing extra or unnecessary packages just because they might be nice to have. For example, you don’t need to include a text editor in a database image.

When you avoid installing extra or unnecessary packages, your images will have reduced complexity, reduced dependencies, reduced file sizes, and reduced build times.


今まで作ってきたDockerfileだと、レイヤーが3つになり十分必要最低限のものだけが揃っているのでは、思っていませんか?

インストール後のお掃除が残っていますよ。パッケージをインストールすると、キャッシュが残り想定より重くなりかねますので、お掃除コマンドでキャッシュを整理しましょう。Amazon Correttoだと yum clean all が使えます。

# ベースイメージ
FROM public.ecr.aws/amazoncorretto/amazoncorretto:21

RUN yum -y update && \
    yum -y install ... && \
    yum clean all

RUN mkdir work_dir

さらに、コンテナに入っているものが少なければ少ないほど、外部から攻撃されにくくなるため、セキュリティの強化につながります!

Point3)いつ再起動してもいいコンテナを作る

今まで紹介したポイントは、コンテナを軽くしビルド時間を短縮したりパフォーマンスの向上を図るためのものでした。

ここからは、コンテナを運用するにあたり、メンテナンスの容易性の観点からご説明します。

Dockerのドキュメントでは、Dockerfileで構築するコンテナを可能な限りエフェメラルに作るべきだと記しています。エフェメラルとは、いつでもコンテナを停止したり落とすことができ、最低限のセットアップで直ちに再構築できる状態のことです。


Create ephemeral containers

The image defined by your Dockerfile should generate containers that are as ephemeral as possible. Ephemeral means that the container can be stopped and destroyed, then rebuilt and replaced with an absolute minimum set up and configuration.


例えば、コンテナ化するアプリケーションの一部は、お互いが依存するなどして一緒に起動させる必要があるかもしれません。「だから、同じコンテナに入れて一緒に実行させよう」と思ったなら、要注目です!

例えば、一つのコンテナでウェブサービスとデータベースを起動させたとしましょう。

ウェブサービスが何らかのエラーにより落ちた場合、コンテナは停止されると思いますか?答えはNOです。データベースがまだ起動中なのでコンテナは実行し続けます。

コンテナでは CMD で指定されたメインプロセスと、メインプロセスから起動された子プロセスが実行されます。コンテナは子プロセスをまとめて管理します。あるコンテナで実行中のメインプロセスがエラーなどで停止すると子プロセスも一緒にkillされて停止します。そしてメインプロセスが停止するとそのコンテナも停止されます。

ただ、片方の子プロセスだけが異常終了したときにもう片方の子プロセスが生き残ってしまうため、このような場合はコンテナを分けましょう。

FYI

ECSを使う場合は、desiredCount を設定しておくことで常に決まった数のタスクを維持しようとします。タスクが突然停止されてもすぐに新しいタスクを起動してくれます。 サービスとタスク定義を更新すれば、タスクが新しいバージョンで再起動されるので、(stop-api.shといったスクリプトなどで)アプリケーションを停止する必要はありません。

Point4)独立したアプリケーションにする

Point3と同じく一つのコンテナの中でウェブサービスとデータベースが起動していると仮定します。

ウェブサービスが必要とするコンピューティング・リソースとデータベースが必要とするものとでは異なるはずです。二つのプロセスが同じコンテナで動いていたら、ウェブサービスだけをスケールイン若しくはスケールアウトすることができなくなります。そして、一つのコンテナを起動するるのに二つのプロセスを考慮した設定を考えなければなりません。

FYI

クラウドサーバーの時代から、起動している時間に対して課金される仕組みになりました。ずっと実行されないと困るアプリケーションと、そうでないアプリケーションを同じコンテナに入れてしまっては、思ったよりコストがかかる恐れがあります。

つまり、一つのコンテナには一つのメインプロセスだけを実行させた方が、拡張性や再利用性の面で優れていると言えます。


Decouple applications

Each container should have only one concern. Decoupling applications into multiple containers makes it easier to scale horizontally and reuse containers. For instance, a web application stack might consist of three separate containers, each with its own unique image, to manage the web application, database, and an in-memory cache in a decoupled manner.

Limiting each container to one process is a good rule of thumb, but it’s not a hard and fast rule.

<中略>

Use your best judgment to keep containers as clean and modular as possible.


特にDockerは、一つのコンテナに専用のファイルシステム、ネットワーク、プロセス構造などを割り当てます。なので、一つのメインプロセスだけを実行させた方が、その恩恵を受けられます。

FYI

複数のコンテナが通信したい場合は、TCPのアドレスとポートを利用します。


When an operator executes docker run, the container process that runs is isolated in that it has its own file system, its own networking, and its own isolated process tree separate from the host.

<中略>

It’s ok to have multiple processes, but to get the most benefit out of Docker, avoid one container being responsible for multiple aspects of your overall application. You can connect multiple containers using user-defined networks and shared volumes.


TIP:複数のプロセスを実行したい場合もある

もちろん、ユースケースによっては複数のプロセスを一つのコンテナで動かす必要があるかもしれません。Dockerのドキュメントには、そのようなケースでは以下の方法を使うことを推奨しています。

  • 複数のプロセスをまとめた一つのスクリプトを ENTRYPOINTCMD で実行する
  • set -m などでBashのジョブコントロール機能を使う
  • supervisord などでプロセスマネジャー機能を使う

一つのスクリプトにまとめた場合、スクリプトが終了したらコンテナで実行中のメインプロセスが存在しないと判断され、コンテナが停止されてしまうので要注意です。

TIP:環境変数を積極的に使う

2012年に、モダンなウェブアプリケーションのあるべき姿としてThe Twelve-Factor Appと呼ばれる方法論が提唱されました。モダンでスケーラブル、メンテナンス性が高いSoftware as a Serviceを作るためのものです。

クラウドネイティブアプリケーションの開発においても、この方法論がよく用いられており、コンテナにも同じく適用されます。AWSのブログでもECSのFargate起動タイプを使用してモダンなアプリケーションを作ることができると紹介しています。

12通りの方法の中でも、「Ⅲ. 設定」によると、「アプリケーションの設定は、デプロイの間で異なり得る唯一のものである」と記しています。ここでいう「設定」は、以下を指します。

  • データベースやバックエンドサービスなどのリソースへのハンドル
  • 外部サービスの認証情報
  • デプロイされたホストの正規化されたホスト名など、デプロイごとの値

コードに設定値が混在している場合、デプロイごとに簡単に変更することが難しく、誤った情報がリポジトリに混入される恐れがあります。なので、コンテナでプロセスを動かすための設定情報は、環境変数として渡せるような設計にすることが大事です。

FYI

ECSのFargateは、プラットフォームバージョンが1.3.0以上であれば、タスクの環境変数としてAWS Secrets Managerのシークレットを挿入してくれます。 大事な認証情報はシークレットとして保管しておき、タスク定義を登録する際にシークレット名だけを渡しましょう。それで、設定ファイルに認証情報をハードコードせずに済みます。

Point5)フォアグラウンドで実行する

物理サーバーでは、複数のプロセスを同時に実行させるため、バックグラウンドで実行する必要がありました。例えば、LinuxやJavaのAPIにはそのための概念やツールが未だ残っています。

一方で、コンテナは一つのメインプロセスだけを実行させるので、あえてバックグラウンドで実行する必要がありません。つまり、コンテナではプロセスをフォアグラウンドで実行しましょう。

FYI

  • バックグラウンド実行:同時に複数のプロセスを実行する際に用いられます。シェルはバックグラウンドプロセスをジョブ単位で管理しますが、ジョブ同士が邪魔しないようにログはファイルに書き出します。ジョブを実行したターミナルを終了したり、ユーザがログアウトするとジョブも一緒に終了されてしまいます。nohup {コマンド} & で実行すれば、ログアウト後も引き続きバックグラウンドでジョブを動かすことができます。
  • フォアグラウンド実行:対話式にジョブを遂行するので同時実行はできません。実行した結果を画面に出力するので、そのプロセスが終了するまでシェルは開いたままです。

フォアグラウンドで実行するプロセスなら、あえてログファイルにログを出力する必要はなく、標準出力で十分です。また、Point3で述べたように、コンテナはいつでも停止できる状態であってほしいのです。基本的にログファイルにはログが書き込めないものと思ってください。

FYI

ECSでコンテナを起動する場合、標準出力へのログはAmazon CloudWatch LogsのLogGroupに書き込まれます。コンテナ毎にLogGroupが分かれているのでログの確認がしやすいです。

終わりに

まとめ

本記事では、「コンテナ流儀」に従い理想的なコンテナ環境を構築する方法について述べました。

最も大事にすべきは、「必要最低限のものだけで運用する」ことであり、大きく2つの観点があります。

  • コンテナを軽くすることでビルド高速化とともに、パフォーマンスを向上させる
  • 拡張性と再利用性の優れたアプリケーションを作る

まず、以下を守ることでビルド時間の短縮、パフォーマンス向上、セキュリティ強化を狙いましょう。

  • ベースイメージなどを用いて、レイヤーをできるだけ減らす
  • 必要最低限のパッケージだけをインストールする

次に、以下を守ることで高い拡張性と再利用性を保つ、メンテナンスしやすいシステムを作りましょう。

  • いつ再起動しても問題のないように、一つのコンテナでは独立した一つのメインプロセスだけを実行する
    • 複数のプロセスを実行する必要がある場合は、それらをまとめるなどメインプロセスを一つにできる方法を工夫する
    • 環境変数を積極的に利用し独立性を強める
  • メインプロセスはフォアグラウンドで実行し、ログは標準出力へ出力する

感想

今年は主に検索システムの基盤改善をしました。

上半期はElasticsearchからAmazon OpenSearch Serviceに移行しマネージド化を行いました。下半期はEC2で動くサービスをECSに移行しました。

私が新卒入社した頃には、すでに検索システムは完成されており、機能として成り立っていました。なので、冒頭にもお話しした通り、ウェブサービスの造りを完璧に把握していなくても改善系の開発はこなせるので、特に調べることなくお仕事をしてきました。怠けたのもあるとは思いますが、ちゃんとそれが理解できるほど経験を積んでいなかったのもあると思います。

今年に入り、ハマりまくりながらタスクを進めてきました。ただ、やはりその分、勉強になることは多く、参考がてら社内の別システムの造りをちゃんと調べたりして、今年も確かに成長したという実感がすごく湧きます。

NewsPicksの検索周りは、やりたいことがまだまだたくさん残っております。来年からも更なるチャレンジをしてまいりますので、その際はまた読んでください!

良いお年を!

FYI

OpenSearchへ移行した話は以下の記事からご確認いただけます。ぜひ一緒に読んでいただけると幸いです。

tech.uzabase.com

告知

NewsPicks ではエンジニアを募集中です!ご興味のある方はこちらまで。

hrmos.co

今回の記事がおもしろいと思ったら NewsPicks アドベントカレンダーの他の記事も見てみてくださいね。 明日はつづくさんが書いてくれます。お楽しみに!

Page top