🤔

Dockerfile書くときに意識してること

2020/09/17に公開約6,500字7件のコメント

最初に

仕事で周りみてると結構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にチェックをつけて検索かけるとかでもいいかと思います

image

サードパーティのイメージ使う場合はちゃんと整備されているか確認する

公式で提供されていない場合(例えば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じゃないけど

https://matsuand.github.io/docs.docker.jp.onthefly/develop/develop-images/build_enhancements/

最近はデフォで有効化されるのかな?

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 --from=builder /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のアプリケーションであれば問題はないのかなと自分は認識しています。

他のビルド設定などという意味であれば、上の例はかなり簡略化しているため、そのままでは動かないです。以下に実行ログをのせておきます。

$  tree .
.
├── Dockerfile
└── main.go

main.go

package main

import "fmt"

func main() {
  fmt.Println("hoge")
}

Dockerfile

FROM golang as builder

WORKDIR hoge
COPY main.go /go/hoge/main.go 
RUN go build -o app

FROM alpine
COPY --from=builder /go/hoge/app /app
CMD ["/app"]

buildして実行

$ docker build . -t hogetest
Sending build context to Docker daemon  3.072kB
Step 1/7 : FROM golang as builder
 ---> 05c8f6d2538a
Step 2/7 : WORKDIR hoge
 ---> Using cache
 ---> c8a9993bfa6f
Step 3/7 : COPY main.go /go/hoge/main.go
 ---> Using cache
 ---> 99a0c70bf392
Step 4/7 : RUN go build -o app
 ---> Running in 1545e1bdb4d6
Removing intermediate container 1545e1bdb4d6
 ---> fa2c5d889c95
Step 5/7 : FROM alpine
 ---> a24bb4013296
Step 6/7 : COPY --from=builder /go/hoge/app /app
 ---> 7d8f5383e287
Step 7/7 : CMD ["/app"]
 ---> Running in 6ef781c0c51c
Removing intermediate container 6ef781c0c51c
 ---> da5d310e935d
Successfully built da5d310e935d
Successfully tagged hogetest:latest

$ docker run hogetest:latest
hoge

本文では文体が一般的な話に終始しており、go言語特有の話にはなってなかったと思いますが、簡単なサンプルを挙げさせていただきます。
https://qiita.com/irugo/items/390bd187871c7716a1e1 から拝借しました。

package main

import (
    "fmt"
    iconv "github.com/djimenez/iconv-go"
)

func main() {
    // sjis string
    data := []byte{0x47, 0x4f, 0x8c, 0xbe, 0x8c, 0xea}
    in := string(data[:])

    // iconv-go
    result, err := iconv.ConvertString(in, "sjis", "utf-8")
    if err != nil {
        panic(err)
    }

    fmt.Printf("r:%v\n", result)
}

このようなcgoを使ったライブラリをimportしていたりすると、リンクしているランタイムが違うので破綻します。

以下は示していただいたサンプルにiconv-goを取得する部分のみを加えただけのDockerfileです。

FROM golang as builder

WORKDIR hoge
COPY main.go /go/hoge/main.go
RUN go get github.com/djimenez/iconv-go && go build -o app

FROM alpine
COPY --from=builder /go/hoge/app /app
CMD ["/app"]

実行すると下記のようになります。

$ docker build . -t sample
Sending build context to Docker daemon  3.072kB
Step 1/7 : FROM golang as builder
 ---> 05c8f6d2538a
Step 2/7 : WORKDIR hoge
 ---> Using cache
 ---> fb4e8dcdb1e5
Step 3/7 : COPY main.go /go/hoge/main.go
 ---> 5c3c25d0e581
Step 4/7 : RUN go get github.com/djimenez/iconv-go && go build -o app
 ---> Running in fa2aa30e199f
Removing intermediate container fa2aa30e199f
 ---> 04165977acf7
Step 5/7 : FROM alpine
 ---> a24bb4013296
Step 6/7 : COPY --from=builder /go/hoge/app /app
 ---> 40fc2499b809
Step 7/7 : CMD ["/app"]
 ---> Running in a15cd226c4c0
Removing intermediate container a15cd226c4c0
 ---> ff4562838b5b
Successfully built ff4562838b5b
Successfully tagged sample:latest
$ docker run sample
standard_init_linux.go:211: exec user process caused "no such file or directory"

これを最後のFROM alpineFROM debianに変更すると動作します。

FROM golang as builder

WORKDIR hoge
COPY main.go /go/hoge/main.go
RUN go get github.com/djimenez/iconv-go && go build -o app

FROM debian
COPY --from=builder /go/hoge/app /app
CMD ["/app"]
$ docker build . -t sample
Sending build context to Docker daemon  3.072kB
Step 1/7 : FROM golang as builder
 ---> 05c8f6d2538a
Step 2/7 : WORKDIR hoge
 ---> Using cache
 ---> fb4e8dcdb1e5
Step 3/7 : COPY main.go /go/hoge/main.go
 ---> Using cache
 ---> 5c3c25d0e581
Step 4/7 : RUN go get github.com/djimenez/iconv-go && go build -o app
 ---> Using cache
 ---> 04165977acf7
Step 5/7 : FROM debian
 ---> f6dcff9b59af
Step 6/7 : COPY --from=builder /go/hoge/app /app
 ---> c87990788857
Step 7/7 : CMD ["/app"]
 ---> Running in 457f92c9f612
Removing intermediate container 457f92c9f612
 ---> 06b21a4a25bb
Successfully built 06b21a4a25bb
Successfully tagged sample:latest
$ docker run sample
r:GO言語

なぜ alpine が軽量になっているのか?その違いをもう一度お調べになったほうが良いと思います。

なるほど、ありがとうございます
cgo使う際にこのような課題が発生するの勉強になりました。

ご指摘ありがとうございました。

ログインするとコメントできます