Dockerを利用したアプリケーション構築のためのイメージの役割とビルドについて整理してみた

問題意識と背景

例えば Docker コンテナとして何らかのアプリケーションをホストしていたとして、Docker image 内で base image と呼ばれる部分に更新が入った場合1、原則として

  1. base image を更新する
  2. 更新された base image の上にアプリケーションのコードを展開し、アプリケーション image を構築
  3. 2 で作った image からコンテナをデプロイ

という手順を踏むのがセオリーだと思う。この時、アプリケーションコードに何らかのコンパイルなどの事前準備が必要だとすると、

  • base image 更新後にアプリケーションのコンパイルも必要

ということになる。

例えばやりたいことが OS レベルの軽微なパッチ適用だとしても、上記の行程をすべて経なければ更新が終わらないというのは、ちと高コスト過ぎやしませんかね?

ということでどうにか楽できないかと調べたり試したりしているうちに、結局 Docker に対する理解が深まり、割り切りができたのでそこで整理できたことを残しておくことにした。

コンテナでアプリケーションを動かすとはどういうことか

実は Docker コンテナでアプリケーションを動かす方法は複数あるのだが、Docker コンテナホスティングの説明がよくないのか、アプリケーションコードを含めて image を作って deploy しろと言われることが比較的多いように思う。

しかし実際にはアプリケーションコードは image とは別に用意してコンテナに読み込ませることはできる。開発環境がまさにそうだ。docker compose で特定のバージョンの言語の動作環境やミドルウェアを準備するだけ、という使い方だ。これは比較的簡単に Docker の恩恵に与ることができる2 のだが、production 環境まで繋がる Docker image を作ろうと思うと急に面倒くさくなるのは image の準備の手間の違いに理由があるように思う。

確かに、アプリケーションコード込みで image を用意しなければいけないサービスはある。例えば Heroku などはユーザーに任意に提供できるストレージサービスがないので、アプリケーションコード込みの Docker image を準備してもらう以外に方法がない3。一方で Amazon ECS や Google Cloud Run などは自前のストレージサービスを利用してもらうことができるので、開発環境同様、アプリケーションコードをストレージから読み込むという選択は可能だ。4

ただし、今回はこうしたホスティングサービスごとの違いを扱いたいのではなく、あくまで アプリケーションコード込みの Docker image のメンテナンスって、どう考えたらいいんだろう? をテーマとする。

従来のホスティング環境のアップデート

ここでいったんコンテナ以前のアプリケーション動作環境のアップデートはどういうものだったかをふり返ってみる。

コンテナ以前の伝統的な環境では

  • アプリケーションのコード
  • OS、ミドルウェア

は別々にアップデート対象とすることができた。主に開発者がアプリケーションのコードをケアし、インフラを担当する人がその下のレイヤーのメンテナンスを行う、といった具合だ。

この際、下のレイヤーのアップデートはアプリケーションのコードとは独立して行うことができる。もちろん影響が一切ないということはないので、検証用の環境を用意してそこにアプリケーションのコードを展開して…みたいな準備はあると思うが、実際のOS、ミドルウェアのアップデートは、多くの場合でアプリケーションコードの再デプロイを必要としない

だるま落としでだるまは落ちずに下の部分だけ挿げ替えるような、そんなイメージだ。

自分で生々しく VPS を扱ったり、EC2 のような伝統的なコンピューティングリソースを使っている場合は、OS アップデート用のコマンドを叩いておしまいだし、レンタルサーバのユーザースペースを借りているのであれば、知らない間に OS やミドルウェアにパッチが適用され、アプリケーションコードは自分に割り当てられたディスクスペースに置きっぱなしで問題ないはずである。

※ 上記は複数のサーバを利用してblue/green deployを行うようなイメージではない。blue/green deploy, disposable infrastructureの世界観ではOS、ミドルウェアの「アップデート」という考え方はしない。

コンテナ後の“アップデート”

上記の図ではイメージの中の OS、ミドルウェアとアプリケーションはひとまとめにして表現したが、実際には下図のように

OS の上にアプリケーションコードとミドルウェアを “含む あるいは 載せた” image を作る必要があるので、例えば OS のアップデートがあった場合は含むもの、載せるものも詰め直して image を作り直す必要がある。一言で言うと

アップデートなんてものはない

ということだ。あるのは最初からアプリケーションの実行環境をビルドし直して、デプロイし直すことだけだった。

