こんにちは、
アプリケーションエンジニアとして働いてる、キムです。
今回は自分が作成、運営中のシステムのDockerイメージの最適化、イメージの軽量化のコツについていくつか方法を共有したいと思います。
確か、この方法以外により良い方法もあると思いますので、なにかあればコメントとかで残していただけると嬉しいです。
入る前に
アプリケーションをDocker Containerで作って使う時、ちゃんと気にしないと作成されたDockerイメージの容量が考えたことより大きくてサーバーにデプロイするときや、共有が難しくなるかもしれません。
しかし、下記の流れをみてDockerイメージ容量に影響を与える部分をどうやって回避するのかを理解できれば、より軽量のイメージを作られると思います。
今日はその方法の中、自分が経験し適用してみたコツを共有しようと思います。
やってみましょう!
それでは比較のために何も気にせずDocker Imageを作ってみましょう。
自分は今回Nuxtでプロジェクトを作ってみました。
$ yarn create nuxt-app qiita_test
yarn create v1.22.17
[1/4] 🔍 Resolving packages...
[2/4] 🚚 Fetching packages...
[3/4] 🔗 Linking dependencies...
[4/4] 🔨 Building fresh packages...
success Installed "create-nuxt-app@4.0.0" with binaries:
- create-nuxt-app
create-nuxt-app v4.0.0
✨ Generating Nuxt.js project in qiita_test
? Project name: qiita_test
? Programming language: JavaScript
? Package manager: Yarn
? UI framework: None
? Nuxt.js modules: (Press <space> to select, <a> to toggle all, <i> to invert selection)
? Linting tools: ESLint, Prettier
? Testing framework: Jest
? Rendering mode: Universal (SSR / SSG)
? Deployment target: Server (Node.js hosting)
? Development tools:
? Continuous integration: GitHub Actions (GitHub only)
? What is your GitHub username? seongjoo kim
? Version control system: Git
これでPJが作成されました。次はDockerfile
を作成して下記のように書いてみましょう。
本当に基本的な、何も考えず基本的な処理を行うDockerfileになります。
FROM node:16
WORKDIR /app
COPY . /app
RUN yarn global add webserver.local
RUN yarn install & yarn build
EXPOSE 3000
CMD webserver.local -d ./.nuxt
コード作成が終わったら下記のコマンドでイメージを作ってみましょう。
$ docker image build . --tag=demo-docker
このコマンドで、イメージが作成されたら内容を確認してみましょう。
$ docker images demo-docker
REPOSITORY TAG IMAGE ID CREATED SIZE
demo-docker latest c6d2943f9cf0 18 seconds ago 1.13GB
なんと…1.13GB…!これはすごいですね。
Docker Imageの軽量化
このすごい容量をどうすれば軽量化できますかね。
この問題を解決するに自分は2つの方法でアプローチしてみました。
1.より軽いベースイメージを選択すること
Dockerイメージは基本ベースイメージを元にし、その上にアプリやパッケージを積み上げていく方法になります。
このベースイメージを我々が普段知っているサーバーOS(CentOS,Ubuntu)をベースとして作ると、Docker環境での不要なパッケージや、ファイルも含まれていて自然に容量が高くなります。
(一般的にサーバーにOSをインストールすると、かなり容量を食いますね?Dockerを使うかそうかも結局サーバーにOSにをインストールすることと同じ概念です。)
なので、この問題点を解決するためのDockerのためのOSがつくられて、その中に代表的なものがAlpineでございます。
Alpine
はDockerのためのLinuxのDistributeであります。
(一般的なOSをDocker上で軽く使いたく、カスタマイジングされたものになります。)
それでは、上記のDockerfileをAlpineをベースに修正してBuildし直してみましょう
FROM node:16-alpine
WORKDIR /app
COPY . /app
RUN yarn global add webserver.local
RUN yarn install & yarn build
EXPOSE 3000
CMD webserver.local -d ./.nuxt
どうでしょうか?!
REPOSITORY TAG IMAGE ID CREATED SIZE
demo-docker latest 1ce499968dde 15 seconds ago 369MB
なんと!1.13GBから、369MBになりました。
でも、私はこれで満足できません!!
もう少し減らしてみましょう!
2.Multi-stage Build
これは、Buildする時、Build処理だけ必要な作業をわけてBuildを行い、最終Containerイメージには追加しない方法になります。
つまり、Buildステップを分けて、不要なものを結果物には含まれないように作る方法になります。
Dockerfileを下記のように修正してみましょう
FROM node:16-alpine AS build
WORKDIR /app
COPY . /app
RUN yarn install & yarn build
FROM node:16-alpine
WORKDIR /app
RUN yarn global add webserver.local
COPY --from=build /app/.nuxt ./.nuxt
EXPOSE 3000
CMD webserver.local -d ./.nuxt
Buildのステップを2つに分けました。
Build環境セットアップ及びBuild後、実際Buildされた結果だけウェブサーバーで使うようにします。
Build結果は下記のようになります。
更に減りましたね!
$ docker images demo-docker
REPOSITORY TAG IMAGE ID CREATED SIZE
demo-docker latest fe177f7962d8 37 seconds ago 119MB
ここまで来たら、なにかもう少し物足りないな…と思うと思います。
それはウェブサーバーをなぜwebserver.local
を使ってるのかです。
我々にはもう少し軽く、早いNginx
とApache
があるのにです!
つぎはこれはNginx
に書き換えてみましょう
FROM node:16-alpine AS build
WORKDIR /app
COPY . /app
RUN yarn install & yarn build
FROM nginx:stable-alpine
COPY --from=build /app/.nuxt /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
じゃぁ、最後の結果を見ましょう
どうでしょうか!?
$ docker images demo-docker
REPOSITORY TAG IMAGE ID CREATED SIZE
demo-docker latest 3d3251e6baf7 56 seconds ago 23.8MB
最初に始まるときに1.19GBだったことを覚えてますでしょうか?
今まで、修正してきたことを見売ればわかりますが、本当に小さな修正がここまで容量の差分を作り出します。
おまけ.1
普段は、このAlpine Linux Dockerイメージで始まることをおすすめします。
その理由としては、上記見たとおりにイメージのサイズが小さいからです。では、なぜ小さいでしょうか?
それは、Cライブラリーとしてmusl
を使ってるし、いろんなUnixツールを持っているbusybox
をベースとしてるからです。
しかし、Pythonアプリケーションを作る時にはAlpineをおすすめしません。
その理由はUsing Alpine can make Python Docker builds 50× slowerこちらを読んでみてください。
この記事をざっくり略してみると、
- PyPIに上げたPythonライブラリーは普段
Wheel
フォーマットを使いますが、Alpine LinuxはWheel
フォーマットをサポートしてないので、ソースコード(.tar.gz)をわざとダウンロードして直接コンパイルする場合があるからです。 -
Wheel
フォーマットをサポートしてない理由はmusl
ライブラリーがGNU Cライブラリー(glibc)としてコンパイルされたWheel
バイナリーをサポートしてないからです。なのでPyPIをつかって.whl
として作られたパッケージではなく、ソースコード(.tar.gz)をダウンロードすることです。 -
Wheel
フォーマットはPythonでインストールなしで直接実行できるメリットがあります。その理由はWheel
フォーマットそのものが、zip
圧縮から拡張子だけ変えたものであり、PythonはJavaの.jar
のようにzip
ファイルをすぐに実行できるからです。
なので、Apline Linuxを使うのであれば、すべてのPythonパッケージからCコードをコンパイルしなきゃいけないので、イメージをBuildするには時間がかなりかかります。
FROM python:3.8-alpine
RUN apk --update add gcc build-base freetype-dev libpng-dev openblas-dev
RUN pip install --no-cache-dir matplotlib pandas
このようなイメージをBuildするには自分の環境だと30分ぐらいかかりました。
ついでに、Alpine LinuxをベースとしたPythonイメージは容量もそこそこあるし、いくつかの脆弱性も発見されてるいます。
結論を言うと、Pythonアプリケーションを使うときにはDebian Busterをベースとしたpython:3.8-buster
または、3.8-slim-buster
などを使うことをおすすめします。
※参考:The best Docker base image for your Python application (August 2021)
おまけ.2 その他のTIPS
イメージレイアの数を減らしましょう
過去、Dockerのバージョンではイメージレイア数がパフォーマンスに影響をかけたと言われてます。
しかし、もう大丈夫です。
それでも、Dockerのレイアー数を減らすことは最適化的な面では役に立つと思います。
レイアーはRUN
,ADD
,COPY
コマンドだけで作られるので、下記のように複数の別れてるコマンドをChainingしてつなげてみましょう
レイアー数を少なくすることでDockerイメージ、Containerのパフォーマンスに影響があるわけではないですが、Dockerfileの可読性や、メンテナンス面では役に立つと思います。
RUN apt-get update
RUN apt-get -y install git
RUN apt-get -y install locales
RUN apt-get -y install gcc
↓
RUN apt-get update && apt-get install -y \
gcc \
git \
locales
このように作成することで、既存4つのレイアーが作られたことを一つのレイアーとして減らせます。
そして、これをやりながら、インストールするパッケージの順番をアルファベット順に書き換えたりすることで、可読性も高く、重複インストールを防止できる効果もあります。
アプリケーションコードをコピーする部分は下へ
下記のサンプルDockerfile
にはCOPY
が2回実行されます。
1回目は依存性パッケージが明示されたファイル、2回目はアプリケーションコードが保存されてるディレクトリーであります。
FROM python:3.8-slim-buster
WORKDIR /app
COPY requirements.txt /app
COPY django_project /app
RUN pip install -r requirement.txt
CMD ["pip", "freeze"]
通常、依存パッケージは頻繁に変わらないため、最初のCOPY
命令で作成されたレイヤーはキャッシュされます。
しかし、2番目のCOPY
はビルドするたびに変わることができるため、キャッシュが頻繁に初期化され、次に実行されるRUN
コマンドで実行される依存パッケージのインストールが毎回実行される可能性が高いです。
このようになると、不必要にビルド時間が増えて、やや依存性パッケージバージョンをlatest
や*
にしておけば、予期せずパッケージバージョンが上がることがります。
したがって、アプリケーションコードコピーコマンドは、頻繁に変更されないステートメントの後に来ると、イメージの構築時間を短縮するのに有利です。
プログラミング言語ごと、パッケージマネージャが提供するLockファイルを使うこと
前の説明で依存パッケージを指定したファイルとしてrequirements.txt
を使用しました。 このファイルは、Pythonの公式パッケージマネージャであるPIP
で使用される慣行的に呼ばれるファイルです。
Python開発環境に基づいてみると、PIP
はパッケージ固有の相互依存関係への関係を管理するのに不十分な面があります。 だから最近では、より進化したLocking
システムを備えたPipenv
やPoetry
を使用することをお勧めします。
これらのツールを使用して生成されたロックファイル(Pipfileなど)に基づいてパッケージをインストールできるようにすることで、上記説明したキャッシュレイヤの利点を得ることができ、予期しないパッケージバージョンの更新を防ぐことができます。
FROM python:3.8-slim-buster
WORKDIR /tmp
RUN pip install pipenv
COPY Pipfile /tmp/Pipfile
COPY Pipfile.lock /tmp/Pipfile.lock
RUN pipenv install --system --deploy
CMD ["pip", "freeze"]
終わり
いかがでしょうか?
もう、Dockerはウェブアプリケーションの開発では当たり前な時期になりました。
Dockerfileの修正ははじめにアプリケーションを立ち上げるとき以外はなかなか手を入れづらいとおもいます。
しかし、このような技を身に着けておくと今後メンテナンスしやすいベースを作り出すと思います。
参考になったら嬉しいです。