ECSへのデプロイ時間をどうやって4分の1にしたのか

## はじめに
最近、業務でECS、GitLab CI/CDを用いてCI/CDパイプラインを構築しました。
しかし、構築当初、非常に大きな問題に直面していました。それは、**デプロイまでの時間が非常に遅いこと**です。

当初は、デプロイまでにかかる時間がテスト（CI）を除いても**30分以上**かかっており、非常に遅かったです。

しかし現在、改良に改良を重ね、ビルド、デプロイまでにかかる時間が7分ほどまでに短縮することができました。この記事では、具体的にどういった方法で早くしたのかを紹介します。

### アプリケーションの構成とデプロイの流れ
まず初めに、アプリケーションのざっくりとした構成と、デプロイまでの一連の流れを説明します。

アプリケーションの構成は主に、フロントエンドがVue.js、バックエンドはRuby on Railsで作られています。

デプロイまでの流れは以下の通りです。以下は非常に時間が遅かった、構築当初の流れです。
技術的にはGitLab CI/CD, Docker, シェルスクリプト, AWS ECS + Fargateを使い構築しました。

1. テスト実行（RSpec）
1. イメージのビルドとプッシュ
1. デプロイ実行(ECSタスク定義更新 & ECSサービス更新)

![スクリーンショット 2021-09-17 10.59.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/290687/7d5c83fb-4a03-191e-a2e6-a957ddd56217.png)

どのように早くしたのか、紹介します。

## [問題点①] assets:precompileが非常に遅い
まず、デプロイする上で一番のボトルネックになっていたのがassets:precompileでした。

こちらは、jsやCSSなどをコンパイルするコマンドですが、当初、コンパイルが完了するまで19分ほどかかっていました。なので、デプロイの半分以上の時間がコンパイルに費やされていました。

### [対応策①] コンパイルした内容をローカルにcacheとして保存しておく
まずはじめにやったこととしては、前回コンパイルした内容をローカルに保存しておき、次にコンパイルを走らせる時にそのcacheを使わせるようにすることです。

Railsでは、コンパイルした内容はpublic配下に保存されるので、buildしたイメージからdocker cpでpublicの内容を抜き出し、次回のイメージのビルドの際のCOPY句でcacheをコピーするように改良しました。

```bash
id=$(docker create ビルドしたイメージ)
docker cp $id:/app/public キャッシュの保存先（ホストのパス）
docker rm -v $id
```

次回コンパイル時には、COPY句でソースコードを持ってくる際に、前回コンパイルされたpublicも一緒に持ってくるようにします。
（少し横道に逸れますが、publicフォルダは.dockerignoreで除外していたので、一度sedを使ってdockerignoreからpublicを消してbuildするようにしていました。）

ただ、この方法だけだと速度の改善としては微妙でした。

そこで色々調べてみると、publicだけじゃなく、tmp/cacheというフォルダにもコンパイルされたcacheが保存されているらしいということを知り、こちらも保存するように変更したところ、コンパイルが結構早くなりました。

```bash
id=$(docker create ビルドしたイメージ)
docker cp $id:/app/public CI/CDを動かしてるサーバのローカル
docker cp $id:/app/tmp/cache CI/CDを動かしてるサーバのローカル
docker rm -v $id
```

**しかしこれでもまだ問題がありました。**それは、この方法では、前回のコンパイルから今回のコンパイルにかけて、Vue.jsのファイルやassets以下のファイルに変更があった際に、コンパイルが走ってしまい、遅くなることがあったことです。

つまり、この方法だと、**前回と今回でコンパイル内容に差異がない場合は、非常に早くコンパイルが終わるが、差異がある場合はコンパイルが以前と比べ少し早くなったかな？というぐらいで全体的には遅いことに変わりないということ**です。

### [対応策②] webpackを使用してコンパイルするように変更
そこで、railsのrakeタスクでコンパイルするのを止めて、yarn run webpackを使うように変更してみました。

すると、コンパイルが高速で終わるようになり、数十秒で終わるようになりました。正直これが一番効果がありました。

```bash
yarn run webpack --config config/webpacker/${RAILS_ENV}.js
```

## [問題点②] イメージのサイズが大きい
次の問題点はイメージのサイズが大きかったことです。

当初Dockerでローカルの開発環境を構築した際には、railsを動かすイメージのサイズは5GBを超えていました。リポジトリに大容量のデータファイルがあったことも原因の一つでしたが、Dockerfileの書き方にも問題がありました。

イメージのサイズが大きいと、以下のような問題が生じました。

1. ECRにプッシュするまでに時間がかかる。
1. ECSでタスクを動かした時にイメージをpullしてくるのに時間がかかる。

イメージのサイズを小さくするのに行った対処を紹介します。

### [対応策①] RUNの数を最小限に
まず、RUNの数を最小にすることでした。

当初は、コマンド一つにつきRUNを一つという感じで書いていました。（コードは適当です。）

```dockerfile
RUN apt-get update
RUN apt-get install -y sudo ...
RUN mkdir -p /app
RUN bundle install
```

RUNの数を最小にして、レイヤを減らした結果、数百MBぐらいは削減できたと思います。

この方法はデメリットもあるのですが、その一つが**build時のエラーでどこでエラーになったか分かりづらい**ことです。また、Dockerfileの可読性も悪くなるので、イメージサイズとトレードオフになるかなと思います。

