Harvard 大学の CS50 という CS 入門用オンラインコースを受講して学んだこと

コンピュータサイエンス (CS) の基礎をしっかり勉強しようと思い、とりあえず Open Source Society University (OSSU) で紹介されているカリキュラムが取っ掛かりにはよさそうだったので、まずこちらの CS50’s Introduction to Computer Science という Harvard 大学のオンラインコースを対象にして、 C 言語の講義回である Week 1 から Week 4 までを受講しました。 OSSU の Introduction to Computer Science セクションの冒頭に書かれている

feel free to skip straight to the second course when CS50 (the first course) moves away from C.

という但し書きの通り、 CS50 の Week 5 以降の講義では主題が C から HTML や CSS, JavaScript, Python に変わってしまい、ざっと見た感じ内容もそれまでと比べて易しくなる印象だったので、 Week 5 以降は受講していません。

受講した結論としては、「C 言語を使ってメモリ管理やアルゴリズム、データ構造などを意識しながら小規模のプログラムを書く」という良い経験ができ、そのへんの素養がなかった私にとってはとても良い勉強になりました。あと、 Week 4 の演習内の最後の課題である “speller” というプログラムの実行速度を競う以下のランキングで、いろいろチューニングを頑張った結果現状 3 位にまでランクインすることができた (2018 年 3 月 14 日時点) のも嬉しい事柄でした。

Big Board - speller

3rd in speller big board

この位置につけるまで結構試行錯誤したので、嬉しくて思わずスクリーンショットを取ってしまいました (でもまだ上がいるんですよね……)。

受けたかった部分の受講は完了し、それなりに満足できる結果も出せて一区切りついたので、 CS50 の Week 1 ~ 4 で学んだ内容をまとめてみようと思います。

CS50’s Introduction to Computer Science について

これは edX というオンラインコースプラットフォーム (いわゆる MOOC) 上で公開されている講座で、 Harvard 大学の CS50 というコンピュータサイエンス入門用の授業がオンラインで公開されているものです。受講するだけならアカウント登録をおこなえば無料で受けられます (これに限らず、 OSSU で紹介されている講座はすべて基本的に無料です。)

この講座に限らずオンライン講座一般のいいところとしては、多くの場合レクチャーのビデオと演習用の課題がセットで用意されており、スムーズに独学できるところだと感じています。

レクチャーはすべて英語ですが、字幕も表示できるのでそれで補足しながらであれば説明されていることは概ね把握できますし、スライドや PC の画面も表示されるので理解の助けになります。かつ、この CS50 の担当教授である David J. Malan 先生の熱量がすごく、たとえば生徒も巻き込んでステージ上でアルゴリズムのイメージを実演してくれるなどしながら、毎回非常にわかりやすく講義内容を説明してくれます。ただし私のように非ネイティブの人にとっては、この人の喋りは勢いがすごくかなり早口に感じると思うので、最初それについていくのが大変かもしれません。

各講義回ごとに演習用の課題が用意されており、実際に課題の要件を満たすプログラムを実装して提出する必要があるのもやっていて楽しい点です。 CS50 では Cloud9 をベースにした CS50 IDE という Web IDE が提供されていて、ブラウザ上でコーディングから課題の提出までおこなえるので、ローカルに開発環境を構築する手間がまったく必要ないのもいいところです。実装結果を提出するとリモートでテストが実行され、要件を満たしているかどうか自動的に評価されるようになっていて、これも素晴らしい点だと思います。

ただし Cloud9 のサーバーがおそらく米国にあるため、日本から使うと IDE のターミナル上でのコマンドの入力に微妙に遅延を感じます (遠い国にあるサーバーに SSH して操作するときに感じるラグと同じ感じ)。なお IDE エディタ上のコーティング作業自体はまったく問題ありません。

Week 1 ~ 4 の講義の内容と学んだこと

コンピュータサイエンスの入門講座のため、とても基礎的な内容ですが、私にとっては良い勉強になりました。特に探索やソートといったアルゴリズムや、 C 言語のメモリ管理、そしてデータ構造の実装について実際に手を動かして学ぶことができました。

演習課題も背景知識や実装方針などが非常に丁寧に説明されていて (動画での解説もある)、初学者でもつまづかないようかなり配慮されているように感じられました。課題の内容も結構おもしろいものが多かったです。

Week 1

何かしらプログラムを書いたことがある立場からすると、 Week 1 の内容は簡単でした。プリミティブなデータ型、演算子、ループ、条件分岐といった、 C 言語の入門として最初に習うものです。

ただし演習課題の1つである Credit という問題で、「クレジットカード番号の単純な認証には Luhn アルゴリズムによるチェックサムが使われている」という背景知識が知れたことは勉強になりました。

レクチャー

  • Hello, World
  • データタイプ (int, double, …)
  • 演算子 (+, -, …)
  • ループ (for, while)
  • 条件分岐 (if, switch)
  • 計算誤差 (桁あふれ、打切り誤差等)

演習課題

  • Mario: 入力値に応じた階段状のブロックを表示するプログラムを実装する
  • Cash: お釣りの金額に必要な最小のコイン数を計算するプログラムを実装する
  • Credit: Luhn アルゴリズムとクレジットカード会社ごとの発行番号の規約から、与えられたクレジットカード番号がどのカード会社のものか判定するプログラムを実装する

演習課題は “less comfortable” と “more comfortable” のうちどちらか 1 つずつをやればいいようになっていましたが、せっかくなので両方やりました。

Week 1, continued

Week 1 の続きの講義もまだまだ初歩的な内容でした。主に文字列と配列に焦点が当たっていました。 C の文字列は非常に原始的で「NUL 文字 \0 で終端された単なる char の配列である」という点は、日頃現代的な言語を利用していると不便さを感じるときもありますよね。

おもしろかったのは Crack という演習課題で、 unistd.hcrypt 関数で暗号化されたパスワードを、ブルートフォースアタックにより解読するという内容でした。

レクチャー

  • 文字列と ASCII コード
  • 配列
  • コマンドライン引数
  • 簡単な暗号

演習課題

  • Caesar: Caesar 暗号で入力文字列を暗号化するプログラムを実装する
  • Vigenère: Vigenère 暗号で入力文字列を暗号化するプログラムを実装する
  • Crack: C の crypt 関数で暗号化されたパスワードのハッシュ文字列から、もとのパスワード文字列を見つけ出すプログラムを実装する

上記 3 つ中 2 つを提出すればよいことになっていましたが、せっかくなので 3 つ全部やりました。 Crack の課題では、パスワードはすべてアルファベットで最長でも 5 文字という前提になっており、総当りで解読できるような条件設定になっています。 C で 1 ~ 5 文字の文字列の全組み合わせを生成する処理の実装方法に少し悩んだ記憶があります。

Week 2

Week 2 から私にとってはだんだんおもしろくなってきました。アルゴリズムの勉強の最初に習う探索、ソート、計算量の話です。マージソートの計算量が O(nlogn)O(n \log n) になる理由がよくわかっていなかったので、ちゃんと理解することができて勉強になりました。ただソートではクイックソートやヒープソートといった、現在主に使われているであろう高速ソートの説明がなかったのが少し残念です。

今回の Music という課題もなかなかおもしろい演習で、「楽譜データを読み込んでその曲の WAV ファイルを生成するプログラムの一部を実装する」というものでした。

レクチャー

  • 文字列操作
  • 探索
    • 線形探索 (linear search)
    • 二分探索 (binary search)
  • ソート
    • バブルソート (bubble sort)
    • 選択ソート (selection sort)
    • 挿入ソート (insertion sort)
    • マージソート (merge sort)
  • 計算量
    • OO 記法と Ω\Omega 記法

演習課題

  • Music: 楽譜データを読み込んでその曲の WAV ファイルを生成するプログラムの一部を実装する

この課題は一部のヘルパー関数を実装するだけで、実装ボリューム自体は全然大したことないのですが、音楽の知識がなかったので背景知識をきちんと把握するまでに手間取りました。あと日本人は音階の表記に「ドレミファソラシ」を使うので、 “CDEFGAB” を使う欧米の表記法との間を脳内で相互変換するのも最初苦労しました。

まずこのプログラムの概要は、楽譜データのテキストファイルを読み込んで、記載された各音符の音階と長さの系列データをもとに、波形データを生成して WAV ファイルとして出力する、というものになっています。

WAV ファイルの生成処理などはライブラリが同梱されており、大部分の処理についてはもともと実装されています。演習で実装するのは、楽譜のテキストデータの各音符の情報を入力として

  • 休符かどうか判定する関数 is_rest
  • 音符の長さを計算する関数 duration
  • 音符の高さから周波数 (Hz) を計算する関数 frequency

という3つのヘルパー関数だけなので、実装ボリューム自体は大して多くありません。

ですが個人的に難しかったのが frequency の実装です。背景知識を読んでへーと思ったのが、黒鍵の音符には2通りの表記法があるという点でした。たとえばの白鍵の間の黒鍵の音は、ド# ともレbとも表せるということです。なのでどちらの音符であっても、同じ周波数を出力しないといけません。

また、各音符の高さの周波数は「4オクターブめの (A4)」の 440 Hz を中心に、そこからの黒鍵を含めた音階の差分をもとに計算します。しかしながら「ファ」や「」の間には黒鍵がありません。また、オクターブは「ドレミファソラシド」で一周すると1つ上下します。なので、たとえば「6オクターブめの (E6) は基準となる4オクターブめの (A4) から何音階分離れているか」といったことをどのように計算すればよいのか結構考えました。

最終的に正しく実装できて、同梱されている楽譜データの「きらきら星」や「ロンドン橋落ちた」、「Jeopardy! のテーマソング」などがちゃんと再生されたときは達成感があり、嬉しかったです。

演習課題としてはおもしろかったのですが、でもこれ探索もソートも計算量も全然関係ない問題なんですよね。なんでこの回の課題がこれなのか不思議です。

Week 3

Week 3 ではメモリ管理やポインタが主題でした。私がよく使う Go 言語だとありがたいことに GC とエスケープ解析があるので、通常各ポインタのオブジェクトがスタックとヒープのどちらに置かれているか意識しないで済んでしまいます。一方 C 言語ではプログラマがちゃんと考えて、スタックにアロケートするかヒープにアロケートするか判断する必要があります。このように自力でメモリ管理をするプログラムを書く経験もやはり大事だなと感じました。

Week 3 の演習課題はすべて Bitmap (BMP) の画像ファイルを扱う内容で、「BMP ファイルのヘッダ情報や画像データのレイアウトはどうなっているのか」といったことを実装を通してよく理解することができるようになっており、これもかなり勉強になる演習でした。 BMP ファイルは他の画像形式と比較すると中身が単純なため、画像ファイルのデータ構造の入門に適していると思います。

レクチャー

  • プログラムのメモリレイアウト (text, data, stack, heap)
  • 関数とスタック
  • ポインタ
  • malloc とヒープ
  • 画像データ (Bitmap)
  • 構造体

演習課題

  • Whodunit: 与えられた BMP ファイルに隠されたメッセージを見つけるプログラムを実装する
  • Resize: BMP ファイルをリサイズするプログラムを実装する
  • Recover: たくさんの BMP ファイルが1つに連結されたバイナリファイルを読み込んで、それを個別の BMP ファイルに復元するプログラムを実装する

どれも BMP ファイルのヘッダ情報の詳細や画像データのレイアウトをきちんと理解した上で取り組まないとできない課題になっているため、 Bitmap の仕様のドキュメントをかなり読みました。また当然 malloc を使うところもあるので、レクチャーの内容に関連した応用課題にもなっています。

個人的に上記 3 つの中で一番難しかったのが Resize の実装で、入力 BMP 画像をリサイズした結果として、ヘッダ情報と画像データレイアウト (特にパディング) が両方とも適切に変更された画像を生成しないといけません。どこで嵌っていたのか忘れてしまったのですが、これがなかなか正しく実装できず、たとえばニコちゃんマーク画像を拡大しようとしてもグチャグチャな出力画像になってしまったりして、正しい挙動をする実装にたどり着くまでにだいぶ苦労した記憶があります。最終的にちゃんと大きなニコちゃんマーク画像が出力されたときはやったーと思いました。

Week 4

Week 4 は一番勉強になった講義回でした。データ構造はコンピュータサイエンスの基礎知識として非常に重要ですし、特にハッシュテーブルは数あるデータ構造の中でももっとも便利で利用頻度の高いものだと思うので、その実装方法を理解しておくことは大切だと感じました。言語組み込みや標準ライブラリにハッシュテーブルがない C 言語で自力で実装してみると、「ハッシュテーブルはリンクリストの配列により実装できる」ということがよく理解できますし、バケット数とエントリ数の比率であるロードファクターという指標がなぜ重要なのかも具体的にわかります。また、検索が確実に O(1)O(1) でおこなえるトライ木も、その有用さがわかりました。

今回の演習課題がこれまでで一番やりがいのあるものでした。プログラムの実行速度を競えるランキングがあるので、いろいろ試行錯誤しながらタイムを短縮していく楽しみがありました。

レクチャー

  • 構造体
  • コールスタック
  • スタックオーバーフロー
  • バッファオーバーフロー
  • Valgrind
  • データ構造
    • リンクリスト
    • スタック
    • キュー
    • ツリー
    • ハッシュテーブル
    • トライ木

演習課題

  • Speller: あらかじめ辞書データを読み込んだ上で、文章が書かれたテキストファイルの各単語を逐次読み込んでいき、それが辞書に載っているかどうか判定して載っていない場合には出力するプログラムの一部を実装する

これがこの記事の冒頭で述べていた演習課題です。プログラムの大枠はあらかじめ実装されており、演習として実装する部分は以下の4つの関数です。

  • 辞書データを読み込む関数 load
  • ある単語が辞書に含まれているかチェックする関数 check
  • 辞書内の単語の総数を返す関数 size
  • 辞書データをアンロードする関数 unload

プログラムの流れとしては、 load 関数で辞書ファイルを読み込んで内部で適切なデータ構造 (ハッシュテーブルやトライ木など) として保持したあと、文章ファイルを単語単位で読み込んでいき、それが辞書データに含まれている単語か否かを check 関数で判別していくという流れです。最後の unload 関数は malloc したデータを適切に解放する処理をおこなうステップで、ヒープにアロケートしたオブジェクトをここですべてきちんと free しないと、 Valgrind によるメモリリークチェックで怒られてしまいます。

