Dockerfileはなぜ複雑になるのか

# はじめに

- Dockerfileとは
    - docker imageを作成する際のコマンドをコード化したもの
    - [公式ドキュメント](https://docs.docker.com/engine/reference/builder/)

Dockerfileは「コンテナを動かす」ためだけなら簡単に作成することが出来るが、工夫せずに書くと運用上いろいろな問題が発生する。
それらの問題点のほとんどは書き方のテクニックによって回避することが出来るが、それらのテクニックを駆使すると、今度はDockerfileの中が複雑になっていく。

- Dockerfileはなぜ複雑にならざるを得ないのか

発生する問題とそれに対するテクニックを例を上げて説明していくことで理解してもらう。

[rails5.1 hello world project](https://github.com/gomesuit/larning-dockerfile)を例に説明する。

# 簡単なDockerfileの例

重要なのは`FROM`と`RUN`と`COPY`のみ

- FROM
    - ベースとなるimageの指定
    - https://docs.docker.com/engine/reference/builder/#from
- RUN
    - コマンドの実行
    - https://docs.docker.com/engine/reference/builder/#run
- COPY
    - ローカルからdocker imageへのファイルの転送
    - https://docs.docker.com/engine/reference/builder/#copy

```docker:Dockerfile.base
FROM ruby:2.4.1

# timezone
RUN cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime

EXPOSE 3000
WORKDIR /home/port/app

# install dependency package
RUN apt-get update
RUN apt-get install -y apt-transport-https libssl-dev

# install nodejs
RUN curl -s -L git.io/nodebrew | perl - setup
ENV PATH /root/.nodebrew/current/bin:$PATH
RUN nodebrew install-binary v8.7.0
RUN nodebrew use v8.7.0

# install yarn
# https://yarnpkg.com/en/docs/install#linux-tab
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
RUN apt-get update
RUN apt-get install -y yarn

RUN gem install bundler

COPY . /home/port/app

# bundle install
RUN bundle install --without development test --path vendor/bundle

# yarn install
RUN yarn install

# assets precompile
RUN RAILS_ENV=production bundle exec rails assets:precompile

CMD bundle exec puma -t 5:5 -p 3000 -e "$RAILS_ENV" -C config/puma.rb
```

```text:.dockerignore
*
!app
!bin
!config
!db
!lib
!public/*.html
!public/*.png
!public/favicon.ico
!public/robots.txt
!config.ru
!Gemfile
!Gemfile.lock
!package.json
!yarn.lock
!.postcssrc.yml
!.babelrc
!Rakefile
!log/.keep
!tmp/.keep
```

- dockerignoreについて
    - gitignoreみたいなもの
    - ビルド及び実行に必要のないファイルを除外する
    - [公式ドキュメント](https://docs.docker.com/engine/reference/builder/#dockerignore-file)

### 概要
- ruby:2.4.1の公式imageをベースに指定
- apt-getで環境依存しているパッケージをインストール
- nodeとyarnとbundlerをインストール
- ソースコードを全てimageにコピー
- gemインストール
- nodeパッケージインストール
- assets precompile
- 1つのRUNに1つのコマンド

### ビルドする

```shell-session
$ docker-compose build rails-base
Building rails-base
Step 1/20 : FROM ruby:2.4.1
 ---> ceb1a85dc7b4
Step 2/20 : RUN cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime
 ---> Using cache
 ---> 7c2be593e2b6
Step 3/20 : EXPOSE 3000
 ---> Using cache
 ---> 0eac29b48116
Step 4/20 : WORKDIR /home/port/app
 ---> Using cache
 ---> 0313aa07291d
Step 5/20 : RUN apt-get update
 ---> Using cache
 ---> 3da49f68bc04
Step 6/20 : RUN apt-get install -y apt-transport-https libssl-dev
 ---> Using cache
 ---> a070a1b5e21e
Step 7/20 : RUN curl -s -L git.io/nodebrew | perl - setup
 ---> Using cache
 ---> da6e0f8812f2
Step 8/20 : ENV PATH /root/.nodebrew/current/bin:$PATH
 ---> Using cache
 ---> c81c90973400
Step 9/20 : RUN nodebrew install-binary v8.7.0
 ---> Using cache
 ---> ac337962e5ac
Step 10/20 : RUN nodebrew use v8.7.0
 ---> Using cache
 ---> ca4ba4f7988f
Step 11/20 : RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
 ---> Using cache
 ---> ac3af5d6d3ff
Step 12/20 : RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
 ---> Using cache
 ---> b2a62c0af10f
Step 13/20 : RUN apt-get update
 ---> Using cache
 ---> 9f585a492494
Step 14/20 : RUN apt-get install -y yarn
 ---> Using cache
 ---> c02e113291cf
Step 15/20 : RUN gem install bundler
 ---> Using cache
 ---> f5c3db51041c
Step 16/20 : COPY . /home/port/app
 ---> Using cache
 ---> 90887301369c
Step 17/20 : RUN bundle install --without development test --path vendor/bundle
 ---> Using cache
 ---> f1c7abb31617
Step 18/20 : RUN yarn install
 ---> Using cache
 ---> 3bc9c402c2dc
Step 19/20 : RUN RAILS_ENV=production bundle exec rails assets:precompile
 ---> Using cache
 ---> 6dff320a2996
Step 20/20 : CMD bundle exec puma -t 5:5 -p 3000 -e "$RAILS_ENV" -C config/puma.rb
 ---> Using cache
 ---> 241c086a76d5
Successfully built 241c086a76d5
Successfully tagged dockerfilesample_rails-base:latest
```

### ビルド時のキャッシュについて
- Step毎にキャッシュが効く
- `Using cache`となっていればキャッシュが使われている
- 一度ビルドが完了すれば、ソースコードを変更しない限りキャッシュが使われるので２度目以降は一瞬で完了する

### 上記のDockerfileの問題点
- ソースコードを変更する度にbuildに時間がかかる
    - 例えば`app/controllers/application_controller.rb`を修正すると毎回bundle installから実行されてしまう

        ```shell-session
        $ docker-compose build rails-base # 1回目のビルド
        $ # application_controller.rbに改行を挿入
        $ echo '
        ' >> app/controllers/application_controller.rb
        $ docker-compose build rails-base # 2回目のビルドはStep17から実行されてしまう
        ```

- image sizeが大きい
    - 上記のDockerfileで`1.05GB`
    - image sizeが大きいほどpullに時間がかかる
    - 複数サーバにデプロイするとネットワーク帯域を逼迫する
- railsの実行ユーザがrootになっている
    - 仮にハッキングされて任意のコマンドが実行される状態になってしまうと、コンテナ内のファイルを自由に変更されてしまう。

# 各問題に対する回避テクニック

## ソースコードを変更する度にbuildに時間がかかる問題
### 可能な限りステップ毎のキャッシュが使われるようにする
- 更新頻度の低いものをDockerfileの上部に、多いものを下部に記載する
- `COPY`を細分化する

```docker:Dockerfile.1
FROM ruby:2.4.1

〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜同じ〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜

RUN gem install bundler

# bundle install
COPY Gemfile Gemfile.lock ./
RUN bundle install --without development test --path vendor/bundle

# yarn install
COPY package.json yarn.lock .postcssrc.yml ./
RUN yarn install

# assets precompile
COPY Rakefile .babelrc ./
COPY config config
COPY app/assets app/assets
COPY app/javascript app/javascript
COPY bin bin
RUN RAILS_ENV=production bundle exec rails assets:precompile

COPY . /home/port/app

CMD bundle exec puma -t 5:5 -p 3000 -e "$RAILS_ENV" -C config/puma.rb
```

### 変更点の概要
- `COPY`時のファイルをに直後のRUN毎に必要な分だけ行うようにする
- Gemfileを変更したときだけbundle installが実行される
- package.jsonを変更したときだけyarn installが実行される
- `app/controllers/application_controller.rb`を修正してもassets:precompileが実行されない
- bundleとyarnのどちらを上に書くかはプロジェクトによって決める

## image sizeが大きい問題
### パッケージマネージャ（apt-get, yumなど）の最適化
- 実行に不要なライブラリはインストールしない
- staticライブラリはビルド後（同じRUN内）に削除する
    - ２種類のライブラリ
        - staticライブラリ
            - コンパイル時にbinaryに取り込まれる
        - sharedライブラリ
            - プログラム実行時に読み込まれる

```docker:Dockerfile.2
FROM ruby:2.4.1

〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜同じ〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜

# install dependency package
RUN apt-get update && \
    apt-get install -y --no-install-recommends apt-transport-https libssl-dev && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

# install nodejs
RUN curl -s -L git.io/nodebrew | perl - setup
ENV PATH /root/.nodebrew/current/bin:$PATH
RUN nodebrew install-binary v8.7.0
RUN nodebrew use v8.7.0

# install yarn
# https://yarnpkg.com/en/docs/install#linux-tab
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add -
RUN echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list
RUN apt-get update && \
    apt-get install -y --no-install-recommends apt-transport-https yarn && \
    apt-get clean && \
    rm -rf /var/lib/apt/lists/*

RUN gem install bundler

〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜同じ〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜

CMD bundle exec puma -t 5:5 -p 3000 -e "$RAILS_ENV" -C config/puma.rb
```

### 変更点の概要
- 1.05GB -> 1.03GB
- RUN内ではじめに`apt-get update`、最後に`apt-get clean`、`rm -rf /var/lib/apt/lists/*`を行うことでサイズが増えるのを防ぐ

### base imageを小さいものに変更する
- alpine(軽量でセキュアなLinuxディストリビューション)を使う

| image | size |
| --- | --- |
| ruby:2.4.1 | 684MB |
| ruby:2.4.1-slim | 224MB |
| ruby:2.4.1-alpine3.6 | 84.4MB |

```docker:Dockerfile.3
FROM ruby:2.4.1-alpine3.6

# timezone
RUN apk add --no-cache tzdata && \
    cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime && \
    apk del --purge tzdata

EXPOSE 3000
WORKDIR /home/port/app

# install nodejs
ENV NODE_VERSION 8.7.0
RUN addgroup -g 1000 node \
    && adduser -u 1000 -G node -s /bin/sh -D node \
    && apk add --no-cache \
        libstdc++ \
    && apk add --no-cache --virtual .build-deps \
        binutils-gold \
        curl \
        g++ \
        gcc \
        gnupg \
        libgcc \
        linux-headers \
        make \
        python \
  # gpg keys listed at https://github.com/nodejs/node#release-team
  && for key in \
    9554F04D7259F04124DE6B476D5A82AC7E37093B \
    94AE36675C464D64BAFA68DD7434390BDBE9B9C5 \
    FD3A5288F042B6850C66B31F09FE44734EB7990E \
    71DCFD284A79C3B38668286BC97EC7A07EDE3FC1 \
    DD8F2338BAE7501E3DD5AC78C273792F7D83545D \
    B9AE9905FFD7803F25714661B63B535A4C206CA9 \
    C4F0DFFF4E8C1A8236409D08E73BC641CC11F4C8 \
    56730D5401028683275BD23C23EFEFE93C4CFFFE \
  ; do \
    gpg --keyserver pgp.mit.edu --recv-keys "$key" || \
    gpg --keyserver keyserver.pgp.com --recv-keys "$key" || \
    gpg --keyserver ha.pool.sks-keyservers.net --recv-keys "$key" ; \
  done \
    && curl -SLO "https://nodejs.org/dist/v$NODE_VERSION/node-v$NODE_VERSION.tar.xz" \
    && curl -SLO --compressed "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt.asc" \
    && gpg --batch --decrypt --output SHASUMS256.txt SHASUMS256.txt.asc \
    && grep " node-v$NODE_VERSION.tar.xz\$" SHASUMS256.txt | sha256sum -c - \
    && tar -xf "node-v$NODE_VERSION.tar.xz" \
    && cd "node-v$NODE_VERSION" \
    && ./configure \
    && make -j$(getconf _NPROCESSORS_ONLN) \
    && make install \
    && apk del .build-deps \
    && cd .. \
    && rm -Rf "node-v$NODE_VERSION" \
    && rm "node-v$NODE_VERSION.tar.xz" SHASUMS256.txt.asc SHASUMS256.txt

# install yarn
ENV YARN_VERSION 1.2.0
RUN apk add --no-cache --virtual .build-deps-yarn curl gnupg tar \
  && for key in \
    6A010C5166006599AA17F08146C2130DFD2497F5 \
  ; do \
    gpg --keyserver pgp.mit.edu --recv-keys "$key" || \
    gpg --keyserver keyserver.pgp.com --recv-keys "$key" || \
    gpg --keyserver ha.pool.sks-keyservers.net --recv-keys "$key" ; \
  done \
  && curl -fSLO --compressed "https://yarnpkg.com/downloads/$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz" \
  && curl -fSLO --compressed "https://yarnpkg.com/downloads/$YARN_VERSION/yarn-v$YARN_VERSION.tar.gz.asc" \
  && gpg --batch --verify yarn-v$YARN_VERSION.tar.gz.asc yarn-v$YARN_VERSION.tar.gz \
  && mkdir -p /opt/yarn \
  && tar -xzf yarn-v$YARN_VERSION.tar.gz -C /opt/yarn --strip-components=1 \
  && ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn \
  && ln -s /opt/yarn/bin/yarn /usr/local/bin/yarnpkg \
  && rm yarn-v$YARN_VERSION.tar.gz.asc yarn-v$YARN_VERSION.tar.gz \
  && apk del .build-deps-yarn

RUN gem install bundler

COPY . /home/port/app

# bundle install
RUN apk --no-cache --virtual gem-builddeps add alpine-sdk sqlite-dev && \
    bundle install --without development test --path vendor/bundle && \
    apk del --purge gem-builddeps

# yarn install
RUN yarn install

# assets precompile
RUn apk --no-cache add tzdata sqlite-libs
RUN RAILS_ENV=production bundle exec rails assets:precompile

CMD bundle exec puma -t 5:5 -p 3000 -e "$RAILS_ENV" -C config/puma.rb
```

### 変更点の概要
- 1.05GB -> 405MB
- alpineではapkを使って依存パッケージをインストールする

## railsの実行ユーザがrootになっている問題
### 一般ユーザで動作させる

- [公式ドキュメント(docker security)](https://docs.docker.com/engine/security/security/)

```docker:Dockerfile.4
FROM ruby:2.4.1

# timezone
RUN cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime

EXPOSE 3000

# create user
RUN useradd port -u 3333 -d /home/port && \
    mkdir -p /home/port/app && \
    chown port.port -R /home/port

WORKDIR /home/port/app

〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜同じ〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜

# assets precompile
RUN RAILS_ENV=production bundle exec rails assets:precompile

RUN chown port.port log && \
    chown port.port -R db
USER port

CMD bundle exec puma -t 5:5 -p 3000 -e "$RAILS_ENV" -C config/puma.rb
```

### 変更点の概要
- 一般ユーザを作成
- 最低限権限が必要なディレクトリ・ファイルのオーナーを変更する
- `USER`で実行ユーザを変更する

## (応用)multi stage build
- Docker 17.05以降のみ使用可能
- imageを小さくするためのテクニック( staticライブラリをビルド後に削除する)により、Dockerfileが複雑になることを防ぐ
- 公式imageが存在するものは同じディストリビューションのものからビルド済みバイナリをコピーすることが出来る（無くても自分で作成すれば同じことが出来る）
- [公式ドキュメント(Use multi-stage builds)](https://docs.docker.com/engine/userguide/eng-image/multistage-build/#before-multi-stage-builds)


```docker:Dockerfile.5
FROM node:8.7.0 as node

FROM ruby:2.4.1

〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜同じ〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜

# install dependency package
RUN apt-get update
RUN apt-get install -y apt-transport-https libssl-dev

# install nodejs yarn
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/include/node /usr/local/include/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=node /opt/yarn /opt/yarn
RUN ln -s /usr/local/bin/node /usr/local/bin/nodejs && \
    ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
    ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn

RUN gem install bundler

COPY . /home/port/app

〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜同じ〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜〜

CMD bundle exec puma -t 5:5 -p 3000 -e "$RAILS_ENV" -C config/puma.rb
```


### 変更点の概要
- `FROM node:8.7.0 as XXXX`でコピー元のimageを指定
    - コピー元のimageのディストリビューションをコピー先のものと一致させる
        - https://hub.docker.com/_/ruby/
            - https://github.com/docker-library/ruby/blob/135c84979b1401a6963e75f3c95161bfe5be2336/2.4/jessie/Dockerfile
        - https://hub.docker.com/_/node/
            - https://github.com/nodejs/docker-node/blob/15d780e932fc8cd4a145a36cff405610c8c71b0c/8.7/Dockerfile
- `COPY --from=XXXX`で必要なファイル（今回はnodeとyarn）をコピーする


## 全てのテクニックを適用したDockerfile

```docker:Dockerfile.6
FROM node:8.7.0-alpine as node

FROM ruby:2.4.1-alpine3.6

# timezone
RUN apk add --no-cache tzdata && \
    cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime && \
    apk del --purge tzdata

EXPOSE 3000

# user
RUN apk --no-cache add shadow
RUN useradd port -u 3333 -d /home/port && \
    mkdir -p /home/port/app && \
    chown port.port -R /home/port

WORKDIR /home/port/app

# install nodejs yarn
COPY --from=node /usr/local/bin/node /usr/local/bin/node
COPY --from=node /usr/local/include/node /usr/local/include/node
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=node /opt/yarn /opt/yarn
RUN ln -s /usr/local/bin/node /usr/local/bin/nodejs && \
    ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
    ln -s /opt/yarn/bin/yarn /usr/local/bin/yarn

RUN gem install bundler

# install dependency package
RUN apk --no-cache add tzdata sqlite-libs libstdc++

# bundle install
COPY Gemfile Gemfile.lock ./
RUN apk --no-cache --virtual gem-builddeps add alpine-sdk sqlite-dev && \
    bundle install --without development test --path vendor/bundle && \
    apk del --purge gem-builddeps

# yarn install
COPY package.json yarn.lock .postcssrc.yml ./
RUN yarn install

# assets precompile
COPY Rakefile .babelrc ./
COPY config config
COPY app/assets app/assets
COPY app/javascript app/javascript
COPY bin bin
RUN RAILS_ENV=production bundle exec rails assets:precompile

COPY . /home/port/app

RUN chown port.port log && \
    chown port.port -R db
USER port

CMD bundle exec puma -t 5:5 -p 3000 -e "$RAILS_ENV" -C config/puma.rb
```

# まとめ
- Dockerfileはある程度複雑になってしまう
- 理由
    - キャッシュを有効に活用しbuildの時間を短縮するため
    - 実行時に不要なstaticライブラリをimage内に残さないため
    - 一般ユーザで実行するため

# 参考
- [公式ドキュメント(Best practices for writing Dockerfiles)](https://docs.docker.com/engine/userguide/eng-image/dockerfile_best-practices/)