Containers & Kubernetes

コンテナ化のメソッドの比較: Buildpacks、Jib、Dockerfile

※この投稿は米国時間 2020 年 10 月 29 日に、Google Cloud blog に投稿されたものの抄訳です。

デベロッパーとしてはソースコードを使用しますが、本番環境システムではソースを実行せず、実行可能なものが必要となります。かなり以前から、ほとんどの企業は Java EE(別名 J2EE)を使用しており、本番環境にデプロイする実行可能な「もの」は「.jar」、「.war」、「.ear」ファイルでした。これらのファイルはコンパイルされた Java クラスから構成されており、JVM 上で実行される「コンテナ」の内部で実行されていました。使用しているクラスファイルが JVM およびコンテナと互換性がある限りアプリは機能し、管理は不要です。

JVM ではない Ruby、Python、NodeJS、Go などが構築で使用され始めるまで、これらはすべてまったく問題なく機能していました。現在では、アプリを本番環境システムで稼働させるには、別の方法でアプリをパッケージすることが必要となっています。そのためには、どのようなアプリでも稼働させることができるある種の仮想化レイヤが必要になりました。Heroku は、これに取り組んだ最初の企業の一つで、Linux コンテナの略である「lxc」と呼ばれる Linux 仮想化システムを使用しました。lxc 上での「コンテナ」の実行は、「コンテナ」を引き続きソースコードから作成する必要があったため、難題となっていました。そこで、Heroku では、「Buildpacks」と呼ばれるものを考案して、ソースをコンテナへと変換する標準的な方法を作成しました。

その少し後には、dotCloud という Heroku の競合企業が、同様の問題に別の方向から取り組んでおり、最終的に Docker を生み出しました。Docker は、Windows、Mac、Linux、Kubernetes、Google Cloud Run などの複数のプラットフォームにわたってコンテナを作成、実行できる標準的なメソッドです。最終的に、Docker で用いられたコンテナ仕様は、Open Container Initiative(OCI)での標準となり、仮想化レイヤは、lxc から runc(OCI のプロジェクトの一つ)に切り替えられました。

Docker コンテナを構築する従来の方法は Docker ツールに組み込まれ、通常は Dockerfile というファイル内にある特殊な命令セットを使用して、ソースコードをコンパイルし、コンテナ イメージの「レイヤ」をアセンブルします。

「コンテナ」には多くの異なった種類があり、それらのコンテナ内での実行方法も多様なため、とても複雑になっています。また、コンテナに内で実行されるものを作成する方法も多数あります。これらすべてを以下の 3 つの部分に分類するうえでは経緯を振り返ることが役立ちます。

  • コンテナ ビルダー - ソースコードをコンテナ イメージに変換します

  • コンテナ イメージ - 「実行可能」なアプリケーションを含むファイルをアーカイブします

  • コンテナ - コンテナ イメージを実行します

Java EE を使用して、これら 3 つのカテゴリは、次のようなテクノロジーにマッピングされます。

  • コンテナ ビルダー == Ant または Maven

  • コンテナ イメージ == .jar、.war、.ear

  • コンテナ == JBoss、WebSphere、WebLogic

Docker や OCI を使用して、これらの 3 つのカテゴリは次のテクノロジーにマッピングされます。

  • コンテナ ビルダー == Dockerfile、Buildpacks、Jib

  • コンテナ イメージ == 通常は直接ではなく「Container Registry」を介して扱われる .tar ファイル

  • コンテナ == Docker、Kubernetes、Cloud Run

Java サンプル アプリケーション

小型の Java サーバー アプリケーション上でのコンテナ ビルダーのオプションについてさらに説明します。続ける場合は、次の comparing-docker-methods project のクローンを作成してください。

  git clone https://github.com/jamesward/comparing-docker-methods.git

cd comparing-docker-methods

このプロジェクトでは、src/main/java/com/google/WebApp.java 内に基本的な Java ウェブサーバーがあり、これは / への GET リクエストで単に「hello, world」で応答します。ソースを以下に示します。

  package com.google;

import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;

public class WebApp {