そしてプログラム内でそれぞれの関数の処理にかかった時間が計測されるようになっており、それを如何に縮められるかが頭の使いどころになっています。辞書データの保持方法として「ハッシュテーブルを使った実装方法」と「トライ木を使った実装方法」がそれぞれ説明されている非常に丁寧な解説動画が用意されているため、要件を満たす各関数を実装すること自体はそれほど苦労せずにおこなえます。しかしながら、単にそれに沿った実装をしただけだとあまり速度は出ません。

この演習課題のおもしろいところは、以下のように実行速度のランキングが集計されて公開されており (誰でも見られます)、生徒同士 (とコーススタッフ) で自分が実装したプログラムのタイムを競えるようになっているところです。2018 年 3 月 14 日時点で 330 名以上がエントリしています。

Big Board - speller

私の場合、一番最初の実装ではたしか 20 ~ 30 位の間ぐらいだったと思います。その後いろいろと試行錯誤しながら実行速度の改善を重ねていき、運の良さもありましたが最終的には現時点で 3 位にランクインするタイムを出すことができました。これを通じてパフォーマンスチューニングという観点での学びがたくさんあり、非常に勉強になりました。今回はもう記事が長くなってしまったので細かい話は書きませんが、今後また別の記事でこの演習において自分がやったことの詳細を書くかもしれません (ただしこの演習課題に特化した話をしても誰のためにもならないと思うので、一般的な話に汎化したいとは思います)。

実際のところ私よりも上位のランカーがまだいるわけなので、まだ最適化の余地は残されているんだろうとは思っていますが、今の私の知識や技術ではこれ以上の改善は難しい状況なので、いつかまた修行を積んでから再度挑戦できたらいいなと思います。このランキングの参加者は基本的に初学者のはずで、演習課題の実装内容自体も簡素なものなので、おそらく速度に気を使って C / C++ 言語を長い間書いてきたプログラマの方であれば、私よりも全然速いプログラムを書ける可能性がかなり高いと思っています。なのでもしこの記事を読んで興味が沸いた方は、この演習だけでもやってみるとおもしろいかもしれません。

勉強の方法の 1 つとしてオンラインコースはおすすめかも

何かを勉強する方法として、良質なオンラインコースがあるようであれば、それをやってみるのは 1 つの方法としてありだなーと感じました。もちろん書籍を読んだりするほうが勉強効率としては桁違いに良いはずで、それで進められるのが理想的だと思います。オンラインコースは時間がかかる割にはあまり多くのことを学ぶことはできないでしょう。しかしながら演習課題があって自分で手を動かし、しかもその結果がすぐに評価される仕組みの上で取り組むと、ゲーミフィケーション的な効果で自分が積極的に参加している感じになり、モチベーションを高く維持することができるなと感じました。特に基礎的・入門的な内容の勉強にはオンラインコースは 1 つの選択肢としてありなのではないかと思います。

あと個人的に良かった副作用として、 C 言語が結構好きになりました。日頃現代的な言語を使っていると古臭い言語に感じることは確かで、実際不便な点やバグを生みやすい点も数多くありますが、この素朴さは特に低レイヤのプログラムを書くのにはやっぱり使いやすいんだろうなと思います。今となっては C でも Go でも問題なくポインタを扱うことができるようになっていますが、実は昔 C のポインタで一度挫折した経験があったので、それを無事克服することができてよかったです。

OSSU のカリキュラムに従い、次は Introduction to Computer Science and Programming Using Python という MIT のオンラインコースを受講しています。これまで Python はほとんど書いたことがありませんでしたが、書いてみるとこれも非常に使いやすい言語で、人気の理由がよくわかります。また一区切りついたらレポートを書くかもしれません。

引き続き日々精進していこうと思います。

Go 言語で学ぶ『暗号技術入門』Part 3 -CBC Mode-

結城浩さんの『暗号技術入門』を Go 言語を使って学ぶ記事の第3回です。前回は AES について解説しました。

今回は第4章「ブロック暗号のモード」です。そもそも、ブロック暗号の「モード」とは何なのでしょうか。

ブロック暗号の「モード」とは

前回の終わりにも書いたように、僕らが暗号化したい平文の長さは、たいていの場合ブロック暗号のブロック長 (AES なら 128 ビット) より長いことが多いです。そのため、ブロック暗号を繰り返し使って、長い文章全体を暗号化する必要があります。このブロック暗号を繰り返す方法のことをブロック暗号の「モード」と呼びます。

ブロック暗号のモードにはいくつもの種類がありますが、今回と次回でその中でも比較的使用が推奨されている CBC モードCTR モードについて説明します。今回は CBC モードについてです。

なお、この記事中の Go のソースコードのバージョンはすべて 1.6 です。

Go におけるブロック暗号モード

Go ではブロック暗号のモードはすべて crypto/cipher パッケージに定義されている BlockMode という統一のインターフェースを介して利用するようになっています。 CryptoBlocks()src のデータを dst に暗号化または復号します。

1
2
3
4
5
6
7
8
9
type BlockMode interface {
// BlockSize returns the mode's block size.
BlockSize() int

// CryptBlocks encrypts or decrypts a number of blocks. The length of
// src must be a multiple of the block size. Dst and src may point to
// the same memory.
CryptBlocks(dst, src []byte)
}

Block インターフェースと違い、 BlockMode インターフェースには CryptBlocks() メソッド1つしかありません。これは、そもそも暗号化と復号で別のオブジェクトを用いる必要があるからです。たとえば、あとで見るように、 CBC モードでの暗号化には NewCBCEncrypter() によるオブジェクトを使って、復号には NewCBCDecrypter() によるオブジェクトを使って CryptBlocks() を呼ぶ必要があります。

CBC (Cipher Block Chaining) モード

CBC モードとは

CBC (Cipher Block Chaining) モードは、1つ前の暗号文ブロックと平文ブロックの内容を混ぜ合わせてから暗号化をおこなう方法です。暗号文ブロックをチェーンのように連鎖させることが名前の由来になっています。

では CBC モードの具体的なアルゴリズムを見てみましょう。

CBC モードのアルゴリズム

以下の図のように、 CBC モードでは1つ前の暗号文ブロックと平文ブロックの XOR をとってから暗号化をおこないます。 “block cipher encryption” の中身には、たとえば AES を用いるのであればその処理が入ります。

https://upload.wikimedia.org/wikipedia/commons/thumb/8/80/CBC_encryption.svg/601px-CBC_encryption.svg.png

ただし、最初の平文ブロックを暗号化するときには「1つ前の暗号文ブロック」が存在しません。そこで、その代わりに「初期化ベクトル (initialization vector; IV)」というランダムなビット列を用意し、それを用います。

最終的に、生成される暗号文ブロックをつなげたものが暗号化されたデータになります。

逆に、復号化は以下の図のようになります。 XOR の対称的な性質により、復号化したブロックと1つ前の暗号文ブロックの XOR を再びとれば、元の平文が得られます。

https://upload.wikimedia.org/wikipedia/commons/thumb/2/2a/CBC_decryption.svg/601px-CBC_decryption.svg.png

CBC モードを使ってみる

Go 言語を使い、実際に CBC モードで長い平文を暗号化してみます。ブロック暗号としては AES を使います。

http://play.golang.org/p/_q54T5eDwe

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package main

import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"fmt"
"io"
"log"
)

func main() {
// 平文。長さが 16 バイトの整数倍でない場合はパディングする必要がある
plainText := []byte("secret text 9999")
// 暗号化データ。先頭に初期化ベクトル (IV) を入れるため、1ブロック分余計に確保する
encrypted := make([]byte, aes.BlockSize+len(plainText))

// IV は暗号文の先頭に入れておくことが多い
iv := encrypted[:aes.BlockSize]
// IV としてランダムなビット列を生成する
if _, err := io.ReadFull(rand.Reader, iv); err != nil {
log.Fatal(err)
}

// ブロック暗号として AES を使う場合
key := []byte("secret-key-12345")
block, err := aes.NewCipher(key)
if err != nil {
log.Fatal(err)
}

// CBC モードで暗号化する
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(encrypted[aes.BlockSize:], plainText)
fmt.Printf("encrypted: %x\n", encrypted)

// 復号するには復号化用オブジェクトが別に必要
mode = cipher.NewCBCDecrypter(block, iv)
decrypted := make([]byte, len(plainText))
// 先頭の IV を除いた部分を復号する
mode.CryptBlocks(decrypted, encrypted[aes.BlockSize:])
fmt.Printf("decrypted: %s\n", decrypted)
// Output:
// decrypted: secret text 9999
}

標準ライブラリのみを用いて直接暗号化をおこなおうとすると、結構面倒なことがわかります。実際にはアプリケーションが使いやすいようなラッパー処理を書き、それを用いることになると思います。

Go での実装

では標準ライブラリの crypto/cipher において、 CBC モードがどのように実装されているのか見てみましょう。

CBC モードを表すのは以下の cbc 構造体です。

1
2
3
4
5
6
type cbc struct {
b Block
blockSize int
iv []byte
tmp []byte
}

cbc 構造体のコンストラクタは newCBC() です。書き換えの影響を受けないように、 cbc.iv には引数の iv を完全にコピーしたものが入れられます。

1
2
3
4
5
6
7
8
func newCBC(b Block, iv []byte) *cbc {
return &cbc{
b: b,
blockSize: b.BlockSize(),
iv: dup(iv),
tmp: make([]byte, b.BlockSize()),
}
}

cbc 構造体は直接 BlockMode インターフェースを実装してわけではありません。実際はそのエイリアスの cbcEncrypter / cbcDecrypter 型が暗号化 / 復号処理を実装しています。同じ構造体に複数の別名型をつけ、それぞれで違うメソッドを実装するというのは、参考になる実装方法ですね。

暗号化用オブジェクトは NewCBCEncrypter() で生成します。この戻り値が BlockMode インターフェースを実装した cbcEncrypter オブジェクトになります。

1
2
3
4
5
6
7
8
9
10
11
type cbcEncrypter cbc

// NewCBCEncrypter returns a BlockMode which encrypts in cipher block chaining
// mode, using the given Block. The length of iv must be the same as the
// Block's block size.
func NewCBCEncrypter(b Block, iv []byte) BlockMode {
if len(iv) != b.BlockSize() {
panic("cipher.NewCBCEncrypter: IV length must equal block size")
}
return (*cbcEncrypter)(newCBC(b, iv))
}

では、 cbcEncrypterCryptBlocks() メソッドの実装を見てみましょう。やっているのは先に説明したアルゴリズム通り、平文ブロック (src[:x.BlockSize]) と1つ前の暗号文ブロック (iv) の XOR をとり (xorBytes())、それを暗号化する (x.b.Encrypt()) ことです。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func (x *cbcEncrypter) CryptBlocks(dst, src []byte) {
if len(src)%x.blockSize != 0 {
panic("crypto/cipher: input not full blocks")
}
if len(dst) < len(src) {
panic("crypto/cipher: output smaller than input")
}

iv := x.iv

for len(src) > 0 {
// Write the xor to dst, then encrypt in place.
xorBytes(dst[:x.blockSize], src[:x.blockSize], iv)
x.b.Encrypt(dst[:x.blockSize], dst[:x.blockSize])

// Move to the next block with this block as the next iv.
iv = dst[:x.blockSize]
src = src[x.blockSize:]
dst = dst[x.blockSize:]
}

// Save the iv for the next CryptBlocks call.
copy(x.iv, iv)
}

一方、復号用オブジェクトは NewCBCDecrypter() で生成します。この戻り値が BlockMode インターフェースを実装した cbcDecrypter オブジェクトになります。

1
2
3
4
5
6
7
8
9
10
11
type cbcDecrypter cbc

// NewCBCDecrypter returns a BlockMode which decrypts in cipher block chaining
// mode, using the given Block. The length of iv must be the same as the
// Block's block size and must match the iv used to encrypt the data.
func NewCBCDecrypter(b Block, iv []byte) BlockMode {
if len(iv) != b.BlockSize() {
panic("cipher.NewCBCDecrypter: IV length must equal block size")
}
return (*cbcDecrypter)(newCBC(b, iv))
}

続いて cbcDecrypterCryptBlocks() メソッドの実装を見てみます。 To avoid making a copy each time, we loop over the blocks BACKWARDS. というコメントがある通り、復号処理は暗号文の末尾から逆順でおこなっているようです。しかしながら、「コピーを防ぐため」ということの意図はよくわかりませんでした。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
func (x *cbcDecrypter) CryptBlocks(dst, src []byte) {
if len(src)%x.blockSize != 0 {
panic("crypto/cipher: input not full blocks")
}
if len(dst) < len(src) {
panic("crypto/cipher: output smaller than input")
}
if len(src) == 0 {
return
}

// For each block, we need to xor the decrypted data with the previous block's ciphertext (the iv).
// To avoid making a copy each time, we loop over the blocks BACKWARDS.
end := len(src)
start := end - x.blockSize
prev := start - x.blockSize

// Copy the last block of ciphertext in preparation as the new iv.
copy(x.tmp, src[start:end])

// Loop over all but the first block.
for start > 0 {
x.b.Decrypt(dst[start:end], src[start:end])
xorBytes(dst[start:end], dst[start:end], src[prev:start])

end = start
start = prev
prev -= x.blockSize
}

// The first block is special because it uses the saved iv.
x.b.Decrypt(dst[start:end], src[start:end])
xorBytes(dst[start:end], dst[start:end], x.iv)

// Set the new iv to the first block we copied earlier.
x.iv, x.tmp = x.tmp, x.iv
}

CBC モードについては以上です。次回はブロック暗号でよく使われるもう1つのモード、 CTR モードについて書く予定です。

Go 言語で学ぶ『暗号技術入門』Part 2 -AES-

結城浩さんの『暗号技術入門』を Go 言語を使って学ぶ記事の第2回です。前回は DES と Triple DES について解説しました。

今回は引き続き第3章「対称暗号(共通鍵暗号)」より、新しい標準暗号アルゴリズムである AES (Advanced Encryption Standard) について解説します。

AES (Advanced Encryption Standard)

AES とは

AES (Advanced Encryption Standard) はアメリカ合衆国の標準化機関である NIST (National Institute of Standards and Technology) が2000年に選定した、DES に代わる新たな対称暗号アルゴリズムです。世界中から応募を受け付け、最終的に Rijndael (ラインダール) というアルゴリズムが AES として選ばれました。

現在では、対称暗号を使う場合には AES を用いることが推奨されています。 TLS においても、本文のデータそのものの暗号化には主に AES が使われています。有名な公開鍵暗号は、この対称暗号の鍵を安全に通信相手に送るために使われており、本文のデータを公開鍵暗号で直接暗号化することはありません。

