発明のための再発明

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

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」は無料で手に入ります。

grpcの動き

grpcがどんな風に動いているかを覗いてみます。

grpcとは

下の画像のようにgoogle製のrpcフレームワーク言語をまたいで扱えるのが特徴です。

f:id:mrasu:20180407195152p:plain

クライアントは言語ごとに作られていて、grpcのリポジトリに直接入っているものと、別リポジトリに分けられているものが有ります。
基本的には、protocolbufferとhttp2を利用したRPCで、cncfの各プロジェクトやコンテナ系のミドルウェアでかなり使われています。

wiresharkで見るgrpcの動き

grpc-goのサンプルコードを基に、通信内容を見てみます。
動いている内容は、

  1. クライアントが、SayHello({Name: "world"})というメソッドを関数を実行する
  2. サーバーがResponse{Message: "Hello world"}と返す

というシンプルな動作で、最もシンプルな Unary RPC を使用しています。

wiresharkで除いて見ると、下のような流れを見ることが出来ます。 (クライアントのポートが42874、サーバーのポートが50051)

f:id:mrasu:20180407194834j:plain

http2を使用していることがわかります。

詳細に見てみると、
クライアントからのDATAはHEADERSと一緒に送られていて、下のような内容が送信されています。

HEADERS内容: 
    :method: POST
    :scheme: http
    :path: /helloworld.Greeter/SayHello
    content-type: application/grpc

DATA内容:
    hexでの表記: 00000c00010000000100000000070a05776f726c64
    最後に、"world"(77 6f 72 6c 64)があるのがわかります

対して、サーバーからのHEADERSとDATAの下のような内容です。

HEADERS内容: 
    :status: 200
    content-type: application/grpc

DATA内容:
    hexでの表記: 000012000000000001000000000d0a0b48656c6c6f20776f726c64
    最後に、"Hello world"(48 65 6c 6c 6f 20 77 6f 72 6c 64)があるのがわかります

つまり、

  • 呼び出したいメソッドはHEADERS
  • protocolbufferの内容はDATA

に書かれているとわかります。

つまり、http2で動いているだけなので、grpcのライブラリを使わずとも、リクエストを送るクライアントが(大変ですが)一応、書けます。

func main() {
    log.Println("start client")

    conn, err := net.Dial("tcp", "localhost:50051")
    if err != nil {
        log.Fatalf("did not connect: %v", err)
    }

    magic := "505249202a20485454502f322e300d0a0d0a534d0d0a0d0a"
    settings := "000000040000000000"
    headers := "00005c010400000001838645956272d141fc1eca245f15852a4b631b87eb1968a0ff418ba0e41d139d09b8d800d87f5f8b1d75d0620d263d4c4d65647a8a9acac8b4c7602bb2b83f40027465864d833505b11f40899acac8b24d494f6a7f867df7db7416ff"
    data := "00000c00010000000100000000070a05776f726c64"

    packets := []string{magic, settings, headers, data}

    for _, p := range packets {
        raw, err := hex.DecodeString(p)
        if err != nil {
            log.Fatalf("cannot decode: %v", p)
        }

        if _, err := conn.Write(raw); err != nil {
            log.Fatalf("cannot write: %v", p)
        }
    }

    buf := make([]byte, 1024)
    for {
        n, err := conn.Read(buf)
        if err != nil {
            log.Fatalf("cannot read from socket: %v", err)
        }
        log.Println(string(buf[:n]))
        log.Println(hex.EncodeToString(buf[:n]))
    }
}

更に詳細な送信内容は、github内のドキュメントにまとめられています

コードから見る動き

grpc(Unary RPC)の動きをgoのコードを基におってみてみます。

クライアントから見ると、

  1. ユーザーがprotoファイルで定義した関数を呼び出す。
  2. 引数の値をprotocolbufferを基にシリアライズして、DATAにする
  3. pathに呼び出したい関数をセットする
  4. サーバーに送信
  5. 受信を待つ
  6. 受信したデータをデシリアライズして、ユーザーに伝える

という流れです。
逆にサーバーから見ると、

  1. サーバーを起動する
  2. 呼び出される関数を登録する
  3. リクエストが来たら、DATAをデシリアライズし、pathの値を基に登録した関数を呼び出す
  4. 実行した戻り値をクライアントへ送信する

以上が、grpcの簡単な流れとなっています。

Dapperを読んだ

Dapperとは

googleが社内で利用している、分散トレーシングツール https://research.google.com/pubs/pub36356.html

この論文から出来たOSSとしてはtwitterによるzipkin(github)、uberによるjaeger(github)が有る。

内容

マイクロサービスだと1つのリクエストに対して複数のサービスが連携するので、一貫した記録が必要になる。 だから、リクエストの中でどのサービスにどれくらい時間がかかったかを記録したかった。 f:id:mrasu:20180401215816p:plain

各サービスはspanという開始と終了時間とリクエストid、annotationという任意のマークを持っている。 サービスが別サービスを呼び出すとspanは入れ子になる。

各spanは一度ローカルに保存され、その後中央(googleだから、bigtable)に送られる。 f:id:mrasu:20180401215825p:plain Dapperにはオーバーヘッドがあるので、サンプリングしている。 全部のリクエストを記録しなくても、トレーシングとしては、十分把握出来る