Circle CI でのDocker Buildを超高速化するテクニック

###背景
モダンでコンパクトな構成のimageであればCircle CIでのdocker buildはそこまで遅くないものです。しかし諸事情によりわりと大きめのイメージをビルドしないといけない場合があり、5〜10分くらいかかるようになってしまう場合もあります。私の場合古いモノリシックなPHPのサービスをコンテナ化しようとしてそういう事象に至りました。そういった場合に試して効果があったことを解説していきます。

###前提
- dockerでimageを作成するためのベストプラクティス一般はここでは解説しません。もちろん重要なので先にやってください。
- 若干バッドノウハウ気味な内容も含まれます。採用は自己判断で
- 自分でJenkins建ててやるとすべてが適切にキャッシュされもっと速いです。しかしそういうことをしたくないのでCircle CIをつかっています

### 1. Machine Executorを使う
まずとしてMachine Executorを使いましょう。Container Executorの場合はDocker Daemonが別マシンに配置されてしまい。レポジトリまるごとCOPYなどが遅くなる傾向にあります。また後述するローカルのディレクトリをmountして何かを実行するhackが利用できなくなります。

```yaml
version: 2.1
jobs:
  build-image:
    machine: true
    steps:
      - checkout
      - run:
          name: Build a container
          command: |
            docker login .......
            docker build --progress plain -t $IMAGE_NAME .
            docker push $IMAGE_NAME
```

### 2. BuildKitを使う
BuildKitはDockerの新しいシステムで色々最適化されています。デフォルトのMachine Executorのdocker versionは古いため、利用には最新のimageを指定する必要があります。 コマンド実行時は`DOCKER_BUILDKIT=1`の環境変数を指定することで有効になります。

```yaml
version: 2.1
jobs:
  build-image:
    machine:
      image: ubuntu-1604:201903-01
    steps:
      - checkout
      - run:
          name: Build a container
          command: |
            docker login .......
            DOCKER_BUILDKIT=1 docker build --progress plain -t $IMAGE_NAME .
            docker push $IMAGE_NAME
```

### 3. Docker Layer Cachingを使う
Docker Layer Cachingはdockerのキャッシュ周りのディレクトリを永続化し、Executor実行時にマウントしてくれる仕組みです。おもにDockerfileのFROMで指定するベースイメージが大きい場合などにダウンロードが省略できるため大幅な時間短縮が見込めます。ただしExecutor起動時にマウント処理が5〜10秒ほどかかるようになるためベースイメージが小さい場合には利用しないほうが高速となるでしょう。

```yaml
version: 2.1
jobs:
  build-image:
    machine:
      image: ubuntu-1604:201903-01
      docker_layer_caching: true
    steps:
      - checkout
      - run:
          name: Build a container
          command: |
            docker login .......
            DOCKER_BUILDKIT=1 docker build --progress plain -t $IMAGE_NAME .
            docker push $IMAGE_NAME
```

### 4. Vendoringなどの作業をdocker buildするExecutor内で行う
Vendoringとは `npm install`, `bundle install`, `composer install` などの作業です。いくつかやり方がありますが
CircleCI上で実施する場合つぎのような方法が考えられます。が、それぞれ問題があります

- Workflow内の上流jobとして実施しWorkspaceやCache経由で受け渡す
  - JobごとにExecutorの起動などが発生し最大で20〜30秒ほどの時間がかかる
- Dockerfile内で行う
  - Executorが永続化していないためDocker本来のキャッシュ機構が利用しにくい

そのためlocalのディレクトリをdockerにマウントしてvendoringを実行。結果はCircle CIのcacheに保存するという戦略が有効になります。

```yaml
version: 2.1
jobs:
  build-image:
    machine:
      image: ubuntu-1604:201903-01
      docker_layer_caching: true
    steps:
      - checkout
      - restore_cache:
          keys:
            - vendoring-{{ checksum "composer.lock" }}
      - run:
          name: Vendoring
          command: |
            docker login .......
            docker run --rm -v /home/circleci/repo:/home/circleci/repo \
                ${BASE_IMAGE} \
                bash -c \
                "cd /home/circleci/repo && \
                php composer.phar config --global github-oauth.github.com ${GITHUB_TOKEN} && \
                php composer.phar install --prefer-dist --ignore-platform-reqs --no-scripts --no-dev"
      - save_cache:
          key: vendoring-{{ checksum "composer.lock" }}
          paths:
            - vendor
      - run:
          name: Build a container
          command: |
            DOCKER_BUILDKIT=1 docker build --progress plain -t $IMAGE_NAME .
            docker push $IMAGE_NAME
```