Rijndael とは

Rijndael (ラインダール) はブロック長 128 ビットの対称暗号アルゴリズムです。鍵のビット長は AES の規格では 128, 192, 256 ビットの3種類あり、それぞれ AES-128, AES-192, AES-256 と呼ばれています。

AES を使ってみる

Go で実際に AES による暗号化/復号をおこなってみます。 AES 用のオブジェクトは crypto/aes パッケージの NewCipher() で生成します。戻り値は cipher.Block インターフェースで、この Encrypt()/Decrypt() メソッドはそれぞれ AES による暗号化と復号の処理を実装しています。

http://play.golang.org/p/jUAHbYacE4

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package main

import (
"crypto/aes"
"fmt"
)

func main() {
// 鍵の長さは 16, 24, 32 バイトのどれかにしないとエラー
key := []byte("aes-secret-key-1")
// cipher.Block を実装している AES 暗号化オブジェクトを生成する
c, err := aes.NewCipher(key)
if err != nil {
panic(err)
}

// 暗号化される平文の長さは 16 バイト (128 ビット)
plainText := []byte("secret plain txt")
// 暗号化されたバイト列を格納するスライスを用意する
encrypted := make([]byte, aes.BlockSize)
// AES で暗号化をおこなう
c.Encrypt(encrypted, plainText)
// 結果は暗号化されている
fmt.Println(string(encrypted))
// Output:
// #^ϗ~:f9��˱�1�

// 復号する
decrypted := make([]byte, aes.BlockSize)
c.Decrypt(decrypted, encrypted)
// 結果は元の平文が得られる
fmt.Println(string(decrypted))
// Output:
// secret plain txt
}

AES のアルゴリズム

Rindael も DES と同じく1回の暗号化処理単位を「ラウンド」と呼びます。1ラウンドは以下の図のような4つの処理から成り立っています。

http://flylib.com/books/4/269/1/html/2/images/0130355488/graphics/02fig09.gif

  1. SubBytes: 入力バイトごとに対応表に基いて別のバイトに変換する
  2. ShiftRows: 4バイト単位でまとめた行を左に規則的にシフトして混ぜこぜにする
  3. MixColumns: 4バイトの値をビット演算を用いて別の4バイトに変換する
  4. AddRoundKey: MixColumns の出力 (4バイト) とラウンド鍵 (4バイト) との XOR を取る

以上のラウンドを10〜14回繰り返します。

復号処理はそれぞれの処理の逆処理を、上記手順と逆順でおこないます。すなわち、

  1. AddRoundKey (XOR は逆処理も XOR)
  2. InvMixColumns (MixColumns の逆処理)
  3. InvShiftRows (ShiftRows の逆処理)
  4. InvSubBytes (SubBytes の逆処理)

という手順を暗号化時と同じラウンド回数分おこなうことで復号します。

Go での実装

AES を実装した crypto.Block インターフェースの実装は以下の aesCipher 構造体です。フィールドには暗号化用のラウンド鍵群 enc と復号化用のラウンド鍵群 dec を持っています。これらは AddRoundKey の処理で使われ、1ラウンドで4バイト使うのでスライスの要素は uint32 になっています。

1
2
3
4
5
// A cipher is an instance of AES encryption using a particular key.
type aesCipher struct {
enc []uint32
dec []uint32
}

NewCipher() コンストラクタは以下のようになっています。鍵の長さは 16, 24, 32 バイトのどれかでないとエラーになります。また、 expandKey() で与えられた鍵からラウンド鍵を生成しています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// NewCipher creates and returns a new cipher.Block.
// The key argument should be the AES key,
// either 16, 24, or 32 bytes to select
// AES-128, AES-192, or AES-256.
func NewCipher(key []byte) (cipher.Block, error) {
k := len(key)
switch k {
default:
return nil, KeySizeError(k)
case 16, 24, 32:
break
}

n := k + 28
c := &aesCipher{make([]uint32, n), make([]uint32, n)}
expandKey(key, c.enc, c.dec)
return c, nil
}

ラウンド鍵を生成する expandKey() の処理を見てみましょう。実は Go における AES の処理は CPU アーキテクチャが AMD64 か否かで分岐しています。 AMD64 の場合には以下のように、アセンブリコードがある場合にはそちらを使用するようになっており、ソースコードには asm_amd64.s というアセンブリも一緒に含まれています。高速に AES の処理をおこなうための工夫でしょう。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// +build amd64

// defined in asm_$GOARCH.s
func hasAsm() bool
func expandKeyAsm(nr int, key *byte, enc *uint32, dec *uint32)

var useAsm = hasAsm()

func expandKey(key []byte, enc, dec []uint32) {
if useAsm {
rounds := 10
switch len(key) {
case 128 / 8:
rounds = 10
case 192 / 8:
rounds = 12
case 256 / 8:
rounds = 14
}
expandKeyAsm(rounds, &key[0], &enc[0], &dec[0])
} else {
expandKeyGo(key, enc, dec)
}
}

先に「ラウンドの回数は10〜14回」と書きましたが、上記を見ると、鍵長が 128, 192, 256 ビットだとそれぞれ 10, 12, 14 回のラウンドをおこなうことがわかります。

一方 AMD64 以外の CPU アーキテクチャの場合には、以下のように通常の Go で記述された処理をおこないます。

1
2
3
4
5
// +build !amd64

func expandKey(key []byte, enc, dec []uint32) {
expandKeyGo(key, enc, dec)
}

また、暗号化/復号をおこなう Encrypt()/Decrypt() メソッドの実装は以下のようになっています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func (c *aesCipher) Encrypt(dst, src []byte) {
if len(src) < BlockSize {
panic("crypto/aes: input not full block")
}
if len(dst) < BlockSize {
panic("crypto/aes: output not full block")
}
encryptBlock(c.enc, dst, src)
}

func (c *aesCipher) Decrypt(dst, src []byte) {
if len(src) < BlockSize {
panic("crypto/aes: input not full block")
}
if len(dst) < BlockSize {
panic("crypto/aes: output not full block")
}
decryptBlock(c.dec, dst, src)
}

encryptBlock(), decryptBlock()expandKey() と同様に、 AMD 64 の場合はアセンブリコードを、そうでなければ Go のコードを使用するようになっています。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// +build amd64

// defined in asm_$GOARCH.s
func hasAsm() bool
func encryptBlockAsm(nr int, xk *uint32, dst, src *byte)
func decryptBlockAsm(nr int, xk *uint32, dst, src *byte)

var useAsm = hasAsm()

func encryptBlock(xk []uint32, dst, src []byte) {
if useAsm {
encryptBlockAsm(len(xk)/4-1, &xk[0], &dst[0], &src[0])
} else {
encryptBlockGo(xk, dst, src)
}
}

func decryptBlock(xk []uint32, dst, src []byte) {
if useAsm {
decryptBlockAsm(len(xk)/4-1, &xk[0], &dst[0], &src[0])
} else {
decryptBlockGo(xk, dst, src)
}
}

AMD64 以外はそのまま Go のコードを呼びます。

1
2
3
4
5
6
7
8
9
// +build !amd64

func encryptBlock(xk []uint32, dst, src []byte) {
encryptBlockGo(xk, dst, src)
}

func decryptBlock(xk []uint32, dst, src []byte) {
decryptBlockGo(xk, dst, src)
}

encryptBlockGo(), decryptBlockGo() の実装の詳細はここでは割愛します。

ブロック暗号の「モード」

これまで見てきた対称暗号はすべて「固定長」の平文(ブロック)を暗号化するアルゴリズムでした。 AES の場合なら1回の処理で暗号化するのは 128 ビット (16 バイト) です。

しかし僕たちが普段暗号化したい文章は当然もっと長いので、このブロック暗号を繰り返し使って、長い文章全体を暗号化する必要があります。ブロック暗号を繰り返す方法のことをブロック暗号の「モード」と呼びます。ブロック暗号のモードにはいろいろな種類があります。

ということで次回は第4章「ブロック暗号のモード」CBC モードCTR モードについて紹介したいと思います。

Go 言語で学ぶ『暗号技術入門』Part 1 -DES, Triple DES-

最近結城浩さんの『暗号技術入門』を読みました。現代の暗号技術について非常にわかりやすく書かれており、とってもおすすめの書籍です。

そこで『暗号技術入門』を参考に、 Go 言語のライブラリを使い、各種暗号技術の実装や使い方について学んでみたいと思います。以下の Go のソースコードのバージョンはすべて 1.5.2 です。

まずは第3章「対称暗号(共通鍵暗号)」です。対称暗号は、「共通の鍵で暗号化と復号をおこなう暗号アルゴリズム」です。

DES (Data Encryption Standard)

DES とは

DES (Data Encryption Standard) は、1977年にアメリカ合衆国の連邦情報処理標準規格 (FIPS) に採用された対称暗号です。しかし現在ではブルートフォースアタックにより短時間で解読されてしまうため、暗号化に用いるべきではありません。

ただ後述する Triple DES は DES による処理を3回おこなう方式であり、これは TLS にも使われている未だ現役な暗号方式であるため、その基礎となる DES の処理を知っておくことは有益です。

DES は 64 ビットの平文を、 64 ビット(実際は 56 ビット) の鍵を使って 64 ビットの暗号文に暗号化するアルゴリズムです。あるまとまり単位で暗号化をおこなうアルゴリズムを「ブロック暗号」と呼び、 DES はブロック暗号の一種です。

Go におけるブロック暗号

Go ではブロック暗号はすべて crypto/cipher パッケージに定義されている cipher.Block という統一のインターフェースを介して利用するようになっています。 Encrypt() で暗号化、 Decrypt() で復号をおこないます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package cipher

type Block interface {
// BlockSize returns the cipher's block size.
BlockSize() int

// Encrypt encrypts the first block in src into dst.
// Dst and src may point at the same memory.
Encrypt(dst, src []byte)

// Decrypt decrypts the first block in src into dst.
// Dst and src may point at the same memory.
Decrypt(dst, src []byte)
}

このインターフェースにより、ライブラリを使う側は具体的なブロック暗号アルゴリズムの違いを意識する必要なく、統一的に扱えるようになっています。

DES を使ってみる

ではまず実際に DES で暗号化/復号をおこなってみます。 DES 用のオブジェクトは crypto/des パッケージの NewCipher() で生成します。戻り値は cipher.Block インターフェースで、この Encrypt()/Decrypt() メソッドはそれぞれ DES による暗号化と復号の処理を実装しています。

http://play.golang.org/p/F7QBes1BFK

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
"crypto/des"
"fmt"
)

func main() {
// 鍵の長さは 8 バイト (64 ビット) にしないとエラー
key := []byte("priv-key")
// cipher.Block を実装している DES 暗号化オブジェクトを生成する
c, err := des.NewCipher(key)
if err != nil {
panic(err)
}

// 平文も暗号化されるのは 8 バイト (64 ビット)
plainText := []byte("plaintxt")
// 暗号化されたバイト列を格納するスライスを用意する
encrypted := make([]byte, des.BlockSize)
// DES で暗号化をおこなう
c.Encrypt(encrypted, plainText)
// 結果は暗号化されている
fmt.Println(string(encrypted)) //=> ����A�

// 復号する
decrypted := make([]byte, des.BlockSize)
c.Decrypt(decrypted, encrypted)
// 結果は元の平文が得られる
fmt.Println(string(decrypted)) //=> plaintxt
}

DES のアルゴリズム

DES のアルゴリズムの概略は以下の図のように表せます。まず 64 ビット (実質使われるのはそのうちの 56 ビット) の鍵から、16 個の「サブ鍵」という鍵を生成します。そしてそのサブ鍵を使い、 64 ビットの平文を「ラウンド」という処理に 16 回かけます。

https://techwith2.files.wordpress.com/2010/07/desa.jpg

各ラウンドでは以下の図のようなことをおこないます。まず暗号化対象の 64 ビットデータを 左右 32 ビットずつに分割します。続いて、サブ鍵と右の 32 ビットをラウンド関数 $f$ にかけたものと左の 32 ビットの XOR を取ります。最後にその左右を交換します。ラウンド関数 $f$ には任意の関数を用いることが可能です。

http://crunchmodo.com/wp-content/uploads/2013/03/Function-of-Rounds-in-DES.png

この処理をサブ鍵 16 個を順に使って繰り返して、最終的に出力されるバイト列が暗号化データになります。

復号処理はサブ鍵を逆順で使って同じ処理を繰り返せばよいだけになります。なぜなら XOR を 2 回かけると元に戻るという性質があるためです。

DES の鍵の長さは 64 ビット、すなわち取りうる鍵の数は高々 264 ≒ 1.8 × 1019 個なので、現代のコンピュータであればブルートフォースアタックにより短時間で解読することが可能になってしまっています(ラウンド関数は暗号解読者に知られているという前提で考えるべきなので)。

Go での実装

DES を実装した cipher.Block インターフェースの実体は以下の desCipher 構造体です。フィールドには subkeys [16]uint64 を持っています。1つの uint64 が1回のラウンドで使用する 64 ビット (8 バイト) 鍵で、 DES はそれを 16 回繰り返すため 16 個のサブ鍵が必要になります。

1
2
3
4
// desCipher is an instance of DES encryption.
type desCipher struct {
subkeys [16]uint64
}

NewCipher() コンストラクタは以下のようになっています。鍵の長さは 8 バイト (64 ビット) でないとエラーになります。また c.generateSubkeys(key) でメインの鍵からサブ鍵の配列を生成しています。

1
2
3
4
5
6
7
8
9
10
// NewCipher creates and returns a new cipher.Block.
func NewCipher(key []byte) (cipher.Block, error) {
if len(key) != 8 {
return nil, KeySizeError(len(key))
}

c := new(desCipher)
c.generateSubkeys(key)
return c, nil
}

暗号化/復号をおこなう Encrypt()/Decrypt() メソッドの実装は以下のようになっています。 DES の暗号化/復号処理はサブ鍵を使う順番が違うだけなので、内部では cryptBlock() という共通の関数を用いていることがわかります。

1
2
3
4
5
6
7
8
9
10
11
12
13
func (c *desCipher) Encrypt(dst, src []byte) { encryptBlock(c.subkeys[:], dst, src) }

func (c *desCipher) Decrypt(dst, src []byte) { decryptBlock(c.subkeys[:], dst, src) }