  public static void main(String[] args) throws IOException {
    int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080"));
    HttpServer server = HttpServer.create(new InetSocketAddress(port), 0);

    server.createContext("/", handler -> {
      byte[] response = "hello, world".getBytes();
      handler.sendResponseHeaders(200, response.length);
      try (OutputStream os = handler.getResponseBody()) {
        os.write(response);
      }
    });

    System.out.println("Listening at http://localhost:" + port);

    server.start();
  }
}

このプロジェクトでは、Java サーバーのコンパイルと実行のために、最小の pom.xml ビルド構成ファイルとともに Maven が使用されます。

  <?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.google</groupId>
  <artifactId>sample-java-mvn</artifactId>
  <packaging>jar</packaging>
  <version>0.1.0-SNAPSHOT</version>

  <properties>
    <maven.compiler.source>8</maven.compiler.source>
    <maven.compiler.target>8</maven.compiler.target>
  </properties>

  <build>
    <plugins>
      <plugin>
        <groupId>org.codehaus.mojo</groupId>
        <artifactId>exec-maven-plugin</artifactId>
        <version>1.6.0</version>
        <executions>
          <execution>
            <goals>
              <goal>java</goal>
            </goals>
          </execution>
        </executions>
        <configuration>
          <mainClass>com.google.WebApp</mainClass>
        </configuration>
      </plugin>

      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.2.0</version>
        <configuration>
          <archive>
            <manifest>
              <mainClass>com.google.WebApp</mainClass>
            </manifest>
          </archive>
        </configuration>
      </plugin>
    </plugins>
  </build>

</project>

これをローカルで実行する場合は、Java 8 がインストールされていることを確認し、プロジェクト ルート ディレクトリから次を実行します。

  ./mvnw compile exec:java

次の URL でアクセスし、サーバーをテストできます。http://localhost:8080

コンテナ ビルダー: Buildpacks

ローカルで実行できるアプリケーションがあるため、これらのコンテナ ビルダーに戻ります。Heroku が Buildpacks を開発して、ソースからコンテナ イメージに移動するための標準的な多言語による方法を作成したことを前に説明しました。Docker コンテナや OCI コンテナが普及し始めたころ、Heroku と Pivotal は共同で、Buildpacks が Docker コンテナや OCI コンテナと連携するようにしました。この取り組みは現在、Cloud Native Computing Foundation のサンドボックス プロジェクト https://buildpacks.io/ となっています。

必要な Buildpacks を使用するには、Dockerそのパックツールをインストールする必要があります。次に、ソースを取得し、コンテナ イメージに変換するようにコマンドラインから Buildpacks に指示します。

  pack build --builder=gcr.io/buildpacks/builder:v1 comparing-docker-methods:buildpacks

すばらしいことに、Buildpacks に Java アプリケーションのコンテナ イメージへの変換をすべてまかせることができました。これは、Go、NodeJS、Python、.Net のアプリでもすぐに機能します。どのように行われたのか見てみましょう。Buildpacks は、ソースを調べて構築方法の識別を試みます。サンプル アプリケーションの場合、pom.xml ファイルが認識され、Maven ベースのアプリケーションの構築方法が決定されました。--builder フラグにより、Buildpacks の入手元が指示されました。この場合、gcr.io/buildpacks/builder:v1 が、Google Cloud's Buildpacks に対するコンテナ イメージ上の座標です。Heroku や Paketo の Buildpacks を使用することもできます。パラメータ comparing-docker-methods:buildpacks は、出力の保存場所を指定するコンテナ イメージの座標です。この場合は、ローカル Docker デーモン上に保存されます。これで、次の Docker でそのコンテナ イメージをローカルに実行できます。

  docker run -it -ePORT=8080 -p8080:8080 comparing-docker-methods:buildpacks

もちろん、Kubernetes や Cloud Run などの Docker コンテナや OCI コンテナが実行されている環境ならどこでも、そのコンテナ イメージを実行できます。

