発明のための再発明

Webプログラマーが、プログラムの内部動作を通してプログラムを作る時の参考になるような情報を書くブログ(サーバーサイドやDevOpsメイン)

gVisorのechoを読む

gVisorが面白そうだったので、どんな風に動いているか書きます。

使用したコミットは、

GitHub - google/gvisor at c400a0356b856e71fd30e3fe10372d7bb94356cb

です。

gVisor概要

gVisorは、先日googleが公開した、アプリケーションとホストの分離を目指したコンテナランタイムです。
システムコールをコンテナから直接実行するのではなく、システムコールをgVisorが受け取って実行を制御することで、コンテナをより安全に動かせるようにしています。

GitHubは、ここ

github.com

gVisorのコードは短くはないのですが、色々な機能を実装するために多くなっているだけで、各機能に必要なコードはそれほど多くなく、つまみ食いにはちょうど良い大きさという印象です。

動かし方

bazelでビルドして、Dockerでランタイムを指定すれば動きます。
READMEに詳しく書かれているので、簡単に動かせると思います(https://github.com/google/gVisor#building)

gVisorのechoの動き

本題の、コンテナ内でechoを実行したときに何が起きているか見ていきます。
今回は、ubuntuを起動し、その中で echo "Hello"を実行しました。
簡単に図にすると、下のように動いています。

f:id:mrasu:20180504185959p:plain

順番に見ていきます。

1. ubuntuシステムコール

echo "Hello" のコマンドを実行したところから始まります。

2. gVisorが気づく

echoが動かすWriteシステムコールにgVisorが反応するところから、gVisor仕事が始まります。
Task#run(pkg/sentry/kernel/task_run.go)という関数がループの中でシステムコールを待っています。

3. システムコールの内容をptraceで取得する

まず、gVisorがどんなシステムコールが実行されたかを知る必要が有ります。
そのために、Task#setRegsからptraceを呼び出します。
PTRACE_GETREGSを使用することで、対象プロセスのレジスタをコピーします。
これによって、gVisorはシステムコールの内容を把握できます。
例えば、Orig_raxにあるシステムコールの番号や、rdiなどにある引数などが取得できる情報です。

4. 実行するべきでないものを除外

システムコールの番号がわかったので、それを実行するべきかどうかを判定します。
Task#checkSeccompSyscallという関数が実行処理の判定をし、許可していないシステムコールを弾いています。

5. 前処理

システムコール(ここでは、Write)を実行するとなったら、まずは前処理として、.bootにログを出したりします。

6. システムコールに対応した処理を実行

gVisorはホストにシステムコールを直接投げるのではなく、gVisor自身が別のシステムコールを実行します。
どのようにシステムコールを取り扱うかは、pkg/sentry/syscalls/linux/linux64.go に色々と処理が書かれています。
ただ、まだ全部の処理が実装されているわけではなく、TODOがあったり、318までしかシステムコール候補がなかったりします。

7. Writeを実行

echoの出すWriteは当然実装されていて、pkg/sentry/syscalls/linux/sys_write.goに処理内容が書かれています。

8. ホストへWriteする処理を実行

sys_writeのwriteには共通処理が書かれているのですがechoはホストに書く処理なので、続いて個別処理としてpkg/sentry/fs/host/file.goのWriteが実行されます。

9. コマンドプロンプトに表示される。

ホスト用のWriteが動いて、ようやくコマンドプロンプトに "Hello"が表示されます。

まとめ

以上が、 echo "Hello" の流れでした。

このように、gVisorはコンテナがシステムコールを直接実行するのではなく、gVisorがホストの間に入っています。
この機能がsentryで、怪しいコンテナが変なシステムコールを実行するのをブロックしてくれます。
そのおかげで、コンテナがより安全に動かせるようにできるようになっているというわけです。

javascriptで記号プログラミング

JS記号プログラミング入門 - Qiita

で紹介されていた記号プログラミングがとてもおもしろかったので、chromeで作ってみました

コンソールに大きめの文字で「hello crazy hacker」と出力するコードです。

綺麗な書き方は、

console.log('%chello crazy hacker', 'font-size:30px')

記号だけで書いたのが、

[]
[(({})+[])[-~-~-~-~-~[]]+(({})+[])[-~[]]+([][[]]+[])[-~[]]+(![]+[])[-~-~-~[]]+(!![]+[])[+[]]+(!![]+[])[-~[]]+([][[]]+[])[+[]]+(({})+[])[-~-~-~-~-~[]]+(!![]+[])[+[]]+(({})+[])[-~[]]+(!![]+[])[-~[]]] // ["constructor"]
[(({})+[])[-~-~-~-~-~[]]+(({})+[])[-~[]]+([][[]]+[])[-~[]]+(![]+[])[-~-~-~[]]+(!![]+[])[+[]]+(!![]+[])[-~[]]+([][[]]+[])[+[]]+(({})+[])[-~-~-~-~-~[]]+(!![]+[])[+[]]+(({})+[])[-~[]]+(!![]+[])[-~[]]]( // ["constructor"](
(({})+[])[-~-~-~-~-~[]]+(({})+[])[-~[]]+([][[]]+[])[-~[]]+(![]+[])[-~-~-~[]]+(({})+[])[-~[]]+(![]+[])[-~-~[]]+(![]+[])[-~-~-~-~[]]+ // console
(({})+[])[+[]]+(/'/+[])[-~[]]+ // ['
(![]+[])[-~-~[]]+(({})+[])[-~[]]+((/ /[(({})+[])[-~-~-~-~-~[]]+(({})+[])[-~[]]+([][[]]+[])[-~[]]+(![]+[])[-~-~-~[]]+(!![]+[])[+[]]+(!![]+[])[-~[]]+([][[]]+[])[+[]]+(({})+[])[-~-~-~-~-~[]]+(!![]+[])[+[]]+(({})+[])[-~[]]+(!![]+[])[-~[]]])+[])[(-~-~-~[]<<-~-~[])+~[]]+ // log
(/'/+[])[-~[]]+(({})+[])[-~-~-~-~-~-~-~[]*-~-~[]]+ // ']
(/\(/+[])[-~-~[]]+(/\(/+[])[-~-~[]]+(/\(/+[])[-~-~[]]+(/\)/+[])[-~-~[]]+(/=/+[])[-~[]]+(/>/+[])[-~[]]+(/'/+[])[-~[]]+  // "((()=>'"
(/\\/+[])[-~[]]+([][[]]+[])[+[]]+[+[]]+[+[]]+[-~-~[]]+[-~-~-~-~-~[]]+(({})+[])[-~-~-~-~-~[]]+  // "%c"
(/\\/+[])[-~[]]+([][[]]+[])[+[]]+[+[]]+[+[]]+[-~-~-~-~-~-~[]]+[-~-~-~-~-~-~-~-~[]]+([][[]]+[])[-~-~-~[]]+(![]+[])[-~-~[]]+(![]+[])[-~-~[]]+(({})+[])[-~[]]+(({})+[])[-~-~-~-~-~-~-~[]]+ // "hello "
(({})+[])[-~-~-~-~-~[]]+(!![]+[])[-~[]]+(![]+[])[-~[]]+(/\\/+[])[-~[]]+([][[]]+[])[+[]]+[+[]]+[+[]]+[-~-~-~-~-~-~-~[]]+(![]+[])[-~[]]+(-~[]/+[]+[])[~(~[]+~[]<<-~-~[])]+(({})+[])[-~-~-~-~-~-~-~[]]+ // "crazy "
(/\\/+[])[-~[]]+([][[]]+[])[+[]]+[+[]]+[+[]]+[-~-~-~-~-~-~[]]+[-~-~-~-~-~-~-~-~[]]+(![]+[])[-~[]]+(({})+[])[-~-~-~-~-~[]]+(/\\/+[])[-~[]]+([][[]]+[])[+[]]+[+[]]+[+[]]+[-~-~-~-~-~-~[]]+({}+[])[-~-~[]]+([][[]]+[])[-~-~-~[]]+(!![]+[])[-~[]]+(({})+[])[-~-~-~-~-~-~-~[]]+ // "hacker "
(/'/+[])[-~[]]+(/\)/+[])[-~-~[]]+(/\(/+[])[-~-~[]]+(/\)/+[])[-~-~[]]+(/,/+[])[-~[]]+(/\(/+[])[-~-~[]]+(/\(/+[])[-~-~[]]+(/\)/+[])[-~-~[]]+(/=/+[])[-~[]]+(/>/+[])[-~[]]+(/'/+[])[-~[]]+  // "')(), (()=>'"
(![]+[])[+[]]+(({})+[])[-~[]]+([][[]]+[])[-~[]]+(!![]+[])[+[]]+(/\\/+[])[-~[]]+([][[]]+[])[+[]]+[+[]]+[+[]]+[-~-~[]]+([][[]]+[])[-~-~[]]+ // font-
(![]+[])[-~-~-~[]]+([][[]]+[])[-~-~-~-~-~[]]+(/\\/+[])[-~[]]+([][[]]+[])[+[]]+[+[]]+[+[]]+[-~-~-~-~-~-~-~[]]+(![]+[])[-~[]]+([][[]]+[])[-~-~-~[]]+(/\\/+[])[-~[]]+([][[]]+[])[+[]]+[+[]]+[+[]]+[-~-~-~[]]+(![]+[])[-~[]]+ //size:
[-~-~-~[]]+[+[]]+(/\\/+[])[-~[]]+([][[]]+[])[+[]]+[+[]]+[+[]]+[-~-~-~-~-~-~-~[]]+[+[]]+(/\\/+[])[-~[]]+([][[]]+[])[+[]]+[+[]]+[+[]]+[-~-~-~-~-~-~-~[]]+[-~-~-~-~-~-~-~-~[]]+ // 30px
(/'/+[])[-~[]]+(/\)/+[])[-~-~[]]+(/\(/+[])[-~-~[]]+(/\)/+[])[-~-~[]]+(/\)/+[])[-~-~[]] // "')())"
)()

これで、生成されるのが、

[]["constructor"]["constructor"](console['log']((()=>'\u0025c\u0068ello cra\u007ay \u0068ac\u006ber ')(), (()=>'font\u002dsi\u007ae\u003a30\u0070\u0078')()))()

実行すると、

f:id:mrasu:20180503151938j:plain

となり、期待通り表示されました

DBマイグレーションを行う技術

データベースのスキーマを変更するということはデータをいじる行為であり、最悪の場合データが消えます。
最悪の事態にはならなくとも、思わぬ場所に影響が起きたり、データの不整合が発生する恐怖と戦う必要が有ります。
テストや切り戻しを含めて計画し、大きな変更の場合にはダウンタイムまで考慮する必要があります。

そこで、RDBを対象にデータベースの変更を行う方法について書いていきます。

スキーマ変更

まずは、スキーマ変更について、

カラムを追加する

一番簡単で、影響も少ない変更です。
気をつけるのは、

といったところでしょうか。
大抵の場合は、スキーマの変更とソースコードの変更の順番にさえ気をつければ問題は発生しません。

カラム名を変更する

「ALTER」でさくっと変えたくなりますが、ソースコードの変更が同時に行われるわけではないので「カラムが見つからない」系のエラーが出てしまいます。
これを防ぐには、

  1. メンテナンス期間を設けて、システムを止める
  2. 新カラムを作成した後、元カラムを削除する

の2通りがあります。
前者の場合、コード修正は簡単なので開発コストはかからないのですが、メンテナンス期間が必要となることから、「メンテナンス期間はどの程度か」、「周知はどのように行うか」と言った、開発とは別の問題が出てくる上、「そこまでする価値はあるのか」というリファクタリングに対する疑問まで出てくるため、多くの人間を巻き込む必要が発生します。
しかも、説得する時間が長くなると開発側からも「コストが大きすぎる」という理由で変更が嫌煙されるようになってしまいます。
なので、個人としては、コード変更は面倒になるものの、後者が好みです。

後者の手順は、

  1. 新カラムを追加する
  2. トリガーを用いて旧カラムが更新された場合に、新カラムを更新するようにする
  3. 全行の新カラムに旧カラムの値を代入する
  4. 新カラムから読むようにコードを変更する
  5. 新カラムへ書き込むようにコードを変更する
  6. 旧カラムとトリガーを削除する

トリガーを用いることで、旧カラムと新カラムのどちらに書き込まれても新カラムが更新されるようになります。
トリガーではなくアプリケーションが両方のカラムに書き込みを行うようにするという対処も可能です。

カラムの型などを変更する

ALTERで大丈夫なら、ALTERを実行します。ダメなら、カラム名変更と同じ手順を踏む必要が有ります。
つまり、

  1. ALTERを使用する
  2. メンテナンス期間を設けて、システムを止める
  3. 新カラムを作成した後、元カラムを削除する

の方法があります。
ALTERが使える状況は、

  • NULL制約の変更
  • DEFAULT値の変更
  • 文字数制限やintからlongへの変更

のような、影響が少ない時です。
ただし、影響が少なくともロックが長くなることはあるので、事前に時間を計算する必要が有ります。
ロックが長くなるようであれば、ロックを短くする方法を模索するか、列追加を用いて変更するかで対処しましょう

カラムを削除する

DELETEを突然打ってはいけません。
カラムが突然消えると、「カラムが見つからない」系のエラーが起きるので、アプリケーションがカラムを使用していないことを確認してから、削除する必要が有ります。
手順は、

  1. カラムからの読み込みを止めるようにコードを変更する
  2. カラムへの書き込みを止める
  3. カラムを削除する

です。

  • リリース中は新旧のコードが同時に動く
  • ロールバックできなくなる

という理由から、最初に読み込みだけをやめ、その後に書き込みをやめることが必要です。
ただ、既に使われていないカラムである場合は、勇気を持っていきなりDELETEするしかありません。

テーブルの名前を変える

カラム追加と同じで、

  1. メンテナンス期間を設けて、システムを止める
  2. 新テーブルを作成した後、元テーブルを削除する

の2通りです。
読み込みや書き込みの変更範囲、外部キーの変更が大きくなりますが、手順は変わりません。

テーブルを削除する

これも、カラム削除と同じです。

大量にデータがあるテーブルに対する変更

データ量が大きいことは、ただそれだけで問題を引き起こします。
注意点として、実行時間の予測に本番データをコピーした環境を使った場合には、本番では別処理が走っているせいで、コピーした環境よりも遅くなることを考慮する必要が有ります。

大量データに対する基本方針としては、

  • ロックを長時間かけない
  • 徐々に移行する

です。
例えば、DEFAULT値のついたカラム追加をすると、既存の全行に対して更新がかかるせいで時間がかかるけれど、DEFAULT値をつけなければ処理が殆ど無いので速いということが有ります。この場合には、一旦nullを許可したカラムを追加してUPDATEでデータを代入した後にDEFAULT値を入れると良いでしょう。
また、UPDATEを適当な行数(例えば1,000行)づつ行うことで、他の処理への影響を少なくすることができます。
他にも、dbによっては列を無効にすることで参照出来なくすることが出来ることもあります。

本当に辛い時は、別テーブルを用意して全行コピーすることが必要になることも有ります。

切り戻しを高速化する

どんなに注意しても、切り戻しが必要になることは避けられないので、切り戻しがしやすい変更方法を検討します。
次のような方法を取ることで、1世代前のコードにすぐ戻せるはずです。
ただし、防御策は作業量が多くなる傾向があるので、問題が発生しなさそうな変更は「ダメだったらシステム停止する」という思い切りも必要です。

前のデータを待機させる

データを消さなければ、万が一必要だった場合にもソースコードを戻すことで運用が続けられます。
DBがカラムを無効にする機能を持っているのであれば、無効にしてしまうのが簡単です。
カラムの無効化をDBがサポートしていないのであれば、カラム名を変更することでも、参照が出来なくするように出来ます。

新旧同時に稼働させる

変更前のデータに対しても更新が続けられていれば、万が一変更後のスキーマが使えないことがわかってもソースコードの変更で済みます。
これは、

ことで、出来ます。トリガーのほうが簡単ですが書き込み先を変更した後には戻れなくなるので、両カラムへ書くほうが被害は減ります。

フラグで新旧を切り替える

ソースコード上に、新スキーマを使うものと旧スキーマを使うものを同時に存在させ、フラグでどちらを使用するか制御します。
フラグの置き場所は適当なテーブルを使用すれば十分です。Redisを使ってもいいでしょう。

フラグを使用すると、「リリース」や「切り戻し」というおおげさなものではなくなり、「使いはじめる」という感覚になります。
更に、変更範囲をユーザーやグループ単位に限定し、新旧同時に稼働することが出来るようになるので、影響範囲も限定出来ます。

手順は、

  1. フラグを使用して、新旧を切り替えるコードをリリースする
  2. バグが出ても良いユーザー(社員のアカウントやテスト用アカウント)に対して新スキーマを使用するようにする
  3. バグが出ないことを確認しつつ、新スキーマを使用するユーザーを徐々に拡大する
  4. 全員が新スキーマを使用する
  5. スキーマを使用するコードを削除する
  6. カラムやテーブルを削除する

です。
影響範囲や切り戻しがかなり早くなりました。

複数アプリケーションが同一のDBを使用しているため、影響範囲が測りづらい

最後に、よくある辛い状況について、
複数アプリケーションが同じDBを使っていると気軽な変更が予期しない影響を及ぼすことが有りますね。

頑張って、分離しましょう。
あるいは、共有データを扱う第3のアプリケーションを作成し、既存のアプリケーションはそのアプリケーションを利用して共有データをいじるように変更しましょう。
これが大変なら、もう・・・

まとめ

DBマイグレーションを行う方法を色々書きました。
このような話は、

に載っているので、参考になると思います。
「データベース・リファクタリング」は絶版になってしまっていて、手に入れるのが難しいですが、「Migrating to Microservice Databases」は無料で手に入ります。