コードでわかると思うのですが速度と引き換えに可読性が落ちるという問題があります。また複数処理を並列に実行なども可能ですが複雑になりすぎるためおすすめできません。

### 5. shallow-checkoutを利用する
これはdockerとは関係ないのですが、さらなる高速化の一手としてgit checkoutを高速化するという方法があります。Circle CI標準のcheckoutではgitレポジトリをまるごとcloneしています。現在のcommitでの状態のみをチェックアウト (shallow checkout)することでこのcheckoutにかかる時間を短縮できます。なぜかMachine Executorではgit checkoutがContainer Executorよりも遅い傾向があり、さらに有効です。

今回はcommandに直接内容を記述していますがOrbが利用できる環境ではOrbにしたほうが保守性が高いかもしれません。

```yaml
version: 2.1
jobs:
  build-image:
    machine:
      image: ubuntu-1604:201903-01
      docker_layer_caching: true
    steps:
      - shallow-checkout
      - restore_cache:
          keys:
            - vendoring-{{ checksum "composer.lock" }}
      - run:
          name: Vendoring
          command: |
            docker login .......
            docker run --rm -v /home/circleci/repo:/home/circleci/repo \
                ${BASE_IMAGE} \
                bash -c \
                "cd /home/circleci/repo && \
                php composer.phar config --global github-oauth.github.com ${GITHUB_TOKEN} && \
                php composer.phar install --prefer-dist --ignore-platform-reqs --no-scripts --no-dev"
      - save_cache:
          key: vendoring-{{ checksum "composer.lock" }}
          paths:
            - vendor
      - run:
          name: Build a container
          command: |
            DOCKER_BUILDKIT=1 docker build --progress plain -t $IMAGE_NAME .
            docker push $IMAGE_NAME
commands:
  shallow-checkout:
    description: "from: https://circleci.com/orbs/registry/orb/datacamp/shallow-checkout"
    steps:
      - run:
          name: Shallow checkout
          command: |
            set -e

            # Workaround old docker images with incorrect $HOME
            # check https://github.com/docker/docker/issues/2968 for details
            if [ "${HOME}" = "/" ]
            then
              export HOME=$(getent passwd $(id -un) | cut -d: -f6)
            fi

            mkdir -p ~/.ssh
            # 全員共通の値かわからなかったのでmaskしています。各自自分の環境でのcheckoutの実行ログからコピーしてfillしてください
            echo 'github.com ssh-rsa XXXXXXXXXXXXXXXX 
            ' >> ~/.ssh/known_hosts

            (umask 077; touch ~/.ssh/id_rsa)
            chmod 0600 ~/.ssh/id_rsa
            (echo $CHECKOUT_KEY > ~/.ssh/id_rsa)

            # use git+ssh instead of https
            git config --global url."ssh://git@github.com".insteadOf "https://github.com" || true
            git config --global gc.auto 0 || true

            mkdir -p $CIRCLE_WORKING_DIRECTORY
            cd $CIRCLE_WORKING_DIRECTORY

            if [ -n "$CIRCLE_TAG" ]
            then
              git clone --depth=1 -b "$CIRCLE_TAG" "$CIRCLE_REPOSITORY_URL" .
            else
              git clone --depth=1 -b "$CIRCLE_BRANCH" "$CIRCLE_REPOSITORY_URL" .
            fi
            git fetch --depth=1 --force origin "$CIRCLE_SHA1" || echo "Git version >2.5 not installed"

            if [ -n "$CIRCLE_TAG" ]
            then
              git reset --hard "$CIRCLE_SHA1"
              git checkout -q "$CIRCLE_TAG"
            elif [ -n "$CIRCLE_BRANCH" ]
            then
              git reset --hard "$CIRCLE_SHA1"
              git checkout -q -B "$CIRCLE_BRANCH"
            fi

            git reset --hard "$CIRCLE_SHA1"
```

### まとめ
自分の利用している環境ではPull Requestにcommitがpushされると自動でKubernetesのdev環境にdeployされる仕組みを整えており、buildにかかる時間はとても重要なものでした。500MBほどの大きなイメージを扱っており最適化前は5分以上かかっていたbuildがこれらの高速化を利用することで90秒ほどで終わるようになっています。
最適化はやりすぎると可読性や保守性が失われてしまうので良いバランスでやっていきたいですね。