Buildpacks には、多くの場合、それだけで特に何もしなくてもソースを実行可能なものに変換できるという大きな利点があります。ただし、Buildpacks から作成されるコンテナ イメージは、やや容量の点でかさばる場合があります。ここで、dive というツールを使用して、作成されたコンテナ イメージの内容を確認します。

  dive comparing-docker-methods:buildpacks
Container Image

ここで、コンテナ イメージに 11 のレイヤがあり、イメージの全体的なサイズは 319 MB であることを確認することができます。dive で各レイヤを調べて、変更部分を確認できます。このコンテナ イメージでは、最初の 6 レイヤは基本オペレーティング システムです。Layer 7 は JVM で、レイヤ 8 はコンパイルされたアプリケーションです。レイヤ化はキャッシュ保存の機能を向上させるため、レイヤ 8 のみを変更した場合、レイヤ 1 から 7 の再ダウンロードは不要です。Buildpacks で一つの課題となるのは(少なくとも現時点では)、すべての依存関係とコンパイルされたアプリケーション コードをどのように 1 つのレイヤに保存するかということです。依存関係とコンパイルされたアプリケーションには、個別のレイヤを用意することをおすすめします。

まとめると、Buildpacks は、すぐに利用できる簡単なオプションです。しかし、コンテナ イメージは若干容量が大きいため、最適にはレイヤ化されません。

コンテナ ビルダー: Jib

オープンソースJib プロジェクトは、Maven と Gradle プラグインを使用してコンテナ イメージを作成するための Java ライブラリです。Maven プロジェクト上で使用するには(上記のように)、pom.xml ファイルにビルド プラグインを追加します。

  <plugin>
    <groupId>com.google.cloud.tools</groupId>
    <artifactId>jib-maven-plugin</artifactId>
    <version>2.6.0</version>
</plugin>

コンテナ イメージを作成して、ローカル Docker デーモンに保存するには、次を実行します。

  ./mvnw compile jib:dockerBuild -Dimage=comparing-docker-methods:jib

dive を使用して、このアプリケーション用のコンテナ イメージが、より軽量なオペレーティング システムと JVM レイヤの効果で、127 MB のみであることを確認します。また、Spring Boot アプリケーション上で、Jib により依存関係、リソース、コンパイルされたアプリケーションがレイヤ化されて、キャッシュ保存機能が向上していることを確認できます。

Spring Boot Application

この例では、18 MB レイヤにはランタイムの依存関係が含まれ、最後のレイヤにはコンパイルされたアプリケーションが含まれます。Buildpacks とは異なり、元のソースコードはコンテナ イメージに含まれません。Jib には、コンテナ イメージを外部コンテナ レジストリ(DockerHub や Google Cloud Container Registry など)に保存する限り、Docker をインストールすることなく使用できる便利な機能があります。Jib は、JVM を使用するコンテナ イメージ用の Maven と Gradle のビルドにおすすめします。

コンテナ ビルダー: Dockerfile

コンテナ イメージの従来の作成方法が、docker ツールに組み込まれており、通常は Dockerfile と呼ばれるファイル内で定義される一連の命令を使用します。サンプル Java アプリケーションで使用できる Dockerfile を次に示します。

  FROM adoptopenjdk/openjdk8 as builder

WORKDIR /app
COPY . /app

RUN ./mvnw compile jar:jar

FROM adoptopenjdk/openjdk8:jre