// Encrypt one block from src into dst, using the subkeys.
func encryptBlock(subkeys []uint64, dst, src []byte) {
cryptBlock(subkeys, dst, src, false)
}

// Decrypt one block from src into dst, using the subkeys.
func decryptBlock(subkeys []uint64, dst, src []byte) {
cryptBlock(subkeys, dst, src, true)
}

暗号化/復号のメインの処理は以下の cryptBlock() でおこなわれます。 decrypt フラグが false であればサブ鍵が昇順で使われて暗号化され、 true であれば逆の降順で使われて復号されます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func cryptBlock(subkeys []uint64, dst, src []byte, decrypt bool) {
b := binary.BigEndian.Uint64(src)
b = permuteInitialBlock(b)
left, right := uint32(b>>32), uint32(b)

var subkey uint64
for i := 0; i < 16; i++ {
if decrypt {
subkey = subkeys[15-i]
} else {
subkey = subkeys[i]
}

left, right = right, left^feistel(right, subkey)
}
// switch left & right and perform final permutation
preOutput := (uint64(right) << 32) | uint64(left)
binary.BigEndian.PutUint64(dst, permuteFinalBlock(preOutput))
}

ラウンド関数 $f$ の実装は feistel() という関数ですが今回は割愛します。

Triple DES (3DES)

Triple DES とは

Triple DES (3DES) は、 DES よりも強力になるよう、 DES を3段重ねにした暗号アルゴリズムです。 Triple DES は TLS でも使われています。ただし、 AES (Advanced Encryption Standard) がある今、あえて Triple DES を使う必然性は薄いです。

Triple DES の特徴は、暗号化を3回おこなうのではなく【暗号化 → 復号化 → 暗号化】という重ね方をするところです。これは、すべての鍵を等しくすれば単なる DES と同じように使えるように互換性が確保されているためです。

http://www.idga.org/images/article_images/small/MilioneCryptF5.jpg

3つの鍵のうち鍵1と鍵3を同じにしたものを DES-EDE2 と呼び、 3つの鍵すべてを違うものにしたものを DES-EDE3 と呼びます。 EDE は Encryption → Decryption → Encryption の略です。

DES の鍵長は 64 ビットなので、 DES-EDE2 であれば鍵の長さは 128 ビット、 DES-EDE3 なら 192 ビットになります。したがって暗号強度的には DES-EDE3 を用いるほうがよいです。

Triple DES を使ってみる

Triple DES も DES と同じ crypto/des パッケージに入っています。 Triple DES 用のオブジェクトは NewTripleDESCipher() で生成します。渡す鍵の長さは 24 バイト (192 ビット) になります。この戻り値も cipher.Block インターフェースですので、 DES の場合と使い方は同じです。

http://play.golang.org/p/UfXv32bO8e

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
package main

import (
"crypto/des"
"fmt"
)

func main() {
// 鍵の長さは 24 バイトにしないとエラー
key := []byte("this is 3des private key")

// Triple DES 暗号化用オブジェクトを生成する
c, err := des.NewTripleDESCipher(key)
if err != nil {
panic(err)
}

// 暗号化される平文の長さは 8 バイト
plainText := []byte("plaintxt")
// 暗号化された byte 列を格納する slice を用意する
encrypted := make([]byte, des.BlockSize)
// Triple DES で暗号化をおこなう
c.Encrypt(encrypted, plainText)
// 結果は暗号化されている
fmt.Println(string(encrypted)) //=> �Y����4�

// Triple DES で復号する
decrypted := make([]byte, des.BlockSize)
c.Decrypt(decrypted, encrypted)
fmt.Println(string(decrypted)) //=> plaintxt
}

Go での実装

Triple DES を実装した cipher.Block インターフェースの実体は以下の tripleDESCipher 構造体です。フィールドには desCipher を3つ持っています。

1
2
3
4
// A tripleDESCipher is an instance of TripleDES encryption.
type tripleDESCipher struct {
cipher1, cipher2, cipher3 desCipher
}

NewTripleDESCipher() コンストラクタは以下のようになっています。鍵の長さは 24 バイト (192 ビット) でないとエラーになります。 3 つの desCipher はそれぞれ、元の鍵の 8 バイト目まで、9 から 16 バイト目まで、 17 から 24 バイト目までを鍵にしてサブ鍵を生成します。

1
2
3
4
5
6
7
8
9
10
11
12
// NewTripleDESCipher creates and returns a new cipher.Block.
func NewTripleDESCipher(key []byte) (cipher.Block, error) {
if len(key) != 24 {
return nil, KeySizeError(len(key))
}

c := new(tripleDESCipher)
c.cipher1.generateSubkeys(key[:8])
c.cipher2.generateSubkeys(key[8:16])
c.cipher3.generateSubkeys(key[16:])
return c, nil
}

暗号化/復号をおこなう Encrypt()/Decrypt() メソッドの実装は以下のようになっています。 Triple DES のアルゴリズムそのままに、 Encrypt() では各 desCipher が順に【暗号化 → 復号化 → 暗号化】の処理をおこないます。一方 Decrypt() では逆に【復号化 → 暗号化 → 復号化】という順で処理をおこなっていますね。

1
2
3
4
5
6
7
8
9
10
11
func (c *tripleDESCipher) Encrypt(dst, src []byte) {
c.cipher1.Encrypt(dst, src)
c.cipher2.Decrypt(dst, dst)
c.cipher3.Encrypt(dst, dst)
}

func (c *tripleDESCipher) Decrypt(dst, src []byte) {
c.cipher3.Decrypt(dst, src)
c.cipher2.Encrypt(dst, dst)
c.cipher1.Decrypt(dst, dst)
}

次回は AES (Advanced Encryption Standard) についての記事を書く予定です。

Go 言語の値レシーバとポインタレシーバ

「レシーバ」とは

Go 言語はある種のオブジェクト指向プログラミング (OOP) 言語であり、 OOP 言語の慣例通り、メソッドを呼び出される対象のことを「レシーバ」と呼びます。

1
2
3
4
  p := Person{Name: "Taro"}
p.Greet("Hi")

コイツ

ちなみになぜ「レシーバ」と呼ぶのかというと、昔の OOP 言語の文脈ではメソッド呼び出しのことを「メッセージの送信」と言い、メソッドを呼び出される側は「メッセージの受信側」だからです。

「値レシーバ」と「ポインタレシーバ」

Go 言語では「値」と「ポインタ」が明示的に区別されているため、たとえばある構造体に対してメソッドを定義する場合でも、「値型」に対する定義なのか「ポインタ型」に対する定義なのかはっきりと区別しなければなりません。それぞれについて簡単に説明します。

値レシーバ

「値型」に対してメソッド定義されたものが「値レシーバ」です。 Go 言語では構造体は値なので、以下の例では Person という値型に対して Greet() というメソッドを定義しています。

1
2
3
4
5
6
7
8
9
10
11
type Person struct{ Name string }

// Person 型に対してメソッドを定義する
func (p Person) Greet(msg string) {
fmt.Printf("%s, I'm %s.\n", msg, p.Name)
}

func main() {
p := Person{Name: "Taro"} // 値型の変数を用意する
p.Greet("Hi") //=> Hi, I'm Taro.
}

ポインタレシーバ

「ポインタ型」に対してメソッド定義されたものが「ポインタレシーバ」です。以下の例では *Person というポインタ型に対して Shout() というメソッドを定義しています。

1
2
3
4
5
6
7
8
9
10
11
type Person struct{ Name string }

// *Person 型に対してメソッドを定義する
func (pp *Person) Shout(msg string) {
fmt.Printf("%s!!!\n", msg)
}

func main() {
pp := &Person{Name: "Taro"} // ポインタ型の変数を用意する
pp.Shout("OH MY GOD") //=> OH MY GOD!!!
}

コンパイラによるレシーバの暗黙的変換

しかし実際には、メソッド呼び出し時にこのあたりのことを意識しなくてもすみます。それは Go 言語仕様の「呼び出し」のセクションにある通り、レシーバの【値型 ⇔ ポインタ型】間の変換はコンパイラが暗黙的におこなってくれるからです。それぞれ例を見てみましょう。

値レシーバの場合

値型に対してあるメソッドが定義されているときに、ポインタ型変数からそのメソッドを呼び出そうとすると、コンパイラが暗黙的に値型のメソッド呼び出しに変換してくれます。以下の例では、 値型 PersonGreet() が定義されていますが、 *Person 型変数 pp からでも問題なく呼び出せます。

1
2
3
4
5
6
7
8
9
10
11
12
type Person struct{ Name string }

// Person 型に対してメソッドを定義する
func (p Person) Greet(msg string) {
fmt.Printf("%s, I'm %s.\n", msg, p.Name)
}

func main() {
pp := &Person{Name: "Taro"} // ポインタ型の変数を用意する
(*pp).Greet("Hi") //=> Hi, I'm Taro. | 当然呼び出せる
pp.Greet("Hi") //=> Hi, I'm Taro. | コンパイラが上のコードに変換してくれる
}

したがって、以下のように nil ポインタ変数から呼び出そうとすると panic を起こします。 *nilp が存在しないからです。

1
2
var nilp *Person // nil ポインタ変数だと...
nilp.Greet("Hi") //=> panic: runtime error: invalid memory address or nil pointer dereference

これは Greet() の中で p.Name を使っていることとは関係なく発生します。

上記コードの Playground: http://play.golang.org/p/SZOv0hTicF

ポインタレシーバの場合

一方、ポインタ型に対してあるメソッドが定義されているときに、値型変数からそのメソッドを呼び出そうとすると、コンパイラが暗黙的にポインタ型のメソッド呼び出しに変換してくれます。以下の例では、 ポインタ型 *PersonShout() が定義されていますが、 Person 型変数 p からでも問題なく呼び出せます。

1
2
3
4
5
6
7
8
9
10
11
12
type Person struct{ Name string }

// *Person 型に対してメソッドを定義する
func (pp *Person) Shout(msg string) {
fmt.Printf("%s!!!\n", msg)
}

func main() {
p := Person{Name: "Taro"} // 値型の変数を用意する
(&p).Shout("OH MY GOD") //=> OH MY GOD!!! | 当然呼び出せる
p.Shout("OH MY GOD") //=> OH MY GOD!!! | コンパイラが上のコードに変換してくれる
}

上記コードの Playground: http://play.golang.org/p/Vs-LOJq_1d

さらに、ポインタレシーバのメソッドは nil ポインタ変数からでも呼び出しが可能です。

1
2
var nilp *Person        // nil ポインタ変数でも...
nilp.Shout("OH MY GOD") //=> OH MY GOD!!! | ちゃんと呼び出せる

ただし当然のことながら、メソッド内でフィールドを使っていたら参照先がないので panic になります。以下の例では、 p.Name を使っているメソッド ShoutNamenil ポインタ変数から呼び出すと、メソッド呼び出しそのものは正常におこなわれるものの、フィールド呼び出しがあるせいで panic になります。

1
2
3
4
5
6
7
8
9
10
11
type Person struct{ Name string }

// *Person 型に対してメソッドを定義する
func (p *Person) ShoutName() {
fmt.Printf("I'm %s!!!\n", p.Name) // p.Name フィールドを呼び出していると...
}

func main() {
var nilp *Person // nil ポインタだと...
nilp.ShoutName() //=> panic: runtime error: invalid memory address or nil pointer dereference
}

上記コードの Playground: http://play.golang.org/p/UzB32jpi2q

メソッド定義とメソッド呼び出しの真実

メソッド定義の真実

メソッド定義は本質的には関数定義と等価です。 Go 言語の場合

1
2
3
func (p Person) Greet(msg string) {
// ...
}

というメソッド定義は、内部的には

1
2
3
func Person.Greet(p Person, msg string) {
// ...
}

という「メソッド式 (method expression)」と呼ばれる関数として定義されます。これは値レシーバの場合で、同様にポインタレシーバの場合

1
2
3
func (pp *Person) Shout(msg string) {
// ...
}

1
2
3
func (*Person).Shout(pp *Person, msg string) {
// ...
}

という関数として定義されます。

コンパイラのソースコードを確かめる

Go 1.5.2 のソースコードを基にこのことを確かめてみましょう。該当箇所は以下になります (コメントはすべて僕が追記したものです)。

src/cmd/compile/internal/gc/dcl.go#L1325-L1350

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// n: メソッドを表す AST ノード
// t: メソッドをもつ型を表す AST ノード
func methodname1(n *Node, t *Node) *Node {
star := ""
if t.Op == OIND {
star = "*"
t = t.Left
}

if t.Sym == nil || isblank(n) {
return newfuncname(n.Sym)
}

var p string
if star != "" {
p = fmt.Sprintf("(%s%v).%v", star, t.Sym, n.Sym) // (*Type).Method
} else {
p = fmt.Sprintf("%v.%v", t.Sym, n.Sym) // Type.Method
}

if exportname(t.Sym.Name) {
n = newfuncname(Lookup(p))
} else {
n = newfuncname(Pkglookup(p, t.Sym.Pkg))
}

return n
}

上記コードの変数 p を見ていただくとわかる通り、メソッド定義においてメソッド名は (*Type).Method または Type.Method という名前をもつ、関数の AST ノードに変換されています。

メソッド呼び出しの真実

メソッド定義と同様に、メソッド呼び出しもメソッド式という関数の呼び出しの糖衣構文にすぎません。たとえば、以下の例では p.Greet(...)Person.Greet(p, ...)pp.Shout(...)(*Person).Shout(pp, ...) はそれぞれ等価です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type Person struct{ Name string }

func (p Person) Greet(msg string) {
fmt.Printf("%s, I'm %s.\n", msg, p.Name)
}

func (p *Person) Shout(msg string) {
fmt.Printf("%s!!!\n", msg)
}

func main() {
p := Person{Name: "Taro"}
// 以下は等価
p.Greet("Hi") //=> Hi, I'm Taro.
Person.Greet(p, "Hi") //=> Hi, I'm Taro.

pp := &Person{Name: "Taro"}
// 以下は等価
pp.Shout("OH MY GOD") //=> OH MY GOD!!!
(*Person).Shout(pp, "OH MY GOD") //=> OH MY GOD!!!
}

上記コードの Playground: http://play.golang.org/p/3ENNQWghCX

ちなみに、メソッド式やメソッドは値としても扱えるので、以下のように変数に代入して利用することもできます。この場合、メソッド式 f にはレシーバ引数が必要で、メソッド値 g には必要ないことに注意してください。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Person struct{ Name string }

