薄いブログ

技術の雑多なことを書く場所

MySQLのエラー番号はどこに定義されているのか

背景

Go のアプリケーションで MySQL のエラー番号によって処理を分岐させたいことがあり、調査していたらドキュメントには記載されていた。 (例: ER_DUP_ENTRY(1062) をハンドリングしたい)

Go から参照しやすいように定数で提供されているものはないかと調査したが見つけることができなかった。

プログラムから参照しやすい形で存在しているだろうと思い、https://github.com/mysql/mysql-server を検索したが見つからなかった。

調査

ソースコードの中からは定数として参照はされているが定義は見つからない。

どうやら comp_err というコマンドによってビルド時に生成していることがわかった。

comp_err は MySQL 8.0.19 より前は errmsg-utf8.txt、以降は messages_to_error_log.txt と messages_to_clients.txt の情報からエラーを生成していると書かれている。

確かにそれらのファイルはソースコードの中からシンボル名を検索していたときに引っかかっていたがどこにもエラー番号がなかったので無視していた。

つまり comp_err 内部の何らかのロジックによってエラー番号が決められているだろうということでロジックを調査した。

comp_err は設定ファイルから

  • languages
  • default-language
  • start-error-number
  • ER_、WARN_、(OBSOLETE_ER_、OBSOLETE_WARN_)
  • (reserved-error-section)

から始まる行とエラーに付随するメッセージを解釈する。括弧がついているものは 8 系から追加された要素である。

languages は言語名の long name と short name の紐付け、文字コードの紐付けを定義する。

default-language は対象の言語のエラーメッセージがなかったときにどの言語のエラーメッセージを使うかを short name で指定する。基本的には eng。

start-error-number によって使用するエラー番号の先頭が決められ、それ以降に現れたエラーのシンボルから順に番号が割り振られていく。 start-error-number は複数回現れることがある。

ER_、WARN_ から始まる行はエラーの定義を行う。SQL State と ODBC State が存在するものに関してはエラーの名前の後に空白区切りで書かれている。

タブや空白から始まる行はエラーに紐づくメッセージの定義を行う。言語名の short name と書式文字列が書かれている。 サポートしている言語がエラーによってまちまちで eng しか定義されていないものも多い。

reserved-error-section はエラー番号として予約済みで使ってはいけない範囲を指定する。

github.com

実際に中身を確認するとより理解が深まる。

結論

MySQL のエラー番号は comp_err によって errmsg-utf8.txt、または messages_to_error_log.txt と messages_to_clients.txt で定義されたエラーに対して採番したものが mysqld_error.h に定義されている。

余談1

背景で書いてあるとおりGoから定数として参照したい。 すでにリリース済みのMySQLのソースの中の mysqld_error.h から生成すればよいが、せっかく調べたので comp_err の再実装を行い Go のパッケージを生成した。

github.com

GitHub に上がっている errmsg-utf8.txt や messages_to_clients.txt から自動的に生成するようになっている。

余談2

MySQL のエラーのサポート言語を集計してみた。

MySQL 5.7

// english(eng) 1123
// german(ger) 661
// swedish(swe) 331
// japanese(jpn) 292
// portuguese(por) 289
// spanish(spa) 286
// russian(rus) 268
// dutch(nla) 256
// ukrainian(ukr) 243
// italian(ita) 231
// serbian(serbian) 219
// french(fre) 212
// danish(dan) 208
// estonian(est) 206
// czech(cze) 192
// hungarian(hun) 183
// romanian(rum) 174
// korean(kor) 160
// greek(greek) 141
// slovak(slo) 138
// norwegian-ny(norwegian-ny) 128
// norwegian(nor) 126
// polish(pol) 121
// bulgarian(bgn) 3
// (bg) 3

1位は英語で圧倒的、2位がドイツ語なのが個人的には意外に感じた。 3位はスウェーデン語、4位が日本語という結果だった。 最下位はブルガリア語で3なのだが明らかにミスのようなものが3つあり、半分しか使えていないのが残念な気持ちになった。

MySQL 8.0