登場人物(コンテナimage)と手順(phase)を整理する

  • アップデートしたいのはアプリケーションを実行する image であり、これを app image と呼ぶ
  • そのためには実行環境 image(同様に runner image と呼ぶ) + アプリケーションコードが必要
  • runner image のアップデート(rebuild)も必要
  • アプリケーションコードにまったく変化がない場合はどこかに保存したコンパイル済みのアプリケーションを利用してもよいが、そうでない場合はアプリケーションのコンパイル環境の image(builder image と呼ぶ)も必要

ということで図にしてみると以下のような感じになる。

stepcommandimage rolephase
1docker buildbuilderbuild builder image
2docker runbuildercompile application
3docker buildrunnerbuild runner image
4docker buildappbuild app image

例えば現代的なフロントエンドを備えた Ruby on Rails 環境で考えると、

1 の builder image は

  • その構築のためには native extension をコンパイルするためのコンパイラ環境と Ruby, Node.js を mix した環境が必要
  • Gemfile, Gemfile.lock, package.json, npm の lock ファイルをもとに必要な依存パッケージをインストール
  • ただし、extension のインストール終了後にはコンパイラ環境は不要なので Docker の multi-stage build を利用して最終的には言語ランタイムと必要な依存パッケージのみの image にする(でないと単純に重い)

※ Docker image は最小で安全にすべし、みたいな知識だけがあって躊躇している人は躊躇してはいけない。必要なものは必要だし、この builder image はインターネットに晒されることはない。

そしてこの builder image を利用した 2 のコンパイルは具体的には以下の意味になる。

  • bundle exec rails assets:precompile を実行
  • Rails の管理下にすべてのコードが収まった実行環境ができあがる

このタイミングで Node.js も node_modules/ 以下のパッケージもコンパイル前の JavaScipt も CSS も不要になっているので、3 の runner image には Ruby の実行環境と一部 extension 用のミドルウェア(例えば DBMS へアクセスするためのライブラリ)だけがインストールされていればよい。5

最後はこの runner image にコンパイル済みの source’ をコピーしてあげれば app image のできあがりとなる。

もし Procfile に慣れているなら、同じコードベースで例えば Web アクセスを受け付けるプロセスとバックグラウンドジョブのための Worker プロセスといった異なるプログラムを起動する使い方をしたいかもしれない。これも builder image のところで活用した multi-stage build で CMD や ENTRYPOINT を書き換えるだけで実現できる。

※ このように multi-stage build に複数の意味があるのもまた docker build が妙に難しくなってよくないところだと思う

肝心のapp imageの更新のためには

ようやく本題に戻ってきた。新鮮で安全な app image を維持するために必要なものは以下ということになる。

  • runner image
  • コンパイル済みのアプリケーションコード
    • なければコンパイル前のアプリケーションコード
    • その場合は保存済みの builder image も

アプリケーションコードは何らかのバージョン管理システムに乗っていて、CI/CD も現代的な仕組みを利用していると仮定すると、

  • コンパイル済みのアプリケーションコードが残っているなら runner image の再構築以降
  • builder image さえ保存されていれば、アプリケーションコードのコンパイル以降

の手順だけで app image をフレッシュに保つことができる。

コンパイル済みのアプリケーションコードを保存しておくには container registry 以外の何らかのストレージが必要なので、あえて二つのパターンを明記した。

例えばコンパイル済みのアプリケーションコードを保存する場所はないが、最終的に必要な app image の更新を高頻度に行いたいと考えたら、

  • 1 の builder image を保存しておき、
  • アプリケーションコードを取得し、2, 3, 4 のプロセスだけを実行

することで比較的ローコストに実現することができる。CI/CD が Docker ベースで、独自のコンテナレジストリを用意していてくれるなら、許容できる範囲なのではないかと思う6

実装例

wtnabe/example-node-and-ruby-docker-scripts-and-app: multi language, multi phase, maintenable docker image example

言語runtime、ミドルウェアのアップデートも考慮したい

最初こそアレコレ試行錯誤はするが、Dockerfile の中はベース OS と利用するミドルウェアの構成さえ固まればあまり触る必要がないので、できればそのまま触らずに済ませたい。

そこでできるだけ更新の可能性のあるものは docker build 時に外から --build-arg で与え、Dockerfile 内で ARG で受け取ることにした。そうなるとこの部分も安定させるために sh script 化したい。その際、ARG は記述場所で意味が変わることに注意が必要になる。

Advanced ARG and ENV Dockerfile tricks | by Dubo Dubon Duponey | Medium

おわりに

正直言うと sh script と Dockerfile とコンテナ内の OS のファイルシステムの様子などを行ったり来たりしながら構築しながら、さらに概念を整理していくのはなかなか面倒で、何度も頭が混乱してしまった。だからこそいったん典型的な例を自分なりに構築して残しておこうと考えた。