func (p Person) Greet(msg string) {
fmt.Printf("%s, I'm %s.\n", msg, p.Name)
}

func main() {
p := Person{Name: "Taro"}
f := Person.Greet
g := p.Greet

f(p, "Hello") //=> Hello, I'm Taro.
g("Hello") //=> Hello, I'm Taro.
}

上記コードの Playground: http://play.golang.org/p/kpaGwzPu1u

Go は関数が第一級オブジェクトなので便利ですね。

メソッド定義で意識すべきこと

改めて強調すると、メソッド定義において意識すべきなのは、レシーバが「第0引数」であるということです。たとえば、以下のメソッド定義

1
2
3
4
5
6
7
8
9
type Person struct{ Name string }

func (p Person) Greet(msg string) {
fmt.Printf("%s, I'm %s.\n", msg, p.Name)
}

func (pp *Person) Shout(msg string) {
fmt.Printf("%s!!!\n", msg)
}

は、内部的には

1
2
3
4
5
6
7
func Person.Greet(p Person, msg string) {
fmt.Printf("%s, I'm %s.\n", msg, p.Name)
}

func (*Person).Shout(pp *Person, msg string) {
fmt.Printf("%s!!!\n", msg)
}

というメソッド式(関数)として定義されます。ただし直接このような関数名での関数定義はできません(コンパイラが . を含むシンボル名を許していないので)。

そして、メソッド呼び出しがメソッド式呼び出しと等価ということは、メソッド定義をする際には以下のことに気をつける必要があります。

1. メソッド呼び出しごとにレシーバの値はコピーされる

関数呼び出しごとに引数の値はスタックにコピーされますので、当然メソッド呼び出し時にはレシーバの値もコピーされます。

1
2
3
p := Person{Name: "Taro"}
Person.Greet(p, "Hi") // p の値はコピーされてメソッド内で使われます
p.Greet("Hi") // p の値はコピーされてメソッド内で使われます

したがって、特にデータ量の大きな構造体に値レシーバのメソッドを定義すると、メソッド呼び出しごとにコピーが発生するので非常に非効率であることがわかります。このことから、構造体におけるメソッド定義は原則ポインタレシーバに対しておこなったほうがよいです。

2. 値レシーバの値はメソッド内で書き換えても元のレシーバの値にはまったく影響がない

値レシーバの場合、値そのものがまるっとコピーされるので、メソッド内でいくら値を書き換えても元のレシーバの値にはまったく影響がありません。

以下の例の場合、 UnchangeName() の処理は一見 setter っぽくレシーバの name を書き換えているように見えますが、 p が値レシーバなので p.Greet() で表示される名前は Jiro ではなく Taro のままになります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Person struct{ name string }

func (p Person) Greet(msg string) {
fmt.Printf("%s, I'm %s.\n", msg, p.name)
}

func (p Person) UnchangeName(name string) {
p.name = name
}

func main() {
p := Person{name: "Taro"}
// p の値はコピーされるので、フィールドを書き換えても影響ない
Person.UnchangeName(p, "Jiro")
p.UnchangeName("Jiro")

p.Greet("Hi") //=> Hi, I'm Taro.
}

上記コードの Playground: http://play.golang.org/p/5kJVlx_XY7

このことを利用すると、不変オブジェクトをつくることができます。その典型例が time パッケージの Time です。

time.Time は不変オブジェクト

time.Time の API 一覧を見てもらうとわかる通り、 Time オブジェクトは

  • フィールドがすべてプライベート
  • レシーバは Unmarshal 系を除きすべて Time 型(値型)
  • オブジェクトを返すメソッドの戻り値もすべて Time 型(値型)

となっており、 *Time 型(ポインタ型)は基本的に現れません。

フィールドがプライベートなので直接の値の変更ができず、またレシーバも戻り値もほぼすべてポインタ型ではなく値型なので、一度生成された Time オブジェクトは一切変更する手段がないということになります。

Java では java.util.Date クラスや java.util.Calendar クラスが可変でスレッドセーフではないことが長年問題となっており、やっと Java 8 で不変オブジェクトを基本とする java.time パッケージが導入されました(Java SE 8 Date and Time -新しい日付/時間ライブラリが必要になる理由-)。 Go では少なくともこの種の問題は避けられているわけですね。

それでも Go ではエクスポートされたフィールドの直接書き換えや、オブジェクトの内部状態を書き換える副作用のあるメソッドが気軽に用いられる傾向にあります。並行処理に耐える不変オブジェクトを設計したい場合は、 Time を参考にした構造体設計にするとよいかもしれません。

3. ポインタレシーバにするとメソッド内でレシーバの値を書き換えられる

一方、レシーバをポインタレシーバにした場合、メソッド呼び出し時にコピーされるのもポインタになるので、そのポインタを使うことでレシーバの実際の値を書き換えることができます(ただし mapchan などの参照型をベースとする型は半分ポインタみたいなものなので、値レシーバでも可能ですが)。

たとえば以下の例の場合、 ChangeName() メソッドのレシーバは *Person なので、 p.name フィールドを書き換えることが可能であり、 p.Greet() で表示される名前は Jiro に変化します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Person struct{ name string }

func NewPerson(name string) *Person {
return &Person{name: name}
}

// p の値を変更したいのでポインタレシーバで定義する
func (p *Person) ChangeName(name string) {
p.name = name
}

func main() {
p := NewPerson("Taro")
p.ChangeName("Jiro")

p.Greet("Hi") //=> Hi, I'm Jiro.
}

上記コードの Playground: http://play.golang.org/p/asH9DZRmxG

逆に言うと、レシーバの内部状態を変更したいメソッドは、(参照型を除き)必ずポインタレシーバで定義しなければなりません

4. ポインタレシーバのメソッド内でフィールドを呼び出す場合には、その前に nil チェックをすべき

ポインタレシーバのメソッドは nil ポインタ変数からでも呼び出しが可能なため、ポインタレシーバのメソッド内でフィールドを呼び出している場合、常に nil pointer dereferencepanic になる危険性を孕んでいます。

以下の例の場合、 ChangeNameUnsafe() には nil チェックがないため panic になりますが、 ChangeNameSafe() のほうは nil チェックをしているため安全です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
type Person struct{ name string }

func (p *Person) ChangeNameUnsafe(name string) {
// これだけだと危険
p.name = name
}

func (p *Person) ChangeNameSafe(name string) {
// 他の言語だと this == nil みたいなものだから変な感じがするが、
// きちんとガード句を設けておけば安全
if p == nil {
return
}
p.name = name
}

func main() {
var pp *Person // nil ポインタ変数だと...
pp.ChangeNameUnsafe("Jiro") //=> panic: runtime error: invalid memory address or nil pointer dereference
pp.ChangeNameSafe("Jiro") // こちらは安全
}

レシーバは他の言語でいう thisself などに相当するため、メソッド内で nil チェックをすることには違和感があるかもしれません。しかし「レシーバは第0引数」であることを思い出せば、他のポインタ引数の nil チェックと同様に考えて、安全なプログラムを書くためにきちんとやったほうがよいです(僕もよく忘れますが)。

逆に考えると、 Java などと違いメソッド内でレシーバが nil か否かに応じたハンドリングができるので、それがきちんとなされていれば、メソッド呼び出し側が事前の nil チェックをする必要がないありがたさもあります。

レシーバの値を使わない場合はレシーバ変数を記述しなくてもよい

上記のことに関連して、実はメソッド定義におけるレシーバ変数は書かなくても問題なくコンパイルできるため、メソッド内でレシーバの値を使わない場合は書かないのも1つの手です。

1
2
3
4
5
6
7
type Person struct{ name string }

// p の値を使わないときは書かない
// ↓
func (*Person) Shout(msg string) {
fmt.Printf("%s!!!\n", msg)
}

そのような処理は本質的にはメソッドにする必要がなく、単なる関数のままでもよいため、このようなケースはあまり多くないとは思います。ただ、パッとシグニチャを見ただけでレシーバの値が使われていないことがわかるので、ドキュメントとしての価値もあると思います。

まとめ:値レシーバとポインタレシーバの使い分け

最後に、値レシーバとポインタレシーバの使い分けについて整理しておきたいと思います。

基本はポインタレシーバ

レシーバの値を変更したい場合は(参照型を除き)必ずポインタレシーバにしなければなりません。構造体も基本的にはポインタレシーバにしたほうがよいでしょう。

また、統一性の観点からも、ある型のメソッドのレシーバを1つでもポインタレシーバにした場合には、値の変更の有無にかかわらずすべてポインタレシーバにしたほうがよいと思います。

値レシーバにしたほうがよい場合

レシーバの値を変更する必要がなく、かつ以下のどれかに該当するときは値レシーバにしたほうがよいです。ただし参照型の場合は値の変更の有無は関係ありません。

int, string などのプリミティブ型をベースとする型

プリミティブ型はコピーコストが小さいため、通常は値レシーバにします。

map, chan といった参照型をベースとする型

参照型は半分ポインタみたいなものなので、値レシーバのままでも保持する要素の値を変更できます。コピーコストも大きくないため、通常は値レシーバにします。

不変型

先に例として出した time.Time のような不変型を定義したい場合には、すべて値レシーバとして定義するのも良い方法です。

小さい構造体

コピーコストの小さい構造体の場合には、値レシーバでメソッドを定義すると処理がスタックで完結するので、ヒープへのアロケート回数や GC の回数が減ることが期待できます。

ただしこの使い分けは感覚的におこなうのではなく、本当に効率的になるのかきちんと計測した結果に基いておこなうべきです。 Go 言語ではベンチマークの計測や pprof などによる解析が容易におこなえるので、これらを活用するとよいでしょう。

Docker Machine v0.5 の Driver Plugins の仕組み

※この記事は Driver Plugins of Docker Machine を基にブログとして書き起こしたものです。

Docker Machine とは

Docker Machine とは、 Docker ホストの管理ツールです。たとえば以下のようにして使います。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# VirtualBox VM でローカル環境に demo という名前のホストをつくる
$ docker-machine create --driver virtualbox demo

# 環境変数を設定する
$ eval "$(docker-machine env demo)"

# ホスト一覧を表示する
$ docker-machine ls
NAME ACTIVE DRIVER STATE URL SWARM
demo * virtualbox Running tcp://192.168.99.100:2376

# docker images で demo 内の image 一覧を表示する
$ docker images
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE

# CentOS の image を取得する
$ docker pull centos
...
Status: Downloaded newer image for centos:latest

# 再び demo 内の image 一覧を表示する
$ docker images
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE
centos latest e9fa5d3a0d0e 4 weeks ago 172.3 MB

このように簡単に Docker ホストを作成することができます。

Docker Machine の特長

複数のホストを簡単に管理できる

Docker Machine は複数のホストを一括して管理できます。以下のように環境変数を切り替えることで、 Docker コマンドでの接続先を簡単に切り替えることができます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# ホスト一覧を表示する
$ docker-machine ls
NAME ACTIVE DRIVER STATE URL SWARM
default - virtualbox Running tcp://192.168.99.121:2376
demo * virtualbox Running tcp://192.168.99.118:2376
dev - virtualbox Stopped

# ホストを default に切り替える
$ eval "$(docker-machine env default)"

# default が active になった
$ docker-machine ls
NAME ACTIVE DRIVER STATE URL SWARM
default * virtualbox Running tcp://192.168.99.121:2376
demo - virtualbox Running tcp://192.168.99.118:2376
dev - virtualbox Stopped

# default にある image は ubuntu
$ docker images
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE
ubuntu latest e9ae3c220b23 4 days ago 187.9 MB

クラウドのホストも簡単に管理できる

もう1つの大きな特長は、クラウドサービスのホストも簡単に作成できることです。たとえば、 Amazon EC2 上にホストを立てるには、以下のようにします。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Amazon EC2 にホストをつくる。各環境変数には適切な値を設定しておく
$ docker-machine create \
--driver amazonec2 \
--amazonec2-access-key $AWS_ACCESS_KEY_ID \
--amazonec2-secret-key $AWS_SECRET_ACCESS_KEY \
--amazonec2-vpc-id $AWS_VPC_ID \
--amazonec2-region ap-northeast-1 \
--amazonec2-zone c \
stg

# つくった EC2 インスタンスがホスト一覧に追加される
$ docker-machine ls
NAME ACTIVE DRIVER STATE URL SWARM
demo * virtualbox Running tcp://192.168.99.100:2376
dev - virtualbox Running tcp://192.168.99.102:2376
stg - amazonec2 Running tcp://52.68.147.113:2376

# ssh サブコマンドでそのまま SSH ログインできる
$ docker-machine ssh stg
...
ubuntu@stg:~$

# 削除も簡単
$ docker-machine rm stg
$ docker-machine ls
NAME ACTIVE DRIVER STATE URL SWARM
demo * virtualbox Running tcp://192.168.99.100:2376
dev - virtualbox Running tcp://192.168.99.102:2376

コマンド1発で EC2 のホストをプロビジョニングできるなんて、とても便利じゃないでしょうか。

v0.5 の変更点

2015年11月3日に v0.5 がリリースされました。その中での最大の変更点が、 Driver Plugins という仕組みです。

Driver Plugins

Docker Machine のプロビジョニングの処理では、ホストのタイプ (VirtualBox なのか EC2 なのかなど) によって、それぞれに対応したドライバが実際の処理をおこないます。 v0.4 までは、その各ドライバはすべて docker-machine バイナリに組み込まれていました。しかし v0.5 から、各ドライバが独立したバイナリとして配布されるようになりました。このことは、以下のようにすると確認できます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# docker-machine 関連のバイナリ一覧
$ ls /usr/local/bin | grep docker-machine
docker-machine
docker-machine-driver-amazonec2
docker-machine-driver-azure
docker-machine-driver-digitalocean
docker-machine-driver-exoscale
docker-machine-driver-generic
docker-machine-driver-google
docker-machine-driver-hyperv
docker-machine-driver-none
docker-machine-driver-openstack
docker-machine-driver-rackspace
docker-machine-driver-softlayer
docker-machine-driver-virtualbox
docker-machine-driver-vmwarefusion
docker-machine-driver-vmwarevcloudair
docker-machine-driver-vmwarevsphere

この仕組みが Driver Plugins です。このような仕組みになったことで、ドライバの追加や削除が簡単におこなえるというプラガブル性や、メインバイナリとドライババイナリの開発・配布が独立しておこなえるというモジュール性が高まりました。

