最近実際に開発現場にコンテナを導入してきた経験から、公式ドキュメントに記載されているベストプラクティスに実際どうなんだということを言ってみようと思います。公式に書いてあることを間違ってると指摘という意図はありません
発言は個人の見解に基づくものであり、所属組織を代表するものではありません。
2023/12/3更新: 燃えかけてるのでタイトルを変えました。
補足: こちらの環境は下記を想定しています。
- Java
- CICD/本番環境イントラネット内に整備
- WF開発
マルチステージ・ビルドを使う
マルチステージビルドの目的
公式ドキュメントには、下記のように記載があります。
マルチステージ・ビルド は、中間レイヤとイメージの数を減らすのに苦労しなくても、最終イメージの容量を大幅に減少できます。
つまり、最終イメージの容量を減らすことが目的であって、その一つの手段としてマルチステージビルドを進めているのかなと思いました。
マルチステージビルドの課題
個人的にマルチステージビルドの課題としては、他にもイメージのサイズを減らす手段があるのにも関わらず、わかりずらいと思います。
例えばマルチステージビルドでJavaのDockerfileを書いてみると下記のようになります。
# syntax=docker/dockerfile:experimental
FROM amazoncorretto:17.0.8 AS build
WORKDIR /workspace/app
COPY . /workspace/app
RUN --mount=type=cache,target=/root/.gradle ./gradlew clean build
RUN mkdir -p build/dependency && (cd build/dependency; jar -xf ../libs/*-SNAPSHOT.jar)
FROM amazoncorretto:17.0.8
VOLUME /tmp
ARG DEPENDENCY=/workspace/app/build/dependency
COPY --from=build ${DEPENDENCY}/BOOT-INF/lib /app/lib
COPY --from=build ${DEPENDENCY}/META-INF /app/META-INF
COPY --from=build ${DEPENDENCY}/BOOT-INF/classes /app
CMD ["java","-cp","app:app/lib/*","com.example.demo.DemoApplication"]
参考: https://spring.pleiades.io/guides/topicals/spring-boot-docker/
確かにマルチステージビルドを利用することで、最終イメージの容量を大幅に減らすことができます。
しかし、最終イメージの容量を減らすことが目的であって、それを実現する手段は他にもあります。
例えば、シンプルにビルドしたjarだけをCOPYするのも一つの戦略です。
FROM amazoncorretto:17.0.8
COPY ./build/libs/app.jar app.jar
ENTRYPOINT ["java","-jar","/app.jar"]
私の環境だとイメージは同じサイズになりました。
REPOSITORY TAG IMAGE ID CREATED SIZE
demo copy 011a5ec77bbd 3 seconds ago 483MB
demo ms-build fc143a5e9e89 29 hours ago 483MB
さらにCIでマルチステージビルドを実装する場合、何も工夫をしないと、下記のような流れになるはずです。
- ビルドステージのベースイメージをPull
- gradleのジョブを実行。その中で各種依存ライブラリをダウンロード
- ビルドステージでできた成果物などを実際のコンテナにコピー。
キャッシュはビルドステージの中のコンテナに溜まっていくため、キャッシュが効かずビルド時間が長くなってしまいます。
一応対応案を記載すると、
- docker buildの
--cache-from
オプションをつけてビルドする - キャッシュ用のイメージを作成する
- キャッシュ用のイメージをリモートレジストリにPushする
この処理を追加してCIでもキャッシュが効くようになります。が、結構構築は面倒になるかなというのが私の所感です。
じゃあどうしているか
個人的にはマルチステージビルドではなく、ビルド用のイメージを作成して、そこでビルドをして、できたものをCOPYをするのが塩梅としてはいいのかなと思います。基本的にCIでの前ジョブで作成されたjarを対象としています
もちろん、ベースイメージを管理するコストは増えますが、アプリケーションが増えるたびにベースイメージの共通化ができるので、むしろ保守コストは落とされるのかなと考えています。
2023/12/3 更新
これはgitlabだったり、codebuildだったりコンテナ環境にてビルドしている環境を想定しています。結局マルチステージビルドとやってることはかわらないですね。(CI定義に押し付けてるか、Dockerfileに押し付けてるかの違い)
ただ、DockerfileのオーナーとCI定義のオーナーは異なるケースがあると思うので、完全に一緒か?というとそうではないのかなとは思います。
また、jibなどのコンテナ作成を簡単にするツールの採用もありでしょう。jibやkoの中ではマルチステージビルドをしているかもしれませんが、そこら辺の処理を隠してくれているため、利用しやすいかなと思います。
正しいベースイメージの選択としてalpine/distrolessを利用する
Dockerの公式ガイドラインでは下記のように書かれています。
(Alpine Linux のような)小さなベースイメージを使う。小さなイメージは配布も簡単。
Alpine Linuxのような
と言う記載なので、使えと書いているわけではないかと思いますが、良く巷ではベースイメージとしては、alpineが利用されていると思っています。
このalpineについては、色々な人が書いているかと思いますが、やめておいた方がいいと言われており、私も同意見です。またalpineの代わりにdistrolessが良いと言う意見もありますが、これは私は避けておいた方がいいかなと思っています。
alpineの課題
alpineの課題については、2つあります。
-
muslの互換性
alpineはmuslを利用しているため、軽いイメージを実現できているのですが、muslはglibcと互換性がなく、一部想定外の挙動をしたり、性能問題が発生したりします。ほとんどのアプリケーションはglibcで作られているため、おかしい挙動にはまる可能性があり、やめといた方が良いと考えています。
参考:
https://medium.com/rocket-travel/alpine-vs-debian-images-for-java-jvm-builds-b8f8e1cc58a8 -
ash問題
基本的にalpineでは、ashが採用されており、bashはインストールされていません。コンテナにshellを入れておいて、(Javaの暖気などの)なんらかのものを動かすケースはあると思います。そこでashだと、bashとは異なり、配列が使えないため、すこし手戻る可能性があるのかなと思います。
distrolessの課題
こんな問題(特に1やセキュリティホールを作らないようにする目的)があって、distrolessイメージが作成されましたが、これも使う分は簡単かと思いますが、運用するにはかなり難易度が高いと思っているので、見極めてから採用する必要があると考えています。
- シェルにログインができない
distrolessのlatestイメージはシェルがなく、外からコンテナを触れません。また基本的にコンテナを採用する場合、開発環境から本番環境まで全ての環境でイメージを同じにすると言うのが、守られるべきと考えています。そのため、そのため本番に合わせることを前提にすると、シェルがないlatestタグのdistrolessを採用するかと思いますが、環境統一のため開発環境もシェルがないイメージになるのかなと思います。開発環境までコンテナの中に入れないのは開発者がなかなか苦労するのではと考えています。
2023/12/3更新
ここで言う開発環境とは、いわゆる連結環境を想定しています。開発者ローカルは、(Javaだったら)ビルドしてできたjarを実行する、のが今までの経験が多かったです。(そもそも全ての開発者がローカルにDockerを入れてるかと言うとそうじゃないんですよね。。。)
では、連結環境でシェルがいるかどうかと言われると、当然チームの成熟度合いにもよるのですが、今の段階ではあった方が良いと考えています。スレッドダンプの取得やその他疎通のための確認などに使うシーンがあるかもと思います。もしshellを入らなくするように変えるという場合は、小さいアプリでシェルなしで運用してみてうまく回ったら徐々に展開していく、みたいな進め方になると思います。人間辞める決断は難しいので。。。
Kubernetes1.25以降の環境ではEphemeral containerなど、デバッグのための機能が追加されていますので、徐々になしでも良くなるのかもしれないですね。
本番環境は設定でログインさせないようにする技もあるよと教えてもらいました。確かにいいなと思いました。
- タグが4種類しかない
distrolessでは、latestとdebugとnonrootとnonroot-debugの4種類のタグしかありません。基本的にlatest or nonrootを使っていくと思います。しかし、githubのdistrolessイメージには頻繁に更新が入っており、いつ使っているイメージに更新が入るかがわからない状態になります。latestの運用はこちらでどの断面のイメージを使うということが制御できないため、なかなか運用に困るのではないかと考えています。
じゃあどうすればいいか
個人的な意見では、下記の優先度でベースイメージを採用するようにしています。
- サポートが入っている場合、サポートから提供されるDockerイメージ
- dockerHub公式のチェックが入っている中で、slimなイメージ
例えば、Javaの場合では、採用するJDKにもよりますが、私であれば下記を使えないかを検討します。
- amazoncoretto:17.0.8
- eclipse-temurin:17.0.8.1_1-jdk-ubi9-minimal
イメージの脆弱性スキャン
Dockerの公式では、下記のように記載されています。
脆弱性検出ツールを使い、イメージのセキュリティ状況の継続的な分析と評価も重要です。
自分も脆弱性検出ツールを使って、イメージのセキュリティ状況を継続的に分析するのは必須だと考えていますが、これはどのようにやるかが難しいです。
実現できるポイントとしては主に、イメージビルド時とレジストリにPushされた後になり、特にイメージ作成時については開発者と合意をとった方が良いと考えています。
両方やれ、と言う意見を良く聞きますが、毎回毎回同じ処理するのは、なんか難しいですよね。
イメージビルド時のスキャンの課題について
CI上でイメージビルドする際に、trivyなどでスキャンを実施するのですが、その処理の分CIが遅くなります。
私の環境では、大体3分ほど追加になりました。
これをアプリチームに見せたところ、パイプラインが遅くなるのは嫌だと言うことを言われています。
もちろん工夫次第でどうとでもなるのですが、ここでは毎コミットごとにスキャンする前提でいます。
ではどうするのがよいか
個人的には、レジストリ側のスキャン機能を使うべきと考えています。AWSのECRではスキャンはできますし、開発の初期段階から開発用のレジストリにPushして、そこで見ればいいと思います。
またいちいちコンソールにアクセスするのが面倒だと言うケースもあって、その場合はスキャン結果をチャットに連携などもでき、CIに問わない方法で開発者体験を損なわずに実現できます。また、チャットサービスに連携するとき、新しい脆弱性が追加されたらみたいな運用を効率化する工夫もできるので、運用をどうするかをイメージして設計してもらえればと思います。
まとめ
今回、Dockerのベストプラクティスにおこがましくも個人的な意見を言ってみましたが、私のこれが正解というふうに思っていません。組織には組織の事情がありますし、プログラミング言語も異なりますし。
大事なのはベストプラクティスで達成したい目的を満たせてるか、です。
ベストプラクティスを守りすぎるにあたって、非常に理解に苦労する仕組みになったり、やりたいことができなくなるくらいなら、チーム内部で検討してそのチームに合ったやり方を合意しましょう