// english(eng) 1672
// german(ger) 661
// swedish(swe) 331
// japanese(jpn) 292
// portuguese(por) 289
// spanish(spa) 286
// russian(rus) 267
// dutch(nla) 256
// ukrainian(ukr) 243
// italian(ita) 231
// serbian(serbian) 219
// french(fre) 212
// danish(dan) 208
// estonian(est) 206
// czech(cze) 192
// hungarian(hun) 183
// romanian(rum) 174
// korean(kor) 160
// greek(greek) 141
// slovak(slo) 138
// polish(pol) 136
// norwegian-ny(norwegian-ny) 128
// norwegian(nor) 126
// bulgarian(bgn) 8

8 系においても順位は変わらないがブルガリア語のミスが修正されており安心した。

json.Marshalがエラーを返すとき

go 1.14.3 で確認した内容で個人的なメモです.

使っているバージョンによって異なる可能性があるので一次情報を参照してください.

golang.org

github.com

ソースコード上では encodeState の error が呼び出されているところを確認すれば良いと思われます.

エラーを返すパターン

主観ですが遭遇しそうな順で書きます.

  1. floatがInf, NaNだったときの json.UnsupportedValueError
  2. ポインタの循環参照のときに発生する json.UnsupportedValueError
  3. json.Marshaler, encoding.TextMarshaler のMarshal失敗したときの json.MarshalerError
  4. mapのkeyがint, uint, string, ptrではなく, encoding.TextMarshaler が実装されていないときの json.UnsupportedTypeError
  5. mapのkeyが encoding.TextMarshaler を実装していて, marshalに失敗したときの fmt.Errorf("json: encoding error for type: %q")
  6. json.Number で不正な数字列だったときの fmt.Errorf("json: invalid number literal: %q")

ハマりそうなポイント

mapのkeyの encoding.TextMarshaler が失敗したときのエラーは json.MarshalerError ではない.

json.Number をMarshalしたときに文字列が数値として正しくない場合に json パッケージのエラーは返ってこない.

なぜ cache-from を指定しなければいけないか

気になるツイートを見かけ, 僕も理由がわからなかったので調査してみました.

TL;DR

  • build 時に cache-from をつけないと dockerd がメモリ上に保持している情報からしか子イメージを取得しない
  • build では子イメージの情報をメモリ上に登録する
  • pull は子イメージの情報をメモリ上に登録しない
  • なので pull してきたイメージは cache-from をつけないとキャッシュ対象にならない

わかっていること

  • docker buildしたイメージは cache-from をつけなくても cache が効く
  • docker pullしたイメージは cache-from をつけないと cache が効かない
  • イメージが等しい条件

orisano.hatenablog.com

  • build の際にイメージが cache が使われる条件
    • 共通の親イメージを持ち, イメージが等しいなかで最も新しいもの

orisano.hatenablog.com

  • イメージが親イメージの情報のみを持つ連結リストの構造であること
    • すべてのイメージは親をたどるとscratchという特殊なイメージにたどり着くこと
    • dockerdが子イメージに伸びる辺をメモリ上に保持している

speakerdeck.com

未知のこと

  • build したイメージと pull したイメージの差分
  • cache-from した場合としなかった場合の差

ソースコードを読む

moby/moby 2019/12/3時点でのHEAD (3152f9436292115c97b4d8bb18c66cf97876ee75) を参照する. https://github.com/moby/moby/tree/3152f9436292115c97b4d8bb18c66cf97876ee75

docker/cli 2019/12/3時点でのHEAD (54d085b857e954a8fc431a9a400f11bc3308efc1) を参照する. https://github.com/docker/cli/tree/54d085b857e954a8fc431a9a400f11bc3308efc1

imageStoreについて

  • NewDaemon (docker/daemon/daemon.go) で imageService が作られる.
    • NewFSStoreBackend で FSStoreBackend を $root/image/$graphDriver/imagedb に作る
    • NewImageStore の中で restore を呼び fs にある情報から子イメージの情報を構築する
      • 親から子に辺を張る(メモリ上)