しかし一方でバイナリ自体が独立したことで、内部の仕組みは大きく変化しました。 v0.5 からは、このドライバとの通信方法に RPC (Remote Procesure Call) を用いるようになっています。ちなみに本家 Docker のプラグイン機構では一足先にこれが採用されています。

Driver Plugin の起動

このことを確かめてみましょう。以下は OS X でおこなった場合ですので、 Docker デーモンは VirtualBox 上で動いているという前提です。

lsof コマンドを使って、ポートを使用しているプロセスを見てみます。まず通常状態では、 TCP ポートを使用している “docker” の名前がつくプロセスはいません。

1
2
3
4
# 通常はポートを使用している "docker" の名前がつくプロセスはいない
$ lsof -nP -iTCP -sTCP:LISTEN | grep docker

$

続いて docker-machine ls コマンドを実行してみます。すると、これまでなかったプロセスが立ち上がっていることがわかります。

1
2
3
4
5
6
7
8
9
10
11
12
# docker-machine ls コマンドを実行すると、各ホストに対応するプラグインが RPC サーバとして立ち上がる
$ docker-machine ls &; sleep 0.5 && lsof -nP -iTCP -sTCP:LISTEN | grep docker
[1] 65804
docker-ma 65806 skatsuta 3u IPv4 0xc8646a3a5297e9ef 0t0 TCP 127.0.0.1:53372 (LISTEN)
docker-ma 65807 skatsuta 3u IPv4 0xc8646a3a573eb4af 0t0 TCP 127.0.0.1:53376 (LISTEN)
docker-ma 65808 skatsuta 3u IPv4 0xc8646a3a4ad3ff4f 0t0 TCP 127.0.0.1:53380 (LISTEN)

NAME ACTIVE DRIVER STATE URL SWARM
default - virtualbox Stopped
demo * virtualbox Running tcp://192.168.99.100:2376
dev - virtualbox Running tcp://192.168.99.102:2376
[1] + 65804 done docker-machine ls

名前が切れていて docker-ma までしか表示されていませんが、実は3つの docker-machine-driver-virtualbox プロセスが起動しています。
これらは RPC サーバとして、メインプロセスからの RPC リクエストを受け付け、ホストのプロビジョニング処理をおこなっています。

ではプラグインを直接起動してみたらどうなるのでしょうか? やってみると以下のようなエラーメッセージが表示され、起動に失敗してしまいます。

1
2
3
4
$ docker-machine-driver-virtualbox
This is a Docker Machine plugin binary.
Plugin binaries are not intended to be invoked directly.
Please use this plugin through the main 'docker-machine' binary.

実は以下のようにすると起動できますが、このプロセスもすぐ終了してしまいます。

1
2
3
4
$ MACHINE_PLUGIN_TOKEN=42 docker-machine-driver-virtualbox
127.0.0.1:59375
# この間1秒以内
$

さすがにこんな早さではメインプロセスと通信するのに十分な時間があるとは思えません。ということでこのことも含めて、 Docker Machine v0.5 の Driver Plugins の内部実装を追ってみることにしましょう。

ちなみに以下に記載するコード内に適宜日本語によるコメントを書いていますが、これらはすべて僕が追記したものであり、元のソースコードにはありません。本来のソースコードへのリンクも併せて記載しているので、ちゃんと確認したい方はそちらをご覧ください。

RPC Client

まず RPC クライアント側、すなわちメインプロセス側の実装です。まず RPC クライアントの役割を担うのは、以下の RpcClientDriver という構造体です。

libmachine/drivers/rpc/client_driver.go#L19-L23

1
2
3
4
5
type RpcClientDriver struct {
plugin localbinary.DriverPlugin // プラグイン管理オブジェクト
heartbeatDoneCh chan bool // heartbeat 終了メッセージ送信チャンネル
Client *InternalClient // Go の標準ライブラリの RPC クライアントをラップしたもの
}

この構造体がプラグインへの RPC リクエストを担当します。 Heartbeat の送信についてはあとで解説します。

クライアント生成とプラグイン起動

docker-machine のコマンドを実行すると、もろもろの前処理 (フラグのパースやホスト名の確認など) をおこなったあと、ほとんどの場合以下の NewRpcClientDriver() が呼ばれ、 RPC クライアントの生成処理とプラグインの起動処理がおこなわれます。

libmachine/drivers/rpc/client_driver.go#L49-L112

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// rawDriverData: ドライバに関する設定情報が JSON にシリアライズされたもの
// driverName: "virtualbox", "amazonec2" などのドライバ名文字列
func NewRpcClientDriver(rawDriverData []byte, driverName string) (*RpcClientDriver, error) {
mcnName := ""

// バイナリプラグイン管理オブジェクトを生成する
p, err := localbinary.NewLocalBinaryPlugin(driverName)
if err != nil {
return nil, err
}

// 非同期にドライバプラグイン (RPC サーバ) を起動する
go func() {
if err := p.Serve(); err != nil {
// TODO: Is this best approach?
log.Warn(err)
return
}
}()

// プラグインへの接続情報を取得する
addr, err := p.Address()
if err != nil {
return nil, fmt.Errorf("Error attempting to get plugin server address for RPC: %s", err)
}

// プラグインへ接続する
rpcclient, err := rpc.DialHTTP("tcp", addr)
if err != nil {
return nil, err
}

// RPC クライアントオブジェクトを生成する
c := &RpcClientDriver{
Client: NewInternalClient(rpcclient),
heartbeatDoneCh: make(chan bool),
}

// 定期的にプラグインへ heartbeat を送信する goroutine を生成する
go func(c *RpcClientDriver) {
for {
select {
case <-c.heartbeatDoneCh:
return
default:
if err := c.Client.Call("RpcServerDriver.Heartbeat", struct{}{}, nil); err != nil {
log.Warnf("Error attempting heartbeat call to plugin server: %s", err)
c.Close()
return
}
time.Sleep(heartbeatInterval)
}
}
}(c)

// サーバのバージョンを確認する
var version int
if err := c.Client.Call("RpcServerDriver.GetVersion", struct{}{}, &version); err != nil {
return nil, err
}
log.Debug("Using API Version ", version)

// プラグイン情報をクライアントに設定する
if err := c.SetConfigRaw(rawDriverData); err != nil {
return nil, err
}

mcnName = c.GetMachineName()
p.MachineName = mcnName
c.Client.MachineName = mcnName
c.plugin = p

return c, nil
}

まず注目してもらいたいのは、 プラグインの起動処理を別の goroutine がおこなっていることです。このようにすることで、時間のかかるプラグインの起動処理を本処理とは別におこなうことができます。
もう1つは、 途中で heartbeat を送信する goroutine を立ち上げていることです。この goroutine は RpcServerDriver.Heartbeat というリモートメソッド呼び出しにより、プラグイン側に heartbeat を送っています。このことの意味は、あとで RPC サーバ側の解説をするときに説明します。

では、プラグインの起動処理についてもう少し追ってみましょう。

プラグインの起動

プラグインの起動処理を実際におこなうのは、以下の LocalBinaryExecutor という構造体です。

libmachine/drivers/plugin/localbinary/plugin.go#L73-L77

1
2
3
4
5
type LocalBinaryExecutor struct {
pluginStdout, pluginStderr io.ReadCloser
DriverName string
binaryPath string
}

この構造体は、以下の Start() メソッドの中で exec.Command()cmd.Start() を実行することで、プラグインのプロセスを起動します。

libmachine/drivers/plugin/localbinary/plugin.go#L105-L132

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func (lbe *LocalBinaryExecutor) Start() (*bufio.Scanner, *bufio.Scanner, error) {
var err error

log.Debugf("Launching plugin server for driver %s", lbe.DriverName)

// プラグインのプロセスを起動するための exec.Cmd を生成する
cmd := exec.Command(lbe.binaryPath)

lbe.pluginStdout, err = cmd.StdoutPipe()
if err != nil {
return nil, nil, fmt.Errorf("Error getting cmd stdout pipe: %s", err)
}

lbe.pluginStderr, err = cmd.StderrPipe()
if err != nil {
return nil, nil, fmt.Errorf("Error getting cmd stderr pipe: %s", err)
}

outScanner := bufio.NewScanner(lbe.pluginStdout)
errScanner := bufio.NewScanner(lbe.pluginStderr)

// 環境変数 $MACHINE_PLUGIN_TOKEN に 42 を設定する
os.Setenv(PluginEnvKey, PluginEnvVal)

// プロセスを起動する
if err := cmd.Start(); err != nil {
return nil, nil, fmt.Errorf("Error starting plugin binary: %s", err)
}

return outScanner, errScanner, nil
}

const (
PluginEnvKey = "MACHINE_PLUGIN_TOKEN"
PluginEnvVal = "42"
)

また、途中で os.Setenv() により、 MACHINE_PLUGIN_TOKEN=42 に設定しているのがわかります。これが先ほどプラグインプロセスを単体で立ち上げてみたときにおこなったことです。

RPC リクエストの送信

では RPC リクエストの送信処理はどうなっているのでしょうか。 RpcClientDriver のメソッドの大部分は、単なる RpcServerDriver へのリモート呼び出しになっています。

libmachine/drivers/rpc/client_driver.go#L256-L278

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (c *RpcClientDriver) Create() error {
return c.Client.Call("RpcServerDriver.Create", struct{}{}, nil)
}

func (c *RpcClientDriver) Remove() error {
return c.Client.Call("RpcServerDriver.Remove", struct{}{}, nil)
}

func (c *RpcClientDriver) Start() error {
return c.Client.Call("RpcServerDriver.Start", struct{}{}, nil)
}

func (c *RpcClientDriver) Stop() error {
return c.Client.Call("RpcServerDriver.Stop", struct{}{}, nil)
}

Create(), Remove(), Start(), Stop() などの代表的な処理は、全部 RPC サーバ側に丸投げしています。ということで、 RPC サーバ側の実装を見てみましょう。

RPC Server

RPC サーバ側、すなわちプラグインプロセス側の実装を見てみます。

プラグインバイナリの起動処理

まず先にプラグインプロセスが起動されたときに最初に実行される処理を追ってみましょう。例として docker-machine-driver-virtualbox のエントリポイントは以下のようになっています。

cmd/machine-driver-virtualbox.go#L8-L10

1
2
3
4
// docker-machine-driver-virtualbox の main 関数
func main() {
plugin.RegisterDriver(virtualbox.NewDriver("", ""))
}

これだけです。では RegisterDriver() の実装はどうなっているのでしょうか。

libmachine/drivers/plugin/register_driver.go#L17-L56

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
var (
// heartbeat の猶予時間
heartbeatTimeout = 500 * time.Millisecond
)

func RegisterDriver(d drivers.Driver) {
// $MACHINE_PLUGIN_TOKEN != 42 の場合は終了する
if os.Getenv(localbinary.PluginEnvKey) != localbinary.PluginEnvVal {
fmt.Fprintln(os.Stderr, `This is a Docker Machine plugin binary.
Plugin binaries are not intended to be invoked directly.
Please use this plugin through the main 'docker-machine' binary.`)
os.Exit(1)
}

libmachine.SetDebug(true)

// RPC サーバオブジェクトを生成し、登録処理をおこなう
rpcd := rpcdriver.NewRpcServerDriver(d)
rpc.Register(rpcd)
rpc.HandleHTTP()

// ポート番号ランダムで TCP リスナを生成する
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading RPC server: %s\n", err)
os.Exit(1)
}
defer listener.Close()

fmt.Println(listener.Addr())

// リクエストを待ち受ける
go http.Serve(listener, nil)

for {
select {
case <-rpcd.CloseCh:
os.Exit(0)
// heartbeat が送られてきてる間はプロセス継続
case <-rpcd.HeartbeatCh:
continue
// 500ms 以内の間隔で heartbeat が送られてこなければ終了する
case <-time.After(heartbeatTimeout):
os.Exit(1)
}
}
}

まず最初に、 $MACHINE_PLUGIN_TOKEN != 42 の場合はエラーメッセージを表示して os.Exit(1) しています。これが特定の環境変数をつけないとプラグイン単体では起動できなかった理由です。意図しない起動のされ方がなされないように、このようなセーフティーネットが張られているようです。 42 なのは 『銀河ヒッチハイクガイド』 に由来しているのでしょうか。

続いて net.Listen("tcp", "127.0.0.1:0") で利用可能な TCP ポートで listen することを宣言します。 net.Listen() が内部で呼んでいる net.ListenTCP() の仕様として、ポート番号 0 の場合には利用可能な TCP ポートのどれかが割り当てられることになっています。

最後にリクエストを待ち受ける goroutine を起動するとともに、 for-select で無限ループに入ります。ここで先ほどプラグインプロセスが即時終了してしまった理由がわかります。プラグインは rpcd.HeartbeatCh チャンネルにメッセージが送られ続けている間は起動していますが、 500ms 以上メッセージが送られてこないと強制終了してしまうようになっているのです。これにより、メインプロセスが予期せぬ終了をしてしまった場合でも、プラグインプロセスがゾンビ化することを防いでいます。

RPC サーバ

では、 RPC リクエストに対する実際の処理を見てみましょう。 RPC リクエストを受け付けるのは以下の RpcServerDriver という構造体です。この構造体のメソッドが RpcClientDriver から呼び出されていましたね。

libmachine/drivers/rpc/server_driver.go

1
2
3
4
5
type RpcServerDriver struct {
ActualDriver drivers.Driver // 実際の処理をおこなう Driver インターフェース
CloseCh chan bool
HeartbeatCh chan bool
}

各メソッドは以下のようになっていて、実際のところは ActualDriver に処理を委譲していることがわかります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func (r *RpcServerDriver) Create(_, _ *struct{}) error {
return r.ActualDriver.Create()
}

func (r *RpcServerDriver) Remove(_ *struct{}, _ *struct{}) error {
return r.ActualDriver.Remove()
}

func (r *RpcServerDriver) Start(_ *struct{}, _ *struct{}) error {
return r.ActualDriver.Start()
}

func (r *RpcServerDriver) Stop(_ *struct{}, _ *struct{}) error {
return r.ActualDriver.Stop()
}

ではこの ActualDriver の型である drivers.Driver について詳しく見てみましょう。

Driver interface

drivers.Driver はインターフェースであり、ホストへの操作を実際におこなうオブジェクトが実装すべきメソッド群を規定しています。

