こんにちは。dely株式会社でAndroidチームのマネージャーをやっているうめもり(Twitter: @kr9ly)です。
この記事は「dely #2 Advent Calendar 2020」の7日目の記事です。
6日目の記事は、knchst さんによる「エンジニアの僕が初めてプロダクトマネージャーをする上で特に意識したこと」でした。僕も人に依頼するときは菓子折り持って行ってその場で食べてもらってから依頼することにします。
「dely #1 Advent Calendar 2020」もありますので、是非そちらもご覧ください。
早速ですが、皆さん、AWS CodeBuild使ってますか?
Amazon Elastic Container Registryと組み合わせて使うと、ビルドイメージのProvisioningがとても高速に終わるのでdelyのAndroidチームでもアプリのビルド用に使っています。
今回の記事は、「Androidのビルド用Dockerイメージダイエット計画」ということで、 AndroidチームでAndroidのビルド用Dockerイメージのサイズを小さくした際のTipsをご紹介します。
はじめに
最近Androidチームではアプリの高速化プロジェクトを進めていて、並列ビルドをしっかり効かせられるプロジェクト構造に変えたおかげでビルド時間が大分短縮できました。元々1回のCI用のビルドに 10分以上 かかっていたものが 2~3分 で終わるようになってそれ自体は非常によいことなのですが、相対的にDockerイメージのProvisioning速度が気になるようになってきました。
AndroidチームではなるべくCIを高速にやるために、ライブラリーのダウンロードなども済ませた状態でカスタムのDockerイメージを作成しているのですが、その反面Dockerイメージ自体のサイズが肥大化しがちで、 2.5GB 程度のサイズになっていました。
この状態ではいくらCodeBuildとAmazon Elastic Container Registryの組み合わせでも、DockerイメージのProvisioning時間が 1分半~2分程度 かかるようになっていました。イメージのサイズを考えるとこれでも十分速いとは思うのですが、これをもっと短縮できないかと考えました。
Dockerイメージが肥大化しがちな原因
Androidのビルド用Dockerイメージは割と肥大化しがちだと思うのですが、それには次のような原因があります。
- Javaを使うのであまり考えずにイメージを構築するとそもそも素のイメージサイズが大きくなる
- Android SDKがそもそも大きい
これらの問題に対してどのように対処したかについてご説明して、最後に出来上がったDockerfileを紹介します。
今回行った工夫
alpineをベースにする
ダイエット前のDockerイメージは、debianのopenjdk-slimイメージをベースに構築していました。手間なく構築する際にはこういったあらかじめ環境が構築されたイメージは便利ですが、今回はなるべくイメージサイズを小さくするためにalpineをベースにイメージを構築することにしました。ただし、alpineはmusl-libcを使っておりglibcを使っていないので、Android SDKを使う場合には問題があるのですが、その問題をどのように解決したかについては後述します。
Java9から導入されたjlinkを使ってJavaランタイムのサイズを小さくする
Java9からはJavaランタイムがモジュール化され、jlinkというツールを使うことで使いたいモジュールだけが入ったJavaランタイムが構築できるようになっています。これを使うことでJavaランタイムのサイズを小さくすることを試みました。なお、今回はJava11を使用しました。
手順としては結果的にはとても泥臭くなってしまったのですが、まず最小のJavaランタイムを作成し、そのJavaランタイムを使ってGradleでビルドを繰り返すことで、一つずつ必要なモジュールを足していくという手段で最小のJavaランタイムを構築しました。
一回の実行にも時間がかかる上、どのモジュールが足りないか調べるのがなかなかしんどい試行錯誤だったので、もしスマートなやり方を知っている方がいたらぜひ教えてください…。
alpine上でglibcがリンクされたバイナリが動くようにする
これらの工夫で大分イメージが小さくなりましたが、そもそもalpineにはglibcが入っていないので、Android SDKが動きません。Android SDKが動かないのであればまったく意味がないので、alpine上でglibcが動くように環境を構築しました。なお、こちらのQiitaの記事を参考にしました。(ありがとうございます)
Android SDKのインストールを済ませた後、emulatorを削除する
Javaランタイムを構築した後、sdkmanagerでAndroid SDKをインストールするのですが、実はデフォルトのインストールではemulatorもインストールされてしまいます。ビルドするだけであればemulatorは不要なので、インストールを済ませた後、emulatorだけ削除しておきます。
Docker multi-stage buildを使い極力不要なファイルがイメージに残らないようにする
AndroidチームではRuby Dangerを利用しているので、Rubyのビルド用に本来実行には不要なファイルをイメージにインストールする必要があります。これをマニュアルで削除することもできますが、Docker multi-stage buildの機能を使い、必要最低限のファイルだけを最終イメージに移すことでDockerイメージを小さくしました。
Dockerfile
そして出来上がったのが以下のDockerfileです。
FROM alpine:3.12 AS alpine-glibc ENV LANG=C.UTF-8 # Here we install GNU libc (aka glibc) and set C.UTF-8 locale as default. RUN ALPINE_GLIBC_BASE_URL="https://github.com/sgerrand/alpine-pkg-glibc/releases/download" && \ ALPINE_GLIBC_PACKAGE_VERSION="2.32-r0" && \ ALPINE_GLIBC_BASE_PACKAGE_FILENAME="glibc-$ALPINE_GLIBC_PACKAGE_VERSION.apk" && \ ALPINE_GLIBC_BIN_PACKAGE_FILENAME="glibc-bin-$ALPINE_GLIBC_PACKAGE_VERSION.apk" && \ ALPINE_GLIBC_I18N_PACKAGE_FILENAME="glibc-i18n-$ALPINE_GLIBC_PACKAGE_VERSION.apk" && \ apk add --no-cache --virtual=.build-dependencies wget ca-certificates && \ echo \ "-----BEGIN PUBLIC KEY-----\ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApZ2u1KJKUu/fW4A25y9m\ y70AGEa/J3Wi5ibNVGNn1gT1r0VfgeWd0pUybS4UmcHdiNzxJPgoWQhV2SSW1JYu\ tOqKZF5QSN6X937PTUpNBjUvLtTQ1ve1fp39uf/lEXPpFpOPL88LKnDBgbh7wkCp\ m2KzLVGChf83MS0ShL6G9EQIAUxLm99VpgRjwqTQ/KfzGtpke1wqws4au0Ab4qPY\ KXvMLSPLUp7cfulWvhmZSegr5AdhNw5KNizPqCJT8ZrGvgHypXyiFvvAH5YRtSsc\ Zvo9GI2e2MaZyo9/lvb+LbLEJZKEQckqRj4P26gmASrZEPStwc+yqy1ShHLA0j6m\ 1QIDAQAB\ -----END PUBLIC KEY-----" | sed 's/ */\n/g' > "/etc/apk/keys/sgerrand.rsa.pub" && \ wget "$ALPINE_GLIBC_BASE_URL/$ALPINE_GLIBC_PACKAGE_VERSION/$ALPINE_GLIBC_BASE_PACKAGE_FILENAME" && \ wget "$ALPINE_GLIBC_BASE_URL/$ALPINE_GLIBC_PACKAGE_VERSION/$ALPINE_GLIBC_BIN_PACKAGE_FILENAME" && \ wget "$ALPINE_GLIBC_BASE_URL/$ALPINE_GLIBC_PACKAGE_VERSION/$ALPINE_GLIBC_I18N_PACKAGE_FILENAME" && \ apk add --no-cache \ "$ALPINE_GLIBC_BASE_PACKAGE_FILENAME" \ "$ALPINE_GLIBC_BIN_PACKAGE_FILENAME" \ "$ALPINE_GLIBC_I18N_PACKAGE_FILENAME" && \ \ rm "/etc/apk/keys/sgerrand.rsa.pub" && \ /usr/glibc-compat/bin/localedef --force --inputfile POSIX --charmap UTF-8 "$LANG" || true && \ echo "export LANG=$LANG" > /etc/profile.d/locale.sh && \ \ apk del glibc-i18n && \ \ rm "/root/.wget-hsts" && \ apk del .build-dependencies && \ rm \ "$ALPINE_GLIBC_BASE_PACKAGE_FILENAME" \ "$ALPINE_GLIBC_BIN_PACKAGE_FILENAME" \ "$ALPINE_GLIBC_I18N_PACKAGE_FILENAME" FROM alpine-glibc AS intermediate ENV ANDROID_SDK_FILENAME=commandlinetools-linux-6514223_latest.zip ENV ANDROID_SDK_ROOT=/opt/android-sdk-linux ENV ANDROID_SDK_CMDLINE_TOOLS=${ANDROID_SDK_ROOT}/cmdline-tools ENV ANDROID_SDK_URL="http://dl.google.com/android/repository/${ANDROID_SDK_FILENAME}" ENV ANDROID_API_LEVELS=android-30 ENV ANDROID_BUILD_TOOLS_VERSIONS=29.0.3 ENV GRADLE_USER_HOME=/usr/local/gradle ENV JAVA_HOME=/opt/jdk-11-mini-runtime ENV BUNDLER_PATH=/opt/bundle ENV PATH=${PATH}:${ANDROID_SDK_CMDLINE_TOOLS}/tools/bin:${JQ_PATH}:${JAVA_HOME}/bin RUN apk update && \ apk --update --no-cache add openjdk11 \ fontconfig ttf-dejavu \ ruby ruby-dev alpine-sdk zlib-dev && \ rm -rf /var/cache/apk/* RUN /usr/lib/jvm/java-11-openjdk/bin/jlink \ --module-path /usr/lib/jvm/java-11-openjdk/jmods \ --compress=2 \ --add-modules java.base,java.compiler,jdk.compiler,java.logging,java.xml,jdk.unsupported,java.naming,java.desktop,java.management,jdk.crypto.ec,java.sql,java.rmi,jdk.zipfs,java.instrument,jdk.attach \ --no-header-files \ --no-man-pages \ --output ${JAVA_HOME} COPY Gemfile ./ RUN gem install bundler && \ bundle config set path ${BUNDLER_PATH} && \ bundle config set without 'development test' && \ bundle install RUN mkdir ${ANDROID_SDK_ROOT} && \ mkdir ${ANDROID_SDK_CMDLINE_TOOLS} && cd ${ANDROID_SDK_CMDLINE_TOOLS} && \ curl -O ${ANDROID_SDK_URL} && \ unzip ${ANDROID_SDK_FILENAME} && \ rm ${ANDROID_SDK_FILENAME} && \ mkdir ${GRADLE_USER_HOME} && \ echo y | sdkmanager $(echo ${ANDROID_BUILD_TOOLS_VERSIONS} | sed 's/,/\n/g' | sed -E 's/(.+)/build-tools;\1/g' | tr '\n' ' ') "platform-tools" $(echo ${ANDROID_API_LEVELS} | sed 's/,/\n/g' | sed -E 's/(.+)/platforms;\1/g' | tr '\n' ' ') && \ sdkmanager --uninstall emulator WORKDIR /workspace FROM intermediate AS dependencies COPY . /workspace RUN ./gradlew --full-stacktrace --project-cache-dir=${GRADLE_USER_HOME} checkCi bundleRelease FROM alpine-glibc ENV JQ_PATH=/usr/local/jq ENV ANDROID_SDK_ROOT=/opt/android-sdk-linux ENV ANDROID_SDK_CMDLINE_TOOLS=${ANDROID_SDK_ROOT}/cmdline-tools ENV GRADLE_USER_HOME=/usr/local/gradle ENV JAVA_HOME=/opt/jdk-11-mini-runtime ENV BUNDLER_PATH=/opt/bundle ENV PATH=${PATH}:${ANDROID_SDK_CMDLINE_TOOLS}/tools/bin:${JQ_PATH}:${JAVA_HOME}/bin COPY --from=intermediate /opt/jdk-11-mini-runtime /opt/jdk-11-mini-runtime COPY --from=intermediate /opt/bundle /opt/bundle COPY --from=intermediate /opt/android-sdk-linux /opt/android-sdk-linux COPY --from=dependencies /usr/local/gradle /usr/local/gradle RUN apk update && \ apk --no-cache add bash ruby ruby-json ruby-bigdecimal wget git curl fontconfig ttf-dejavu && \ rm -rf /var/cache/apk/* RUN gem install bundler && \ bundle config set path ${BUNDLER_PATH} RUN mkdir $JQ_PATH && cd $JQ_PATH && \ curl -o jq https://github.com/stedolan/jq/releases/download/jq-1.5/jq-linux64 && chmod +x $JQ_PATH/jq && \ cd ~ && \ curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" && \ unzip awscliv2.zip && rm awscliv2.zip && \ ./aws/install && rm -rf ./aws WORKDIR /workspace
結果
そうしてDockerイメージをダイエットした結果、 2.5GB -> 1.4GB 程度までDockerイメージがダイエットできました。Provisioning時間も約半分程度まで短くできたので満足です。
オチ
ちなみに、最初想定したよりはイメージが小さくならなかったのですが、最低限Gradleが走るように必要なファイルに絞るのであれば、イメージサイズは0.7GB程度まで落とせることを確認しました。あらかじめビルドに必要なライブラリなどを組み込んでおり、それがかなりのイメージサイズを消費していたということが分かったというのが今回のオチですね…。