CodeBuildで軽量かつ高速にDockerImageをビルドする with サンタバナー


株式会社アピリッツにて、ソーシャルゲームのサーバ＆インフラエンジニアをしております、高倉といいます。

この記事は [AWS & Game Advent Calendar 2020](https://qiita.com/advent-calendar/2020/aws-game) の 24 日目の記事になります。

## はじめに
* ECS＆CodeBuildを用いたソーシャルゲームプロジェクトでCodeBuildの設定で試行錯誤した際のフィードバックです
* DockerfileやCodeBuildについて「未使用なので重要なポイントを知りたい」「他のプロジェクトの知見を得たい」といった方向けの記事となっています
* 今回はテストコードやデプロイ処理など付随するCI処理には言及していません
* サンプルアプリケーション：Ruby:2.7.2 Rails:6.1（rails new コマンドで生成したバニラを使用）

## Dockerfile最適化
<img width="100" alt="diet.png" src="https://3.bp.blogspot.com/-6xJvGXiSjKI/UbVvYAISNyI/AAAAAAAAUwg/B51TyzoLTNM/s800/waist_man_thin.png
">
### 目的
* Dockerfileの最適化を施すことで、ビルドされたDockerImageのサイズを極力小さくする
*  ※サイズを減らす目的は[こちらのエントリ](https://orisano.hatenablog.com/entry/2018/12/18/045510)によくまとまっています

### Dockerfileサンプル
```Dockerfile
FROM ruby:2.7.2-alpine # ※ポイント1
...

# パッケージインストール # ※ポイント2
RUN apk add --update --no-cache --virtual=.build-dependencies \
      build-base \
      curl-dev \
      libxml2-dev \
...
      && \
    apk add --update --no-cache \
      libxml2 \
      libxslt \
      linux-headers \
      mariadb-connector-c \
...
      && \
    gem install bundler --no-document && \
    bundle install -j4 && \
    apk del .build-dependencies
...
```

### ポイント１：サーバ用のディストリビューション選定

公式DockerImageにはサーバ運用に不要のパッケージが多数含まれており、Imageサイズの増加に繋がります
使用する言語や環境に応じて[alpine](https://ja.wikipedia.org/wiki/Alpine_Linux)、[Distroless](https://github.com/GoogleContainerTools/distroless)など不要なものを削ったディストリビューションを選定します

```shell
# 例：rubyのベースimageのサイズ比較。同一バージョンでも約800MBもの差が発生している
$ docker images | grep ruby
ruby     2.7.2                                      7e58098089a4   5 days ago      842MB
ruby     2.7.2-alpine                               f811257adce0   6 days ago      51.7MB
```

### ポイント２：依存ライブラリ＆ビルドのRUNコマンド最適化
libxml2-devやlinux-headersなどのパッケージはbundle installなどに代表されるライブラリインストール時にのみ必要なことが多く、
サーバアプリケーション起動に不要なファイル群になります
このためサンプルのようにvirtualとapk delを組み合わせ、一つのRUNコマンド内で完結させることでサイズの現象を見込めます

→例としてですが、サンプルアプリケーションはこの最適化により195.40MB →111.36MBと半分近くまで減らすことができました


### その他
上記点を含め、Docker公式にベストプラクティスが非常によくまとまっています
https://matsuand.github.io/docs.docker.jp.onthefly/develop/develop-images/dockerfile_best-practices/


## CodeBuild(buildspec.yml)
<img width="200" alt="dash.png" src="https://4.bp.blogspot.com/-PXpJStdx5lk/W64DQwm6tgI/AAAAAAABPFc/tpqIPB8EXO8Qe__jSnde_UqhGAjrrm7tQCLcBGAs/s400/animal_mawashi_guruma_degu.png
">

### 最適化の目的
* 1. ビルド待ち時間の短縮
    * ビルド、デプロイなどで発生する待ち時間が短縮されることで様々な恩恵があります。早いのは正義
* 2. CodeBuild利用料金の節約
    * [ビルドの実行にかかった時間に基づいて料金が決定される](https://aws.amazon.com/jp/codebuild/pricing/)ため、利用料金の節約に繋がります
* 3. DockerImageタグ管理の向上
    * ソーシャルゲーム開発は更新が頻繁に行われる関係上、常時3〜4バージョンのブランチが並行で開発されるのが基本です
    * 必然的にDockerImageのバージョンも増加するため、管理しやすいタグ付けを模索しました

### 前提
* 対象となるソースコードはGithubで管理されている
* Githubにpushした段階で毎回CodeBuildによるDockerImageビルドが走るようになっている

### buildspec.ymlサンプル
```yaml
version: 0.2

env:
  variables:
    DOCKER_BUILDKIT: "1"      
    DOCKERHUB_USER: "hoge" 
    DOCKERHUB_PASS: "fuga"
    AWS_ACCOUNT_ID: "xxx"
    IMAGE_REPO_NAME: "dfast"
    AWS_DEFAULT_REGION: "ap-northeast-1"
    IMAGE_TAG_BASE: "latest"
    ALTER_CACHE_BRANCH_NAME: "main"

phases:
  pre_build:
    commands:
      # ECR & DockerHubログイン
      - $(aws ecr get-login --no-include-email --region $AWS_DEFAULT_REGION)
      - echo $DOCKERHUB_PASS | docker login -u $DOCKERHUB_USER --password-stdin
      # 各種env設定
      - export BRANCH_NAME=${CODEBUILD_WEBHOOK_TRIGGER#branch/} # ブランチ名はCODEBUILD_WEBHOOK_TRIGGERで参照可能
      - export IMAGE_TAG_NAME=${BRANCH_NAME/\//_}_${IMAGE_TAG_BASE} # Image名指定時に「/」が使用できないため「_」に変換
      - export REPOSITORY_URI=${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_DEFAULT_REGION}.amazonaws.com/${IMAGE_REPO_NAME}
      - export GIT_HASH=${CODEBUILD_RESOLVED_SOURCE_VERSION}
      # キャッシュ用の設定
      - export CACHE_URI=${REPOSITORY_URI}:${IMAGE_TAG_NAME}
      - export ALTER_IMAGE_TAG_NAME=${ALTER_CACHE_BRANCH_NAME/\//_}_latest
      - export ALTER_CACHE_URI=${REPOSITORY_URI}:${ALTER_IMAGE_TAG_NAME}
      - export use_alter_cache=0
  build:
    commands:
      # ※ポイント1
      # 対象ブランチの最新Imageを取得する
      # CACHE_URIが存在しない場合、そのままでは途中でビルドが止まるので「||」でつなげてビルドが継続するようにしている
      - docker pull ${CACHE_URI} || use_alter_cache=1
      - | # 最新imageが無い(=対象ブランチ初回ビルド) → 代替Image(=mainブランチの最新Image)をキャッシュとして利用
        if [ "${use_alter_cache}" = 1 ] ; then
          CACHE_URI=${ALTER_CACHE_URI}
          docker pull ${CACHE_URI} || echo "error ignore because no cache docker image ..."
        fi
      - echo CACHE_URI:${CACHE_URI}
      - docker build --cache-from ${CACHE_URI} --build-arg BUILDKIT_INLINE_CACHE=1 -t ${REPOSITORY_URI}:${IMAGE_TAG_NAME} .
      - docker tag ${REPOSITORY_URI}:${IMAGE_TAG_NAME} ${REPOSITORY_URI}:${GIT_HASH}
  post_build:
    commands:
      # ※ポイント2
      # <ブランチ名>_latest、Gitハッシュ値の両方のタグをImageに付与 
      - docker push ${REPOSITORY_URI}:${IMAGE_TAG_NAME}
      - docker push ${REPOSITORY_URI}:${GIT_HASH}

```

### ポイント1 複数バージョンの—cache-from指定

#### 問題点
Dockerは「ビルド時にcache-fromに既存のImageを指定する」ことで変更されていないLayerをキャッシュとして使うことができます

このキャッシュ指定をすることでapkやbundleでのインストールが飛ばされるので非常に高速化を図れるのですが、
ブランチ単位でキャッシュ対象を指定する関係上 **初回push限定で「該当ブランチ名のImage」が存在しないためキャッシュが使われずフルビルドが走る** 状態になります。

普段は良いのですが「開発環境にQAが止まるバグがあったから至急修正してほしい」など専用のブランチを作成する場合、
「どんな軽微な対応でもキャッシュが使われずにフルビルドが走る」という問題がありました。


#### 改善策
この問題を可決するため「該当ブランチ名のImageの存在有無でcache-fromを動的に変更する」という方法を取りました
こうすることで極力キャッシュを使いビルド高速化が保たれるようになります

* 依存ライブラリ変更なし： どのブランチでもキャッシュが使用される
* 依存ライブラリ変更あり： 初回pushのみはフルビルドが走るが、2回目以降はキャッシュが使用される

### ポイント2 複数バージョンのDockerImageタグ運用

複数バージョンのDockerImageを管理しやすいように、ビルド時に「ブランチ名_latest」というタグをImageに付与しています
このタグを何も考えずにECSへのデプロイや単発タスク実行に使用すると、
いわゆる[latest運用](https://dev.classmethod.jp/articles/ecs-deploy-all/#toc-7)のアンチパターンになるので、以下のような対応で運用することにしました

* Imageの管理のため「ブランチ名_latest」＋「Githubハッシュ値」という2つのタグを付加
    * 「ブランチ名_latest」」：ブランチ単位の最新Image検出用タグ
    * 「Githubハッシュ値」：ECSのタスク定義、サービス定義で使用するタグ
* ECSへのデプロイや単発タスク実行時に、以下のシェルのように最新の「Githubハッシュ値」のタグが付いたImageを検出して使用する
    * こうすることで「ブランチごとの最新Imageを判別する」ことと「ECSタスクのトレーサビリティやソース一意性」という両方を満たす状態を作りました

```shell
# ECRから該当ブランチの最新Imageの「GitHubハッシュ」タグを取得
export SEARCH_TAG=${BRANCH_NAME/\//_}_latest
# 最新のImageには「ブランチ名_latest」と「GitHubハッシュ地」の2つのタグが付いているので「Githubのハッシュ」のみを取得する
tags=`aws ecr describe-images --repository-name ${REPO_NAME} --image-ids imageTag=${SEARCH_TAG} | jq -r .imageDetails[0].imageTags`
len=$(echo $tags | jq length)
for i in $( seq 0 $(($len - 1)) ); do
  tag=$(echo $tags | jq -r .[$i])
  if [ ! $tag = SEARCH_TAG ]; then
      echo $tag
      break
  fi
done
```

## クリスマスイヴ特別処理：CodeBuild起動サンタバナー表示

<img width="200" alt="santa2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9468/d2347333-9ae0-20c4-3806-4c28f37dbe16.png">

さて、本エントリの公開日は12/24ですので、特別に起動用のサンタバナーを[netpbm](http://netpbm.sourceforge.net/index-japanese.html)を用いてCodeBuildコンソールに表示させることにしました
変換や加工用のプログラムをパイプで繋ぐことで、画像ファイルを等幅アスキーアートで出力することが可能になります

※netpbmのインストール等でビルド完了までに+1分ほどかかるようになるので注意してください

```yaml:buildspec.yml
...

    BANNER_IMG_URL: https://xxx/yyy.png
    BANNER_FILE_NAME: santa.png
    # BANNER_IMG_URL: https://xxx/yyy.jpg
    # BANNER_FILE_NAME: santa.jpg

phases:
  pre_build:
    commands:
      # サンタAAバナーの表示
      - curl ${BANNER_IMG_URL} --output ${BANNER_FILE_NAME}
      - yum install -y netpbm-progs
      - pngtopam -mix -background=#ffffff ${BANNER_FILE_NAME} | pamscale -xscale .3 -yscale .3 | ppmtopgm | pgmtopbm | pbmtoascii
      # JPEG参照の場合の例 - jpegtopnm  ${BANNER_FILE_NAME} | pamscale -xscale .3 -yscale .3 | ppmtopgm | pgmtopbm | pbmtoascii
...
```
### 表示例
<img width="400" alt="santa1.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9468/5cb4975a-0af7-5c9e-a04b-49ad5d794d40.png"> <img width="400" alt="santa2.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/9468/a2800fd4-3c61-1bc2-92f1-322ff0e41e03.png">

今回は時間の関係上CodeBuildのコンソールのみに表示させるのみにとどめましたが、
扱っているのは単なるテキストデータなので「Lambdaでslackにpostする」などのいろいろな応用が可能となります

# その他_参考サイト
* https://blog.kasei-san.com/entry/2018/03/11/002752
* https://masakimisawa.com/reduce_codebuild_build_time/
* https://engineer.recruit-lifestyle.co.jp/techblog/2020-09-25-docker-build/

# さいごに
ここに示したのはほんの1例であり、まだまだいろいろな最適化や設定方法があるはずです
コンテナ技術は今が旬の日進月歩な技術なので、これからも進化し続けていくでしょう
エンジニアとして現役であり続けるために技術を追求していきましょう！