libmachine/drivers/drivers.go#L11-L73

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
// Driver defines how a host is created and controlled. Different types of
// driver represent different ways hosts can be created (e.g. different
// hypervisors, different cloud providers)
type Driver interface {
// Create a host using the driver's config
Create() error

// DriverName returns the name of the driver as it is registered
DriverName() string

// GetCreateFlags returns the mcnflag.Flag slice representing the flags
// that can be set, their descriptions and defaults.
GetCreateFlags() []mcnflag.Flag

// GetIP returns an IP or hostname that this host is available at
// e.g. 1.2.3.4 or docker-host-d60b70a14d3a.cloudapp.net
GetIP() (string, error)

// GetMachineName returns the name of the machine
GetMachineName() string

// GetSSHHostname returns hostname for use with ssh
GetSSHHostname() (string, error)

// GetSSHKeyPath returns key path for use with ssh
GetSSHKeyPath() string

// GetSSHPort returns port for use with ssh
GetSSHPort() (int, error)

// GetSSHUsername returns username for use with ssh
GetSSHUsername() string

// GetURL returns a Docker compatible host URL for connecting to this host
// e.g. tcp://1.2.3.4:2376
GetURL() (string, error)

// GetState returns the state that the host is in (running, stopped, etc)
GetState() (state.State, error)

// Kill stops a host forcefully
Kill() error

// PreCreateCheck allows for pre-create operations to make sure a driver is ready for creation
PreCreateCheck() error

// Remove a host
Remove() error

// Restart a host. This may just call Stop(); Start() if the provider does not
// have any special restart behaviour.
Restart() error

// SetConfigFromFlags configures the driver with the object that was returned
// by RegisterCreateFlags
SetConfigFromFlags(opts DriverOptions) error

// Start a host
Start() error

// Stop a host gracefully
Stop() error
}

Create(), Start(), Stop(), Remove() など、ホストの操作に必要なメソッドが並んでいます。そして、ホストが VirtualBox なのか Amazon EC2 なのかなどに応じて、各ホストに対する具体的な処理の実装が変わってくるわけです。では例として VirtualBox 用の Driver の実装を見てみることにしましょう。

VirtualBox 用の Driver の実装

drivers/virtualbox/virtualbox.go#L49-L61

1
2
3
4
5
6
7
8
9
10
11
type Driver struct {
VBoxManager // vbm(), vbmOut(), vbmOutErr() をもつインターフェース
*drivers.BaseDriver
CPU int // デフォルト 1
Memory int // デフォルト 1024 MB
DiskSize int // デフォルト 20000 MB
Boot2DockerURL string // Boot2Docker のダウンロード URL
Boot2DockerImportVM string // Boot2Docker の ISO のパス
...
NoShare bool // 共有ディレクトリをつくるかどうか
}

ここで重要なのは VBoxManager という埋め込みフィールドです。 VBoxManager はインターフェースになっていて、その実装は VBoxCmdManager に書かれています。

drivers/virtualbox/vbm.go#L51-L80

1
2
3
4
5
6
7
8
9
10
11
func (v *VBoxCmdManager) vbmOutErr(args ...string) (string, string, error) {
// `VBoxManage args...` コマンドを実行する
cmd := exec.Command(vboxManageCmd, args...)
log.Debugf("COMMAND: %v %v", vboxManageCmd, strings.Join(args, " "))
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
...
}

vboxManageCmd は VirtualBox をインストールすると付属してくる VBoxManage という CLI のパスを指していて、これはコマンドラインから VirtualBox の VM を操作できるツールです。つまり、内部的には VBoxManage コマンドを呼び出してそちらに処理をおこなわせているということです。以下の Driver.Create() の実装を見ることで、どのように VBoxManage を呼び出しているのか見てみましょう。

Driver.Create() の実装

一番泥臭さがわかりやすそうだったので、 VirtualBox の VM を立ち上げる処理をおこなう Driver.Create() の実装を詳しく追ってみます。

drivers/virtualbox/virtualbox.go#L229-L417

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
func (d *Driver) Create() error {
// Boot2Docker の ISO を読み込む
b2dutils := mcnutils.NewB2dUtils(d.StorePath)
if err := b2dutils.CopyIsoToMachineDir(d.Boot2DockerURL, d.MachineName); err != nil {
return err
}

// CPU レベルでの仮想化機能が有効化チェックする
if d.IsVTXDisabled() {
// Let's log a warning to warn the user. When the vm is started, logs
// will be checked for an error anyway.
// We could fail right here but the method to check didn't prove being
// bulletproof.
log.Warn("This computer doesn't have VT-X/AMD-v enabled. Enabling it in the BIOS is mandatory.")
}

log.Infof("Creating VirtualBox VM...")

// import b2d VM if requested
if d.Boot2DockerImportVM != "" {
name := d.Boot2DockerImportVM

// make sure vm is stopped
_ = d.vbm("controlvm", name, "poweroff")

diskInfo, err := d.getVMDiskInfo()
if err != nil {
return err
}

if _, err := os.Stat(diskInfo.Path); err != nil {
return err
}

if err := d.vbm("clonehd", diskInfo.Path, d.diskPath()); err != nil {
return err
}

log.Debugf("Importing VM settings...")
vmInfo, err := d.getVMInfo()
if err != nil {
return err
}

d.CPU = vmInfo.CPUs
d.Memory = vmInfo.Memory

log.Debugf("Importing SSH key...")
keyPath := filepath.Join(mcnutils.GetHomeDir(), ".ssh", "id_boot2docker")
if err := mcnutils.CopyFile(keyPath, d.GetSSHKeyPath()); err != nil {
return err
}
} else {
log.Infof("Creating SSH key...")
if err := ssh.GenerateSSHKey(d.GetSSHKeyPath()); err != nil {
return err
}

log.Debugf("Creating disk image...")
if err := d.generateDiskImage(d.DiskSize); err != nil {
return err
}
}

// `VBoxManage createvm` コマンドで VM を作成する
if err := d.vbm("createvm",
"--basefolder", d.ResolveStorePath("."),
"--name", d.MachineName,
"--register"); err != nil {
return err
}

log.Debugf("VM CPUS: %d", d.CPU)
log.Debugf("VM Memory: %d", d.Memory)

cpus := d.CPU
if cpus < 1 {
cpus = int(runtime.NumCPU())
}
if cpus > 32 {
cpus = 32
}

// `VBoxManage modifyvm` コマンドで VM に各種設定を反映させる
if err := d.vbm("modifyvm", d.MachineName,
"--firmware", "bios",
"--bioslogofadein", "off",
"--bioslogofadeout", "off",
"--bioslogodisplaytime", "0",
"--biosbootmenu", "disabled",
"--ostype", "Linux26_64",
"--cpus", fmt.Sprintf("%d", cpus),
"--memory", fmt.Sprintf("%d", d.Memory),
"--acpi", "on",
"--ioapic", "on",
"--rtcuseutc", "on",
"--natdnshostresolver1", "off",
"--natdnsproxy1", "off",
"--cpuhotplug", "off",
"--pae", "on",
"--hpet", "on",
"--hwvirtex", "on",
"--nestedpaging", "on",
"--largepages", "on",
"--vtxvpid", "on",
"--accelerate3d", "off",
"--boot1", "dvd"); err != nil {
return err
}

if err := d.vbm("modifyvm", d.MachineName,
"--nic1", "nat",
"--nictype1", "82540EM",
"--cableconnected1", "on"); err != nil {
return err
}

if err := d.setupHostOnlyNetwork(d.MachineName); err != nil {
return err
}

// `VBoxManage storagectl` コマンドでストレージコントローラを付加する
if err := d.vbm("storagectl", d.MachineName,
"--name", "SATA",
"--add", "sata",
"--hostiocache", "on"); err != nil {
return err
}

// `VBoxManage storageattach` コマンドでストレージを VM にアタッチする
if err := d.vbm("storageattach", d.MachineName,
"--storagectl", "SATA",
"--port", "0",
"--device", "0",
"--type", "dvddrive",
"--medium", d.ResolveStorePath("boot2docker.iso")); err != nil {
return err
}

if err := d.vbm("storageattach", d.MachineName,
"--storagectl", "SATA",
"--port", "1",
"--device", "0",
"--type", "hdd",
"--medium", d.diskPath()); err != nil {
return err
}

// let VBoxService do nice magic automounting (when it's used)
if err := d.vbm("guestproperty", "set", d.MachineName, "/VirtualBox/GuestAdd/SharedFolders/MountPrefix", "/"); err != nil {
return err
}
if err := d.vbm("guestproperty", "set", d.MachineName, "/VirtualBox/GuestAdd/SharedFolders/MountDir", "/"); err != nil {
return err
}

// 共有ディレクトリ名を設定する
var shareName, shareDir string // TODO configurable at some point
switch runtime.GOOS {
case "windows":
shareName = "c/Users"
shareDir = "c:\\Users"
case "darwin":
shareName = "Users"
shareDir = "/Users"
// TODO "linux"
}

// 共有ディレクトリをマウントする
if shareDir != "" && !d.NoShare {
log.Debugf("setting up shareDir")
if _, err := os.Stat(shareDir); err != nil && !os.IsNotExist(err) {
return err
} else if !os.IsNotExist(err) {
if shareName == "" {
// parts of the VBox internal code are buggy with share names that start with "/"
shareName = strings.TrimLeft(shareDir, "/")
// TODO do some basic Windows -> MSYS path conversion
// ie, s!^([a-z]+):[/\\]+!\1/!; s!\\!/!g
}

// woo, shareDir exists! let's carry on!
if err := d.vbm("sharedfolder", "add", d.MachineName, "--name", shareName, "--hostpath", shareDir, "--automount"); err != nil {
return err
}

// enable symlinks
if err := d.vbm("setextradata", d.MachineName, "VBoxInternal2/SharedFoldersEnableSymlinksCreate/"+shareName, "1"); err != nil {
return err
}
}
}

log.Infof("Starting VirtualBox VM...")

return d.Start()
}

このように VBoxManage createvmVBoxManage modifyvm コマンドなどに必要なフラグをつけて呼び出すことで、 VirtualBox ホストのプロビジョニング処理をおこなっていることがわかります。ここでは解説しませんが、最後に呼び出している Driver.Start() の中では VM の起動やネットワークの設定などがおこなわれます。なかなか泥臭さが伝わってきますね。

Amazon EC2 用の Driver の実装

もう1つだけ、 Amazon EC2 用の Driver の実装も見てみましょう。

drivers/amazonec2/amazonec2.go#L23-L69

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
type Driver struct {
*drivers.BaseDriver
Id string
AccessKey string
SecretKey string
SessionToken string
Region string // default: us-east-1
AMI string // default: ami-615cb725 (Ubuntu 14.04)
SSHKeyID int
KeyName string
InstanceId string
InstanceType string // default: t2.micro
PrivateIPAddress string
SecurityGroupId string
SecurityGroupName string
ReservationId string
RootSize int64
IamInstanceProfile string
VpcId string
SubnetId string
Zone string // default: a
keyPath string
RequestSpotInstance bool
SpotPrice string
PrivateIPOnly bool
UsePrivateIP bool
Monitoring bool
}

こちらの構造体では、 EC2 に必要な access key や secret key, region, AMI などの情報を保持していることがわかります。具体的なメソッドの実装は解説しませんが、基本的には AWS SDK for Go を介して対応する EC2 の API を呼び出しています。

Summary

僕が今回 Driver Plugins の仕組みを追ってみようと思ったのは、「コンパイル型の静的言語でプラグイン機構を実現するにはどうすればよいのか?」ということを考えていたからでした。スクリプト言語であればプラグイン機構は簡単に実現できるのですが、 Go のように単一バイナリになり、 DLL のような仕組みもない言語において、プラグイン機構を実現する仕組みがわからなかったのです。 RPC を使うというのが常套手段なのはのちのちわかったのですが、具体的な実装を追ってみて腑に落ちた感じです。 Docker Machine のコードは比較的読みやすいので、とても勉強になりました。

Aerospike にキーの情報も格納するには

aql コマンドでキーそのものも確認したい

分散型 KVS である Aerospike では、 aql という SQL ライクにデータを確認・操作できるコマンドラインインターフェースが提供されています。使える機能はかなり限られているものの、 KVS にもかかわらずまるで RDB であるかのように扱えるので、データの確認には重宝しています。

たとえば、以下のように INSERT したり SELECT したりできます。

1
2
3
4
5
6
7
8
9
10
aql> INSERT INTO test.testset (PK, a, b) VALUES ('xyz', 'abc', 123)
OK, 1 record affected.

aql> SELECT * FROM test.testset
+-------+-----+
| a | b |
+-------+-----+
| "abc" | 123 |
+-------+-----+
1 row in set (0.058 secs)

しかしながらここで注目してもらいたいのが、キーである 'xyz' が表示されていないということです。

Aerospike は内部的にはキーそのものを使用していない

Aerospike は内部的には、レコードを指定するのにキーそのものではなくそのハッシュ値を使用しています。 Aerospikeのデータモデルの説明 から引用してみます。

Key / Digest

In the application, each record will have a key associated with it. This key is what the application will use to read or write the record.

However, when the key is sent to the database, the key (together with the set information) is hashed into a 160-bit digest. Within the database, the digest is used address the record for all operations.

The key is used primarily in the application, while the digest is primarily used for addressing the record in the database.

The key maybe either an Integer, String, or Bytes value.n

キー / ダイジェスト

アプリケーションの中では、各レコードにはそれに対応するキーがあります。このキーはアプリケーションがレコードを読み出したり書き出したりするのに使うものです。

しかしながら、キーがデータベースに送られるときには、そのキー(とセットの情報)は 160 bit のダイジェストにハッシュ化されます。データベースの中では、すべての操作において、レコードを指定するのにはそのダイジェストが使用されます。

キーは主にアプリケーションの中で使用され、ダイジェストは主にデータベースの中でレコードを指定するのに使用されます。

キーは整数か文字列、バイト値のどれかです。

このような事情のため、 aql ではそのままではキーが表示されないようです。

Aerospike にキーの情報も格納するには

aql でキーも表示するようにするためには、まずそもそも Aerospike にキーそのものの情報も格納するようにしなければなりません。つまり、クライアントアプリケーションからデータを送る際に、キーの情報も保存するように明示的に指定する必要があります。どのクライアントライブラリにも WritePolicy.sendKey のようなフラグがあるはずなので、それを true に設定します。

たとえば、 Go 言語のサンプルアプリケーションの場合には、以下のようにします。

main.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main

import (
"fmt"
"os"

asc "github.com/aerospike/aerospike-client-go"
)

func panicOnErr(err error) {
if err != nil {
panic(err)
}
}