pullの実装について知る 実装

  • cliで認証, 検証, タグの解決を行う
  • 認証情報, 解決されたタグの情報を /images/create に POST する
  • postImagesCreate(import,pull共通)の処理が実行される (docker/api/server/router/image/image_routes.go)
  • ImageService.PullImage の処理が実行される (docker/daemon/images/image_pull.go)
  • ImageService.pullImageWithReference の処理が実行される (docker/daemon/images/image_pull.go)
    • config.ImageStore は distribution.NewImageConfigStoreFromStore(ImageService.imageStore)
    • config.ReferenceStore は ImageService.referenceStore
    • config.DownloadManager は ImageService.downloadManager
  • distribution.Pull が実行される (docker/distribution/pull.go)
  • newPuller によって作られた v2Puller の Pull が実行される (docker/distribution/pull_v2.go)
  • v2Puller の pullV2Repository が実行される (docker/distribution/pull_v2.go)
  • pullV2Tag が実行される (docker/distribution/pull_v2.go)
    • マニフェストを取得する
    • pullSchema2 が実行される (docker/distribution/pull_v2.go)
      • pullSchema2Layers が実行される (docker/distribution/pull_v2.go)
      • config.ImageStore に digest が存在するか確認してある場合はそちらを使う
      • pullSchema2Config で Config を取得する (docker/distribution/pull_v2.go)
      • config.DownloadManager がある場合は RootFS を Download メソッドによってダウンロードする
      • Config と Download した RootFS が等しいか検証する
      • config.ImageStore に Put する
        • ImageStore って名前だが ImageConfigStore
        • Put すると image.Store に対して Create を実行する
        • Create は子イメージの情報がない状態で追加する (docker/image/store.go)
    • config.ReferenceStore がある場合は, tag の内容が更新されたかを確認して反映する

buildの復習

  • 基本的な流れは here
  • build 時に imageStore を触るタイミングはイメージを commit するとき
    1. dispatch (docker/builder/dockerfile/evaluator.go)
    2. dispatchXXX (docker/builder/dockerfile/dispatcher.go)
    3. commit (docker/builder/internals.go)
    4. commitContainer (docker/builder/internals.go)
    5. CommitBuildStep (docker/daemon/images/image_commit.go)
    6. CommitImage (docker/daemon/images/image_commit.go)
  • container の id から取得した read write の layer の tar を読み込む
  • layerStore に登録する
  • 親イメージIDを取得する
  • 今の状態を image.Store で Create する
  • SetParent で parent という metadata ファイルに書く
  • 親がいる場合は更新する, 元の親の children から自分の id を削除する
  • 親の children に自分の id を登録する

cache-fromの挙動

  • MakeImageCache (docker/daemon/images/cache.go) で builder.ImageCache が作られる
  • cache-from が指定されていない場合は getLocalCachedImage を使う (docker/image/cache/cache.go)
    • メモリ上の children だけを参照して,キャッシュの対象を見つける
  • cache-from が指定されている場合は ImageCache.GetCache が呼ばれる (docker/image/cache/cache.go)
    • メモリ上の children だけを参照して,キャッシュの対象を見つける
      • キャッシュの対象が cache-from で指定したイメージの親イメージだった場合のみ使用される
    • 親イメージを Store から取得する
    • 親イメージと cache-from で指定したイメージで history が前方一致, RootFS の DiffID が前方一致しているイメージがキャッシュ対象になる

結論

  • cache-from をつけない build はメモリ上で保持している children しか参照しない
  • pull したイメージは親イメージ群に対して SetParent しないのでメモリ上で保持している children からは参照できない
  • よって pull したイメージは cache-from をつけないと build 時のキャッシュ対象にならない

なんでこんな仕様になっているんでしょうか, 詳しい人教えて下さい

kaniko が何をしているか, 何ができるか

TL;DR

  • kaniko を理解してない限りコンテナから出してはいけない.
  • kaniko を使っていればmulti stage buildだろうとCIのcacheについて余計なことを考えなくてよい (Dockerfileの書き方はcacheを意識して)

kaniko とは

github.com

kanikoはGoogleが作っているコンテナの中やKubernetes上で動くコンテナのbuilderです.

Cloud Buildなどで使うことができます.

kaniko の内部実装

早速ですが kanikoのbuildの詳細を調査するために以下から実装を読み進めました.

8c732f6f52bb334727194d090826e8dbd1df3793 における実装の詳細です.

github.com

Directory

/kaniko 以下が内部状態

  • (1) /kaniko/<image name>/... 依存しているイメージのlayersが完全に展開されている
  • (2) /kaniko/stages/<image name> --fromで依存しているイメージのtar
  • (3) /kaniko/stages/<stage id> ステージごとのtar
  • (4) /kaniko/<stage id>/... 別のステージから依存されているファイルを置く