COPY --from=builder /app/target/*.jar /server.jar

CMD ["java", "-jar", "/server.jar"]

この例で、最初の 4 つの命令は、AdoptOpenJDK 8 コンテナ イメージで開始され、ソースを Jar ファイルに構築します。最後のコンテナ イメージは、AdoptOpenJDK 8 JRE コンテナ イメージから作成され、作成された Jar ファイルを含みます。docker を実行し、Dockerfile の次の命令を使用して、コンテナ イメージを作成できます。

  docker build -t comparing-docker-methods:dockerfile

dive を使用して、209 MB の非常に軽量なコンテナ イメージを確認できます。

Container Image

Dockerfile を使用して、レイヤ化とベースイメージを完全に制御することができます。例えば、Distroless Java ベースイメージを使用して、コンテナ イメージをさらに軽量化できます。コンテナ イメージを作成するこのメソッドにはかなりの柔軟性がありますが、命令の記述と保守は行う必要があります。

この柔軟性により、いくつか便利なことが可能になります。例えば、GraalVM を使用して、アプリケーションの「ネイティブ イメージ」を作成できます。これは、コンテナ イメージで、起動時間の短縮、メモリ使用量の削減、JVM の必要性の軽減を実現できる、事前にコンパイルされたバイナリです。さらに進んで、実行に必要なすべてを含む統計的にリンクされたネイティブ イメージを作成して、コンテナ イメージでオペレーティング システムが不要になるようにすることもできます。ここでそのための Dockerfile を示します。

  FROM oracle/graalvm-ce:20.2.0-java11 as builder

WORKDIR /app
COPY . /app

RUN gu install native-image

# BEGIN PRE-REQUISITES FOR STATIC NATIVE IMAGES FOR GRAAL 20.2.0
# SEE: https://github.com/oracle/graal/blob/master/substratevm/StaticImages.md
ARG RESULT_LIB="/staticlibs"

RUN mkdir ${RESULT_LIB} && \
    curl -L -o musl.tar.gz https://musl.libc.org/releases/musl-1.2.1.tar.gz && \
    mkdir musl && tar -xvzf musl.tar.gz -C musl --strip-components 1 && cd musl && \
    ./configure --disable-shared --prefix=${RESULT_LIB} && \
    make && make install && \
    cd / && rm -rf /muscl && rm -f /musl.tar.gz && \
    cp /usr/lib/gcc/x86_64-redhat-linux/4.8.2/libstdc++.a ${RESULT_LIB}/lib/

ENV PATH="$PATH:${RESULT_LIB}/bin"
ENV CC="musl-gcc"

RUN curl -L -o zlib.tar.gz https://zlib.net/zlib-1.2.11.tar.gz && \
   mkdir zlib && tar -xvzf zlib.tar.gz -C zlib --strip-components 1 && cd zlib && \
   ./configure --static --prefix=${RESULT_LIB} && \
    make && make install && \
    cd / && rm -rf /zlib && rm -f /zlib.tar.gz
#END PRE-REQUISITES FOR STATIC NATIVE IMAGES FOR GRAAL 20.2.0

RUN ./mvnw compile jar:jar

RUN native-image \
  --static \
  --libc=musl \
  --no-fallback \
  --no-server \
  --install-exit-handlers \
  -H:Name=webapp \
  -cp /app/target/*.jar \
  com.google.WebApp

FROM scratch

COPY --from=builder /app/webapp /webapp

ENTRYPOINT ["/webapp"]

静的ネイティブ イメージのサポートに多少の設定が必要であることを確認できます。この設定を行うと、Jar は以前のように Maven を使用してコンパイルされます。次に、native-image ツールは、Jar からバイナリを作成します。FROM scratch 命令とは、最後のコンテナ イメージが空で開始されることを意味します。次に、native-image で作成される統計的にリンクされたバイナリが、空のコンテナにコピーされます。

以前のように、docker を使用して、コンテナ イメージを構築できます。

  docker build -t comparing-docker-methods:graalvm .

dive を使用して、最後のコンテナ イメージはわずか 11 MB であることを確認できます。

Container Image

JVM、OS などが不要なため、高速で起動します。もちろん、GraalVM は、リフレクションやデバッグの処理などの課題があるため、常にはおすすめできる方法ではありません。この詳細については、ブログ GraalVM Native Image Tips & Tricks をご覧ください。

この例では、Dockerfile メソッドの柔軟性と、必要なすべてを実施できる機能について説明しています。この回避策は、必要な場合にはとても便利です。

選択できるメソッド

  • 多言語で記述された最も簡単なメソッド: Buildpacks

  • JVM アプリ向けの便利なレイヤ化: Jib

  • 上記のメソッドが適さない場合の回避策: Dockerfile

comparing-docker-methods project で、これらの方法および言及した「Spring Boot + Jib」の例について詳細をご確認ください。


-デベロッパー アドボケイト James Ward