func main() {
cl, err := asc.NewClient("127.0.0.1", 3000)
panicOnErr(err)

key, err := asc.NewKey("test", "testset", "xyz")
panicOnErr(err)

// define some bins with data
bins := asc.BinMap{
"a": "abc",
"b": 123,
}

// write the bins
wp := asc.NewWritePolicy(0, 0)
wp.SendKey = true // ここでキーそのものも送信するように指定する!
err = cl.Put(wp, key, bins)
panicOnErr(err)

rec, err := cl.Get(nil, key)
panicOnErr(err)

fmt.Printf("%#v\n", *rec)
}

このプログラムを実行してから、 aql で確認してみます。

1
2
3
4
5
6
7
8
9
10
11
$ go run main.go
aerospike.Record{Key:(*aerospike.Key)(0xc8200da000), Node:(*aerospike.Node)(0xc8200ae000), Bins:aerospike.BinMap{"a":"abc", "b":123}, Generation:1, Expiration:432000}

$ aql
aql> SELECT * FROM test.testset
+-------+-------+-----+
| key | a | b |
+-------+-------+-----+
| "xyz" | "abc" | 123 |
+-------+-------+-----+
1 row in set (0.055 secs)

きちんとキーも表示されるようになりましたね。

OS X 10.11 で RubyGems の native extension のビルドに失敗したら

OS X 10.11 El Capitan にアップグレードしたのですが、その後 native extension を利用する gem のインストールに失敗するようになりました。今後のために、エラーの内容と解決方法について残しておくことにします。

Native extension のビルドに失敗する

たとえば sqlite3 の gem を入れようとしたところ、以下のようなエラーが発生して、 native extension のビルドに失敗しました。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
$ gem install sqlite3 -v '1.3.10'
Building native extensions. This could take a while...
ERROR: Error installing sqlite3:
ERROR: Failed to build gem native extension.

/Users/skatsuta/.rbenv/versions/2.1.5/bin/ruby extconf.rb
checking for sqlite3.h... *** extconf.rb failed ***
Could not create Makefile due to some reason, probably lack of necessary
libraries and/or headers. Check the mkmf.log file for more details. You may
need configuration options.

Provided configuration options:
--with-opt-dir
--without-opt-dir
--with-opt-include
--without-opt-include=${opt-dir}/include
--with-opt-lib
--without-opt-lib=${opt-dir}/lib
--with-make-prog
--without-make-prog
--srcdir=.
--curdir
--ruby=/Users/skatsuta/.rbenv/versions/2.1.5/bin/ruby
--with-sqlite3-dir
--without-sqlite3-dir
--with-sqlite3-include
--without-sqlite3-include=${sqlite3-dir}/include
--with-sqlite3-lib
--without-sqlite3-lib=${sqlite3-dir}/lib
/Users/skatsuta/.rbenv/versions/2.1.5/lib/ruby/2.1.0/mkmf.rb:456:in `try_do': The compiler failed to generate an executable file. (RuntimeError)
You have to install development tools first.
from /Users/skatsuta/.rbenv/versions/2.1.5/lib/ruby/2.1.0/mkmf.rb:587:in `try_cpp'
from /Users/skatsuta/.rbenv/versions/2.1.5/lib/ruby/2.1.0/mkmf.rb:1120:in `block in find_header'
from /Users/skatsuta/.rbenv/versions/2.1.5/lib/ruby/2.1.0/mkmf.rb:918:in `block in checking_for'
from /Users/skatsuta/.rbenv/versions/2.1.5/lib/ruby/2.1.0/mkmf.rb:351:in `block (2 levels) in postpone'
from /Users/skatsuta/.rbenv/versions/2.1.5/lib/ruby/2.1.0/mkmf.rb:321:in `open'
from /Users/skatsuta/.rbenv/versions/2.1.5/lib/ruby/2.1.0/mkmf.rb:351:in `block in postpone'
from /Users/skatsuta/.rbenv/versions/2.1.5/lib/ruby/2.1.0/mkmf.rb:321:in `open'
from /Users/skatsuta/.rbenv/versions/2.1.5/lib/ruby/2.1.0/mkmf.rb:347:in `postpone'
from /Users/skatsuta/.rbenv/versions/2.1.5/lib/ruby/2.1.0/mkmf.rb:917:in `checking_for'
from /Users/skatsuta/.rbenv/versions/2.1.5/lib/ruby/2.1.0/mkmf.rb:1119:in `find_header'
from extconf.rb:30:in `<main>'

extconf failed, exit code 1

Gem files will remain installed in /Users/skatsuta/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/gems/sqlite3-1.3.10 for inspection.
Results logged to /Users/skatsuta/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/extensions/x86_64-darwin-14/2.1.0-static/sqlite3-1.3.10/gem_make.out

どうやら Makefile の生成に失敗しているようです。 Check the mkmf.log file for more details. とあるので、 mkmf.log を探してみます。

1
2
$ find ~/.rbenv | grep sqlite | grep mkmf.log
/Users/skatsuta/.rbenv/versions/2.1.5/lib/ruby/gems/2.1.0/extensions/x86_64-darwin-14/2.1.0-static/sqlite3-1.3.10/mkmf.log

ありました。こちらを見てみます。

mkmf.log
1
2
3
4
5
6
7
8
9
10
11
12
"clang -o conftest -I/Users/skatsuta/.rbenv/versions/2.1.5/include/ruby-2.1.0/x86_64-darwin14.0 -I/Users/skatsuta/.rbenv/versions/2.1.5/include/ruby-2.1.0/ruby/backward -I/Users/skatsuta/.rbenv/versions/2.1.5/include/ruby-2.1.0 -I. -I/Users/skatsuta/.rbenv/versions/2.1.5/include  -D_XOPEN_SOURCE -D_DARWIN_C_SOURCE -D_DARWIN_UNLIMITED_SELECT -D_REENTRANT    -O3 -Wno-error=shorten-64-to-32  -pipe conftest.c  -L. -L/Users/skatsuta/.rbenv/versions/2.1.5/lib -L. -L/Users/skatsuta/.rbenv/versions/2.1.5/lib  -fstack-protector     -lruby-static -framework CoreFoundation  -lpthread -lgmp -ldl -lobjc "
ld: library not found for -lgmp
clang: error: linker command failed with exit code 1 (use -v to see invocation)
checked program was:
/* begin */
1: #include "ruby.h"
2:
3: int main(int argc, char **argv)
4: {
5: return 0;
6: }
/* end */

ld: library not found for -lgmp とあるので、 gmp というライブラリをリンカが見つけられていません。 GMP とは GNU Multi-Precision Library のことで、多倍長整数など任意の精度の算術ライブラリのようです (Wikipedia)。

しかしながら、 gmp は Homebrew ですでにインストールされており、 /usr/local/lib にも存在します。

1
2
3
4
5
6
7
8
9
10
$ brew list | grep gmp
gmp

$ find /usr/local/lib | grep gmp
/usr/local/lib/libgmp.10.dylib
/usr/local/lib/libgmp.a
/usr/local/lib/libgmp.dylib
/usr/local/lib/libgmpxx.4.dylib
/usr/local/lib/libgmpxx.a
/usr/local/lib/libgmpxx.dylib

ここから先の詳しい原因はわからないのですが、 El Capitan になってからデフォルトで Clang が認識するライブラリパスに /usr/local/lib が含まれなくなったのではないかと思っています。このあたりは新しいセキュリティ機能である System Integrity Protection (SIP; 別名 rootless) とも関連がありそうな気がします。

解決方法

場当たり的でよいのであれば、先の gem install 時のエラーログにもある通り、 --with-opt-lib オプションで明示的に指定してやればよいです。

1
$ gem install sqlite3 -v '1.3.10' -- --with-opt-lib=/usr/local/lib

ただしこの方法だと gmp を使う gem をインストールするたびに、毎回指定する必要あり面倒です。

永続的に効果のある解決方法は、 $LIBRARY_PATH 環境変数に /usr/local/lib を追加してやることです。これで以下のようにインストールに成功するようになります。

1
2
3
4
5
6
7
8
9
$ export LIBRARY_PATH=/usr/local/lib:$LIBRARY_PATH
$ gem install sqlite3 -v '1.3.10'
Fetching: sqlite3-1.3.10.gem (100%)
Building native extensions. This could take a while...
Successfully installed sqlite3-1.3.10
Parsing documentation for sqlite3-1.3.10
Installing ri documentation for sqlite3-1.3.10
Done installing documentation for sqlite3 after 0 seconds
1 gem installed

export LIBRARY_PATH=/usr/local/lib:$LIBRARY_PATH は .bashrc や .zshrc に書いておくとよいでしょう。

El Capitan では SIP があることで、 /usr/local 以下を使う Homebrew でも

1
$ sudo chown -R $(whoami):admin /usr/local

というコマンドを実行して権限を変えないと正常に動作しなかったりするなど、いろいろなトラブルシューティングが必要になりそうですね。

HTTPie で Docker Remote API にアクセスする方法

Docker のアーキテクチャ

Docker はクライアントサーバモデルも採用しており、サーバ側の API 仕様は Docker Remote API として公開されています。つまり、 Docker クライアント (Docker コマンド) はこの Remote API を叩いて Docker サーバ (Docker デーモン) に操作を依頼しているのであり、設定をすれば普通の HTTP 経由でも API へアクセスすることが可能になっています。

Docker デーモンはデフォルトでは Unix ソケットの unix:///var/run/docker.sock を listen しますが、 Windows や OS X で Boot2Docker や Docker Machine を使っている場合には、 TCP ソケット (2376 番ポート) を listen するように設定されています。また、接続は TLS で暗号化されています。この辺りのことをクライアントからのアクセスの際に指定するために、以下の環境変数の設定が必要となるわけです。

1
2
3
4
5
6
7
8
9
10
# Docker Machine の場合
$ docker-machine env dev
export DOCKER_TLS_VERIFY="1"
export DOCKER_HOST="tcp://192.168.99.100:2376"
export DOCKER_CERT_PATH="/Users/skatsuta/.docker/machine/machines/dev"
export DOCKER_MACHINE_NAME="dev"
# Run this command to configure your shell:
# eval "$(docker-machine env dev)"

# Boot2Docker なら `boot2docker shellinit`

HTTPie で Docker Remote API にアクセスする

たとえばイメージ一覧を取得してみます。現状のイメージ一覧は以下のようになっているとしましょう。

1
2
3
4
$ docker images
REPOSITORY TAG IMAGE ID CREATED VIRTUAL SIZE
golang latest 124e2127157f 4 weeks ago 517.3 MB
centos latest 7322fbe74aa5 8 weeks ago 172.2 MB

では Docker Remote API に直接アクセスしてみます。 wget を使った方法はこちらの記事で紹介されていますが、僕は HTTPie を愛用しているので、今回はそちらを使ってやってみたいと思います。

結論から言えば、以下のようにオプションを指定して API にアクセスします。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# 簡略化のため、あらかじめホスト VM の IP アドレスを取得しておく
$ export DOCKER_IP=`docker-machine ip dev`

# HTTPie で TLS 接続でイメージ一覧を取得する
$ http --verify=no --cert=$DOCKER_CERT_PATH/cert.pem --cert-key=$DOCKER_CERT_PATH/key.pem https://$DOCKER_IP:2376/images/json
HTTP/1.1 200 OK
Content-Length: 533
Content-Type: application/json
Date: Mon, 17 Aug 2015 06:23:18 GMT
Server: Docker/1.8.1 (linux)

[
{
"Created": 1436855204,
"Id": "124e2127157f398735e5888601e8b02cf832e037ef951317bc0a4f6256723d7b",
"Labels": {},
"ParentId": "69c177f0c117c1ea8c4593b4fbfa7affb4096f7abc751c9d818721bfdea087bb",
"RepoDigests": [],
"RepoTags": [
"golang:latest"
],
"Size": 2481,
"VirtualSize": 517283915
},
{
"Created": 1434648509,
"Id": "7322fbe74aa5632b33a400959867c8ac4290e9c5112877a7754be70cfe5d66e9",
"Labels": {},
"ParentId": "c852f6d61e65cddf1e8af1f6cd7db78543bfb83cdcd36845541cf6d9dfef20a0",
"RepoDigests": [],
"RepoTags": [
"centos:latest"
],
"Size": 0,
"VirtualSize": 172237380
}
]

詳細なイメージ情報が JSON で取得できました。この API 情報を使うことで、サードパーティでもさまざまな Docker 用ツールが開発できそうです。

Docker Machine で管理している VirtualBox の VM をローカルにポートフォワーディングする

VirtulaBox の VM からローカルへのポートフォワーディング

Linux 以外の OS 上で Docker を利用する場合、 VirtualBox などで Linux のホスト VM を立てる必要があります。このとき、ホスト VM からローカルへポートフォワーディングしたいと思うときがあります。これをおこなうには、 VirtualBox の CLI である VBoxManagecontrolvm コマンドを使います。

1
VBoxManage controlvm <VM_NAME> natpf<1-N> "<RULE_NAME>,tcp,<LOCAL_IP>,<LOCAL_PORT>,<VM_IP>,<VM_PORT>

<LOCAL_*> はローカルマシンのものを、 <VM_*> はホスト VM のものを表します。
<RULE_NAME> はルール名で、わかりやすいように名付けるとよいです。 また <VM_IP> は省略可能です。

では実際に試してみましょう。たとえば、 PostgreSQL の Docker コンテナのポート 5432 をローカルの 5432 にポートフォワーディングしたい場合を考えます。以下のように、コンテナからホスト VM へはあらかじめポートフォワーディングされているものとします。

1
2
3
4
5
$ docker run -e POSTGRES_PASSWORD=password -d -p 5432:5432 postgres
fd17b2e20ee3b09bbb446449f4182ad0aea24d2a4f4e8ba1a700c11af6671970
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
fd17b2e20ee3 postgres "/docker-entrypoint.s" Less than a second ago Up 1 seconds 0.0.0.0:5432->5432/tcp serene_varahamihira

このとき、ホスト VM 名が dev であるとすると、ホスト VM の 5432 から ローカルの 5432 にポートフォワーディングするには、以下のコマンドを実行します。

1
$ VBoxManage controlvm dev natpf1 "psql_pf,tcp,127.0.0.1,5432,,5432"

では確かめてみましょう (OS X の場合)。

1
2
$ lsof -P -i -n | grep ':5432 '
VBoxHeadl 7103 skatsuta 21u IPv4 0xd95e1bb443caee89 0t0 TCP 127.0.0.1:5432 (LISTEN)

きちんと 5432 番で listen していますね。