DoBuild

  1. Dockerfileをパースし, ステージごとに分割する (--target まで)
    1. ステージ名を内部でidに変換する
    2. ベースイメージとして別のステージから参照されているかどうか調べる
  2. .dockerignoreを解釈する
  3. 外部の依存イメージをフェッチする
    1. ステージが持っているCOPYコマンドの--fromから外部のイメージに依存しているかを確認する
    2. 依存している場合はダウンロードしてkaniko内部のStageを保持するディレクトリ(2) にtarとして保存される
    3. kaniko内部のディレクトリ (1) にイメージを展開する
  4. ステージ間の依存ファイルを調べる
    1. COPYの--fromで他のステージから参照されているファイルを抽出する
  5. ステージごとにbuildする
    1. ベースイメージの解決を行う
      1. scratchかどうか
      2. 他のステージがベースイメージかどうか
      3. CacheDirが設定されている場合そこから取得する
      4. リモートから取得するようにする
    2. キャッシュの設定が有効な場合, 最適化を行う
      1. 親イメージのハッシュまたは直前のコマンドのcache key, コマンド名, COPY/ADD で参照されるファイル(中身,UID,GID,権限)のMD5 からcache keyを計算する
      2. RUNの場合はcache keyで外部ストレージに保存されているか確認する
      3. 保存されていない, CacheTTLを超えている場合はその場で最適化終了
      4. Cacheが有効な場合はRUNの内部実装をCacheに置き換える
      5. RUN/ADD/COPY/WORKDIR 以外のコマンドは実行される (cache可能性に影響するので)
    3. そのステージがCacheに失敗したRUNを持っているか,別のステージからファイルを参照されている場合にベースイメージを / に展開する
      1. 展開の際に /proc/self/mountinfoに含まれているpath,/kaniko,/var/run,/etc/mtab 以下に書き込まないようにしている
    4. 最初のsnapshotを取る
      1. / 以下のすべてのファイルをスキャンする
      2. メモリ上にある前のsnapshotの状態と比較する
      3. メモリ上にあってファイルが存在しないものはwhiteout listに追加する
      4. メモリ上にない, またはメモリ上とhashが違う場合はメモリ上の状態を更新する
        1. snapshotの際のhashはsnapshot modeによって異なる. (timeとfullがある)
        2. timeはmtimeだけを使う
        3. fullはmodeとmtime,uid,gid,ファイルの中身を使う
    5. 各コマンドを実行する
      1. RUNはprocessを実際にexecする
      2. SingleSnapShotモードの場合は最後のコマンドだけ以下を実行する
      3. RUNコマンド以外はコマンドから差分のファイルがわかるので差分だけメモリ上の状態を更新しtarとして書き出す
      4. RUNコマンドは / 以下のfull scanから差分を求めて(5.4と同様の処理) tarとして書き出す
      5. キャッシュが有効で, キャッシュが効かなかったRUNコマンドの結果のlayerを非同期でregistryにpushする
      6. layerをイメージに追加する
    6. layer cacheのregistryへのpushが終わるのを待つ
    7. 後のステージのベースイメージになっている場合はディレクトリ(3)にtarとして保存
    8. 別のステージからCOPY --fromで参照されているファイルをディレクトリ(4)にコピー
    9. /proc/self/mountinfoに含まれているpath,/kaniko,/var/run,/etc/mtab以外のファイルをすべて削除する
  6. 最終ステージをpushする

ベースイメージのcache

上の流れでは毎回, ベースイメージをfetchしてきます.

もしkanikoが永続化されたstorageを使えるならgcr.io/kaniko-project/warmer--cache-dirを使うことでベースイメージのcacheができます.

buildの高速化を目指すなら取り組んでみてもよいと思います.

終わりに

kanikoは--cache=trueにすることでシームレスに実行したすべてのレイヤーをpushします.

既存のbuildだと生成されたイメージ単位でしかpushすることができませんでした.

そのため一度に複数のイメージをまたぐmulti stage buildだと状態を保持できない環境でのcacheが困難でした. (cache-from地獄)