```dockerfile
RUN apt-get update && \
    apt-get install -y sudo ... && \
    mkdir -p /app && \
    bundle install
```

ただ、サイズを軽くする上で一番手軽な方法でもあると思うので、一度やってみることは非常に有効だと思います。

### [対応策②] 必要なパッケージの見直しとキャッシュの削除
まず、現状で必要なパッケージの見直しを行いました。

具体的には、環境を構築する上でインストールする必要がないパッケージを順次削除していきました。結果的にいくつかのパッケージが不要なことがわかり、数MBほどですが、サイズが小さくなりました。

次に、apt-getしたときのキャッシュの削除を行いました。

こちらは、公式のDockerfileベストプラクティスで紹介されていた内容を用いているのですが、apt-get cleanとrm -rf /var/lib/apt/lists/*を加えました。また、apt-get install時に--no-install-recommendsオプションを付け加えました。

https://docs.docker.jp/engine/articles/dockerfile_best-practice.html

```dockerfile
RUN apt-get update && apt-get install -y --no-install-recommends \
    sudo \
    # (中略)
 && apt-get clean \
 && rm -rf /var/lib/apt/lists/*
```
こちらもごくわずか（数十MBぐらい？）ですが、イメージを軽くできました。

### [対応策③] multi stage buildを利用
イメージのサイズを軽量化する上で一番効果があったのがmulti stage buildを使うことでした。

具体的にはbundle installとyarn installをインストールするステージを分けました。最新版のdockerを使っていると、ステージごとに並列に処理してくれるので、buildも大分早くなりました。

また、bundle install用にインストールしていたg++やqt4-qmakeなどは、bundle installのステージのみでインストールするように変更してイメージを軽くすることができました。

具体的なコードは以下の通りです。

```dockerfile
FROM ruby:2.7.2 AS gem_installer
RUN apt-get update && \
    apt-get install -y --no-install-recommends g++ qt4-qmake libqtwebkit-dev && \
    mkdir -p /app
COPY ["./workspace/Gemfile", "./workspace/Gemfile.lock", "/app/"]
WORKDIR /app
RUN bundle install

FROM node:10.24.0 AS webpack_builder
RUN mkdir /app
WORKDIR /app
ENV NODE_ENV development
COPY ["./workspace/app/package.json", "./workspace/app/yarn.lock", "/app/"]
RUN yarn install

FROM ruby:2.7.2

ENV QMAKE="/usr/bin/qmake-qt4"
ENV BUNDLER_VERSION=2.1.4

RUN apt-get update && \
    apt-get install -y --no-install-recommends nodejs default-mysql-client sudo npm vim fonts-ipa* ssh cron && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/* && \
    curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add - && \
    echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list && \
    sudo apt update && \
    sudo apt -y install --no-install-recommends yarn && \
    npm i npm@latest -g && \
    npm install webpack-dev-server -g && \
    gem update --system && \
    echo "gem: --no-rdoc --no-ri" > ~/.gemrc && \
    gem install 'bundler:2.1.4' 'rails:5.2.5' 'mailcatcher' 'git' 'spring' && \
    mkdir -p /run/sshd /tools && \
    ln -sf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime

WORKDIR /app

COPY ./web/git.rb /tools/
COPY --from=gem_installer /usr/local/bundle /usr/local/bundle
COPY --from=webpack_builder /app/node_modules /app/node_modules
```

### [対応策④] alpineイメージを使用する
こちらは実際にはまだ対応できていませんが、イメージを軽くする上では非常に効果が高いと思いますので紹介いたします。

alpine linuxという非常に軽量なOSをベースにして作られたイメージを使用すると、800MBほど軽量化ができました。 

ただ、一度入れようかとチャレンジしたのですが、apkという独自のパッケージ管理を使用しており、結構取っ付きづらかったです。さらに、必要最小限のパッケージのみに絞られているため、shしか入っておらず、触りはじめの頃は大分苦戦しました。

結局、残念ながらbundle installのcapybaraをインストールするところで必ずエラーになり、導入には至れませんでした。

```dockerfile
FROM ruby:2.7.2-alpine
```

プロジェクトの初期などにイメージを構築する際には、alpineを使用した構築をおすすめいたします。

#### 9/17 追記
alpineを安易に使用するのはやめておいた方が良いという記事もあるようです🤔 
記事ではDistoressイメージというイメージの利用をおすすめしているようです。代わりに、こちらの方を使ってみても良いかもしれないですね。（私は全く知りませんでした。。）

https://blog.inductor.me/entry/alpine-not-recommended

## パイプラインの最適化
### 並列で処理
こちらはマシンのリソース的な問題でまだできていませんが、CI/CDパイプラインで並列に実行できる箇所を今後並列化してゆきたいと思っています。具体的には、「イメージのビルド後、ビルドした内容をホストにキャッシュ」する部分と「イメージをECRにプッシュする処理」は、互に独立しているので、並列で実行ができます。これが実現できれば、さらにCI/CDの時間を短縮することができそうです。

結果的にパイプラインの流れは以下のようになりました。

![スクリーンショット 2021-09-17 11.10.32.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/290687/9edd5191-6bc8-8279-f09f-62c4176d371d.png)

## 終わりに
こんな感じの対応を行い、デプロイの時間を30分超→8分ぐらいまで短縮できました。非常に長い苦労を伴いましたが、ここまで短縮できてよかったです。

是非参考にしてみてください。