今回の整理には Cloud Native Buildpacks の考え方をおおいに参考にさせてもらっている。あれと同じ構成ではもちろんない7が、builder image ( CNBP 用語だと build image ) と runner image ( 同 run image ) を明確に線引きできているので、その image の果たさなければならない役割や割り切りをはっきりさせることができた。

What is a builder? · Cloud Native Buildpacks

しかし git push してあとは Heroku に任せておけばよかった10年前に比べて、なんだかずいぶんといろんなものが面倒になってしまった感がある。とは言え、あの頃のように典型的な Web アプリをただ deploy するだけでコトが済むかというと、意外とそうではなくなってきており、バッチジョブはじめ時間の掛かる処理やストレージ容量を気にすることなく長期間運用できる DWH を含めてクラウドで、できるだけ生々しいサーバ管理を行わずに済まそうと思うと、ここは一つの踏ん張りどころかなと感じるとともに、

Ruby のような、実行時に OS に近いレイヤーの環境に依存する言語 runtime は面倒だな

と改めて思ったのもまた事実である。例えばこれがコンパイラ言語で JVM + .jar や Go のスタンドアロンバイナリのようなものであれば、少なくとも runner image、app image の更新は比較的簡単にできそうな印象もある。ただ WASM 以降は Ruby もシンプルなものについてはこれらと同程度とはいかないまでも、ある程度簡単に行うことができそうという予感もある

Wasmで少しだけ手軽にRubyとRubyスクリプトを持ち運ぶ (2024-05-25) | あーありがち

し、今回改めて Docker と image についてあれこれ調べ回っているうちに、高級 sh script のようなものなら distroless image でも十分まかなえる可能性もある

chainguard/ruby - Docker Image | Docker Hub

と感じたので、こうしたものも視野に入れ、個人的にはアプリ寄りの位置をキープしながらできる範囲を狭めすぎないようにやっていきたいと改めて思った。

きれいな部分は書き終えたのでほんとに最後に。

疲れた。Cloud Native Buildpacks もそうだったが、Docker もカバーする領域が絶妙に中途半端で、アプリケーション側の人間からすると正直言うと面倒が増えてしまっている部分はおおいにあると思う。単にサーバプロセスが動けばよいなら公式配布の image を pull しておしまいなのは確かにそうで、ツールとしての Docker のチュートリアルはその程度で済むかもしれないが現実はそんなことはなく、例えば image の更新チェックみたいな Docker がサポートしない部分、Cloud Native Buildpacks がサポートしない部分、CI/CD がサポートしない部分、コンテナホスティングサービスがサポートしない部分があり、そこの隙間を縫い合わせていく作業が必要で、コンテナのおかげでフルマネージドで従量課金なコンピューティングリソースが手軽に使えるようになったのは助かるが、本当に安心して存分に活用していこうとするとなかなかに面倒な縫い合わせ作業が必要だなと改めて思った。今後は今回自分で作ったものをテンプレートにしていけばなんとかなるとは思うので、まとめきれて本当によかったと思う。

本当はコンテナの更新チェックくらいは自動化したかったのだが、だいぶやっかいそうなので、方針転換して素朴にぶん回す際にどこをぶん回せばよいのかの整理だけで止まらざるを得なかったのはけっこうくやしいところではある。

  1. OSや言語にセキュリティパッチが配布された場合が該当 

  2. とは言えコンテナの中と外の両方を考えなければいけないので、一つハマると途端に知らなければいけないことが増えるつらさはある 

  3. Heroku 自身が git deploy の方を推している 

  4. 2024-06 時点で Cloud Run は物理的なブロックデバイスを mount する方法はなさそうなのと、シンプルに image の中から直接メモリで展開してしまう(ファイルシステムは実際にはメモリ上の仮想ファイルシステムなので)方が起動は速いし、ストレージから読み込む方が有利だよ、という話をしたいわけではない。 

  5. よく軽量化や attack surface が話題になるのはここ 

  6. それぞれ計測してみれば分かるが、よほど大きなアセットを扱っていない限りは builder image の構築にいちばん時間が掛かる。 

  7. buildpack や lifecycle に該当する部分はざっくり sh script と Dockerfile に置き換わっていると考えてよい。Cloud Native Buildpacks は典型的で単純な構成の Web アプリならよいが、少しでもミドルウェアに工夫が必要(例えば Redis 以外の key-value ストアとか)な場合、けっきょく Dockerfile を CNBP 流儀で書かなければいけないシーンが出てくるので、かえって面倒が増える場合もあると今は思っている。 

More