orisano/castage のようなcache-fromを自動生成するツールであったり, cacheの効果が最大化するようなステージのみを明示的にbuildしてcache-fromすることで対応していました.

kanikoのシームレスなpushによってこのような煩雑な作業から開放され,高速なCIにおけるdocker buildが実現されます.

イメージ単位でのpushではなくレイヤー単位でのpush, それをシームレスに行うことによるcacheに気がついたとき感動してしまいました. (この記事を書くきっかけ)

どこまでもlayerのみで考えられておりmulti stage buildにおけるcache問題が解決されており, 状態が保持できないCIのような環境下向けに作られていると思います.

注意

kaniko自体はprocessの隔離等は行いません, コンテナ上で動かすこと前提としています.

実行される環境のファイルをめちゃくちゃに書き換えます. 間違えてもローカルでは実行しないでください.

まとめ

kanikoは上位のコンテナ技術に依存した作りになっています.

  • 自分ではプロセスの隔離などは行わずos.Execします
  • kanikoは隔離されている前提なので / にtarを展開したり, / から殆どのファイルを削除したりします
  • cacheの状態を簡単にremoteに置くことができる仕組みになっています
  • cacheのpushはシームレスに行われ, 最小単位はlayerです. multi stage buildでの悩みが解決されます

CIにおけるMulti-stage Buildsのcache

CIの時間を短くする活動を行っており, 特にその一部のアプリケーションコンテナの継続的 docker buildの改善について書きたいと思います.

CIにおけるMulti-stage Builds

Multi-stage Buildsについては以下の記事を参照すると良いと思います.

docs.docker.com

Multi-stage Buildsは最終イメージサイズを簡単に小さくするために導入された機能です.

イメージサイズを小さくするだけではなく様々な活用方法が生み出されました.

medium.com

speakerdeck.com

今では効率的なdocker buildにはMulti-stage Buildsは欠かせないものになっています.

Multi-stage Builds以前のCI環境では1 Dockerfile 1 Cacheの考え方で問題なかったのですが, Multi-stage Builds環境では問題が起こります.

ローカルで実行する分には何も問題は起きませんがdockerdが毎度変わる状況, つまりCI環境やDocker In Docker(dind)などの状況においてです.

最終イメージの中にbuild processのすべてが含まれていれば1 Dockerfile 1 Cache, つまり単純な--cache-fromを指定するだけで良いです. しかし複数ステージにまたがる場合は最終イメージに含まれない部分はcacheできず, Multi-Stage Buildsの意義的には最終イメージは小さくなりcacheするべき情報は殆ど含まれていないはずです. つまり意味のないcacheになってしまいます.

cacheしたいステージを明示的に--targetで指定してbuildされたイメージを外部に記憶してもらう必要があります.

いろんなやり方がありますがdocker saveした結果をobject storageにuploadしたり, registryにtagをつけてpushしてdocker registryをobject storageの代わりに使うものがあります.

object storageを新しく用意しなくて良い分, stageごとにtagをつけてregistryにpushする手法が始めやすくて良いと思っています.

しかしstageが増えれば増えるほどbuild processが変わったり, cache-fromに指定するものが増えたりするので人間がやるのが厳しくなってしまいます.

そこを簡単にするためのツールをPoC的に作ってみました.

github.com/orisano/castage

github.com

これはDockerfileを入力として与えるとstageをtagをつけながらbuildしてpushまでするshell scriptを生成します.

これによりCI環境下においても適切にcacheできる様になります. build processにbuild用のスクリプトを生成する部分を作ってしまえばあとは好きにstageを追加することができます. Dockerfileの改善活動を行っているとstageを追加したりすることが頻繁にあり, そのたびにpipelineを書き換えるのは非常に億劫です. pipelineを書き換えるのが億劫なのでDockerfileの改善活動のハードルになるのはおかしいので今回作成しました.

ぜひ使ってみて良いと思ったらGitHubでstarしていただけると嬉しいです. また使ってみてのフィードバックやPRは大歓迎です.

今回書いた内容は以下のスライドにも書いてあるのでぜひ読んで見てください.

speakerdeck.com

GoでASTと戯れる

GoでAST(Abstract Syntax Tree)を使ってソースコードを解析, 生成を行うに際してライブラリを作ったりしたので紹介したいと思います.

