Chrome の Back button を押した際に「意図しない Cache」が利用されて、期待と違うページが表示される問題について調査した
以前、同僚と話していた際に「同じ URL で Accept
request header に応じて response の Content-Type
を変えていると、Chrome で Back button を押した際の結果が意図しないものになる」ということが話題になりました。
これは、自分も過去に経験した事があり、不思議に思っていた部分です。Safari や Firefox では発生せず、Chrome だけで発生するというのもポイントです。この挙動について、今回は調査してみました。
TL; DR
今回の調査内容を簡潔にまとめると、以下のようになります。
- 同じ URL で
Accept
request header に応じて response のContent-Type
を変えると、Chrome では Back button を押した際に意図しないコンテンツが表示される事がある。Safari や Firefox ではこの現象は発生しない。 - この現象は、Chrome の Back button を押した際の「Cache の使われ方」に起因して発生する。この部分は Formal standard が存在しないため、Browser によって挙動が違う。
- 上記の挙動は、Response Header に
Vary: Accept
をつけること、あるいはCache-Control: no-store
をつけることで回避が可能。また、今回はAccept
header に注目したが、それ以外にも request header の内容に応じて resnponse が変わる場合はVary: <対象の request header>
を指定する事が推奨される。
上記の内容について、以下で順番に説明いたします。
再現手順について
「同じ URL で Accept
request header に応じて response の Content-Type
を変えていると、Chrome で Back button を押した際の結果が意図しないものになる」というのが今回注目したい挙動です。
まずはじめに、この挙動を再現するためのデモサイトを作ってみました。以下に再現動画も載せています。動画を見て頂ければわかると思いますが、「/index
を開いた状態で、"Other Page" リンクをクリックしてから Back button をクリックして元のページに戻ると、{"message":"Hello from an index (application/json)"}
という JSON response が表示される」という挙動になっています。
Chrome の Back button では「先ほど開いたページが表示されること」を期待するので、期待とは違う挙動になっていることが分かります。
なお、デモサイトの URL および GitHub 上で公開したソースコードは以下になります。また、OS version, Chrome の version も以下にまとめています。
- デモサイトの URL: https://chrome-browserback-demo.herokuapp.com/index
- デモサイトの GitHub URL: https://github.com/south37/chrome-browserback-demo
- 実験環境
- macOS Catalina - Version 10.15.7
- Google Chrome - Version 87.0.4280.88 (Official Build) (x86_64)
何が起きているのか?
さて、それでは何が起きているのでしょうか?挙動を Chrome の Developer Tools の Network panel で確かめてみます。
まず、Chrome で Back Button を押した時に表示される GET /index
request の表示を見てみます。ここでは、Size として (disk cache)
が表示されています。どうやらページを表示する際に Cache が利用されていそうです。
実は、ここで表示されたコンテンツは「GET /index
を HTML として開いた際に、JavaScript から Fetch API で GET /index
へ JSON を request して取得した内容の Cache」となっています。つまり、「意図したもの(= /index
の HTML response)とは違う Cache」が利用されてしまっているということです。
では、なぜ「意図しない Cache」が利用されてしまっているのでしょうか?この「意図しない Cache の謎」を探りたいところですが、その前に「最初に /index
ページを開いた際に何が起きていたか」をもう少し正確に理解してみましょう。この振る舞いについては、実際にコードを見てみるほうが理解が簡単です。上記のデモサイトは以下のようなコードで動いています。
// main.go package main import ( "io" "log" "net/http" ) func main() { fs := http.FileServer(http.Dir("./static")) http.Handle("/", fs) http.HandleFunc("/index", handleIndex) http.HandleFunc("/other", handleOther) log.Fatal(http.ListenAndServe(":8080", nil)) } func handleIndex(w http.ResponseWriter, r *http.Request) { acceptMediaType := r.Header["Accept"] if len(acceptMediaType) == 1 && acceptMediaType[0] == "application/json" { handleIndexJSON(w, r) } else { handleIndexHTML(w, r) } } func handleIndexJSON(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") io.WriteString(w, `{"message":"Hello from an index (application/json)"}`) } func handleIndexHTML(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") io.WriteString(w, `<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Index Page</title> <script src="/index.js"></script> <body> <div>Hello from an Index Page!</div> <a href="/other">Other Page</a> </body> </html> `) } func handleOther(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") io.WriteString(w, `<!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>Other Page</title> <body> <div>Hello from an Other Page!</div> </body> </html> `) }
// static/index.js let myHeaders = new Headers(); myHeaders.append('Accept', 'application/json'); let myInit = { method: 'GET', headers: myHeaders, cache: 'default' }; let myRequest = new Request('index', myInit); fetch(myRequest) .then(response => console.log(response)) .catch(err => console.log(err))
簡単に挙動を説明します。まず、GET /index
の handler である handleIndex
に注目してください。ここでは、「Accept
request header の値を check して、application/json
であれば JSON response を返す handler(= handleIndexJSON
)の呼び出し、そうでなければ HTML response を返す handler(= handleIndexHTML
)の呼び出しを行う」というロジックになっています。つまり、Accept
request header の内容に応じて、response の Content-Type
を変えるという挙動になっている訳です。
handleIndexHTML
の response の HTML では、script tag として /index.js
を load して実行しています。この中身は static/index.js
の中で記述されていて、Fetch API を利用して Accept: application/json
request header 付きで GET /index
への request をしています(なお、デモなので response の内容を利用してませんが、実際にはここで JSON が描画に利用されるという挙動を想定してます)。
上記の挙動は、以下のように Developer Tools の Network panel をみると分かりやすいでしょう。まず最初に、GET /index
への request で Content-Type: text/html
の response を取得します。これが、Network panel で document
Type と記載されている request です。次に、HTML に記載された script tag で /index.js
がロードされます。これが、Network panel で script
Type と記載されている request です。最後に、/index.js
に実装された JavaScript コードによって Accept: application/json
request header 付きで GET /index
への request が行われて、Content-Type: application/json
の Response が返されます。これが、Network panel で fetch
Type と記載されている request です。
そして、"Other Page" リンクをクリックして別のページに遷移した後、Chrome の Back button を click して元のページに戻ると、「Fetch API で取得した response」が (disk cache)
として表示されます。これが、発生していた現象の一連の振る舞いです。
この挙動を回避するためにはどうしたら良いのか?
さて、この挙動を回避するには何をしたら良いでしょうか?Cache が関係しているなら、Cache に関しての設定を考え直す必要があるのでしょうか?
まず、自明な解決策としては、「同じ URL で request header に応じて異なる Content-Type
のコンテンツを返すのを避ける」という方法が考えられます。 これは実際に効果を発揮します。しかし、場合によってはこのアプローチを現実的ではないケースもあるでしょう。
そこで、Cache の設定を考え直す方法を考えます。例えば、「Cache を許可するが、常に Validation を要求する Content-Type: no-cache
」を response header として指定してみることにしましょう。これは、元々のコードに対して以下のような diff で実現することになります(注: 以下の例では Validation に利用する ETag
もつけてます。Validation を適切に扱うには If-None-Match
の handling logic も本当は追加する必要がありますが、ここでは省略しています)。
$ git diff diff --git a/main.go b/main.go index c00db5d..65c559a 100644 --- a/main.go +++ b/main.go @@ -32,11 +32,13 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { func handleIndexJSON(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("ETag", ...) io.WriteString(w, `{"message":"Hello from an index (application/json)"}`) } func handleIndexHTML(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("ETag", ...) io.WriteString(w, `<!DOCTYPE html> <html> <head>
しかしながら、 実は Content-Type: no-cache
という header をつけても、「Chrome の Back button で意図しない Cache が利用される」という挙動を防ぐことはできません 。これは実際に動かしてもらうと分かりますが、Content-Type: no-cache
response header をつけて返した response であっても、Back button を利用した場合には (disk cache)
が利用されてしまいます。
これは、(少なくとも個人的には)直感に反する挙動です。Content-Type: no-cache
response header がついてるコンテンツは、Browser に Cache はされるものの必ず Validation が行われるはずです。つまり、 必ず If-None-Match
or If-Modified-Since
header をつけて request が行われて、server で同一性が確かめられて 304
が返ってくれば Cache が利用され、そうでなければ 200
として返った response が利用されるはずです(注: Cache validation の詳細は MDN の document である Cache validation - MDN を参照してください)。ところが、Back button で戻った場合には、そもそもこの Validation が行われません。
この挙動について調査をすると、以下の Blog や、この Blog の著者が立てた chromium の issue にたどり着きました。chromium の issue における回答では、端的に言えば「Browser の back/forward navigation は normal load ではないため、Cache validation を skip するようになっている」と説明されています。つまり、Cache validation が行われないために、Cache が無条件で利用されてしまっている訳です。
This has been filed as #516846. But since there isn't a formal standard for back/forward browser button behavior, it's hard to argue what the right behavior should be.
cf. https://www.mixmax.com/engineering/chrome-back-button-cache-no-store/
Working as intended.
Back/forward navigations retrieve stuff from the cache without revalidation.
cf. https://bugs.chromium.org/p/chromium/issues/detail?id=516846#c3
Because no-cache doesn't mean "don't cache this" (that would be no-store). no-cache means don't use this for normal loads unless the resource is revalidated for freshness. History navigations are not normal loads.
cf. https://bugs.chromium.org/p/chromium/issues/detail?id=516846#c5
さて、Cache-Control: no-cache
では「back button を押した際の意図しない Cache の利用を防げない」とすると、どうするのが良いのでしょうか?
解決方法の1つは、「Cache-Control: no-store
response header を利用する」というものです。Cache-Control: no-store
を指定している場合、chrome はそもそもその response の Cache を行いません。その為、必ず server への request が行われるようになり、その結果として Back button で戻った際に意図しない Cache が利用されるのを防ぐ事ができます。これは、上記 Blog でも説明されている方法です。実際に、以下のように Cache-Control: no-store
を指定すると、Chrome で Back button を押した際に意図通り「/index
の HTML response」が表示される事が確認できます。
$ git diff diff --git a/main.go b/main.go index c00db5d..332cd53 100644 --- a/main.go +++ b/main.go @@ -32,11 +32,13 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { func handleIndexJSON(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") io.WriteString(w, `{"message":"Hello from an index (application/json)"}`) } func handleIndexHTML(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") + w.Header().Set("Cache-Control", "no-store") io.WriteString(w, `<!DOCTYPE html> <html> <head>
もう1つの方法が、「Vary
response header を利用する」という方法です。Vary
は「response の内容に影響を与える request header」を指定するための response header で、「Chrome などの Browser」および「CDN などの中間 Cache server」は Vary
header の内容を加味した Cache の handling が求められます。具体的には、「URL
だけでなく、Vary
header に指定した request header も一致する」ことが Cache を利用する条件になります。
実際に、以下のように Vary: Accept
response header をつけて「Accept
header も一致することを Cache 利用の条件とする」ことで、Chrome で Back button を押した際に意図通り「/index
の HTML response」が表示されることが確認できます。
$ git diff diff --git a/main.go b/main.go index c00db5d..4d0f527 100644 --- a/main.go +++ b/main.go @@ -32,11 +32,13 @@ func handleIndex(w http.ResponseWriter, r *http.Request) { func handleIndexJSON(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") + w.Header().Set("Vary", "Accept") io.WriteString(w, `{"message":"Hello from an index (application/json)"}`) } func handleIndexHTML(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html") + w.Header().Set("Vary", "Accept") io.WriteString(w, `<!DOCTYPE html> <html> <head>
実は、この「Vary
header を利用する」という方法は 上記の chromium issue で提案されている方法でもあります。Cache の恩恵を最大限受けつつ、request 内容に応じて違う response を返したい場合には Vary
header を使うと良いでしょう。
まとめ
Chrome の Back button を押した際に意図せず利用される Cache の挙動について、その再現手順と対応方法をまとめてみました。以下が、今回調査した内容のまとめになります。
- 同じ URL で
Accept
request header に応じて response のContent-Type
を変えると、Chrome では Back button を押した際に意図しないコンテンツが表示される事がある。Safari や Firefox ではこの現象は発生しない。 - この現象は、Chrome の Back button を押した際の「Cache の使われ方」に起因して発生する。この部分は Formal standard が存在しないため、Browser によって挙動が違う。
- 上記の挙動は、Response Header に
Vary: Accept
をつけること、あるいはCache-Control: no-store
をつけることで回避が可能。また、今回はAccept
header に注目したが、それ以外にも request header の内容に応じて resnponse が変わる場合はVary: <対象の request header>
を指定する事が推奨される。
個人的に、直感に反する挙動だったのが「Cache-Control: no-cache
を指定してもこの挙動を防げない」という部分です。通常の Browsing (= URL を入力しての遷移やリンクをクリックしてのページ遷移)では、Cache-Control: no-cache
header がついた response に対しては必ず Validation が行われるため、Vary
header などに気を使う必要がありません。ところが、Chrome の Back button(実は Forward button も)を利用する場合には Validation が行われないため、Vary
header を考慮する必要が出てきます。そこが自分にとっては盲点でした。
今回の調査の学びとしては、Cache-Control: no-cache
はあくまで「Cache される前提の header」である事に留意しつつ、Cache されるなら基本的には Vary
header もちゃんと設定した方が良いと言えそうです。
おまけ: Browser の Back/forward navigation における Cache の挙動の標準化について
今回、Chrome の Back button の挙動について調査をする中で、Browser の Back/forward navigation の Cache として bfcache
と呼ばれるものが存在する事がわかりました。
ここで bfcache
と呼んでいるものは、 このブログで説明した「(disk cache)
の利用」とは全く違うものです 。具体的には、「コンテンツの load や JavaScript の実行などを行った状態の page」を memory に残しておき、Back/forward navigation 時にそれを復元するという挙動のようです。
Google Chrome では、2019 年に bfcache
の実装に取り組み始めたというアナウンスが行われています。2019/2/22 の Blog Post では、 bfcache
の Demo 動画も公開されています。また、bfcache のドキュメントも公開されています。
cf. https://developers.google.com/web/updates/2019/02/back-forward-cache
cf. https://www.chromestatus.com/feature/5815270035685376
実は、bfcache
は Firefox や Safari では何年も前から実装されており、Chrome では version 79 で実装が行われたようです。しかし、Desktop の Chrome では今はデフォルトで無効化されており、chrome://flags
から有効化しなければ使えない状態です(Android では Chrome 86 から一部で有効化されたようです)。
Google implements backward-forward cache in Chrome 79 Canary
cf. https://www.ghacks.net/2019/09/20/google-implements-backward-forward-cache-in-chrome-79-canary/
bfcache has been supported in both Firefox and Safari for many years, across desktop and mobile.
Starting in version 86, Chrome has enabled bfcache for cross-site navigations on Android for a small percentage of users. In Chrome 87, bfcache support will be rolled out to all Android users for cross-site navigation, with the intent to support same-site navigation as well in the near future.
cf. https://web.dev/bfcache/#browser-compatibility
これは自分の解釈ではありますが、「Safari や Firefox は bfcache
を有効化しているのに対して、Chrome は bfcache
をデフォルトでは無効化している」事が原因で、「Chrome だけ、このブログで説明した意図しない Cache 利用が発生している」のではないかと考えられます。実際に、以下のように chrome://flags
から Back-forward cache
を Enabled force caching all pages (experimental)
にすると、今回調査に利用したデモサイトでも Chrome の Back button を押した際にちゃんと意図した挙動になることが確認できました。
また、「Back/forward navigation における Cache の挙動」に標準仕様がない事が、挙動の差異を生むそもそもの根本原因にも思えます。
実は、「bfcache
に対して標準仕様を定めようとする動き」は存在するようです。例えば、以下の issue では「Back/forward navigation における cache (= bfcache
) の opt-out の API を決めよう」という提案がなされています。また、「bfcache
の標準化の良い出発点になるかもしれない」ということにも言及されています。
Currently, we only have "implicit" ways to opt-out of bfcache that might have other side effects. A few examples from Firefox's bfcache page and problems associated with them:
...
I think having an explicit API to specifically opt-out of bfcache (and does nothing more than that) might be good, since there might be legitimate cases of not wanting to be bfcached (stale data/state of the previous page, logging in/out, etc.) specifically.
...
This might also be a good starting point into standardizing back-forward cache behavior across browsers.
cf. https://github.com/whatwg/html/issues/5744
これは、WHATWG と呼ばれる「Apple, Mozilla, Google, Microsoft などのブラウザベンダーを中心とする Web 標準を議論する団体」の、HTML Standard を議論するための repository である https://github.com/whatwg/html に対しての issue です。提案をしているのは Chrome の開発者の方のようです(注: WHATWG と W3C の関係が気になる方は HTML標準仕様の策定についてW3CとWHATWGが合意 今後はWHATWGのリビングスタンダードが唯一のHTML標準仕様に - ITmedia NEWSなどを参照してください)。
こういった標準化の動きが進み、さらに Chrome でも bfcache
がデフォルトで有効化されれば、「Chrome でのみ挙動が異なる」といったことも起きづらくなることが期待出来るでしょう。未来が楽しみですね!
2019、2020 年の振り返り
年が明けて 2021 年になりました。この機会に、2019、2020 年の2年間を振り返ってみたいと思います。
2019年の仕事面の振り返り
2019 年は、全体的にアウトプットが多い年だったように思います。
ブログや登壇では以下のようなアウトプットをしました。
2019年のブログ
2019年の Conference 登壇
2019年の新卒研修講義
2019年のその他 Meetup などの登壇
- gcpc: Google Cloud Pub/Sub Client for Ruby #tqrk13 - Speaker Deck
- puma v4 では SIGTERM での worker process ゾンビ化に気をつけよう / Be aware of zombie processes in puma v4 - Speaker Deck
- Proto definition file (.proto file) で JSON API の schema を管理する - Speaker Deck
2019年に仕事で進めたプロジェクトについて
仕事で進めたプロジェクトとしては、まず「2019 年の新卒研修」の「全体設計及び実行を行う責任者」をやりました。2018年までは CTO が片手間で取り組んでいた部分だっただけに手順なども確立されていなかったため、「なぜやるのか(= WHY)、何をやるのか(= WHAT)、どうやるのか(= HOW)」という部分から改めて定義して進めました。社内の各チームリーダーやメンバーへ「新しくチームへ入るメンバーに何を身につけていて欲しいか?」をヒアリングするなど、社内のニーズを拾いながら設計を進められたのは、なかなか良かったんじゃないかと考えています。また、自分自身もいくつか講義を担当しました。取り組んだ内容の詳細はブログにもまとめています。
cf. 2019年度 Wantedly 新卒研修の内容を公開します! | Wantedly Engineer Blog
その後、マイクロサービスのアーキテクチャ改善の一環として、「ユーザーマイクロサービスへの書き込み RPC の実装およびその利用」を進めました。その一貫として payload の Protocol Buffer 化なども進めており、これが後述する「ユーザーマイクロサービスの gRPC 化」を後押ししてくれました。マイクロサービスのアーキテクチャ改善については、 CloudNative Days Tokyo 2019 (= CNDT2019) で話した以下のスライドでも詳細を述べてます。
cf. 理想的なマイクロサービスアーキテクチャを目指す継続的改善 / Re-architecturing of Microservices #CNDT2019 - Speaker Deck
ユーザーマイクロサービスが一段落したあとは、社内で「Ruby の gRPC server として小さいマイクロサービスを新しく作ろう」という動きがあったので、そこに関わる形で gRPC の基盤整備を進めました。これが2019年の年末にかけてのことです。具体的には、gRPC interceptor を揃えたり、社内の共通 library に組み込んで他のマイクロサービスからも簡単に利用出来るようにしたりといったことに取り組みました。その時点ではあまり意識してませんでしたが、振り返るとこれは2020年に取り組むことの助走となっていたように思います。
次に、2020年についても振り返ってみたいと思います。
2020年の仕事面の振り返り
2020 年は登壇回数は減った代わりに、ブログを書く量が増えました。特に意識して変えた訳では無いのですが、COVID-19 もあって外出が減り、その代わりにじっくり考えて文章を書く機会が増えたのかもしれません。
以下が、ブログや登壇の内容です。
2020年のブログ
2020年の Conference 登壇
2020年の新卒研修講義
2020年に仕事で進めたプロジェクトについて
2019年末に取り組んだ gRPC の基盤整備からの延長で、2020年は「ユーザーマイクロサービスの gRPC 化」から始まりました。元々、network latency に苦しんでいたのである程度の効果は期待していたのですが、結果として当初の想定以上に劇的な改善効果がありました 。そこで、それ以降は「社内外に gRPC のインパクトを広める & gRPC を利用するワークフローを整備してその知見を伝える」という事に取り組んでいました。
gRPC について以下の3つのブログを書いたのもその一貫です。特に、「Go 言語を利用するケースでは OSS や企業でかなり一般的に gRPC が利用されているが、Ruby などではまだそこまで一般的ではない。その状況を変えたい、コミュニティが盛り上がって欲しい」というモチベーションが強かったです。
- gRPC Internal - gRPC の設計と内部実装から見えてくる世界 | Wantedly Engineer Blog
- Real World Performance of gRPC - gRPC 利用による劇的なパフォーマンス改善 | Wantedly Engineer Blog
- gRPC Development Environment - Wantedly の gRPC Server/Client 開発環境 | Wantedly Engineer Blog
登壇についても、今年は基本的に gRPC の話をしました。
- Real World Migration from HTTP to gRPC in Ruby #grpcconf - Speaker Deck
- Real World Migration from HTTP to gRPC #CNDT2020 - Speaker Deck
gRPC Conf については、実は元々は存在を知らなかったのですが、上記ブログを読んだ方から存在を教えてもらい、CFP を出しました。
gRPC confがAustin, TXで6月25日に開催されます。CFPの締め切りが13日です。僕も一個応募します。
— Suzuki Tomohiro (@suztomo) 2020年3月9日
WantedlyもよかったらCFPいかがですか?
コロナウイルスの影響と、開催日が1日だけっていうのが難しい所です。https://t.co/UclTbEKkOx
gRPC Conf は COVID-19 の影響を受けて Virtual Event に変わり、開催時期も何度か変更されるなど運営は大変そうでしたが、なんとか開催されて登壇する事が出来ました。以前から海外カンファレンスで話してみたいと思っていたので、一つの目標が叶いました。同僚やCTOには発表練習などかなり協力していただいたので、大変ありがたかったです。gRPC Conf 登壇については、ブログでもまとめています。
cf. gRPC をテーマとした国際カンファレンス gRPC Conf で Wantedly の gRPC 活用事例について話しました! | Wantedly Engineer Blog
CloudNative Days Tokyo 2020 (= CNDT2020) は、引き続きブログと同じモチベーションで、gRPC の利用が盛り上がって欲しいと思い登壇しました。社内では Ruby 以外の言語(Go, Python, Node.js)でも利用が広がっており、新規マイクロサービスでも使われるようになって来ていたので、そのアップデートを盛り込みつつ社外にもその空気感を伝えるようにしました。
gRPC 関連以外では、「社内の推論スコアデプロイ基盤を100倍以上スケーラブルにする」という事にも取り組みました。自分は同僚と比較すると「パフォーマンス改善やメモリ使用量削減などランタイムの動作を最適化する」のがかなり好き & 得意なようで、それを見込まれて取り組んだプロジェクトでした。結果的には良い形で改善が出来たのかなと思ってます。これについてもブログでまとめています。
cf. PoC 活用のすすめ - 社内の推論スコアデプロイ基盤を100倍以上スケーラブルにした話 | Wantedly Engineer Blog
また、2020年の大きなアップデートとしては、Infrastructure Team の Leader になったことも挙げられます。これは、いわゆる Engineering Manager と Tech Lead を合わせたような役割です。以前、Product Team にいたときも Team Leader をやっていた時期はありましたが、改めて取り組む事になりました。
Team の Mission を再定義したり、運用を改善したりするなど、Team Member のパフォーマンスを出すために日々考えて改善に取り組んでいます。この辺りはいずれまたアウトプット出来たらと思っています。 ちなみに、Team Member と考えをすり合わせる中で、自分の嗜好性も見えて来たのが面白いと感じています。具体的には、「継続的に改善が行われる事にモチベーションがある、プロジェクトを進めて価値を生み出す事を楽しいと思う」などは自分の特徴的な部分なのかなと思っています。
2020年について思うこと
2020年を振り返って思うのは、「良い方向にも悪い方向にも予想外の事が多かった」という事です。例えば、COVID-19 がこれほどまでに生活にインパクトを与えるとは、2020年が始まったばかりの時点では完全に予想外でした。Work from Home やそれに合わせたチーム・組織の運用変更、カンファレンスなどの在り方の変化、社会全体の変化など、予想しない形で多くの変化があったように思います。ただ、これらは必ずしも悪いことばかりでは無く、必要に迫られることで生まれた良い変化も多数あったんじゃないかと思います。
仕事面でも、今年やった事の多くは当初計画していたものではありませんでした。gRPC がこれほど急速に社内で浸透するとは思っていませんでしたし、そのインパクトも想像以上でした。gRPC Conf はそもそも存在すら知らないところから登壇に至りました。Infrastructure Team Leader になることも全く予想していませんでした。
一方で、「これまでの積み重ねが結果に繋がった」と感じるシチュエーションは数多くありました。例えばユーザーマイクロサービスはこれまでにインターフェースをちゃんと決めて、そこに実装詳細として通信技術などは隠蔽出来ていたお陰で、gRPC 化を予想以上に簡単に実施できました(Client 側は library の内部実装を変えただけで、library を利用するコードは一切変えずに済みました)。今年はパフォーマンスチューニングなどをする機会も多かったですが、そこではこれまでにハードウェアや OS、ミドルウェアについて勉強してきた知見が活かされていたように思います。分散データベースについてのブログ(= The History of Distributed Databases)も今年書きましたが、これも元々は完全に趣味で調べていた内容をまとめたものでした。社内のシステムのマイクロサービス化が進むなかで、ここで調べた知見は活かされて来ていると感じます。このように、予想外の状況に対して、「これまでの積み重ねで上手く対応出来る」という感覚がありました。
ここから考えてみると、「すぐに返ってくるか分からない投資を継続的にやり続ける」ということが重要なのだと思います。Steve Jobs の Stanford 大学卒業スピーチでも、彼は「振り返ってみたときに点と点をつなぐことができる」と述べています。日々の取り組みが、振り返ってみると今に活かされているのかなと思います。
Again, you can't connect the dots looking forward; you can only connect them looking backward.
cf. https://news.stanford.edu/2005/06/14/jobs-061505/
プライベートの振り返り
プライベートで取り組んでいたことも少し振り返ってみたいと思います。
まずは競技プログラミングについてです。2019年の8月頃、同僚の影響で AtCoder のコンテストに出るようになりました。実は以前アカウントを作って一度だけコンテストに出たことがあったのですが、それっきり放置してしまっており、そこからの復帰です。AtCoder はそこから1年弱続けて、6月頃になんとか青まで行くことが出来ました。PAST も受けてみて、88点上級をとる事ができました。
south37さんのAtCoder Beginner Contest 172での成績:606位
— south37/Nao Minami (@south37777) 2020年6月27日
パフォーマンス:1734相当
レーティング:1595→1610 (+15) :)
Codeforces, TopCoder に引き続き、AtCoder でも青色になりました!嬉しい!#AtCoder #ABC172 https://t.co/5mDdKAqHmo pic.twitter.com/xlt5QRTdvt
Atcoder のアルゴリズム実技検定 PAST 受けてみました!結果は88点で上級でした!次はエキスパート目指して頑張ります! pic.twitter.com/p8JmpknVv7
— south37/Nao Minami (@south37777) 2020年5月23日
一時期 Codeforces や Topcoder に出ていた時期もあり、どちらも青までは行く事ができました。
実は、11月に入ってから Codeforces という競技プログラミングサイトでこっそりコンテストに出てたんですが、昨日のコンテストで青(Expert)になりました!嬉しい!https://t.co/w1KKw6dqKT
— south37/Nao Minami (@south37777) 2019年11月17日
最近は出場回数が減ってしまったものの、競技プログラミングは趣味の1つと言えるくらいには打ち込めたのかなと思います。
その他、英語学習も少しやっていました。自分は Reading はまあ問題ない、Listening はそこそこ、Speaking と Writing が苦手という状態です。Speaking は昔よりはかなりマシになったと思うものの、「話し相手に気を使ってもらう前提で会話が成立する」という状態なので、もっと英語力を向上させたいと考えています。今は DMM 英会話を少しやったり YouTube の英会話の動画(EnglishClass101.com の動画が多い)を見たりしてますが、うまく習慣化してボリュームを増やさねば、と思っています。
2020年末には、プライベートでもブログを書く量を増やしてみました。特にきっかけがあった訳ではないのですが、このころ「自分のために調査した内容を memo に残しておく」という事をやってみたところ、そこからのブログ化が簡単に出来る事に気づいたのが大きかったです。今も memo は増えていっているので、いずれ放出したいと考えています。
- コンテナ標準化の現状と Kubernetes との関係性について - Nao Minami's Blog
- 手を動かして学ぶコンテナ標準 - Container Image と Container Registry 編 - Nao Minami's Blog
- 手を動かして学ぶコンテナ標準 - Container Runtime 編 - Nao Minami's Blog
- BigQuery の内部実装の変遷について - Nao Minami's Blog
- kind (Kuberenetes in Docker) に deep dive してみる - Nao Minami's Blog
プライベート面でも、仕事で書いたのと同様に予想外の事が多くありましたが、充実した日々を過ごせたように思います。
OSS 活動について
OSS 活動は仕事とプライベート両面に関わってくる部分です。まず、仕事としては、「理由がない限りは書いたコードはオープンにしよう」という事を考えてました。実際、多くのコードは以下のように OSS にしています。
- https://github.com/wantedly/grpc_access_logging_interceptor
- https://github.com/wantedly/grpc_opencensus_interceptor
- https://github.com/wantedly/grpc_newrelic_interceptor
- https://github.com/south37/reloader_interceptor
- https://github.com/south37/grpc_server
- https://github.com/south37/fmparser
また、利用している OSS についても、bug や不自然な挙動など気が付いた事については積極的に issue を立てたり PR を送ったりするように心がけていました。1つ1つの貢献は小さいものではありますが、それでも積み重なることに価値があると信じるようにしています。
- https://github.com/protocolbuffers/protobuf/issues/6616
- https://github.com/protocolbuffers/protobuf/pull/6676
- https://github.com/datawire/ambassador-docs/pull/93
- https://github.com/ruby/rbs/pull/75
- https://github.com/fluent/fluent-logger-ruby/issues/78
- https://github.com/rails/rails/issues/38933
- https://github.com/grpc/grpc/pull/22734
- https://github.com/grpc/grpc/pull/24210
- https://github.com/census-instrumentation/opencensus-ruby/pull/123
- https://github.com/kubernetes-sigs/kubebuilder/issues/1925
- https://github.com/kubernetes-sigs/kubebuilder/pull/1927
まとめ
仕事、プライベート、OSS 活動など様々な面から 2019 年と 2020 年を振り返ってみました。
2020 年は特に予想外のことが多く、改めて未来は予測出来ないと気づかされました。一方でこれまでの積み重ねによって上手く出来たケースも多く、「すぐに返ってくるか分からない投資を継続的にやり続ける」ことの重要性も実感しました。
2021 年は、仕事、プライベート両方で、コンフォートゾーンを抜け出してチャレンジしていくという事を意識したいと思います。「出来る事で勝負する」のはもちろん必要ですが、それ以上に「出来ない事を出来るようになる」経験をまだまだ積んでいきたいと考えています。
kind (Kuberenetes in Docker) に deep dive してみる
kuKubernetes の Install Tools というページでは、kubernetes を local で動かすための tool として kind が紹介されています。今日はこの kind について内部構造及び使い方を見てみます。
kind とは
kind は「Docker container の中で Kubernetes を動かすことが出来るツール」です。kind という命名は「Kubernetes in Docker」から来ていて、K-in-D という頭文字をとったものになっています。
kind については KubeCon + CloudNativeCon で何度か紹介されているようです。例えば KubeCon + CloudNativeCon North America 2019 における以下の "Deep Dive: Kind" というトーク では、「kind とは何か?」について内部実装など含めて紹介されています。
上記のトークについて簡単に summary を書くと、kind は以下のようなものとして紹介されています。
- Docker container として Node(をシミュレートする container)を動かし、その中で Kubernetes を動かすツール
- Node image の中に、Kubernetes を動かすために必要な全てを詰める
- kubelet
- kubeadm
- docker
- systemd
- core images (Kuberentes にとって重要な container image)
- etcd
- coredns
- pause
- kube-apiserver
- etc.
- Node image の中に、Kubernetes を動かすために必要な全てを詰める
- multi-node 構成も可能
- Kubernetes を source code から build して動かすことが可能
- 30s 程度で Kuberenetes cluster を作ることが出来る
- kind は「Kuberentes 自体の test」のために作られた
- Kuberenetes の CI は Kubernetes で動いており、全ての test は Pod の中で実行される。そのため、Kuberenetes 自体を test するには、「Kuberentes を container の中で動かす」必要があった。
上記のトークの中で出てくる以下のスライドが、kind の仕組みを端的に示していて分かりやすいと思います。"Node" Container の中で、kubelet や containerd, そして containered を通じて起動された「Kuberentes の動作を支える各種 container」が動いている事が分かります。
ここまでで、「kind の内部構造」を説明しました。次に、実際に kind を動かしてみましょう。
kind を動かしてみる
kind の利用方法は実はとても簡単で、README で以下のように言及されている通り「GO111MODULE="on" go get sigs.k8s.io/kind@v0.9.0 && kind create cluster
を実行するだけ」となっています。
If you have go (1.11+) and docker installed
GO111MODULE="on" go get sigs.k8s.io/kind@v0.9.0 && kind create cluster
is all you need!
実際に実行すると、以下のように Kubernetes cluster 作成が進みます。自分の環境では、Kuberentes cluster 作成にかかる時間は 1min 強でした。
(なお、Ensuring node image
という step で少し時間がかかりますが、これは後述する「kind が利用する kindest/node
という docker image の pull に時間がかかっている」だけです。一度 image pull が完了すると次からは 30s 程度で高速に Kubernetes cluster が作成出来る様になります)。
$ GO111MODULE="on" go get sigs.k8s.io/kind@v0.9.0 && kind create cluster go: downloading sigs.k8s.io/kind v0.9.0 go: downloading github.com/spf13/cobra v1.0.0 go: downloading k8s.io/apimachinery v0.18.8 go: downloading gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 go: downloading github.com/mattn/go-isatty v0.0.12 go: downloading github.com/alessio/shellescape v1.2.2 go: downloading sigs.k8s.io/yaml v1.2.0 go: downloading github.com/pelletier/go-toml v1.8.0 go: downloading github.com/evanphx/json-patch v0.0.0-20200808040245-162e5629780b go: downloading golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed go: downloading github.com/evanphx/json-patch/v5 v5.1.0 GO111MODULE="on" go get sigs.k8s.io/kind@v0.9.0 8.93s user 3.64s system 102% cpu 12.228 total Creating cluster "kind" ... ✓ Ensuring node image (kindest/node:v1.19.1) 🖼 ✓ Preparing nodes 📦 ✓ Writing configuration 📜 ✓ Starting control-plane 🕹️ ✓ Installing CNI 🔌 ✓ Installing StorageClass 💾 Set kubectl context to "kind-kind" You can now use your cluster with: kubectl cluster-info --context kind-kind Not sure what to do next? 😅 Check out https://kind.sigs.k8s.io/docs/user/quick-start/ kind create cluster 4.95s user 2.65s system 9% cpu 1:16.71 total
これで既に「Kubernetes cluster が Docker container の中で動いている状態」となっています。簡単ですね!
kind で動く Kubernetes cluster を利用してみる
次に、実際に kind で動く Kuberentes cluster を利用してみましょう。
$ kind create cluster
を実行した際の message に Set kubectl context to "kind-kind"
と出ていたように、既に kubectl の context は「kind で作成した Kubenetes cluster(= kind-kind
という名前の cluster)」に切り替わっています。つまり、この状態で $ kubectl
を利用すれば、kind-kind
cluster の API server に対して通信が行われるようになっています。
実際、以下のように $ kubectl config current-context
や $ kubectl cluster-info
の結果を見てみると「local で動く kind-kind
cluster に context が切り替わっている」事が確認出来ます。
$ kubectl config current-context kind-kind $ kubectl cluster-info Kubernetes control plane is running at https://127.0.0.1:55999 KubeDNS is running at https://127.0.0.1:55999/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.
$ kubectl
で cluster 内の k8s object を見てみましょう。例えば namespace や pod, service を見てみると、以下のような内容になっています。
$ kubectl get namespaces NAME STATUS AGE default Active 11m kube-node-lease Active 11m kube-public Active 11m kube-system Active 11m local-path-storage Active 11m $ kubectl get po --all-namespaces NAMESPACE NAME READY STATUS RESTARTS AGE kube-system coredns-f9fd979d6-9cgbl 1/1 Running 0 11m kube-system coredns-f9fd979d6-wlnmw 1/1 Running 0 11m kube-system etcd-kind-control-plane 1/1 Running 0 11m kube-system kindnet-phgz9 1/1 Running 0 11m kube-system kube-apiserver-kind-control-plane 1/1 Running 0 11m kube-system kube-controller-manager-kind-control-plane 1/1 Running 0 11m kube-system kube-proxy-dxx9q 1/1 Running 0 11m kube-system kube-scheduler-kind-control-plane 1/1 Running 0 11m local-path-storage local-path-provisioner-78776bfc44-66wln 1/1 Running 0 11m $ kubectl get svc --all-namespaces NAMESPACE NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE default kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 18m kube-system kube-dns ClusterIP 10.96.0.10 <none> 53/UDP,53/TCP,9153/TCP 18m
さらに、適当に deployment と service の追加もしてみましょう。自分が昔作った https://github.com/south37/dumper という「request header を print するだけの Docker container」を動かしてみます。
まず、deployment と service の manifest file を用意します。
# dumper-deployment.yaml apiVersion: apps/v1 kind: Deployment metadata: labels: app: dumper name: dumper namespace: default spec: selector: matchLabels: app: dumper template: metadata: annotations: labels: app: dumper name: dumper spec: containers: - image: south37/dumper livenessProbe: httpGet: path: /ping port: 8080 name: dumper ports: - containerPort: 8080 name: http readinessProbe: httpGet: path: /ping port: 8080
# dumper-service.yaml apiVersion: v1 kind: Service metadata: labels: app: dumper name: dumper namespace: default spec: ports: - name: http port: 80 protocol: TCP targetPort: 8080 selector: app: dumper type: ClusterIP
次に、これらの manfest file を apply します。
$ kubectl apply -f dumper-deployment.yaml deployment.apps/dumper created $ kubectl apply -f dumper-service.yaml service/dumper created
apply したものがちゃんと作られている事を確認します。
$ kubectl get all -n default NAME READY STATUS RESTARTS AGE pod/dumper-6465654fdc-qn729 1/1 Running 0 118s NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE service/dumper ClusterIP 10.110.60.42 <none> 80/TCP 114s service/kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 21m NAME READY UP-TO-DATE AVAILABLE AGE deployment.apps/dumper 1/1 1 1 118s NAME DESIRED CURRENT READY AGE replicaset.apps/dumper-6465654fdc 1 1 1 118s
まず、pod の log を見てみます。すると、healthcheck 用の GET /ping
の reqeust が来ている事が確認できます。
$ kubectl logs dumper-6465654fdc-qn729 . . . 2020/12/30 12:09:55 GET /ping HTTP/1.1 2020/12/30 12:09:55 Host: 10.244.0.5:8080 2020/12/30 12:09:55 User-Agent: kube-probe/1.19 2020/12/30 12:09:55 Accept-Encoding: gzip 2020/12/30 12:09:55 Connection: close
次に、Kuberentes cluster の中から Service を通した request をしてみましょう。 curl が入っている docker image として radial/busyboxplus:curl
を使うことにします(注: radial/busyboxplus:curl
は https://kubernetes.io/docs/concepts/services-networking/connect-applications-service/ の中でも利用されてるので、安全な image だと判断して利用してます)。
すると、以下のように Kubernetes cluster 内の pod から $ curl http://dumper.default
で 「default
namespace の dumper
service へ HTTP request」をして、ちゃんと response が返る事を確認出来ました!
$ kubectl run --rm -it busybox --image=radial/busyboxplus:curl If you don't see a command prompt, try pressing enter. [ root@busybox:/ ]$ curl http://dumper.default Hello, dumper!
pod の log を見てみると、ちゃんと上記 request が pod に到達していることも確認できます。
$ kubectl logs dumper-6465654fdc-qn729 . . . 2020/12/30 12:12:00 GET / HTTP/1.1 2020/12/30 12:12:00 Host: dumper.default 2020/12/30 12:12:00 User-Agent: curl/7.35.0 2020/12/30 12:12:00 Accept: */*
簡単にではありますが、Kubernetes cluster としての動作が確認できました。
Docker container として動く Node container の中を見てみる
次に、kind の動作をもう少し深掘りしてみます。具体的には、Deep Dive: Kind - KubeCon + CloudNativeCon North America 2019 の中で紹介されていた「Node container の動作」をもう少し見てみます。
まず、Kubernetes cluster を一度削除して作り直すことにします。これは、「作成されたばかりの状態の Kubernetes cluster」で実験するための操作です(特に気にならない人は不要です)。
$ kind delete cluster Deleting cluster "kind" ... $ kind create cluster Creating cluster "kind" ... ✓ Ensuring node image (kindest/node:v1.19.1) 🖼 ✓ Preparing nodes 📦 ✓ Writing configuration 📜 ✓ Starting control-plane 🕹️ ✓ Installing CNI 🔌 ✓ Installing StorageClass 💾 Set kubectl context to "kind-kind" You can now use your cluster with: kubectl cluster-info --context kind-kind Not sure what to do next? 😅 Check out https://kind.sigs.k8s.io/docs/user/quick-start/ kind create cluster 4.62s user 2.53s system 19% cpu 36.518 total
次に、$ kind create cluster
を実行した後の状態で $ docker ps
すると、kindest/node:v1.19.1
という image の container が起動している事が分かります。これが、kind が利用する「Node container」のようです。
$ docker ps CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES 45a383679dd2 kindest/node:v1.19.1 "/usr/local/bin/entr…" About a minute ago Up 59 seconds 127.0.0.1:56856->6443/tcp kind-control-plane
$ docker exec
して Node container の中をみてみましょう。$ ps fax
で process 一覧を見てみると、Deep Dive: Kind - KubeCon + CloudNativeCon North America 2019 の中で説明されていた通り、Kubernetes の動作を支える様々な process が起動している事が分かります。containerd
や kubelet
など一部を除くと、そのほかの process は /usr/local/bin/containerd-shim-runc-v2
経由で起動している(= container として起動している)ことも分かります。
$ docker exec -it 45a383679dd2 bash root@kind-control-plane:/# ps fax PID TTY STAT TIME COMMAND 2060 pts/1 Ss 0:00 bash 2189 pts/1 R+ 0:00 \_ ps fax 1 ? Ss 0:00 /sbin/init 124 ? S<s 0:00 /lib/systemd/systemd-journald 135 ? Ssl 0:11 /usr/local/bin/containerd 310 ? Sl 0:00 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 1c426469eb3ae09b744f1c116e6798c65886e218271dfa105fba747b4bfde0d3 -address /run/containerd/containerd.soc 405 ? Ss 0:00 \_ /pause 502 ? Ssl 0:06 \_ kube-scheduler --authentication-kubeconfig=/etc/kubernetes/scheduler.conf --authorization-kubeconfig=/etc/kubernetes/scheduler.conf --bind-address=127.0.0.1 --ku 311 ? Sl 0:00 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 23d54713b4401fb309725273c78961ea5d7f3a5be157d2a139813b9b9611e220 -address /run/containerd/containerd.soc 418 ? Ss 0:00 \_ /pause 620 ? Ssl 0:20 \_ etcd --advertise-client-urls=https://172.19.0.2:2379 --cert-file=/etc/kubernetes/pki/etcd/server.crt --client-cert-auth=true --data-dir=/var/lib/etcd --initial-a 318 ? Sl 0:00 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id a24a84c14111721de85b657f2bb0b89db44d87e97452ef86690060a0f0fcd3bc -address /run/containerd/containerd.soc 425 ? Ss 0:00 \_ /pause 563 ? Ssl 1:08 \_ kube-apiserver --advertise-address=172.19.0.2 --allow-privileged=true --authorization-mode=Node,RBAC --client-ca-file=/etc/kubernetes/pki/ca.crt --enable-admissi 373 ? Sl 0:00 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 8a6a56e83a590f4958dbd3262e4b0adbe36acad229ebcb54ef13c657f39c2c0a -address /run/containerd/containerd.soc 433 ? Ss 0:00 \_ /pause 548 ? Ssl 0:24 \_ kube-controller-manager --allocate-node-cidrs=true --authentication-kubeconfig=/etc/kubernetes/controller-manager.conf --authorization-kubeconfig=/etc/kubernetes 667 ? Ssl 0:25 /usr/bin/kubelet --bootstrap-kubeconfig=/etc/kubernetes/bootstrap-kubelet.conf --kubeconfig=/etc/kubernetes/kubelet.conf --config=/var/lib/kubelet/config.yaml --cont 797 ? Sl 0:00 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 841020b0c947c1a8c2047a542268bf3dd91f8fb6b15cfc0a993dc61c0769e660 -address /run/containerd/containerd.soc 819 ? Ss 0:00 \_ /pause 898 ? Ssl 0:00 \_ /usr/local/bin/kube-proxy --config=/var/lib/kube-proxy/config.conf --hostname-override=kind-control-plane 833 ? Sl 0:00 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 6171f628b9c0658a1983b198160fe76b53eeb4118aa8a0c71491ff5516453268 -address /run/containerd/containerd.soc 864 ? Ss 0:00 \_ /pause 948 ? Ssl 0:00 \_ /bin/kindnetd 1110 ? Sl 0:00 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 1fd32fb832a5e3782d1f39b26b99a30de8872a0a69600a77db3631f274eeb819 -address /run/containerd/containerd.soc 1153 ? Ss 0:00 \_ /pause 1226 ? Ssl 0:03 \_ /coredns -conf /etc/coredns/Corefile 1112 ? Sl 0:00 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 725b634f7fc5d93ebfb1e63afb8aabcd936e6297ed7ab552e314e1cc90efeec5 -address /run/containerd/containerd.soc 1160 ? Ss 0:00 \_ /pause 1229 ? Ssl 0:01 \_ local-path-provisioner --debug start --helper-image k8s.gcr.io/build-image/debian-base:v2.1.0 --config /etc/config/config.json 1350 ? Sl 0:00 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 9d07db434b356d9fddb2ae31715adc1209406a604b7068560f379832f5d79ba2 -address /run/containerd/containerd.soc 1373 ? Ss 0:00 \_ /pause 1404 ? Ssl 0:03 \_ /coredns -conf /etc/coredns/Corefile
containerd で起動している container は、containerd の CLI tool である ctr
で管理する事が出来るはずです。少し見てみましょう。
まず、namespace 一覧を見てみると k8s.io
という名前の namespace が見つかります(注: この namespace は kuberentes の namespace とは無関係で、「containerd が container を管理する際に利用する namespace 機能」のはずです)。
root@kind-control-plane:/# ctr namespaces ls NAME LABELS k8s.io
次に、この k8s.io
namespace 内の container 一覧を $ ctr --namespace k8s.io containers ls
で見てみると、予想通り「Kuberentes cluster の動作に利用される container 一覧」をみる事が出来ました。
root@kind-control-plane:/# ctr --namespace k8s.io containers ls CONTAINER IMAGE RUNTIME 0e7fc11f71638b86f8fd41f046101ebcb16b48976f06826add2d35df4e2ccc10 k8s.gcr.io/kube-controller-manager:v1.19.1 io.containerd.runc.v2 114d8f7f34ebc00c00236d7a111961193a6fa300dc90a4114385134f9eeda412 k8s.gcr.io/kube-proxy:v1.19.1 io.containerd.runc.v2 1c426469eb3ae09b744f1c116e6798c65886e218271dfa105fba747b4bfde0d3 k8s.gcr.io/pause:3.3 io.containerd.runc.v2 1fd32fb832a5e3782d1f39b26b99a30de8872a0a69600a77db3631f274eeb819 k8s.gcr.io/pause:3.3 io.containerd.runc.v2 2299e2a7b2b7afbb0789b30a4d7f4e57220a650f7368c04439e91c72e5049356 k8s.gcr.io/kube-apiserver:v1.19.1 io.containerd.runc.v2 23d54713b4401fb309725273c78961ea5d7f3a5be157d2a139813b9b9611e220 k8s.gcr.io/pause:3.3 io.containerd.runc.v2 35ab22ae753e043553188810bddfec43aa048d8c93f1ca38cf868ee31dbe06fc k8s.gcr.io/coredns:1.7.0 io.containerd.runc.v2 6171f628b9c0658a1983b198160fe76b53eeb4118aa8a0c71491ff5516453268 k8s.gcr.io/pause:3.3 io.containerd.runc.v2 6212a3ea51397a8491a8defb188faa1c9afb4b678a8fa102ab15ba8c78f98aa2 sha256:0369cf4303ffdb467dc219990960a9baa8512a54b0ad9283eaf55bd6c0adb934 io.containerd.runc.v2 624a9cf197014dcdbf0be5ddd68995566d14ceab00c8ca18fd51eb35cfe999cb k8s.gcr.io/kube-scheduler:v1.19.1 io.containerd.runc.v2 62d494fe1a94a396494ecd30cfa8538db2e1d2055fedac216d19fd21332d3841 sha256:b77790820d01598b2c56f823fa489e3f56be2cb5d6f7dd9eecd68a1995b89c13 io.containerd.runc.v2 725b634f7fc5d93ebfb1e63afb8aabcd936e6297ed7ab552e314e1cc90efeec5 k8s.gcr.io/pause:3.3 io.containerd.runc.v2 841020b0c947c1a8c2047a542268bf3dd91f8fb6b15cfc0a993dc61c0769e660 k8s.gcr.io/pause:3.3 io.containerd.runc.v2 8a6a56e83a590f4958dbd3262e4b0adbe36acad229ebcb54ef13c657f39c2c0a k8s.gcr.io/pause:3.3 io.containerd.runc.v2 9d07db434b356d9fddb2ae31715adc1209406a604b7068560f379832f5d79ba2 k8s.gcr.io/pause:3.3 io.containerd.runc.v2 a24a84c14111721de85b657f2bb0b89db44d87e97452ef86690060a0f0fcd3bc k8s.gcr.io/pause:3.3 io.containerd.runc.v2 b7775d2582ea9fdd481cf308d9c8bafa28fffbdaa2c8c0bad3377a9254876a59 k8s.gcr.io/coredns:1.7.0 io.containerd.runc.v2 ecbad3d6f6f5321e46b0d3ac395cb25227b42cfc04b1cec5a2b659fe45fab6cc sha256:e422121c9c5f97623245b7e600eeb5e223ee623f21fa04da985ae71057d8d70b io.containerd.runc.v2
このように、「Node container の中で containerd を動かし、その containerd 経由で Kuberentes cluster に必要な container を動かす」という形で kind は動作するようです。Deep Dive: Kind - KubeCon + CloudNativeCon North America 2019 の中で説明されていた事ではありますが、改めてその動作を確認できました。
kind と minikube の使い分けについて
さて、ここまでは kind というツールの機能について説明してきました。一方で、「local で Kubernetes を動かすツール」としては他に minikube も存在します。これらの使い分けはどうするのが良いでしょうか?
kind vs minikube
で検索すると、この2つの使い分けについて言及している記事がいくつか見つかります。例えば https://brennerm.github.io/posts/minikube-vs-kind-vs-k3s.html という記事では、以下のようにそれぞれの特徴が述べられています。
- minikube
- VM で Kubernetes を起動する
-
$ minikube dashboard
機能や minikube の addon system が有用
- kind
- Docker container で Kubernetes を起動する
-
$ kind load docker-image <my-custom-image>:<tag>
を実行する事で local で build した image を container registry へ push する事なく Kubernetes cluster から利用する事が可能
kind について言えば、$ kind load docker-image
の機能はかなり強力で、例えば custom controller のような「Kubernetes の中で動かしながら開発を進めたいもの」においては極めて有用です。実際、こういった側面があるためか、kubebuilder の Tutorial の Quick Start では「local で Kuberentes cluster を動かす選択肢」として kind が紹介されていたりします。
You’ll need a Kubernetes cluster to run against. You can use KIND to get a local cluster for testing, or run against a remote cluster.
cf. https://book.kubebuilder.io/quick-start.html#test-it-out
また、「VM よりも Docker container の方が扱いに慣れてる」などのケースでも kind の利用にはメリットがありそうだと個人的には感じました。
まとめ
kind の内部構造や使い方についてざっと眺めてみました。
「Kuberentes を Docker container の中で動かす」という言葉だけを聞くと突拍子も無いアイディアに聞こえますが、kubelet
や containerd
, その他の Kubernetes cluster に必要な component を "Node" image の中にまとめて配布してると思えば確かに自然ですし、実際にちゃんと動作することも確認できました。
Kuberentes の CI で使われている限り、これからも継続してメンテナンスされていきそうなのも良い点です。「30s で Kubernetes cluster を起動して気軽に動かせるツール」として、とても有用なものだと言えそうです。
なお、今回は「kind の Node container で動く container 一覧を眺めた」だけで、1つ1つの container の動きについては特に言及しませんでした。そのほとんどは「Kubernetes cluster に共通で必要な component」のはずですが、kindnetd
は kind におけるデフォルトの CNI plugin である kindnet の daemon だそうです。
Deep Dive: Kind - KubeCon + CloudNativeCon North America 2019 ではこの kindnet を含めて、このブログで言及してない様々な事を説明してるので、さらに詳細が気になる方はぜひ動画の方もみてみてください。また、自分も一部しか読んでませんが、kind の document (https://kind.sigs.k8s.io/) も理解を深める上でとても有用だと思います。
参考情報
- Kubernetes tools: https://kubernetes.io/docs/tasks/tools/
- kind: https://github.com/kubernetes-sigs/kind
- kind quick start: https://kind.sigs.k8s.io/docs/user/quick-start
- Deep Dive: Kind - KubeCon + CloudNativeCon North America 2019: https://www.youtube.com/watch?v=tT-GiZAr6eQ
おまけ: 既に node image を pull 済の場合に Kuberentes cluster 作成にかかる時間について
最初に kind を動かしてみた際に、「kindest/node
の docker pull 部分で時間がかかる」と書きました。そこで、「既に node image を pull 済みの場合」についても、試しに計測してみましょう。
まず、先ほど作成した cluster を削除します。
$ kind delete cluster Deleting cluster "kind" ...
この状態でも、kindest/node
docker image は残っている事が確認できます。
$ docker images | grep kindest kindest/node <none> 37ddbc9063d2 3 months ago 1.33GB
次に、この状態で再度 $ kind create cluster
を実行してみます。
$ kind create cluster Creating cluster "kind" ... ✓ Ensuring node image (kindest/node:v1.19.1) 🖼 ✓ Preparing nodes 📦 ✓ Writing configuration 📜 ✓ Starting control-plane 🕹️ ✓ Installing CNI 🔌 ✓ Installing StorageClass 💾 Set kubectl context to "kind-kind" You can now use your cluster with: kubectl cluster-info --context kind-kind Have a nice day! 👋 kind create cluster 4.53s user 2.37s system 21% cpu 32.535 total
今回は、上記のように 30s 程度で Kubernetes cluster が起動することを確認できました!🎉 Node image の pull が無ければ、Kuberentes cluster をとても素早く作成出来る事が分かりますね!
おまけ 2: deployment を apply した状態で Node container の中を見てみる
deployment を apply した状態で、Node container の中を見てみましょう。
$ kubectl apply -f dumper-deployment.yaml deployment.apps/dumper created
この状態だと、(当然ではありますが)apply した内容の pod に相当する container が起動している様子を確認できます。containerd の動作を感じる事が出来て、とても良いですね!
$ docker exec -it 7ff1d05ef709 bash root@kind-control-plane:/# ps fax PID TTY STAT TIME COMMAND . . . 1729 ? Sl 0:00 /usr/local/bin/containerd-shim-runc-v2 -namespace k8s.io -id 34794c98df201080b5b22bcef08805e03748023c35f0deec77f962ff301a6835 -address /run/containerd/containerd.sock 1752 ? Ss 0:00 \_ /pause 1879 ? Ssl 0:00 \_ /app/dumper root@kind-control-plane:/# ctr --namespace k8s.io images ls | grep dumper docker.io/south37/dumper:latest application/vnd.docker.distribution.manifest.v2+json sha256:5efcf15fbd3439b2c2fff2415957933b45b9531401526c026c41219aed15701c 290.0 MiB linux/amd64 io.cri-containerd.image=managed docker.io/south37/dumper@sha256:5efcf15fbd3439b2c2fff2415957933b45b9531401526c026c41219aed15701c application/vnd.docker.distribution.manifest.v2+json sha256:5efcf15fbd3439b2c2fff2415957933b45b9531401526c026c41219aed15701c 290.0 MiB linux/amd64 io.cri-containerd.image=managed root@kind-control-plane:/# ctr --namespace k8s.io containers ls | grep dumper ab80cafc653433c2b74713b85679797b4ffad5ae54eed733bb40d41af7bb9f43 docker.io/south37/dumper:latest io.containerd.runc.v2
おまけ 3: $ kind load docker-image
の実装について
kind の強力な機能である $ kind load docker-image
機能がどう実現されているのか気になったので、少しコードを読んでみました(対象は kind の v0.9.0
tag のコードです)。
まず、$ kind load
コマンド自体は https://github.com/kubernetes-sigs/kind/blob/v0.9.0/pkg/cmd/kind/load/load.go で実装されていて、その中の $ kind load docker-image
サブコマンドは https://github.com/kubernetes-sigs/kind/blob/v0.9.0/pkg/cmd/kind/load/docker-image/docker-image.go で実装されてるようです。さらにコードを読み進めると、nodeutils
package の LoadImageArchive
という関数にたどり着きます。
import ( ... dockerimage "sigs.k8s.io/kind/pkg/cmd/kind/load/docker-image" ... ) // NewCommand returns a new cobra.Command for get func NewCommand(logger log.Logger, streams cmd.IOStreams) *cobra.Command { cmd := &cobra.Command{ Args: cobra.NoArgs, Use: "load", Short: "Loads images into nodes", Long: "Loads images into node from an archive or image on host", } // add subcommands cmd.AddCommand(dockerimage.NewCommand(logger, streams)) cmd.AddCommand(imagearchive.NewCommand(logger, streams)) return cmd }
cf. https://github.com/kubernetes-sigs/kind/blob/v0.9.0/pkg/cmd/kind/load/load.go#L38
// Load the image on the selected nodes fns := []func() error{} for _, selectedNode := range selectedNodes { selectedNode := selectedNode // capture loop variable fns = append(fns, func() error { return loadImage(imageTarPath, selectedNode) }) }
// loads an image tarball onto a node func loadImage(imageTarName string, node nodes.Node) error { f, err := os.Open(imageTarName) if err != nil { return errors.Wrap(err, "failed to open image") } defer f.Close() return nodeutils.LoadImageArchive(node, f) }
nodeutils.LoadImageArchive
は以下のような実装になっていて、「Node container の中で $ ctr
コマンドを呼び出して、container image の load を行う」ようです。
// LoadImageArchive loads image onto the node, where image is a Reader over an image archive func LoadImageArchive(n nodes.Node, image io.Reader) error { cmd := n.Command("ctr", "--namespace=k8s.io", "images", "import", "-").SetStdin(image) if err := cmd.Run(); err != nil { return errors.Wrap(err, "failed to load image") } return nil }
cf. https://github.com/kubernetes-sigs/kind/blob/v0.9.0/pkg/cluster/nodeutils/util.go#L77-L84
nodeutils.LoadImageArchive
は「1 つ 1 つの Node container に対して loop を回して実行してる」ようです。つまり、魔法のように見えた $ kind load docker-image
の機能は、「それぞれの Node container の中で $ ctr images import
を実行して、container image を import する」事で実現しているようです。面白いですね!
BigQuery の内部実装の変遷について
BigQuery(正確にはそのクエリエンジンである Dremel)の内部実装の変遷をまとめた以下のブログポストおよび論文を読みました。
とても面白い内容で、Twitter にメモをポストしたのですが、後で参照しやすいようにブログにも同じ内容を載せておきます。
BigQuery(正確にはそのクエリエンジンである Dremel)の内部実装の変遷をまとめた論文の解説ブログ。面白かった。https://t.co/s9UaH4MjBS
— south37/Nao Minami (@south37777) December 28, 2020
解説対象の論文は "Dremel: A Decade of Interactive SQL Analysis at Web Scale"。上記ブログポストでは 3 章の Disaggregation と 5 章の Serverless Computing をピックアップして解説していたが、より網羅的に知りたい場合 & 原文を読みたい場合はこちらを読むと良い。https://t.co/XolZ7fMXFY
— south37/Nao Minami (@south37777) December 28, 2020
以下、雑なメモ。まず前提として 2010 年の Dremel についての最初の論文である "Dremel: Interactive Analysis of Web-Scale Datasets" の 6 章 Query Execution は目を通しておくと良い。Dremel が Query をどう実行するかが説明されている。https://t.co/A98EC7oBqE
— south37/Nao Minami (@south37777) 2020年12月28日
Dremel は「巨大なデータに対しての解析 query」である入力 query を「小さなデータに対しての解析 query」の集合に分解する。分解は 2010 年時点では tree の形で行われて、leaf query は storage からデータを読み取る形で実行される。中間 node は「child から集めたデータに対する query」を実行。
— south37/Nao Minami (@south37777) 2020年12月28日
中間 node の挙動は「query の書き換え」として実現される。具体的には、"SELECT A, COUNT(B) FROM T GROUP BY A" は "SELECT A, SUM(c) FROM (R1 UNION ALL ... Rn) GROUP BY A" へと書き換えられる。Ri は child node で query を実行した結果となっている。
— south37/Nao Minami (@south37777) 2020年12月28日
上記の Query Execution の前提がある中で、Dremel が 2010 年時点から進化したポイントがいくつかある。1つは、storage として local storage ではなく GFS (やその後継の Colossus)を利用するようになったこと(= Disaggregated storage)。
— south37/Nao Minami (@south37777) 2020年12月28日
元々 Dremel の server(特に leaf server)は local storage を利用していた。それが GFS を利用するようになり、Dremel の信頼性向上やメンテナンス性向上につながった(fully-managed な GFS を利用することで robustness が向上したり、local disk へのデータロードが不要になったりしたらしい)
— south37/Nao Minami (@south37777) 2020年12月28日
Dremel は join, aggregation, analytic operation のために shuffle という機構を持つが、それも進化したらしい。これは以下の blog post でも説明されてる。shuffle は元々 Map Reduce で導入され、Hadoop, Spark など主要な分散データ処理システムの動作を支える重要な機構https://t.co/ycJwG0aTo4
— south37/Nao Minami (@south37777) 2020年12月28日
2014 年に Dremel の shuffle は進化して、中間データを " distributed transient storage system" に持つようになった(= Disaggregated memory)。これにより、完全な in-memory での query 実行が実現されるようになったらしい。
— south37/Nao Minami (@south37777) 2020年12月28日
新しい shuffle infrastructure は latency 削減や shuffle 対象のデータ量の向上、20%以上の resource cost 削減に繋がった(おそらく、resource utilization が高まったのだと思われる)とのこと。ちなみに、同じ shuffle infrastructure が Google Cloud Dataflow でも使われているらしい。
— south37/Nao Minami (@south37777) 2020年12月28日
また、disaggregated memory shuffle system が利用されるようになったことで、query 実行の compute resource の割り当て(= scheduling)に柔軟性が生まれた。具体的には、shuffle 結果を checkpoint として、動的に worker を preempt 出来るようになったらしい(= Shuffle Persistence Layer)
— south37/Nao Minami (@south37777) 2020年12月28日
また、2010年時点では "fixed execution tree" として実行されていた query が、スケジューラの進化(= "centralized scheduling")と "shuffle persistence layer" のおかげで「DAG として表現された query plan に対して、柔軟に worker を割り当てる」という形で実行されるようになった。
— south37/Nao Minami (@south37777) 2020年12月28日
まとめると、storage, memory など様々な resource が分離され、それによって compute resource の柔軟な割り当てが可能になった。performance, resource utilization の両面で劇的に進化しており、さらに改善が積み重なっている様子が感じ取られて面白い内容だった。
— south37/Nao Minami (@south37777) 2020年12月28日
上記で言及してない部分で面白い箇所はいっぱいあるので、ぜひ論文をご一読ください!https://t.co/XolZ7fMXFY
— south37/Nao Minami (@south37777) 2020年12月28日
手を動かして学ぶコンテナ標準 - Container Runtime 編
コンテナ標準化の現状と Kubernetes の立ち位置について というブログではコンテナ標準の現状についてまとめてみました。
また、手を動かして学ぶコンテナ標準 - Container Image と Container Registry 編 というブログでは Container Image と Container Registry について手を動かして学んでみました。
このブログでは、runc, containerd などの Container Runtime について、実際に手を動かして学んでみたいと思います。
なお、前回のブログ 同様、基本的にこのブログ内のコマンドは Linux で実行するものとします(自分は MacOS で Vagrant で Ubuntu VM を立てて実験してます)。
runc を動かしてみる
runc は Low-Level Container Runtime と呼ばれるもので、OCI Runtime Specification に準拠した CLI tool となっています。実際に runc を動かしてみることで、Container Runtime に対して理解を深めてみましょう。
まず runc の install ですが、自分が試している ubuntu-20.04 では apt
で install することができます。
vagrant@vagrant:~$ sudo apt update -y vagrant@vagrant:~$ sudo apt install -y runc vagrant@vagrant:~$ which runc /usr/sbin/runc
help を見てみると以下のような内容になっていて、container を操作するために必要な各種 subcommand が存在することがわかります。実は、これらの subcommand によって OCI Runtime Specification の Runtime and Lifecycle で定義された「Operations」 の機能が提供されています。ただし、見比べてみると分かりますが runc 自体はよりリッチな機能を提供しているようです。
vagrant@vagrant:~$ runc --help NAME: runc - Open Container Initiative runtime runc is a command line client for running applications packaged according to the Open Container Initiative (OCI) format and is a compliant implementation of the Open Container Initiative specification. runc integrates well with existing process supervisors to provide a production container runtime environment for applications. It can be used with your existing process monitoring tools and the container will be spawned as a direct child of the process supervisor. Containers are configured using bundles. A bundle for a container is a directory that includes a specification file named "config.json" and a root filesystem. The root filesystem contains the contents of the container. To start a new instance of a container: # runc run [ -b bundle ] <container-id> Where "<container-id>" is your name for the instance of the container that you are starting. The name you provide for the container instance must be unique on your host. Providing the bundle directory using "-b" is optional. The default value for "bundle" is the current directory. USAGE: runc [global options] command [command options] [arguments...] VERSION: spec: 1.0.1-dev COMMANDS: checkpoint checkpoint a running container create create a container delete delete any resources held by the container often used with detached container events display container events such as OOM notifications, cpu, memory, and IO usage statistics exec execute new process inside the container init initialize the namespaces and launch the process (do not call it outside of runc) kill kill sends the specified signal (default: SIGTERM) to the container's init process list lists containers started by runc with the given root pause pause suspends all processes inside the container ps ps displays the processes running inside a container restore restore a container from a previous checkpoint resume resumes all processes that have been previously paused run create and run a container spec create a new specification file start executes the user defined process in a created container state output the state of a container update update container resource constraints help, h Shows a list of commands or help for one command GLOBAL OPTIONS: --debug enable debug output for logging --log value set the log file path where internal debug information is written --log-format value set the format used by logs ('text' (default), or 'json') (default: "text") --root value root directory for storage of container state (this should be located in tmpfs) (default: "/run/user/1000/runc") --criu value path to the criu binary used for checkpoint and restore (default: "criu") --systemd-cgroup enable systemd cgroup support, expects cgroupsPath to be of form "slice:prefix:name" for e.g. "system.slice:runc:434234" --rootless value ignore cgroup permission errors ('true', 'false', or 'auto') (default: "auto") --help, -h show help --version, -v print the version
実際に runc を利用して container を起動してみましょう。runc の README の Using runc というセクションがぴったりの題材なので、これを参考にしてみます。
OCI Runtime Speicification の内容を見てもらうと分かるのですが、runc のような Low-Level Container Runtime は Filesystem Bundle と呼ばれる「Container を起動するのに利用される configuration file と、container にとっての root filesystem となる directory の組み合わせ」を必要とします。まずはこの Filesystem Bundle を用意します。
Filesystem Bundle は config.json と呼ばれる configuration file と、その config.json の中の root.path
で path を指定される 「container の root filesystem となる directory」から構成されます。それぞれ順番に作成していきます。
まずは Filesystem Bundle を格納する directory として mycontainer
という directory を作っておきます。
vagrant@vagrant:~$ mkdir mycontainer vagrant@vagrant:~$ cd mycontainer/
次に、config.json
についてですが、 $ runc spec
を実行すると自動でデフォルト設定の config.json
が作成されます。今回はこれを使うことにしましょう。
vagrant@vagrant:~/mycontainer$ runc spec vagrant@vagrant:~/mycontainer$ ls config.json
デフォルト設定の config.json は以下のような内容になっています。実行時のコマンドや環境変数、root.path などに加えて、namespace や volume mount, capability の設定なども記載されています。
vagrant@vagrant:~/mycontainer$ cat config.json { "ociVersion": "1.0.1-dev", "process": { "terminal": true, "user": { "uid": 0, "gid": 0 }, "args": [ "sh" ], "env": [ "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "TERM=xterm" ], "cwd": "/", "capabilities": { "bounding": [ "CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE" ], "effective": [ "CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE" ], "inheritable": [ "CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE" ], "permitted": [ "CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE" ], "ambient": [ "CAP_AUDIT_WRITE", "CAP_KILL", "CAP_NET_BIND_SERVICE" ] }, "rlimits": [ { "type": "RLIMIT_NOFILE", "hard": 1024, "soft": 1024 } ], "noNewPrivileges": true }, "root": { "path": "rootfs", "readonly": true }, "hostname": "runc", "mounts": [ { "destination": "/proc", "type": "proc", "source": "proc" }, { "destination": "/dev", "type": "tmpfs", "source": "tmpfs", "options": [ "nosuid", "strictatime", "mode=755", "size=65536k" ] }, { "destination": "/dev/pts", "type": "devpts", "source": "devpts", "options": [ "nosuid", "noexec", "newinstance", "ptmxmode=0666", "mode=0620", "gid=5" ] }, { "destination": "/dev/shm", "type": "tmpfs", "source": "shm", "options": [ "nosuid", "noexec", "nodev", "mode=1777", "size=65536k" ] }, { "destination": "/dev/mqueue", "type": "mqueue", "source": "mqueue", "options": [ "nosuid", "noexec", "nodev" ] }, { "destination": "/sys", "type": "sysfs", "source": "sysfs", "options": [ "nosuid", "noexec", "nodev", "ro" ] }, { "destination": "/sys/fs/cgroup", "type": "cgroup", "source": "cgroup", "options": [ "nosuid", "noexec", "nodev", "relatime", "ro" ] } ], "linux": { "resources": { "devices": [ { "allow": false, "access": "rwm" } ] }, "namespaces": [ { "type": "pid" }, { "type": "network" }, { "type": "ipc" }, { "type": "uts" }, { "type": "mount" } ], "maskedPaths": [ "/proc/acpi", "/proc/asound", "/proc/kcore", "/proc/keys", "/proc/latency_stats", "/proc/timer_list", "/proc/timer_stats", "/proc/sched_debug", "/sys/firmware", "/proc/scsi" ], "readonlyPaths": [ "/proc/bus", "/proc/fs", "/proc/irq", "/proc/sys", "/proc/sysrq-trigger" ] }
次に、container の root filesystem となる directory を用意します。今回は楽をするために、「$ docker export
の機能を利用する」ことにします(自分は vagrant で synced_folder した directory で docker export しました)。原理的には、どんな手段で用意しても良いはずです。
$ mkdir rootfs $ docker export $(docker create busybox) | tar -C rootfs -xvf -
上記コマンドまで実行が終わると、mycontainer
directory の中は以下のような状態になっているはずです。
vagrant@vagrant:~/mycontainer$ ls config.json rootfs
この状態で $ sudo runc run containerid
を実行すると、container が起動します。
vagrant@vagrant:~/oci-playground/runc-playground/mycontainer2$ sudo runc run containerid / #
上記で作成した config.json
はデフォルトで「sh
コマンドを実行する」という設定になっているので、sh
が起動しています。
そのため、一度 exit してから config.json
を例えば「ls
を実行するもの」に書き換えると、ls
が container 内で実行されます。
vagrant@vagrant:~/oci-playground/runc-playground/mycontainer2$ vim config.json # ここで以下のように変更 "args": [ - "sh" + "ls" ], vagrant@vagrant:~/oci-playground/runc-playground/mycontainer2$ sudo runc run containerid bin dev etc home proc root sys tmp usr var
ということで、runc
を利用して無事に container を起動することができました!
config.json + container の root filesystem になる directory という「Filesystem Bundle」を用意さえすれば、「container の起動が簡単に出来る」ということが分かったかと思います。なお、これは OCI Runtime Specification で定められている挙動であるため、この仕様を満たしてさえいれば「runc 以外の Low-Level Container Runtime」においても同様のことができるはずです。
上記の例では runc run
を利用しましたが、その他のオペレーション(create, start, delete など)を利用することでより細かな制御も可能です。詳細が気になる方は、ぜひご自分でも試してみてください。
containerd を動かしてみる
Low-Level Container Runtime の runc の次は、High-Level Container Runtime である containerd を動かしてみましょう。runc は「container を起動する」という部分は実行してくれましたが、それ以外の「Container Image の Pull、Container Image から Filesystem Bundle への変換」などはやってくれませんでした。実は、この部分の機能を提供してくれてるのが containerd です。
containerd は、daemon として常駐して、client からの request を受け付けて動作するという振る舞いになっています。containerd の動作のイメージを掴むために、Getting started というページに従って containerd を利用してみましょう。
まずは、containerd downloads の Installing binaries に従って containerd の binary を download します。そして、その binary を /usr/local/bin
など PATH の通った場所に配置します。
vagrant@vagrant:~$ wget https://github.com/containerd/containerd/releases/download/v1.4.3/containerd-1.4.3-linux-amd64.tar.gz vagrant@vagrant:~$ tar xvf containerd-1.4.3-linux-amd64.tar.gz vagrant@vagrant:~$ sudo mv bin/* /usr/local/bin/
これで、containerd
がコマンドとして利用できるようになります。
vagrant@vagrant:~$ which containerd /usr/local/bin/containerd vagrant@vagrant:~$ containerd --help NAME: containerd - __ _ __ _________ ____ / /_____ _(_)___ ___ _________/ / / ___/ __ \/ __ \/ __/ __ `/ / __ \/ _ \/ ___/ __ / / /__/ /_/ / / / / /_/ /_/ / / / / / __/ / / /_/ / \___/\____/_/ /_/\__/\__,_/_/_/ /_/\___/_/ \__,_/ high performance container runtime USAGE: containerd [global options] command [command options] [arguments...] VERSION: v1.4.3 DESCRIPTION: containerd is a high performance container runtime whose daemon can be started by using this command. If none of the *config*, *publish*, or *help* commands are specified, the default action of the **containerd** command is to start the containerd daemon in the foreground. A default configuration is used if no TOML configuration is specified or located at the default file location. The *containerd config* command can be used to generate the default configuration for containerd. The output of that command can be used and modified as necessary as a custom configuration. COMMANDS: config information on the containerd config publish binary to publish events to containerd oci-hook provides a base for OCI runtime hooks to allow arguments to be injected. help, h Shows a list of commands or help for one command GLOBAL OPTIONS: --config value, -c value path to the configuration file (default: "/etc/containerd/config.toml") --log-level value, -l value set the logging level [trace, debug, info, warn, error, fatal, panic] --address value, -a value address for containerd's GRPC server --root value containerd root directory --state value containerd state directory --help, -h show help --version, -v print the version
ただし、前述したように containerd は daemon として動作する必要があるので、これだけではまだ利用できません。
Getting started を見ると、どうやら containerd のソースコードに同梱されている containerd.service に言及しています。これを利用して、systemd で containerd を起動することにしてみましょう。
vagrant@vagrant:~$ sudo apt install -y unzip # unzip を install しておく vagrant@vagrant:~$ wget https://github.com/containerd/containerd/archive/v1.4.3.zip # ソースコードを取得 vagrant@vagrant:~$ unzip v1.4.3.zip vagrant@vagrant:~$ ls containerd-1.4.3 v1.4.3.zip vagrant@vagrant:~$ ls containerd-1.4.3/containerd.service # systemd 向けの service file が存在 containerd-1.4.3/containerd.service vagrant@vagrant:~$ sudo cp containerd-1.4.3/containerd.service /etc/systemd/system/ vagrant@vagrant:~$ sudo systemctl enable containerd Created symlink /etc/systemd/system/multi-user.target.wants/containerd.service → /etc/systemd/system/containerd.service. vagrant@vagrant:~$ sudo systemctl start containerd
これで、無事 containerd が起動しました。
vagrant@vagrant:~$ ps aux | grep containerd root 13444 1.6 4.5 1344964 45620 ? Ssl 16:39 0:00 /usr/local/bin/containerd vagrant 13455 0.0 0.0 9032 664 pts/0 S+ 16:39 0:00 grep --color=auto containerd
Getting started の document には /etc/containerd/config.toml
の存在も言及されているので、これも用意しておきます。これで準備 OK です。
vagrant@vagrant:~$ sudo mkdir /etc/containerd vagrant@vagrant:~$ sudo vim /etc/containerd/config.toml # ここで containerd の設定ファイルである config.toml を作成 vagrant@vagrant:~$ sudo systemctl restart containerd # ここで、設定ファイルを読み込む形で containerd を再起動
# これが /etc/containerd/config.toml の内容 subreaper = true oom_score = -999 [debug] level = "debug" [metrics] address = "127.0.0.1:1338" [plugins.linux] runtime = "runc" shim_debug = true
さて、containerd が daemon として動くようになりました。次は、containerd と通信するコードを書いてみましょう。Getting started を参考に、以下のような Go コードを書いてみます。
package main import ( "context" "fmt" "log" "syscall" "time" "github.com/containerd/containerd" "github.com/containerd/containerd/cio" "github.com/containerd/containerd/namespaces" "github.com/containerd/containerd/oci" ) func main() { if err := redisExample(); err != nil { log.Fatal(err) } } func redisExample() error { // create a new client connected to the default socket path for containerd client, err := containerd.New("/run/containerd/containerd.sock") if err != nil { return err } defer client.Close() // create a new context with an "example" namespace ctx := namespaces.WithNamespace(context.Background(), "example") // pull the redis image from DockerHub image, err := client.Pull(ctx, "docker.io/library/redis:alpine", containerd.WithPullUnpack) if err != nil { return err } // create a container container, err := client.NewContainer( ctx, "redis-server", containerd.WithImage(image), containerd.WithNewSnapshot("redis-server-snapshot", image), containerd.WithNewSpec(oci.WithImageConfig(image)), ) if err != nil { return err } defer container.Delete(ctx, containerd.WithSnapshotCleanup) // create a task from the container task, err := container.NewTask(ctx, cio.NewCreator(cio.WithStdio)) if err != nil { return err } defer task.Delete(ctx) // make sure we wait before calling start exitStatusC, err := task.Wait(ctx) if err != nil { fmt.Println(err) } // call start on the task to execute the redis server if err := task.Start(ctx); err != nil { return err } // sleep for a lil bit to see the logs time.Sleep(3 * time.Second) // kill the process and get the exit status if err := task.Kill(ctx, syscall.SIGTERM); err != nil { return err } // wait for the process to fully exit and print out the exit status status := <-exitStatusC // Block here. code, _, err := status.Result() if err != nil { return err } fmt.Printf("redis-server exited with status: %d\n", code) // For Debug fmt.Printf("Client: %v\n", client) fmt.Printf("Image: %v\n", image) fmt.Printf("Container: %v\n", container) fmt.Printf("Task: %v\n", task) return nil }
「Go 環境の構築」は良い感じにやってください。その状態で上記の Go コードを実行すると、以下のような出力が行われます。
vagrant@vagrant:~/containerd-playground$ go build main.go && sudo ./main 1:C 10 Dec 2020 17:11:43.852 # oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo 1:C 10 Dec 2020 17:11:43.852 # Redis version=6.0.9, bits=64, commit=00000000, modified=0, pid=1, just started 1:C 10 Dec 2020 17:11:43.852 # Warning: no config file specified, using the default config. In order to specify a config file use redis-server /path/to/redis.conf 1:M 10 Dec 2020 17:11:43.856 # You requested maxclients of 10000 requiring at least 10032 max file descriptors. 1:M 10 Dec 2020 17:11:43.856 # Server can't set maximum open files to 10032 because of OS error: Operation not permitted. 1:M 10 Dec 2020 17:11:43.856 # Current maximum open files is 1024. maxclients has been reduced to 992 to compensate for low ulimit. If you need higher maxclients increase 'ulimit -n'. 1:M 10 Dec 2020 17:11:43.858 * Running mode=standalone, port=6379. 1:M 10 Dec 2020 17:11:43.858 # Server initialized 1:M 10 Dec 2020 17:11:43.858 # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or run the command 'sysctl vm.overcommit_memory=1' for this to take effect. 1:M 10 Dec 2020 17:11:43.858 * Ready to accept connections 1:signal-handler (1607620306) Received SIGTERM scheduling shutdown... 1:M 10 Dec 2020 17:11:46.877 # User requested shutdown... 1:M 10 Dec 2020 17:11:46.877 * Saving the final RDB snapshot before exiting. 1:M 10 Dec 2020 17:11:46.879 * DB saved on disk 1:M 10 Dec 2020 17:11:46.879 # Redis is now ready to exit, bye bye... redis-server exited with status: 0 Client: &{{<nil> <nil> <nil> <nil> map[] <nil> <nil> <nil> <nil> <nil>} {0 0} 0xc00010ee00 io.containerd.runc.v2 {0xc0002131a0} 0xe6c020} Image: &{0xc0002f4000 {docker.io/library/redis:alpine map[] {application/vnd.docker.distribution.manifest.list.v2+json sha256:b0e84b6b92149194d99953e44f7d1fa1f470a769529bb05b4164eae60d8aea6c 1645 [] map[] <nil>} {312279631 63742833489 <nil>} {749989962 63743217103 <nil>}} {0xc0002131a0}} Container: &{0xc0002f4000 redis-server {redis-server map[] docker.io/library/redis:alpine {io.containerd.runc.v2 <nil>} 0xc0003ce1e0 redis-server-snapshot overlayfs {775955650 63743217103 <nil>} {775955650 63743217103 <nil>} map[]}} Task: &{0xc0002f4000 0xc0001020c0 0xc0003a86c0 redis-server 65235}
上記の Go コードおよび出力について少し説明します。
上記の Go コードは「containerd と /run/containerd/containerd.sock
を通じて通信する client の動作」が記述されています。コード内では client の作成、Container Image の pull、Container Image からの Snapshot (= Container 実行時に root filesystem として利用するもの) および Spec (= Container 実行時のメタデータ、Filesystem Bundle における config.json に相当) の作成、Task (= container の中で実際に実行したいコマンド) の作成、開始、終了およびその待ち合わせを行っています。
また、最後にデバッグ用途に様々な struct を print してみています(これは Getting started に掲載されていたコードに自分が後から追加したものです)。
Pull している Container Image は docker.io/library/redis:alpine
で、ログの出力としても redis server の起動および終了が行われていることが分かります。
これで、containerd を実際に利用してみる事が出来ました!
なお、containerd は OSS でコードが公開されているので、気になった挙動はコードを読んで理解する事が出来ます。例えば、 containerd.WithNewSnapshot
や containerd.WithNewSpec
というのは以下のコードに該当します。
https://github.com/containerd/containerd/blob/v1.4.3/container_opts.go#L144-L169 https://github.com/containerd/containerd/blob/v1.4.3/container_opts.go#L243-L253
前者のコードを読み進めると「Snapshotter によって Snapshot 作成を行って、Container には SnapshotKey を設定して id を通して参照できるようにしている」事が分かりますし、後者のコードを読み進めると「OCI Runtime Specification の Go binding で定義された Spec
struct が generate されて利用される」事が分かります。「runc などの Low-Level Container Runtime が必要とする情報を用意する」という部分をしっかりやってくれてるようです(注: これは自分がコードを読んだ理解なので、誤解してる可能もあります。何か気がついた際はコメントいただけるとありがたいです)。
その他、Container Image を Pull している部分や Task の管理部分なども興味深い部分です。気になった箇所はぜひ読み進めて理解を深めてみてください。
まとめ
runc や containerd などの Container Runtime を実際に触ってみる事で、理解を深めました。runc については、OCI Runtime Specification で定義された挙動が CLI tool として提供されている事が分かりました。containerd については、Container Image の Pull や Container Image から Filesystem Bundle 相当の情報(= Container 実行に必要な Snapshot および Spec)への変換、Container や Task の作成・削除・開始などの管理を行っている事が分かりました。
runc やcontainerd は扱っている領域が異なる一方で、どちらも「Container Runtime」という名前で呼ばれるために混乱してしまいがちです。実際に手を動かしてみる事で、「それぞれの責任範囲」や「レイヤー構造」について理解が深まったように思います。
なお、このブログ自体は自分の理解のために試したことをまとめたものですが、誰か他の人にとっても理解を助けるものになっていればとても幸いです。
Vagrant での実験環境
以下のような Vagrantfile を使ってます。ubuntu-20.04 を使ってます。
# -*- mode: ruby -*- # vi: set ft=ruby : VAGRANTFILE_API_VERSION = "2" Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| config.vm.box = "bento/ubuntu-20.04" config.vm.synced_folder "./", "/home/vagrant/oci-playground" end
手を動かして学ぶコンテナ標準 - Container Image と Container Registry 編
先日は、コンテナ標準化の現状と Kubernetes の立ち位置について において、各種ドキュメントをベースにコンテナ標準についてまとめてみました。
このブログでは、実際に tool などに触れて手を動かすことで、コンテナ標準についてさらに理解を深めてみたいと思います。
なお、基本的にこのブログ内のコマンドは、Linux で実行するものとします(自分は MacOS で Vagrant で Ubuntu VM を立てて実験してます)。
OCI Image の中身を見てみる
skopeo と呼ばれる「container image に対して様々な操作を行えるツール」があります。このツールを利用することで、「docker image から OCI Image への変換」を行うことができます。このツールを利用して、実際に OCI Image の中身を見てみましょう。
まず、以下のコマンドを実行して ruby:2.7.2-slim
という docker image を oci:ruby-oci:2.7.2
という名前の OCI Image に変換します。
vagrant@vagrant:~/oci-playground$ skopeo copy docker://ruby:2.7.2-slim oci:ruby-oci:2.7.2 Getting image source signatures Copying blob 852e50cd189d done Copying blob 6de4319615e2 done Copying blob 150eb06190d1 done Copying blob cf654ff9d9df done Copying blob 0a529f6cf42e done Copying config 3265430f5e done Writing manifest to image destination Storing signatures
上記コマンドを実行すると、ruby-oci という directory が出来ています。
vagrant@vagrant:~/oci-playground$ ls ruby-oci
ruby-oci
directory の中を見てみると、以下のように blobs
という direcyory と index.json
, oci-layout
という file が出来ています。これは、OCI Image Format Specification で定められた Image Layout の内容に一致しています。
vagrant@vagrant:~/oci-playground/ruby-oci$ ls blobs index.json oci-layout
oci-layout file には imageLayoutVersion
だけが記載されています。現時点では 1.0.0
が記載されているだけなので、将来の拡張のための file と考えると良いでしょう。
vagrant@vagrant:~/oci-playground/ruby-oci$ cat oci-layout | jq . { "imageLayoutVersion": "1.0.0" }
index.json は OCI Image のエントリーポイントとも呼べる file で、ここには以下のように「manifest fileへの参照(= Image Manifest を指し示す Content Descriptor)」が記載されています。
vagrant@vagrant:~/oci-playground/ruby-oci$ cat index.json | jq . { "schemaVersion": 2, "manifests": [ { "mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": "sha256:ad39959791540e6213fbe4675b9f3ee11e96456df3601b0936973ca7ae766bd7", "size": 976, "annotations": { "org.opencontainers.image.ref.name": "2.7.2" } } ] }
ここで出てきた「Content Descriptor」というのが OCI Image Format において特徴的なもので、これは「mediaType
, digest
, size
の 3 つ組 + optional な情報 (e.g. annotations)」となっています。
mediaType
が参照先の情報の種類、digest
が参照先の情報の path、size
が参照先の情報のバイト数を表しています。
digest で示されているのは「blobs
directory 以下の file path」になっていて、例えば上記の sha256:ad39959791540e6213fbe4675b9f3ee11e96456df3601b0936973ca7ae766bd7
という digest は blobs/sha256/ad39959791540e6213fbe4675b9f3ee11e96456df3601b0936973ca7ae766bd7
という path を表しています。実際に、file の中身を見てみると以下のような JSON になっています。
vagrant@vagrant:~/oci-playground/ruby-oci$ cat blobs/sha256/ad39959791540e6213fbe4675b9f3ee11e96456df3601b0936973ca7ae766bd7 | jq . { "schemaVersion": 2, "config": { "mediaType": "application/vnd.oci.image.config.v1+json", "digest": "sha256:3265430f5e5babe0664d7f7bcc77db2ef7d5feaa1625c06c10b1409ad2952133", "size": 4598 }, "layers": [ { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:852e50cd189dfeb54d97680d9fa6bed21a6d7d18cfb56d6abfe2de9d7f173795", "size": 27105484 }, { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:6de4319615e27e1aaaadc89b43db39ea0e118f47eeecfa4c8b910ca2fd810653", "size": 12539406 }, { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:150eb06190d1ba56f7b998da25a140c21258bca436d33e2e77df679d77ab364a", "size": 198 }, { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:cf654ff9d9df475122683b6bd070fa57a1e1969ced2a45f2c1f76a0678495ef2", "size": 22852677 }, { "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", "digest": "sha256:0a529f6cf42e0fb49fe3fb4d12e232b26db923ab85a442563b0a7ae0a28c5971", "size": 143 } ] }
mediaType
が application/vnd.oci.image.manifest.v1+json
だったことから、これは Image Manifest であると分かります。実際に、Image Manifest の仕様で定義された内容と一致しており、config (Container Image のメタデータ)や layers (Container Image の Layer、Docker Image における Layer Cache の単位となるもの)を情報として持つことも分かります。また、それらの情報への参照も、先ほどと同様の Content Descriptor 形式で表されていることが分かります。
config
の内容は、以下のような Image Configuration となっています。環境変数や Command など Container 実行時に必要な各種メタデータや、Container Image 作成時の history の情報が記載されています。
vagrant@vagrant:~/oci-playground/ruby-oci$ cat blobs/sha256/3265430f5e5babe0664d7f7bcc77db2ef7d5feaa1625c06c10b1409ad2952133 | jq . { "created": "2020-11-18T15:35:15.373100656Z", "architecture": "amd64", "os": "linux", "config": { "Env": [ "PATH=/usr/local/bundle/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "LANG=C.UTF-8", "RUBY_MAJOR=2.7", "RUBY_VERSION=2.7.2", "RUBY_DOWNLOAD_SHA256=1b95ab193cc8f5b5e59d2686cb3d5dcf1ddf2a86cb6950e0b4bdaae5040ec0d6", "GEM_HOME=/usr/local/bundle", "BUNDLE_SILENCE_ROOT_WARNING=1", "BUNDLE_APP_CONFIG=/usr/local/bundle" ], "Cmd": [ "irb" ] }, "rootfs": { "type": "layers", "diff_ids": [ "sha256:f5600c6330da7bb112776ba067a32a9c20842d6ecc8ee3289f1a713b644092f8", "sha256:70ca8ae918406dce7acc5fe0f49e45b9275a266b83e275922e67358976c2929e", "sha256:e8ace463e6f7085a5439cf3b578a080fbefc8ad8424b59b9f35590adb1509763", "sha256:71e4ad27368acf7dbb5c90aa65d67cc462267836aa220cbafb9bb62acd9d48de", "sha256:1946ed62a3cb062940077a7a1dbfc93d55be6ef3d4f605883b42f71970381662" ] }, "history": [ { "created": "2020-11-17T20:21:17.570073346Z", "created_by": "/bin/sh -c #(nop) ADD file:d2abb0e4e7ac1773741f51f57d3a0b8ffc7907348842d773f8c341ba17f856d5 in / " }, { "created": "2020-11-17T20:21:17.865210281Z", "created_by": "/bin/sh -c #(nop) CMD [\"bash\"]", "empty_layer": true }, { "created": "2020-11-18T15:21:22.717162717Z", "created_by": "/bin/sh -c set -eux; \tapt-get update; \tapt-get install -y --no-install-recommends \t\tbzip2 \t\tca-certificates \t\tlibffi-dev \t\tlibgmp-dev \t\tlibssl-dev \t\tlibyaml-dev \t\tprocps \t\tzlib1g-dev \t; \trm -rf /var/lib/apt/lists/*" }, { "created": "2020-11-18T15:21:23.811888513Z", "created_by": "/bin/sh -c set -eux; \tmkdir -p /usr/local/etc; \t{ \t\techo 'install: --no-document'; \t\techo 'update: --no-document'; \t} >> /usr/local/etc/gemrc" }, { "created": "2020-11-18T15:21:24.004412503Z", "created_by": "/bin/sh -c #(nop) ENV LANG=C.UTF-8", "empty_layer": true }, { "created": "2020-11-18T15:30:41.383881949Z", "created_by": "/bin/sh -c #(nop) ENV RUBY_MAJOR=2.7", "empty_layer": true }, { "created": "2020-11-18T15:30:41.629378277Z", "created_by": "/bin/sh -c #(nop) ENV RUBY_VERSION=2.7.2", "empty_layer": true }, { "created": "2020-11-18T15:30:41.868222399Z", "created_by": "/bin/sh -c #(nop) ENV RUBY_DOWNLOAD_SHA256=1b95ab193cc8f5b5e59d2686cb3d5dcf1ddf2a86cb6950e0b4bdaae5040ec0d6", "empty_layer": true }, { "created": "2020-11-18T15:35:11.770005784Z", "created_by": "/bin/sh -c set -eux; \t\tsavedAptMark=\"$(apt-mark showmanual)\"; \tapt-get update; \tapt-get install -y --no-install-recommends \t\tautoconf \t\tbison \t\tdpkg-dev \t\tgcc \t\tlibbz2-dev \t\tlibgdbm-compat-dev \t\tlibgdbm-dev \t\tlibglib2.0-dev \t\tlibncurses-dev \t\tlibreadline-dev \t\tlibxml2-dev \t\tlibxslt-dev \t\tmake \t\truby \t\twget \t\txz-utils \t; \trm -rf /var/lib/apt/lists/*; \t\twget -O ruby.tar.xz \"https://cache.ruby-lang.org/pub/ruby/${RUBY_MAJOR%-rc}/ruby-$RUBY_VERSION.tar.xz\"; \techo \"$RUBY_DOWNLOAD_SHA256 *ruby.tar.xz\" | sha256sum --check --strict; \t\tmkdir -p /usr/src/ruby; \ttar -xJf ruby.tar.xz -C /usr/src/ruby --strip-components=1; \trm ruby.tar.xz; \t\tcd /usr/src/ruby; \t\t{ \t\techo '#define ENABLE_PATH_CHECK 0'; \t\techo; \t\tcat file.c; \t} > file.c.new; \tmv file.c.new file.c; \t\tautoconf; \tgnuArch=\"$(dpkg-architecture --query DEB_BUILD_GNU_TYPE)\"; \t./configure \t\t--build=\"$gnuArch\" \t\t--disable-install-doc \t\t--enable-shared \t; \tmake -j \"$(nproc)\"; \tmake install; \t\tapt-mark auto '.*' > /dev/null; \tapt-mark manual $savedAptMark > /dev/null; \tfind /usr/local -type f -executable -not \\( -name '*tkinter*' \\) -exec ldd '{}' ';' \t\t| awk '/=>/ { print $(NF-1) }' \t\t| sort -u \t\t| xargs -r dpkg-query --search \t\t| cut -d: -f1 \t\t| sort -u \t\t| xargs -r apt-mark manual \t; \tapt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false; \t\tcd /; \trm -r /usr/src/ruby; \t! dpkg -l | grep -i ruby; \t[ \"$(command -v ruby)\" = '/usr/local/bin/ruby' ]; \truby --version; \tgem --version; \tbundle --version" }, { "created": "2020-11-18T15:35:12.227711802Z", "created_by": "/bin/sh -c #(nop) ENV GEM_HOME=/usr/local/bundle", "empty_layer": true }, { "created": "2020-11-18T15:35:12.563337139Z", "created_by": "/bin/sh -c #(nop) ENV BUNDLE_SILENCE_ROOT_WARNING=1 BUNDLE_APP_CONFIG=/usr/local/bundle", "empty_layer": true }, { "created": "2020-11-18T15:35:12.907595531Z", "created_by": "/bin/sh -c #(nop) ENV PATH=/usr/local/bundle/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "empty_layer": true }, { "created": "2020-11-18T15:35:14.977063521Z", "created_by": "/bin/sh -c mkdir -p \"$GEM_HOME\" && chmod 777 \"$GEM_HOME\"" }, { "created": "2020-11-18T15:35:15.373100656Z", "created_by": "/bin/sh -c #(nop) CMD [\"irb\"]", "empty_layer": true } ] }
layers
は Filesystem Layer を表しています。tar+gzip
という mediaType の suffix は「gzip 圧縮された tar archive」を表しています。試しに、最も root にあった sha256:852e50cd189dfeb54d97680d9fa6bed21a6d7d18cfb56d6abfe2de9d7f173795
の中身を見てみます。
vagrant@vagrant:~/oci-playground/ruby-oci$ mkdir rootfs vagrant@vagrant:~/oci-playground/ruby-oci$ tar xvzf blobs/sha256/852e50cd189dfeb54d97680d9fa6bed21a6d7d18cfb56d6abfe2de9d7f173795 -C rootfs/ . . .
上記コマンドで、rootfs directory 以下に圧縮されていた中身が展開されます(注: tar: bin/uncompress: Cannot hard link to ‘bin/gunzip’: Operation not permitted
など一部の file について error は出ていて、そのせいで tar: Exiting with failure status due to previous errors
という失敗 message も出てしまいましたが、それはここでは無視します)。
rootfs
の中身を見てみると、以下のようにいくつかの directyory が並んでいます。
vagrant@vagrant:~/oci-playground/ruby-oci$ ls rootfs/ bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
試しに変換前の ruby:2.7.2-slim
docker image を利用して container を起動してみると、root directory の中身がそっくりであることが確認できます。
$ docker run -it ruby:2.7.2-slim bash root@f6be3c7c619d:/# ls / bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
実は、これらの layer が「どう apply されるのか」は Image Layer Filesystem Changeset の Applying Changesets というセクション の中で以下のように明確に定義されています。ざっくり言えば「layer の上から順に tar archive を展開したようなもの」になります。 「file や directory の削除」は Whiteouts と呼ばれる特別な仕様で表現されますが、特別に注意を払う必要があるのはそれくらいのようです。
Applying Changesets
- Layer Changesets of media type application/vnd.oci.image.layer.v1.tar are applied, rather than simply extracted as tar archives.
- Applying a layer changeset requires special consideration for the whiteout files.
- In the absence of any whiteout files in a layer changeset, the archive is extracted like a regular tar archive.
cf. https://github.com/opencontainers/image-spec/blob/v1.0.1/layer.md#applying-changesets
ということで、OCI image の中身に目を通してみました。「Conatainer を走らせるために必要な情報(= 実行時のメタデータ + Layer 化された filesystem の情報)」が格納されてることがわかったかと思います。
Container Registry との通信内容を見てみる
ここまでで、「Container Image の内容」については把握できました。次に、「Container Registry から Container Image をどのように pull しているのか」を調べてみましょう。
現在、各種 Container Registry は Docker 社が公開している Docker Registry HTTP API V2 と呼ばれる仕様に従う形で Container Image の Pull を出来るようにしています。実は、「Container Image の Pull」にあたる操作はただの HTTP request であるため、$ curl
を利用して実行する事ができます。ここでは、実際に $ curl
で request してみることで、Container Registry との通信内容を見てみる事にしましょう。
なお、自分が試した範囲では、どの Container Image も OCI Image Format ではなく Docker Image Manifest V 2, Schema 2 に従う形の response を返してきました。ただ、OCI Image Format と Docker Image V2.2 は一部の mediaType 名を除いてほぼ同一なので、先ほど眺めた内容は理解に役立つはずです。
さて、実際に curl で request を送ってみましょう。対象 Container Image は何でも良いのですが、ここでは https://github.com/GoogleContainerTools/base-images-docker に記載されてる Debian の Container Image である gcr.io/google-appengine/debian9
を対象にしてみます。
まず、以下のように Container Registry の Authentication に必要な Token を取得します。この時、「google-appengine/debian9
の pull」という形で scope
を指定しておきます。
$ export TOKEN=$(curl "https://gcr.io/v2/token?service=gcr.io&scope=repository:google-appengine/debian9:pull" | jq -r '.token') % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 453 0 453 0 0 1088 0 --:--:-- --:--:-- --:--:-- 1088
次に、https://gcr.io/v2/<name>/manifests/<reference>
へ先ほど取得した Token 付きで GET
request を送ります。こうすると、Docker Image V2.2 における manifest file が取得できます。
$ curl -H "Authorization: Bearer ${TOKEN}" https://gcr.io/v2/google-appengine/debian9/manifests/latest | jq . % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 528 100 528 0 0 469 0 0:00:01 0:00:01 --:--:-- 469 { "schemaVersion": 2, "mediaType": "application/vnd.docker.distribution.manifest.v2+json", "config": { "mediaType": "application/vnd.docker.container.image.v1+json", "size": 463, "digest": "sha256:18c47921b263ac67af3d654e3b485c998d1e6bab56edc5a15b6b7a8fad3ac18a" }, "layers": [ { "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip", "size": 47965538, "digest": "sha256:faa9d9046d25e5fd30ac4444c7b6c30a1a6fff7c461410547156aed2001668a1" } ] }
まず、config
の中身を見てみましょう。
digest を利用して参照を辿る際は https://gcr.io/v2/<name>/blobs/<digest>
へ request すれば良いです。実際に request してみると、以下のような response が返ってきます。先ほど OCI Image の中身を見てみた時と同様に、Container 実行に必要なメタデータが格納されていることが分かります。
$ curl -L -H "Authorization: Bearer ${TOKEN}" https://gcr.io/v2/google-appengine/debian9/blobs/sha256:18c47921b263ac67af3d654e3b485c998d1e6bab56edc5a15b6b7a8fad3ac18a | jq . % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 13 0 13 0 0 23 0 --:--:-- --:--:-- --:--:-- 23 100 463 100 463 0 0 750 0 --:--:-- --:--:-- --:--:-- 750 { "architecture": "amd64", "author": "Bazel", "created": "1970-01-01T00:00:00Z", "history": [ { "author": "Bazel", "created": "1970-01-01T00:00:00Z", "created_by": "bazel build ..." } ], "os": "linux", "rootfs": { "type": "layers", "diff_ids": [ "sha256:0a3dcb016bd8a852985044291de00ad6a6b94dcb0eac01b34b56afed409b9999" ] }, "config": { "Cmd": [ "/bin/sh", "-c", "/bin/bash" ], "Env": [ "DEBIAN_FRONTEND=noninteractive", "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "PORT=8080" ] } }
なお、注意点として、どうやら GCR は https://gcr.io/v2/<name>/blobs/<digest>
への request では Google Cloud Storage への redirect response を返すようです。-L
オプションを付けない場合は以下のような結果になることには留意してください。
$ curl --include -H "Authorization: Bearer ${TOKEN}" https://gcr.io/v2/google-appengine/debian9/blobs/sha256:18c47921b263ac67af3d654e3b485c998d1e6bab56edc5a15b6b7a8fad3ac18a HTTP/2 302 docker-distribution-api-version: registry/2.0 location: https://storage.googleapis.com/artifacts.google-appengine.appspot.com/containers/images/sha256:18c47921b263ac67af3d654e3b485c998d1e6bab56edc5a15b6b7a8fad3ac18a content-type: application/json date: Wed, 09 Dec 2020 13:38:51 GMT server: Docker Registry cache-control: private x-xss-protection: 0 x-frame-options: SAMEORIGIN alt-svc: h3-29=":443"; ma=2592000,h3-T051=":443"; ma=2592000,h3-Q050=":443"; ma=2592000,h3-Q046=":443"; ma=2592000,h3-Q043=":443"; ma=2592000,quic=":443"; ma=2592000; v="46,43" accept-ranges: none vary: Accept-Encoding {"errors":[]}%
上記では config の取得を行いましたが、Layer (mediaType: application/vnd.docker.image.rootfs.diff.tar.gzip
のデータ)についても同様にhttps://gcr.io/v2/<name>/blobs/<digest>
への request によって取得する事ができます。先ほどと同様に、tar
コマンドで展開すると container 実行に利用される file を取得することが出来ます。
$ curl -L -H "Authorization: Bearer ${TOKEN}" https://gcr.io/v2/google-appengine/debian9/blobs/sha256:faa9d9046d25e5fd30ac4444c7b6c30a1a6fff7c461410547156aed2001668a1 --output /tmp/faa9d9046d25e5fd30ac4444c7b6c30a1a6fff7c461410547156aed2001668a1 % Total % Received % Xferd Average Speed Time Time Time Current Dload Upload Total Spent Left Speed 100 13 0 13 0 0 28 0 --:--:-- --:--:-- --:--:-- 28 100 45.7M 100 45.7M 0 0 13.6M 0 0:00:03 0:00:03 --:--:-- 20.1M $ ls -la /tmp/faa9d9046d25e5fd30ac4444c7b6c30a1a6fff7c461410547156aed2001668a1 -rw-r--r-- 1 minami wheel 47965538 Dec 9 23:48 /tmp/faa9d9046d25e5fd30ac4444c7b6c30a1a6fff7c461410547156aed2001668a1 $ mkdir /tmp/rootfs $ tar xvzf /tmp/faa9d9046d25e5fd30ac4444c7b6c30a1a6fff7c461410547156aed2001668a1 -C /tmp/rootfs/ $ ls /tmp/rootfs bin boot dev etc home lib lib64 media mnt opt proc root run sbin srv sys tmp usr var
という事で、Container Registry との通信について、特に「Container Image の Pull」に絞って通信内容を見てみました。Docker Image V2.2 をベースにした通信である事、特に config や layer などがそれぞれの単位で通信できることなどが分かったかと思います。より詳しい内容が気になる場合は、Docker Registry HTTP API V2 を参照してみてください。
なお、「Container Image 全てをまとめた file を一括でダウンロードしないのは何故なのか?」という疑問についてですが、これは自分の理解では「Layer Cache を効かせた形での Image Pull を実現するため」だと捉えています。Layer のデータは巨大であるため最低限の通信で済ませたいというのが大前提にあり、そのために「コンテンツの中身を反映した digest 値を用いて、Layer ごとに通信する」という振る舞いになっているのだと思われます。
まとめ
OCI image を実際に作成して眺めて見ることで、Container Image について理解を深めました。また、curl
で Container Registry との通信を行うことで、Container Registry との通信内容についても理解を深める事が出来ました。
ドキュメントを読むだけだとどうしても理解が曖昧になってしまいがちですが、実際に手を動かす事で具体的な動作をイメージ出来るようになります。このブログ自体は自分の理解のために試したことをまとめたものですが、誰か他の人にとっても理解を助けるものになっていれば幸いです。
なお、今回は Container Image + Container Registry 編でしたが、後日 Container Runtime についても「手を動かして調べた内容」についてまとめたいと思っています。特に、「Container Image から Container Runtime が利用する Filesystem Bundle への Conversion」や、「runc などの low-level Container Runtime の動作」、「containerd や CRI-O などの high-level Container Runtime の動作」について試したことをまとめる予定です。
補足: Vagrant での実験環境
以下のような Vagrantfile を使ってます。ubuntu-20.04 を使ってます。
# -*- mode: ruby -*- # vi: set ft=ruby : VAGRANTFILE_API_VERSION = "2" Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| config.vm.box = "bento/ubuntu-20.04" end
参考文献
以下のブログは、ツールやコマンド、内容において大幅に参考にさせて頂きました。ありがとうございました。
コンテナ標準化の現状と Kubernetes との関係性について
コンテナ標準化が進んでいる事は知りつつも、標準化された仕様の具体的な内容についてはあまり知らない事に気づいたので、この機会に調べてみました。個人向けメモとして残しておきます。
余力があれば、後でもう少し詳細をまとめる予定です(docker image を OCI Image Format に変換して眺めてみたり、runc や containerd などを実際に動かしてみたり、containerd や CRI-O などの Container Runtime の実装に目を通してみたりしたので、その辺りについてもいつかまとめたいと思ってます)。
追記: 手を動かして調べた内容は以下の2つのブログにまとめました。
以下、調査した内容をまとめたメモです。
コンテナ標準と Open Container Initiative (OCI) について
コンテナ標準は Open Container Initiative (OCI) と呼ばれる団体によって仕様策定が進められている。 image format, (low-level) runtime については既に標準が存在している(v1.0.0 をリリース済み)。distribution (container registry 周り) については仕様策定中の状態(2020年12月6日時点で GitHub の tag では v1.0.0-rc1 が出ている状態)。
以下、 OCI が定める各種標準仕様について簡単にまとめる。
OCI Image Format Specification
- Docker Image のような「container を記述する image format」の標準仕様。これは Docker Image の最新 format である Docker Image Manifest V2, Schema 2 をベースに標準化したもの。
- 以下が Docker 社からの公式声明。
Given this state of the world in late 2015, the OCI image specification work began in earnest with a strong group of collaborating independent and vendor-associated participants, using the Docker v2.2 image format as a starting point.
- cf. https://opencontainers.org/posts/blog/2018-10-11-oci-image-support-comes-to-open-source-docker-registry/
- Container Registry とのデータのやり取りも OCI Image Format(およびそれの元になった Docker Image Manifest V2, Schema2)に準拠する形で行われる。
- 後述する OCI Runtime Specification で利用される Filesystem Bundle を生成する Conversion 処理 についても仕様が策定されている。
OCI Runtime Specification
- Container 管理を行う Container Runtime の標準仕様。後述するが、Low-Level Container Runtime と呼ばれるものはこの仕様に準拠している。
- これは Docker 社が OCI に寄贈した "runC" とほぼ対応づくもの。
runc depends on and tracks the runtime-spec repository. We will try to make sure that runc and the OCI specification major versions stay in lockstep. This means that runc 1.0.0 should implement the 1.0 version of the specification.
- https://github.com/opencontainers/runc
- 「コンテナの configuration file およびコンテナの root filesystem をまとめたもの」である Filesystem Bundle や、OCI Runtime 準拠の Container Runtime で行える操作 について仕様を策定している。
OCI Distribution Specification
- Container Registry との通信における標準仕様。Docker Hub や Google Container Registry など各種 Container Registry が従っている Docker Registry HTTP API V2 をベースに仕様策定が進められている。最新は v1.0.0-rc1。
The spec is based on the specification for the Docker Registry HTTP API V2 protocol apdx-1.
- cf. https://github.com/opencontainers/distribution-spec/blob/master/spec.md
Container Runtime について
Container Runtime と呼ばれるものは複数存在するが、ものによって担当する layer が違う。以下、一例を紹介(注: 他にも Container Runtime と呼ばれるものはいくつかあるが、ここでは自分の理解のために調べた一部に限定している)。
runc
- 前述した OCI Runtime Specification を素朴に実装したもの。後述する containerd と対比して、Low-Level Container Runtime と紹介をされる例を見かける。
runc is a low-level container runtime, as it directly uses namespace and cgroups to create containers.
- https://insujang.github.io/2019-10-31/container-runtime
- Docker 社が「ツールとしての Docker」の一部を OSS として公開したのが出自。
And today we are spinning out runC as a standalone tool, to be used as plumbing by infrastructure plumbers everywhere.
- https://www.docker.com/blog/runc/
- OCI Runtime Specification で定められた Filesystem Bundle と呼ばれる「コンテナの configuration file およびコンテナの root filesystem をまとめたもの」を元に、コンテナの作成、削除、状態の取得などの操作が可能。
- README の Using runc を参照して動かしてみると動作がイメージできる。
containerd
- container registry からの image 取得、runc などの low-level container runtime を利用した container 起動(OCI Runtime Specification で定められた filesystem bundle 生成を含む)、container 管理などを行うもの。
- daemon として動作する。client からは
/run/containerd/containerd.sock
経由の gRPC API で通信を行い、container 作成や task 実行などの操作が可能。 - 後述する Container Runtime Interface (CRI) もサポートしている
- 「ツールとしての Docker」から利用されている。そもそも、Docker 社が CNCF へ寄贈したのが出自。
Today, Docker announced its intention to donate the containerd project to the Cloud Native Computing Foundation (CNCF).
- https://www.docker.com/blog/docker-donates-containerd-to-cncf/
CRI-O
- containerd とだいたい似たレイヤーを担当。「Kubernetes から利用されること(CRI に準拠していること)」を念頭に開発されてる。
Last year, the Kubernetes project introduced its Container Runtime Interface (CRI)
Building on that work, the CRI-O project (originally known as OCID) is ready to provide a lightweight runtime for Kubernetes.
- https://www.redhat.com/en/blog/introducing-cri-o-10
- runc を始めとして、いくつかのコンポーネントを組み合わせた実装になっている
The plan is to use OCI projects and best of breed libraries for different aspects:
- Runtime: runc (or any OCI runtime-spec implementation) and oci runtime tools
- Images: Image management using containers/image
- Storage: Storage and management of image layers using containers/storage
- Networking: Networking support through use of CNI
- https://github.com/cri-o/cri-o#what-is-not-in-scope-for-this-project
Kubernetes が定める Container Runtime Interface (CRI) について
- Kubernetes は Container Runtime Interface (CRI) と呼ばれる「独自で定義した API」を利用して、Container Runtime と通信を行う
- CRI は基本的には「protocol buffer で記述された gRPC API」。container を操作するために必要な操作が RPC として定義されている。
CRI consists of a protocol buffers and gRPC API, and libraries, with additional specifications and tools under active development
- cf. https://kubernetes.io/blog/2016/12/container-runtime-interface-cri-in-kubernetes/
- アクティブに開発されてる Container Runtime は、CRI を実装している。一例は以下(注: Docker については Kubernetes 開発者によって dockershim と呼ばれる CRI サポート用のツールが実装されていた)。
This page lists details for using several common container runtimes with Kubernetes, on Linux:
- containerd
- CRI-O
- Docker
- https://kubernetes.io/docs/setup/production-environment/container-runtimes/
- 先週、 Docker が Kubernetes の Container Runtime としては Deprecated になった ことが話題になったが、これは dockershim のメンテナンスを将来のバージョンで止めるというアナウンス。
- 自分が調べた限りでは、containerd, CRI-O などの Container Runtime はどれも複数の image format をサポートしていて、「docker build で生成した container image」は引き続き利用可能。
- "Docker Image Manifest V2, Schema 2" と "OCI Image Format Specification" はどちらもサポートされている。
- そもそもこれらは「ほぼ同一」ある(一部の mediaType が違うくらいで、1対1対応)
The OCI image manifest is also a registry image manifest that defines components that make up an image. The format is essentially the same as the Docker V2.2 format, with a few differences.
- mediaType - must be set to application/vnd.oci.image.manifest.v1+json
- config.mediaType - must be set to application/vnd.oci.image.config.v1+json
Each object in layers must have mediaType be either application/vnd.oci.image.layer.v1.tar+gzip or application/vnd.oci.image.layer.v1.tar.
- https://containers.gitbook.io/build-containers-the-hard-way/#registry-format-oci-image-manifest
- そもそもこれらは「ほぼ同一」ある(一部の mediaType が違うくらいで、1対1対応)
- "Docker Image Manifest V2, Schema 2" と "OCI Image Format Specification" はどちらもサポートされている。
- そのため、「docker build で作成した container image を container registry へ push して、k8s から利用する」という一連のワークフローに関していえば、containerd or CRI-O を使っても問題になる事は一切無い はず
- もちろん、以下の Blog で言及されてるように「Kuberrnetes 内で Docker 自体の機能に依存していた場合」は対応が必要