Dockerfile書くときに意識してること
最初に
仕事で周りみてると結構Dockerfile適当にかいてる?って思うことがあったので
自分の知識整理も含めて、整理
2018年くらいまではDockerちゃんと追えてたけど、最近はk8sの方くらいしか見てないので新しい便利機能追えてなかったり、古い推奨とかあるかも..
ある程度Docker buildに関する知識ある前提
またDockerfileも用途(開発用Imageか, 運用Imageなのか)で良い方法は変わってくると思う
- 共通事項
- 開発時のDockerfileで意識すること(docker buildの速度をあげる)
- 運用時のDockerfileで意識すること(docker imageのサイズを落とす)
で書ければ良いかと
共通事項
なるべくDocker Official Imagesをベースにする
ベースイメージはDocker Hubが公式で提供しているイメージを使いましょう
どれが公式イメージ?かというと, image名だけのパスです
例えばnginx:latest
, redis:latest
など
Dockerのimageパスは <registry host>/<user>/<image name>:<tag>
となっています
Docker Hubの場合はdocker.io
というregistry host名があるのですが、これは省略できます
またDocker Hubの公式 imageはlibrary
というuser名が付いてますが、これも省略できます
結局残るのが、image nameとtagだけになります
(nginx:latest
もフルパスはdocker.io/library/nginx:latest
というわけです)
またDocker HubでOfficial Imagesにチェックをつけて検索かけるとかでもいいかと思います
サードパーティのイメージ使う場合はちゃんと整備されているか確認する
公式で提供されていない場合(例えばkafkaとかは公式提供なし)
次の選択肢が出てきます
- サードパーティのimageを使う
-
centos
などのベースイメージにして、ライブラリインストール手順を自分で組み込む
サードパーティが信用できない場合は後者にします。
サードパーティの信頼基準ですが、自分の場合は以下をみてます
- サードパーティ(ユーザー)がそのイメージの開発者か?(例えばkafkaというイメージだったらGitHubのkafkaリポジトリのメンテナか)
- サードパーティが個人でなく組織か?(会社だと安心感)
- DockerHubにDockerfileの登録や、GitHubの紐付けがされているか
- Pull数が少なすぎないか?
これらと、案件のセキュリティに対する考え方とかを合わせて選びます。
(個人があげてるようなイメージはよっぽどのことがない限りは使いません、仕事では)
タグはちゃんと指定しよう
タグを指定しない場合、latest
タグを勝手にDockerがつけます。
しかしながら、latest
タグは基本的に可変であることを意識しないといけません
Github のreleaseのlatest releaseと同じくらいに思っておけばいい
latest指定で開発を続けていると、昨日まで動いたのに、今日は動かないとかが発生する可能性があります
(latestイメージが使っているライブラリがメジャーバージョンアップして、利用してた機能が廃止されたとか)
そういうのがあるので、基本的にタグはちゃんと指定しましょう
(指定したところで、それが上書きされる可能性はありますが、latestよりは更新頻度少ないですし, ちゃんとしたリポジトリなら破壊的更新はしないはず...)
選び方ですが、例えばredisイメージを使うとして
使うバージョンが確定しているのであれば、tagでそのバージョンを選びましょう
特にバージョンは指定ない場合は、latestが紐づいてるタグを選ぶと良いでしょう
Imageにはtagと合わせてDIGESTというHASH値が割り振られてます。
latestは基本的に今の一番安定しているtagの別名的なものなので、latestとのDIGESTで検索かければ、同じDIGESTでバージョンなどがちゃんとついてるtagが見つかると思います。
特になければこれを使うと良いです。
またバージョン決まってても redis:5.5, redis:5.5-streachみたいに色々あるかと思います。
この辺はだいたいutil系(vimとか)を入れてるか、削除しているかの違いだと思います。
特に希望なければ、イメージサイズが小さいのを選んでおくと良いと思います。
これはDockerfileの書き方に関するナレッジなので、深く書きませんが
もちろんbuildするときもちゃんとtag指定するようにしましょう(アプリとかの場合はバージョンつけたり)
全部latestで上書きpushは...
Dockerはlayerごとに保存されるので、別タグでpushしても差分情報だけが保存されるためさほどストレージは圧迫しません
Dockerのストレージの仕組みはここを参照
ENVとARGをちゃんと使い分けよう
ENVとARGはどちらもDocker imageの中に環境変数を突っ込むものですが
違いは, docker build時のみ利用するのか、build後、RUNするときも残ってて欲しいかが違いです
よくある例として、Dockerfileの中で、社内のgit repositoryからcloneするときに認証情報を渡さないといけない、ただハードコーディングはしたくないから環境変数で渡したいというパターン
このような場合はARGを使うようにしましょう
どういうときにENVを使うかというと、Docker imageで動かすアプリが環境変数をパラメータでもち、かつそれにデフォルト値を指定しておきたい場合などです。
ENVで指定された環境変数ですが、docker run時に-e
オプションをつけることで、上書きすることができるので
複雑なインストール手順などは別途スクリプトファイルを書こう
Dockerfileでは、やろうと思えば以下のようにRUNで改行して、長い処理もかけます
RUN wget foobar.com/hoge && \
cd hoge && \
make && \
make install
しかし、bashの構文使うような複雑な処理は書きづらいですし、ミスも発生しやすくなるので
長いshellコマンドはファイルに切り出して、Dockerfileに入れる形にした方がいいです。
#!/bin/bash
wget foobar.com/hoge
cd hoge
make
make install
こんな感じでshellファイルに切り出して
COPY install.sh install.sh
RUN chmod +X install.sh
RUN ./install.sh
のようにCOPY
でDockerfileの中に入れて、動かす感じにした方が
管理もしやすくなります
開発時のDockerfileで意識すること(docker buildの速度をあげる)
開発時に意識していること(Dockerのbuild速度を上げるためにすること)を書いていきます.
あまり更新されないレイヤーを前の方に、頻繁に更新されるレイヤーほど後ろにしてキャッシュを聞かせる
docker buildはdockerfileを上からなめて行って、前回と異なるレイヤーが出たところ以降がキャッシュが使われなくなります。
そのためなるべく頻繁に更新される部分(例えばソースコードのCOPYとか)はDockerfileの後ろにおくようにします。
packge管理用のファイルとアプリのファイルCOPYレイヤーを分ける
前の節と被りますが
$ ls
src pom.xml Dockerfile
みたいなフォルダ構成で
FROM java:8
WORKDIR /app
COPY . .
RUN 依存ライブラリダウンロード処理
RUN build処理
みたいなDockerfileにしてしまうと、ソースコードを書き換えただけで、pom.xmlをいじっていない場合でも毎回依存ライブラリのダウンロードが入ってしまう。
これの回避策として以下のようにすれば
FROM java:8
WORKDIR /app
COPY pom.xml .
RUN 依存ライブラリダウンロード処理
COPY . .
RUN build処理
ソースコードのみ変更時はRUN 依存ライブラリダウンロード処理
レイヤーまでがキャッシュ利用されるので、buildが高速になる
javaで書いたけど、他の言語もそう(nodeだったらpackege.jsonとか、goだったらgo.modとかを先にCOPYする)
なるべくレイテンシーが少ないミラーリポジトリを使う
何か大きめのファイルを取得するようなDockerfile(例えばSparkなど)
海外ミラーリポジトリとかでなく国内のファイルサーバを使うようにした方がいいです。
帯域細い場合はAWS内でBuildするのもあり
Dockerfileの書き方ではないけど、開発環境の回線がしょぼい場合は
AWSのvpcの中でDocker buildした方が速かったりします
複数のDockerfileで共通のベースイメージを用意しておく
複数のアプリ開発で、それぞれが共通のライブラリをあらかじめapt-getやyumなりでインストールしておく必要がある場合は
事前にそれらだけを行うDockerfileを作成し、共通repositoryにpushし、それをアプリ開発のbaseにすれば、ビルド時間の短縮につながります。
1ファイルに完結しなくなるため、多用は良くない気がする..
プロジェクト内で完結するImageであれば、試してみると良いと思います。
Docker BuildKit使う
Dockerfileじゃないけど
最近はデフォで有効化されるのかな?
buildが並列で行われるので、並列なbuildを意識したDockerfileを書くと速くなる
普通のDockerfileでも使うだけで多少速くなったと思うので、使うと良い
運用時のDockerfileで意識すること(docker imageのサイズを落とす)
運用時に意識していること(Dockerイメージのサイズを落とすこと)を書いていきます.
Multi Stage Build
Dockerの機能でMulti Stage Buildというのがあります。
分かりやすいのがGoアプリのDocker Imageを作るときなんかですが
Goはビルドするとシングルバイナリが生成されます。
このビルド処理にはGoのランタイムがいるのですが、バイナリの実行にはランタイム不要です。
Goのランタイムを入れるだけで、ディスクが100MBくらい埋まってしまうので運用イメージに使いたくないなと思います。
前であれば、Dockerfileの外でGoのビルドをしてバイナリを生成し、Dockerfileの中でCOPY使って、バイナリだけ運ぶとかやってました
これだとDockerfileだけで完結しないため、ポータビリティ性が低いしビルドが各自の環境に依存してしまいます。そのためなるべくDockerfile内でBuildしたい..
これを解決するのが、Multi-Stage Buildです
FROM golang:alpine as builder
RUN git clone github.com/hoge/hoge.git
WORKDIR hoge
RUN go build
FROM alpine
COPY /go/hoge/app /app
CMD ["/app"]
こんな感じで、1つのDockerfile内に2回FROM
が登場します。
最初のFROM golang as builder
でGoのランタイムをつかってGoアプリのリポジトリをクローンしてきてbuildを実行してバイナリを生成してます.
2回目のFROM
でalpineのイメージでbuildをしていきます。
次の行でCOPY --from=builder /go/hoge/app /app
とありますが
ここで、前のbuild処理で生成した、バイナリファイルだけを取得してきてます。
削除処理は取得処理と同一レイヤー内でする
最近は逆に分かりづらくなるってことでアンチパターンにもなってるかも?
RUN apt-get update && apt-get install -y \
vim \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
こんな感じで、apt-getをしたら同じRUN
の中で削除処理もしてしまう。
Dockerはレイヤー(Dockerfileの1行)ごとに保存されるので、レイヤーが増えてもイメージサイズが減ることはありません
例えば、以下のように分けても2行目以降は, 削除されるファイルに参照しないというマーク付をするだけで、実体の削除は行われません。
そのためイメージサイズも減りません。
(この辺もこちらを参照)
RUN apt-get update && apt-get install -y vim
RUN apt-get clean
RUN rm -rf /var/lib/apt/lists/*
削除処理をする場合は、同一レイヤ内でやるようにしましょう
なるべく小さいbaseimageを選ぶ
なるべくalpineベースが良い
centosやubuntuはそれだけど100MBとかあるけど
alpineだと10MB程度
utility的な使い方しないのであれば、alpineが良い
Discussion
Dockerのマルチステージビルドの例の中の最初のFROMで
golang
とだけ指定されているものをalpineのイメージに持ち込んでいますが、golang
のみで指定された場合にpullされるイメージは debian ですのでそのままでは動作に支障があると思うのですが、いかがでしょうか?alpineに持ち込むのでしたら最初の指定は
golang:alpine
となるべきかと。Dockerというよりはgoの話になってしまうかもしれないのですが
今回書いた例ですと、golang(debian)のステージからalpineのステージへ移すものがビルドされたgoアプリのバイナリファイルだけになります。
goはクロスコンパイルの機能を提供しているため、ディストリビューションは異なっても同一OS(linux)であれば動作するかと思います。
ディストリビューションに依存するような処理(極端ですが
exec.Command("apt-get", "update").Run()
)を行うのであれば、難しいとは思うのですが基本的なGoのアプリケーションであれば問題はないのかなと自分は認識しています。他のビルド設定などという意味であれば、上の例はかなり簡略化しているため、そのままでは動かないです。以下に実行ログをのせておきます。
main.go
Dockerfile
buildして実行
本文では文体が一般的な話に終始しており、go言語特有の話にはなってなかったと思いますが、簡単なサンプルを挙げさせていただきます。
https://qiita.com/irugo/items/390bd187871c7716a1e1 から拝借しました。
このようなcgoを使ったライブラリをimportしていたりすると、リンクしているランタイムが違うので破綻します。
以下は示していただいたサンプルにiconv-goを取得する部分のみを加えただけのDockerfileです。
実行すると下記のようになります。
これを最後の
FROM alpine
をFROM debian
に変更すると動作します。なぜ alpine が軽量になっているのか?その違いをもう一度お調べになったほうが良いと思います。
なるほど、ありがとうございます
cgo使う際にこのような課題が発生するの勉強になりました。
ご指摘ありがとうございました。
Multi stage buildの指摘箇所を修正しました。