AST

Goは標準で go/ast でASTを扱うことができます.

便利なものもありますし詳しく記述されているのでgo/astのgodocを見るのが一番良いとは思うのですが,

とりあえず静的解析がしたい場合には

akito0107.hatenablog.com

http://goast.yuroyoro.net/

のようなツールを使って実際のコードがどういったASTになるのかを知りながら進めるのが良いかなと思います.

はじめてのAST

はじめに僕が作ったASTを使うツールは WithContextという終わり方をしていて第一引数がcontext.Contextの関数に常時context.Background()を渡す関数を生成するものでした.

github.com

Goを始めて2ヶ月くらいでAPI Clientを作っているときでした. その当時の僕はcontext.Contextを何も理解しておらず, とりあえずcontext.Backgroud()を渡しておけば良いと思っていました. しかし, どこかで第一引数はcontext.Contextを渡すようにするといいという情報を目にして思考停止して第一引数はcontext.Contextにしていました. API Clientで使用するエンドポイントが増えるたびに, リファクタで変数が変わるたびに人間が修正するのは正気じゃないと思ったのがきっかけでした. その程度の理解度の人間でも扱えるので, 非常に良くできていると思います.

実際に動作させると以下の様になります.

$ go get github.com/orisano/nocontext
$ cat main.go
package main

import "context"

func main() {}

func GetFooWithContext(ctx context.Context, a, b int) {

}

上のようなスカスカの関数があるだけのファイルを入力として与えると

$ nocontext -f main.go
func GetFoo(a, b int) {
    GetFooWithContext(context.Background(), a, b)
}

という風な関数を出力してくれます. 当時はgo generateなどをほとんど意識していなかったので全然使えません.

このくらいならsedでできるみたいな声が聞こえて来そうですが, 意外と引数の型やreturn文のパターン, 引数の一覧を展開するなどを考えるとASTで処理するのが最も良い様に思えます.

StructからInterfaceを生成したい

外部ライブラリでstructで実装が提供される場合があり, リファクタやテストのためのmockingためにinterfaceにしなければいけないことがありました. インターフェースは小さくせよとプログラミング言語Goには書いてありますが, 人間は怠惰なもので大きなinterfaceを作ってしまいます. すべてのメソッドをinterfaceにするのはなかなかに骨の折れる作業です. 複数の外部ライブラリの実装を共通で扱えるようにするためのinterface化の作業は辛いです.

上で挙げた問題を解決するためにツールを作成しました.

https://github.com/orisano/impast#interfacer

$ go get -u github.com/orisano/impast/cmd/interfacer
$ interfacer -out HTTPClient net/http.Client
type HTTPClient interface {
    Do(req *http.Request) (*http.Response, error)
    Get(url string) (resp *http.Response, err error)
    Head(url string) (resp *http.Response, err error)
    Post(url, contentType string, body io.Reader) (resp *http.Response, err error)
    PostForm(url string, data url.Values) (resp *http.Response, err error)
}
$ interfacer -out Conn database/sql.DB database/sql.Tx
type Conn interface {
    Exec(query string, args ...interface{}) (sql.Result, error)
    ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
    Prepare(query string) (*sql.Stmt, error)
    PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
    Query(query string, args ...interface{}) (*sql.Rows, error)
    QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
    QueryRow(query string, args ...interface{}) *sql.Row
    QueryRowContext(ctx context.Context, query string, args ...interface{}) *sql.Row
}

一つでも複数でも共通のメソッドを求めることができます. 構造体の埋込に関しても対応しています. -pkgを指定するとコンパイル可能な形で出力されるのでgo generateからで使用することができます.

InterfaceからMockが作りたい

github.com

recruit-tech.co.jp

ではソースコード生成部は文字列連結のような形で実現しましたが, やはりいろんなケースに対応するのは難しいと感じたので完全にASTでやりたいという気持ちがありました.

これはこれで純粋に自分でMock生成部を実装したいと思ったので実装しました.

https://github.com/orisano/impast#mocker

$ go get -u github.com/orisano/impast/cmd/mocker
$ mocker -pkg io -type ReadWriter
type ReadWriterMock struct {
    ReadMock    func(p []byte) (n int, err error)
    WriteMock   func(p []byte) (n int, err error)
}

func (mo *ReadWriterMock) Read(p []byte) (n int, err error) {
    return mo.ReadMock(p)
}

func (mo *ReadWriterMock) Write(p []byte) (n int, err error) {
    return mo.WriteMock(p)
}

というようなフィールドをベースとしたMockの実装を生成してくれます. 引数の名前が指定されている場合はそちらを優先し,されていない場合は連番でつけます.

InterfaceからStubを作りたい

GoLandではcmd+Nでinterfaceのstubの実装が生成できます. IDEから独立した形でこの機能を提供したいと思ったので実装しました.

www.jetbrains.com

のCode generationにある機能です.

https://github.com/orisano/impast#stuber

$ go get -u github.com/orisano/impast/cmd/stuber
$ stuber -pkg net -implement Conn -export -name c -type "*MyConn"
func (c *MyConn) Read(b []byte) (n int, err error) {
    panic("implement me")
}

func (c *MyConn) Write(b []byte) (n int, err error) {
    panic("implement me")
}

func (c *MyConn) Close() error {
    panic("implement me")
}

func (c *MyConn) LocalAddr() net.Addr {
    panic("implement me")
}

func (c *MyConn) RemoteAddr() net.Addr {
    panic("implement me")
}

func (c *MyConn) SetDeadline(t time.Time) error {
    panic("implement me")
}

func (c *MyConn) SetReadDeadline(t time.Time) error {
    panic("implement me")
}

func (c *MyConn) SetWriteDeadline(t time.Time) error {
    panic("implement me")
}

必要なメソッドの中身をpanic("implement me")で生成します. すでにあるメソッドを考慮してくれたりはしません.

まとめ

  • structからinterfaceを生成するツール
  • interfaceからmockを生成するツール
  • interfaceからstubを生成するツール

この3つのツールがあればinterfaceから定義するスタイルでも, 実装からするスタイルでもストレスのない開発が実現できると思います.

あとお気づきの方もいるとは思いますが, 上のツール群は以下のライブラリのコマンドとして実装されています.

github.com

impastは外部ライブラリのASTを読みたいと思ったときに簡単には実現できなかったことから作成したライブラリです. go/buildImportを使ってディレクトリだけ求めてparser.ParseDirを使用して実現しています.

importする機能だけでなく, 上の3つのツールを作る上で必要な*ast.Packageに関するユーティリティ関数が提供されていたりします.

上記のツールでもいいですし, impastそのものでもいいのでぜひ使ってみてフィードバックをいただけると嬉しいです.

良いと思ったらGitHubのStarをしていただけると励みになります.

コンテナイメージを小さくするために

CIの時間を短くする活動を行っており, 特にその一部のアプリケーションコンテナビルドの改善について書きたいと思います.

TL;DR

  • コンテナイメージを小さくするのは pull / push のコストを減らすため
  • ベースイメージを小さいものにする (ex. alpine linux, distroless)
  • Multi-stage Buildsを活用する
  • イメージに不要なファイルが含まれていないかを確認する. dlayer

コンテナイメージを小さくするために

なぜ小さくするのか

pull / pushのコストを減らすためです. CIの時間を短くするという文脈においては

  1. 成果物の push
  2. 依存イメージの pull (docker-compose を用いたテストなど)
  3. docker build

が主な改善対象になります.

(1), (2) に関してはコンテナイメージを小さくすることで直接的な効果があります. とはいえマネージドなコンテナレジストリを使っていることが多いと思いますし, クラウド内の内部通信なのでそれなりに大きなイメージでない限りあまり効果はないかもしれません.

(3) が CI の中で結構な割合を占めることになると思います. docker には build のための cache 機構が存在しますが状態を保持しない CI においては使うことができません. 状態を保持しない環境においても cache を使えるように --cache-from というオプションが存在します. --cache-from は手元で build していないイメージを cache の対象とするためのオプションで pull と組み合わせると CI 上でも docker の build cache の機構を使うことができます.

詳しくは以下を参照ください.

どうやって小さくするか

(2018/12時点で)よくインターネット上で見られた方法として

  • ベースイメージを小さいものにする
    • Alpine Linux
      • Alpine Linuxベースのイメージが提供されている場合も多いですが, glibc ではなく musl なので glibc でしか動かないソフトウェアや musl が嫌いな人間は使わない感じになっています.
    • Distroless
      • こっちはglibcなどをgoogleがメンテナンスしてくれる軽量の実行環境のみが存在するイメージです. 基本的にタグは打たれずlatestのみのような運用がなされています. 思想に共感できたり, glibcを使いたい場合などに使う感じです.
  • apk --no-cacheをつけたり/tmpを最後に消すようにしたり, apk add --virtual fooを使ってコマンドの最後に削除したりする
  • Multi-stage Buildsを使う
  • レイヤーの数を少なくする

などがありました. しかし何故小さくなるかに触れている文献を当時(2018/12)はあまり見ることができませんでした.

ベースイメージを小さくする

ベースイメージを小さくするというのは比較的簡単にできる解決策です.

ですがAlpine等の環境は慣れ親しんでいない人が多いので思わぬ落とし穴にハマりがちです, 運用しやすさなどを考慮して選択すると良いです.

不要なキャッシュやパッケージを削除する

apk --no-cache などの環境ごとのパッケージマネージャーのキャッシュをオフにするオプションは毎回調べる, もしくは覚えることになります.

外部に公開するイメージでもない限り無視しても問題ない程度の大きさであることが多いです.

不要なパッケージの削除に関しては docker のレイヤーの仕組み上 RUN を一つにまとめないとレイヤーに残ってしまうので一つにまとめなければいけなくなります, これによる Dockerfile のメンテナンス性の低下を考えると後述の Multi-stage Buildsの活用のほうが良いと思います.

orisano/minid の様に RUN を自動でまとめるアプローチなどもありますがCIのパイプラインが複雑になりがちです.

Multi-stage Buildsを使う

Multi-stage Buildsは意外と知名度が低い問題があって(2018/12~)悲しいです.

これを使うことでイメージ(ステージ)間のファイルのCOPYが可能になり, ビルドに必要な依存関係と実行に必要な依存関係の分離が実現されます.

ビルドに必要な依存関係と実行に必要な依存関係が大きく異なる場合, 最終イメージを小さくすることにかなり寄与します.

しかしCIの時間を短くするためのdocker build cacheの観点で見ると最終ステージ以外も小さくしないといけないので本質な問題は解決しません.

レイヤーの数を少なくする

(2018/12時点で)これは少し前まで結構見た言説ですがこれは本当なんでしょうか. (2020/7)正しくないわけではないが Multi-stage Buildsを使ったほうが良いです.

なぜ小さくなるのか

コンテナイメージが大きい原因調査のためにdocker historyを使って説明しているものがありました.

RUNを2つ書いたものとRUNを1つにまとめたものの docker history の結果を比較して小さくなっていることを確認したり, パッケージの install をしている部分が大きくなっているので cache が作られているのではないかという仮説を立てたりしているものでした.

(2018/12時点で)あまりコンテナイメージのサイズに興味がない人が多いので原因を突き詰めないのは仕方のないことかもしれません.

docker historyは非常に有用なツールですがどのレイヤーが重そうかのあたりをつけるくらいにしか使えません. なぜ重いのかを掘り下げることができません.

そもそもコンテナイメージがどの様に保持されているかを知っているとなぜ重いかの理解が捗ります.

moby/v1.md at master · moby/moby · GitHub

イメージの仕様を見てみるとコンテナイメージは差分により構成されていることがわかります. 以下のページの図が理解しやすいです.

http://docs.docker.jp/engine/userguide/storagedriver/aufs-driver.html#deleting-files-with-the-aufs-storage-driver

どこの差分がなぜ重くなっているか知ることでより本質的な改善が行えるのではないかと考え, ツールを作りました.

github.com

docker saveで生成されるファイルを分析して, レイヤーごとに差分のサイズとファイル名が出力するツールです.

不明なcacheファイルが残っていることや, 不要なツール, 重複して作られているファイルの存在の認識ができコンテナイメージを小さくする際に力を発揮します.

実際にGoの公式イメージサイズを小さくすることに成功しました. 改善活動をするときは原因特定ができる状況を作りましょう.

ぜひdlayerを使ってコンテナイメージを小さくしてみてください!

余談

このツールが出た少しあとに以下のツールが爆発的に盛り上がりました.

github.com