Mirrativ tech blog

株式会社ミラティブの開発者(サーバサイドエンジニア,iOSエンジニア,Androidエンジニア,機械学習エンジニア,インフラエンジニア, etc...)によるブログです

【Android】FlipperのCustomPluginを作成してデバッグ効率を改善する

Mirrativ Androidエンジニアのmorizoooです。MirrativではデバッグツールとしてFlipperを使っています。Flipperはモバイルアプリデバッグのためのデスクトップアプリケーションで、アプリ内のデータの整形や可視化を行うことができます。また、Flipperはネットワークの通信状況を確認するNetworkPluginなど、標準でいくつかの機能が用意されています。詳細についてはこちらをご覧ください。 tech.mirrativ.stream

Flipperは標準機能だけでなく、独自のCustomPluginを作成することもできます。MirrativではCustomPluginを積極的に作成し、バグ調査や開発効率の改善に役立てています。一つ例を上げると、Mirrativではコメントやギフトの機能のために、WebSocketベースの独自のPubSubライブラリを使用しています。以前はここでなにか問題が起こったときにペイロードのJSONをLogcatに出力してデバッグしていました。現在ではFlipperのCustomPluginを利用しており、これによりデータの検索、フィルター、閲覧がしやすくなり、バグの原因を特定するスピードが圧倒的に改善しました。

今回の記事では、実装例としてクライアント一覧でJSONを表示するCustomPluginの作成方法についてお話します。

続きを読む

CSS Variablesを使ってWeb LP制作のエンジニア作業時間を0にした話

こんにちは。Webフロントエンジニアの駒木です。

Mirrativでは毎週の様に運営主催イベントやゲーム会社様とのコラボ企画イベント等が開催されます。 そのイベント情報をユーザーへお伝えするメディアとして、イベント毎にWebページ いわゆる LP ( Landing Page ) を制作・公開しています。

f:id:eightbeeeaaat:20210128003034p:plain
Mirrativで公開している多種多様なLP
ですが毎週の様に新しいイベントが企画・開催されますので、LPをエンジニアが都度制作していてはとても追いつきません。

そこでミラティブではCSS Variablesを活用することで、イベントの魅力が伝わるWeb LPをエンジニアが作業することなく制作・運用できる体制を構築しています。

本記事ではここまでに至った過程も含めお伝えします!

続きを読む

【iOS】ミラティブにウィジェット機能を実装した際の開発Tips

こんにちは、iOS エンジニアの千吉良です。iOS14 にはウィジェット機能が新しく搭載*1されて、アプリ側で対応をすることで iOS 端末のホーム画面に独自のウィジェットを置けるようになりました。ミラティブでも、たまにはオシャレしたいよねということで昨年ウィジェット機能に対応しました。まだまだ対応しているアプリは少ないですが、ホーム画面に置いておくとアプリへの愛着も増すし、 SwiftUI での開発が経験できて今後対応していくであろう新しい開発環境を経験できるという点にもメリットがあります。今回はミラティブで導入したウィジェット機能について、いくつかの実装に触れてご紹介します。

続きを読む

Goで開発した配信サーバーのメモリ使用量問題に向き合う

こんにちは。ストリーミングチームの松本です。

Mirrativのストリーミングチームは、低遅延配信や、通知ぼかしというような機能を追加するため、配信のorigin serverの前段にtranscoder serverというものを導入してきました。

tech.mirrativ.stream

tech.mirrativ.stream

transcoder serverはGoによる内製のミドルウェアであり、主に映像の変換を行う目的で作られました。現在は配信プロトコルの変換(既存プロトコル -> 低遅延プロトコル)などを行っています。また、実際にはサーバー上のDockerコンテナ内で動作しています。

f:id:hma2moto:20210120154004p:plain

transcoder serverを展開していくにあたり、メモリ使用量が常に増え続ける問題が起きていたため、その際に直面したGoの実メモリ使用量に関する話を書きたいと思います。

メモリ使用量の増加問題

ミラティブでは Sharding を行っているため、簡易的な構成図では下記のようになっています。

f:id:hma2moto:20210120155256p:plain

これに対してtranscoder server の動作するサーバーインスタンスでは数日〜数週間動作させているとプロセスのメモリ使用量があるタイミングで急激に増えその後低下しない状態になっていき、結果的にメモリ使用量の監視アラートが通知されるという問題が起きていました。

アラートを検知した際には対象の配信shardをサービスから外す・翌日になり対象の配信shardのすべての配信が終了してからtranscoder serverを再起動する。というような対応をおこなっていました。

Goのヒープ使用量調査

このようなメモリ使用量の大幅な上昇は開発環境上では再現していませんでした。

まず最初に疑ったのは、Goのアプリケーション自体がメモリリークしている箇所があるのではということでした。特に goroutineリークにより、実行中のgoroutineが残ってしまい、そこで利用されているメモリが開放されていないのではということです。

transcoder serverに対しては、pprofをhttp apiでリクエストできるようにしています1。そのため、transcoder serverの動作するDockerコンテナ内に入りさえすれば任意のタイミングで現在実行中のアプリケーションのpprofの結果を取得することができるようになっています。

以下のようにして取得しました。

go tool pprof http://localhost:6060/debug/pprof/heap
go tool pprof http://localhost:6060/debug/pprof/goroutine

結果としては、Goのヒープに実際に処理に必要なもの以外で数百MB、数GBのような割当があるものは見つからず。また、goroutineリークも見つけることはできませんでした。

実メモリ使用量が減少しないのはなぜ?

Goのヒープ上のメモリは不要になったタイミングで開放されているのですが、メトリクス上で確認できるメモリ使用量のグラフは減少せず一定のままになっています。また、メモリ使用量は常に一定ではなく急激に増える箇所もありました。

transcoder serverのプロセスについてtopで確認できるRSSやpsコマンドで確認できるRESも高い値のままです。一体何が実メモリ使用量として割り当てられているのかを調べる必要がありました。

以下はTelegrafで取得した統計情報をソースとして グラフ化したものです。

transcoder serverのメモリ使用量内訳(対応前)

f:id:hma2moto:20210119162619p:plain

以下は赤線が使用メモリ(total memory - free memory)量(左軸)、黄線が使用率(右軸)

transcoder serverのメモリ使用量と使用率(対応前)

f:id:hma2moto:20210119162513p:plain

Go 1.12以上 + Linux 4.5以上においてMADV_FREEが利用される際の問題

実メモリ使用量が増え続ける理由の一つとして、Goのmadvise呼び出しに関する以下のような問題がありました。

  • Go1.12で、LinuxのKernel 4.5以上2ではmadviseのシステムコールについてデフォルトでMADV_FREEを使用するようになっていました。
  • ページフォルトが頻繁に発生しないことで理論上はMADV_DONTNEEDよりもMADV_FREEのほうがパフォーマンスが良くなるはずでしたが、実際にそのメリットを享受できているデータがほとんどないようでした。また、統計ツール上にあらわれる値は次に述べるように監視を行うエンジニアにとってユーザーエクスペリエンスの低下を招いていました。github.com

  • MADV_FREEを利用したことによる問題の例

    • 不要になった実メモリがすぐに開放されていないため、メモリリークではないのに メモリリークに見えることがある
    • 実メモリ使用量は増えつづけるため、実際にメモリリークしていることに気付きにくい
    • LazyFreeの値をRSSの値から差し引けばMADV_DONTNEED指定した際と同等の値になるが、それを知る方法が /proc/<PID>/smaps を参照するしかなく非常にわかりにくい。github.com

上記の問題は Go 1.16 において GOOS == "linux" の場合に debug.madvdontneed = 1 が付くようになる(デフォルトでMADV_DONTNEED) ことで対応されるようです。

また、Go 1.12〜1.15をLinuxで利用する場合においては、環境変数で GODEBUG=madvdontneed=1 を設定することにより、MADV_DONTNEEDが利用されるようになります。

ミラティブでは transcoder server に MADV_DONTNEEDを利用するようにした結果、メモリ使用量が増加傾向にあることは変わりませんでしたが、時間帯によって実メモリの使用量は増減していることがわかるようになりました。

transcoder serverのメモリ使用量内訳(MADV_DONTNEED設定後)

f:id:hma2moto:20210119163203p:plain

transcoder serverのメモリ使用量と使用率(MADV_DONTNEED設定後)

f:id:hma2moto:20210119162611p:plain

まとめ

Go1.12以降ではLinux上において、メモリをOSへ返却する際の実装が監視ツールとの相性が悪く結果としてUXの低下を招いていました。

ミラティブでは配信サーバの運用上、メモリ使用量が減らない状況にあり原因究明をしようとしていましたが、MADV_DONTNEEDを使うことでメトリクス上でメモリ使用量がわかりやすくなりました。

残念ながら解決しようとしてたtranscoder serverのメモリリークの問題はまだ残っています。根本的な解決に向けて今後さらに深堀りが必要です。

We are hiring!

ミラティブではGoでライブ配信をゴリゴリ開発できるストリーミングエンジニアを募集中です!

www.mirrativ.co.jp


  1. 配信サーバ以外のミラティブ本体ではprofefe の導入 を行っています。

  2. transcoder serverが動作しているインスタンスのOSはUbuntuでありカーネルは4.5以上です。また、transcoder serverのアプリケーション本体は、Dockerコンテナ上で動作しています

【インフラ】Mirrativのデータベースを最小限の影響で切り替える運用の紹介

こんにちは、ミラティブのインフラを担当している清水です。 今回はミラティブのデータベースのマスタをどのようにフェイルオーバさせているかノウハウをお伝えしようかと思います。

ミラティブではデータベースにMySQLを利用しており、マスタ・スレーブ構成で冗長化しています。 マスタ・スレーブ構成の優れている点はデータをフルダンプすればデータベースを完全に複製でき、マルチマスタ構成で発生しうるデータ不整合を気にかけなくて良い点です。 データベースのクラスタリングには MySQL Cluster や GaleraCluster などもありますが、マスタ・スレーブ構成はストレージエンジンに依存せず素のMySQLで運用できるので、クラスタ固有の制約にハマったりせずシンプルに運用できるのも強みです。

ただし、マスタ・スレーブ構成の鬼門となるのがマスタのフェイルオーバです。 スレーブは参照のみリクエストを処理するので1台停止しても別のスレーブから再び参照すればよいだけなので復旧が容易です。 一方でマスタは、データの書き込み処理を行っているため、フェイルオーバ時にはデータ不整合なく書き込み先を切り替える必要があります。

生きているスレーブをマスタに昇格するにしても、

  1. スレーブが複数台ある場合はデータ欠損を最小とするため、もっともRelaylogのポジションの進んだスレーブを探し出してマスタ昇格候補にする
  2. スレーブが複数台ある場合はRelaylogのポジションにズレがないか確認し、ズレが発生していたら欠損しているバイナリログを手動で解消させる
  3. マスタ昇格候補のスレーブにレプリケーションを張り直す

といった作業が発生します。

とても慎重且つ神経を使う作業が要求されますが、作業中にユーザさんはサービスを完全な状態で利用できないわけで、焦りや緊張でオペレーションミスを誘発しかねません。

そこで、ミラティブではマスタ切り替えにMHA for MySQL(Master High Availability Manager and tools for MySQL)というHAツールを利用して、データ不整合を最小限に解消させ、安全且つ短時間にフェイルオーバを行えるようにしています。

MHA for MySQL(Master High Availability Manager and tools for MySQL)とは

MHA for MySQL(以下、MHAと略す)はDeNA社がオープンソースとして公開している MySQL の HAツールで、githubにソースコードが公開されています。

MHAはMySQLサーバにmha4mysql-nodeをインストールし、外部サーバからmha4mysql-managerに含まれるスクリプトを動かしてフェイルオーバを行います。 インストール方法は本家 mha4mysql wiki で解説されているので本記事では割愛します。

MHAを利用すれば複雑なマスタのフェイルオーバ作業をワンコマンド化することができ、デーモンとして動かしておけば自動化させることもできますが、動かすためにいくつか注意点もあります。

一つ目はスレーブでもBinlogを吐くようにしておく必要があることです。 これはMHAがマスタ・スレーブをフェイルオーバさせた時にマスタからBinlogを回収してスレーブとの差分を埋めてくれるためで、Binlogが吐かれていないとスレーブがマスタ昇格後にMHAを実行できなくなってしまうからです。

二つ目はマスタと昇格対象のスレーブのスペックを揃えておくことです。 マスタ昇格後にスペックが下がってしまうようなことがあると、もともと捌けていたリクエストを昇格後に捌くことができず障害になりかねないからです。

ミラティブではマスタと昇格対象のスレーブはスペックを揃えてBinlogを吐くように運用していて、いつでもスレーブをマスタに昇格できるようにしています。

MHAの処理の流れを追ってみる

MHAを実行した時にどのような制御をしてマスタのフェイルオーバを行っているのか紹介します。 ここの所を理解しておけばMHAの実行に失敗してもパニックにならず落ち着いて作業できるかと思います。

mha4mysql-managerに含まれるmasterha_master_switchを利用してマスタをフェイルオーバした時の処理の流れを見て行きましょう。

マスタが停止している場合

マスタが停止してしまったときのMHAの処理を見ていきましょう。 MHAは切り替えをPhaseで管理しており、マスタが停止している場合はPhase1~5まで進んでフェイルオーバが完了します。

  • Phase 1: Configuration Check Phase..
  • Phase 2: Dead Master Shutdown Phase..
  • Phase 3: Master Recovery Phase..
  • Phase 3.1: Getting Latest Slaves Phase..
  • Phase 3.2: Saving Dead Master's Binlog Phase..
  • Phase 3.3: Determining New Master Phase..
  • Phase 3.3: New Master Diff Log Generation Phase..
  • Phase 3.4: Master Log Apply Phase..
  • Phase 4: Slaves Recovery Phase..
  • Phase 4.1: Starting Parallel Slave Diff Log Generation Phase..
  • Phase 4.2: Starting Parallel Slave Log Apply Phase..
  • Phase 5: New master cleanup phase..

Phase 1: Configuration Check Phase..

f:id:masaya-shimizu:20201221153601j:plain

Phase1はMHAのconfを検証してくれます。 MHAのconfは環境ごとに異なりますが、概ねこのような設定を記述します。

[server default]
user=${MYSQL_USER}
password=${MYSQL_PASSWORD}
repl_user=${REPL_USER}
repl_password=${REPL_PASSWORD}
remote_workdir=/path/to/workdir
master_binlog_dir=/path/to/mysql
ssh_user=${SSH_USER}
master_pid_file=/path/to/mysqld.pid
master_ip_failover_script=/path/to/master_ip_failover_script
master_ip_online_change_script=/path/to/master_ip_online_change_script
shutdown_script=/path/to/shutdown_script
report_script=/path/to/report_script

manager_workdir=/path/to/workdir
manager_log=/path/to/mha/log

[server1]
hostname=${SERVER1}
ip=${ADDRESS1}

[server2]
hostname=${SERVER2}
ip=${ADDRESS2}
candidate_master=1

[server3]
hostname=${SERVER3}
ip=${ADDRESS3}
candidate_master=1

[server4]
hostname=${SERVER4}
ip=${ADDRESS4}
candidate_master=0

MHAのconf内容が間違っていたり、スレーブが停止していたり、スレーブにssh接続できないときはPhase1で中断されます。 この段階でマスタ切り替えは行われていないので、落ち着いてconf内容と実際に動いているマスタ・スレーブ構成を見直してみて下さい。

Phase 2: Dead Master Shutdown Phase..

f:id:masaya-shimizu:20201221154024j:plain

Phase2は停止したマスタを完全停止させます。 ハングアップしたと思われていたマスタが実は生きていて、マスタ切替中にアプリケーションからデータの書き込みが発生してデータ不整合が発生することを防いでくれます。

Phase2に入るとまず master_ip_failover_scriptが--command=stop|stopssh 引数とともに実行されます。

/path/to/master_ip_failover_script  \
  --command=stop|stopssh \
  --orig_master_host=${ORIG_MASTER_HOST} \
  --orig_master_ip=${ORIG_MASTER_IP} \
  --orig_master_port=${ORIG_MASTER_PORT}

Phase2で実行されるmaster_ip_failover_scriptはこれからマスタを完全停止するための事前処理を記述して実行します。 例えば、停止したマスタのレコードを引けなくしたり、これから停止するマスタの情報を通知させたりできますが、何もさせたくない場合は処理を記述しなければよいです。

mha4mysql-managerに master_ip_failover というサンプルスクリプトが付属しているので、サンプルを参考にしつつ自前で処理を記述してみましょう。 Perl製ですが、同じ引数を受け取ることができれば別言語でも実装可能です。

続いて、マスタを完全停止させるためshutdown_script が実行されます。

/path/to/shutdown_script \
  --command=stop \
  --host=${HOSTNAME}  \
  --ip=${ADRESS}  \
  --port=${PORT} \
  --pid_file=/path/to/mysqld.pid

mha4mysql-managerに power_manager というshutdown_scriptがスクリプトが付属していますが、ミラティブのMySQLデータベースはGCP(Google Cloud Platform)で動いており、GCPと連携して確実にマスタを停止させたかったのでGo製のツールを自作しています。 このGo製のマスタ停止ツールはssh越しにMySQLの停止を試みて、失敗した場合はGCPからインスタンスを強制停止してくれます。

例ですが、shutdown_scriptはこんな感じで実装しています。

package main

import(
  "fmt"
  "log"

  "gopkg.in/urfave/cli.v1"

  "infra-tool"
  "infra-tool/util"
  "infra-tool/mha"
)

func mha_shutdown(c *cli.Context) error {
  ...
  sshPrivateKey := c.String("ssh-private-key")
  maxRetry      := uint64(c.Int("max-retry"))
  project       := c.String("project")

  if util.FileExists(sshPrivateKey) != true {
    return fmt.Errorf("ssh-private-key not exist: %s", sshPrivateKey)
  }
  if maxRetry < 1 {
    maxRetry = 1
  }

  // port22 に接続できないと--ssh_user が引数に渡されないのでrootを引き渡す
  sshUser := c.String("ssh_user")
  if c.String("ssh_user") == "" {
    sshUser = "root"
  }

  mhaOptions          := mha.MHAShutdownOptions{}
  mhaOptions.Command  = c.String("command")
  mhaOptions.SshUser  = sshUser
  mhaOptions.Host     = c.String("host")
  mhaOptions.Ip       = c.String("ip")
  mhaOptions.Port     = c.Int("port")
  mhaOptions.PidFile  = c.String("pid_file")

  log.Printf("debug: command: %s", mhaOptions.Command)
  log.Printf("debug: ssh_user: %s, host: %s, ip: %s, port: %d, pid_file: %s",
    mhaOptions.SshUser, mhaOptions.Host, mhaOptions.Ip, mhaOptions.Port, mhaOptions.PidFile,
  )

  if mhaOptions.Command == "stopssh" || mhaOptions.Command == "stop" {
    if err:= shutdownStopsshCommand(mhaOptions, sshPrivateKey, maxRetry, project); err != nil {
      return err
    }
  }

  return nil
}

func shutdownStopsshCommand(mhaOptions mha.MHAShutdownOptions, sshPrivateKey string, maxRetry uint64, project string) error {
  if err := mha.KillMySql(mhaOptions.Host, mhaOptions.Ip, mhaOptions.SshUser, sshPrivateKey, maxRetry, mhaOptions.PidFile, project); err != nil {
    log.Printf("warn: %s", err.Error())
    if err := mha.ShutdownInstance(mhaOptions.Host, project); err != nil {
      return err
    }
  }
  return nil
}

func init(){
  addCommand(cli.Command{
    Name: "mha-shutdown",
    Usage: "mha shutdown_script",
    Flags: []cli.Flag{
      cli.StringFlag{
        Name: "ssh-private-key",
        Usage: "/path/to/.ssh/id_rsa",
        Value: watch.DEFAULT_MS_SSH_PRIVATE_KEY,
        EnvVar: "INFRA_WATCH_MS_SSH_PRIVATE_KEY",
      },
      cli.IntFlag{
        Name: "max-retry",
        Usage: "maximum number of times to retry on failure",
        Value: watch.DEFAULT_MS_MAX_RETRY,
        EnvVar: "INFRA_WATCH_MS_MAX_RETRY",
      },
      cli.StringFlag{
        Name: "project",
        Usage: "specify gcp project",
        Value: watch.DEFAULT_MS_PROJECT,
        EnvVar: "INFRA_WATCH_MS_PROJECT",
      },
      cli.StringFlag{
        Name: "command",
        ...
      },
      cli.StringFlag{
        Name: "ssh_user",
        ...
      },
      cli.StringFlag{
        Name: "host",
        ...
      },
      cli.StringFlag{
        Name: "ip",
        ...
      },
      cli.IntFlag{
        Name: "port",
        ...
      },
      cli.StringFlag{
        Name: "pid_file",
        ...
      },
    },
    Action: mha_shutdown,
  })
}

Phase2で失敗した場合はmaster_ip_failover_scriptまたはshutdown_scriptの実行に失敗しているので、スクリプトをデバッグしてみてください。 スクリプトでマスタを落とし切ることができずエラー判定となる場合は手動で落としてしまうのも手です。

Phase 3: Master Recovery Phase..

Phase3はスレーブをマスタに昇格させるための下準備を進めるフェーズです。 3.1 ~ 3.4 まであるのでそれぞれ見ていきましょう。

Phase 3.1: Getting Latest Slaves Phase..

f:id:masaya-shimizu:20201221155914j:plain

Phase3.1は全てのスレーブのRelaylogポジションをチェックしてもっともポジションの進んでいるスレーブを探し出します。 図ではSlave2がもっともポジションの進んだsalveです。

Phase 3.2: Saving Dead Master's Binlog Phase..

f:id:masaya-shimizu:20201222175743j:plain

Phase3.2は停止したマスタにsshログインを試行し、もっともRelaylogポジションの進んだスレーブと停止したマスタのBinlogポジションの差分を回収します。 インスタンスが停止してしまっている場合はsshログインできないのでスキップされます。

f:id:masaya-shimizu:20201222175900j:plain

停止したマスタからBinlogの回収に成功した場合は全てのスレーブに差分を転送します。

Phase 3.3: Determining New Master Phase..

f:id:masaya-shimizu:20201221161135j:plain

Phase3.3パート1はマスタの昇格候補となるスレーブを決定します。 もっともRelaylogポジションの進んでいるスレーブが昇格候補となりますが、MHAのconfに candidate_master=1 を定義すると優先的に特定のスレーブを昇格候補とすることができます。

Phase 3.3: New Master Diff Log Generation Phase..

f:id:masaya-shimizu:20201221161240j:plain

Phase3.3パート2はもっとものRelaylogポジションの進んでいるスレーブとマスタ昇格候補スレーブのRelaylogの差分を取り出し、マスタ昇格候補スレーブに転送します

Phase 3.4: Master Log Apply Phase..

f:id:masaya-shimizu:20201221161656j:plain

Phase3.4はマスタ昇格候補のスレーブに停止したマスタから回収したBinlogの差分と、もっともRelaylogポジションの進んでいるスレーブとの差分を適用します。

ここまで進むともう後戻りはできません。差分適用に失敗したら戻すのは困難なので壊れていない他のslaveからdumpを取ってマスタ・スレーブを作り直した方が早いです。 無事終わることを見守りましょう。

差分適用に成功したらmaster_ip_failover_scriptが--command=start引数とともに実行されます。

/path/to/master_ip_failover_script  \
  --command=start \
  --ssh_user=${SSH_USER} \
  --orig_master_host=${ORIG_MASTER_HOST} \
  --orig_master_ip=${ORIG_MASTER_IP} \
  --orig_master_port=${ORIG_MASTER_PORT} \
  --new-master_host=${NEW_MASTER_HOST} \
  --new_master_ip=${NEW_MASTER_IP} \
  --new_master_port=${NEW_MASTER_PORT} \
  --new_master_user=${NEW_MASTER_USER} \
  --new_master_password=${NEW_MASTER_PASSWORD}

Phase 3.4で実行されるmaster_ip_failover_scriptはアプリケーションの書き込み先をマスタ昇格候補のスレーブに切り替えるための処理を記述します。 DNSで制御している場合はマスタのレコードを切り替えたり、IPで書き込み先を制御している場合はIPを付け替えたりします。

Phase 4: Slaves Recovery Phase..

Phase4はマスタ昇格候補のスレーブとその他スレーブの差分を埋めてレプリケーションを張り直します。

Phase 4.1: Starting Parallel Slave Diff Log Generation Phase..

f:id:masaya-shimizu:20201222180020j:plain

Phase 4.1はマスタ昇格候補のスレーブとその他スレーブのRelaylogの差分を生成してそれぞれのスレーブに転送します。

Phase 4.2: Starting Parallel Slave Log Apply Phase..

f:id:masaya-shimizu:20201221162216j:plain

Phase 4.2は各スレーブで停止したマスタから回収したBinlogの差分と、もっともRelaylogポジションの進んでいるスレーブとの差分を適用します。 ここで差分適用に失敗してしまってもマスタ昇格候補のスレーブは復元が完了しているので、そこからダンプを取ってスレーブを作り直しましょう。

f:id:masaya-shimizu:20201221162329j:plain

差分適用に成功したらマスタ昇格候補のスレーブにレプリケーションを張り直します。

Phase 5: New master cleanup phase..

Phase5はマスタ昇格候補のスレーブで reset slave all が実行されて、停止したマスタとレプリケーションを張っていたときの情報がクリーニングされます。

Master failover to ${HOSTNAME}(${ADDRESS}:${PORT}) completed successfully.

メッセージが表示されればマスタ切り替えは完了です。おつかれさまでした。

マスタが起動している場合

マスタが停止せずともスレーブをマスタに昇格させたい場合もよくあります。 例えば、CPUやメモリといったサーバのスペックを増強したり、コスト最適化のためにディスク容量を減らしたり、サーバの性能劣化による入れ替えを行いたいケースなどです。

停止メンテナンスを伴う時間を確保すれば切り替えはできますが、ユーザへの告知、サービス連携している協力会社さんへの連絡、停止中のユーザアクセスの停止が発生するためできればやりたくはありません。 MHAはマスタが起動状態でも切り替えられるように作られているので停止メンテナンスを伴う時間を確保せずとも切り替えることができます。

それでは、マスタが起動している場合のmasterha_master_switchの挙動を見ていきましょう。 マスタが起動している場合のPhaseは1,2,5で、マスタが停止している場合と異なるのはBinlogとRelaylogの差分回収と適用が無い点です。

  • Phase 1: Configuration Check Phase..
  • Phase 2: Rejecting updates Phase..
  • Phase 5: New master cleanup phase..

Phase 1: Configuration Check Phase..

f:id:masaya-shimizu:20201221222549j:plain

Phase1はマスタが停止している時と概ね同じ挙動をします。 スレーブが停止していたりssh接続できないときはPhase1で中断されるので、落ち着いてMHAのConf内容とマスタ・スレーブ構成の状態を見比べてみましょう。

マスタが停止している場合と異なる挙動はPhase1で FLUSH NO_WRITE_TO_BINLOG が実行されてBinlogの書き出しが行われる点です。 書き込みが多いとIO詰まりを誘発しかねないので、書き込みの少ない時間帯にあらかじめ1台ずつ FLUSH NO_WRITE_TO_BINLOG を実行しておくと安全です。 もし IO に余裕がある環境であれば、 cron などで FLUSH NO_WRITE_TO_BINLOG を定期実行しておき、 Binlog を定期的に書き出しておくのも有効かもしれません。

Phase 2: Rejecting updates Phase..

f:id:masaya-shimizu:20201221222847j:plain

Phase2はマスタ切り替え中のデータ不整合を防ぐためにアプリケーションからの書き込みをブロックします。 書き込みのブロックはmaster_ip_online_change_scriptが --command=stop|stopssh 引数とともに呼び出されて行ってくれます。

/path/to/master_ip_online_change_script \
  --command=stop|stopssh \
  --orig_master_host=${ORIG_MASTER_HOST} \
  --orig_master_ip=${ORIG_MASTER_IP} \
  --orig_master_port=${ORIG_MASTER_PORT} \
  --orig_master_user=${ORIG_MASTER_PORT} \
  --orig_master_password=${ORIG_MASTER_PASSWORD} \
  --new_master_host=${NEW_MASTER_HOST} \
  --new_master_ip=${NEW_MASTER_IP} \
  --new_master_port=${NEW_MASTER_PORT} \
  --new_master_user=${NEW_MASTER_USER} \
  --new_master_password=${NEW_MASTER_PASSWORD} \
  --orig_master_ssh_user=${ORIG_MASTER_SSH_USER} \
  --new_master_ssh_user=${NEW_MASTER_SSH_USER}

mha4mysql-managerに master_ip_online_change がサンプルスクリプトとして付属しているので環境にあわせてカスタマイズしてみましょう。

ミラティブではアプリケーション用のMySQLユーザを以下表のとおり書き込み用と参照用を分けており、 Goで実装したmaster_ip_online_change_script が書き込み用ユーザをアンダースコア付きのユーザ名にrenameして新規の書き込み用のセッションを落としています。

書き込みユーザ 参照ユーザ
master writeuser readuser
slave1 _writeuser readuser
slave2 _writeuser readuser
slave3 _writeuser readuser

持続的な接続があると効果が無いので注意が必要ですが、ミラティブのアプリケーションは切り替えを考慮して処理毎に都度、接続を切断して接続が残らないように実装しています。

一般的なサービスではmysql接続時のオーバヘッドを減らす目的でkeepaliveで実装されていますが、ミラティブはフェイルオーバ発生時のダウンタイムを極力減らす目的でコネクションプールでも長時間接続が残らないようにしています。 持続的な接続に比べオーバヘッドも含んでしまいますが、接続がmax-connectionになるまで溜まることもほとんどなくなります。

万が一書き込みを復旧させたい時でも、MySQLユーザをrenameしているだけなので切り戻しも簡単です。 また、書き込み先を1箇所に限定できるので切り替え中に意図せぬスレーブへアプリケーションが書き込んでしまう事故も防げます。

master_ip_online_change_script で安全に新規書き込みの接続を落とすことができたら、 FLUSH TABLES WITH READ LOCK でテーブルロックされて完全に書き込みできない状態となり、マスタの切り替えが開始されます。

まず、master_ip_online_change_scriptが --command=start とともに呼び出されます。 ここではマスタ昇格先のスレーブで書き込みを行えるようにするための処理を記述しておきます。 ミラティブの場合ですと、書き込み用ユーザをアプリケーションが利用できるようにrenameして、DNSを切り替えてAレコードを昇格したマスタに向けるように実装しています。

/path/to/master_ip_online_change_script  \
  --command=start \
  --orig_master_host=${ORIG_MASTER_HOST} \
  --orig_master_ip=${ORIG_MASTER_IP} \
  --orig_master_port=${ORIG_MASTER_PORT} \
  --orig_master_user=${ORIG_MASTER_USER} \
  --orig_master_password=${ORIG_MASTER_PASSWORD} \
  --new_master_host=${NEW_MASTER_HOST} \
  --new_master_ip=${NEW_MASTER_IP} \
  --new_master_port=${NEW_MASTER_PORT} \
  --new_master_user=${NEW_MASTER_USER} \
  --new_master_password=${NEW_MASTER_PASSWORD} \
  --orig_master_ssh_user=${ORIG_MASTER_SSH_USER} \
  --new_master_ssh_user=${NEW_MASTER_SSH_USER}

master_ip_online_change_scriptの実行が完了したら set global read_only = 0 が実行されて書き込みが行える状態となります。

f:id:masaya-shimizu:20201221223255j:plain

そして、マスタに昇格したスレーブにレプリケーションを張り直します。

Phase 5: New master cleanup phase..

Phase5はマスタに昇格したスレーブで reset slave all が実行されます。 Switching master to ${HOSTNAME}(${ADDRESS}:${PORT}) completed successfully. メッセージが表示されれば切り替え完了です。

最後に

MHAのフェイルオーバの動きは理解していただけたでしょうか。MHA実行時のトラブルに遭遇した時にお役いただけるとうれしいです。

MHAは非常によくできたHAツールですが、あくまでマスタ・スレーブの構成管理ができている前提で動作します。 ミラティブでは構成管理するためにマスタ・スレーブの構成監視やMHAのconfを動いているマスタ・スレーブ構成から生成していて、いつでもMHAが実行できる環境を整えています。

今回は紹介しきれなかったので、いずれまた紹介できたらなと思います。

We are hiring!

ミラティブでは サービスの拡大と安定化を支えるインフラエンジニアを募集中です! meetup も開催しているため気軽にご参加ください!

www.mirrativ.co.jp

speakerdeck.com

【Go】profefeでContinuous Profilingをやっていく話

こんにちは、サーバーエンジニアの牧野です。 今回はGoで開発しているアプリケーションでContinuous Profilingを実践するために導入した profefe を紹介したいと思います。

Continuous Profilingとは

Continuous Profilingとは、ざっくり言うと本番環境で継続的にプロファイリングすることを指します。Continuous Profilingができると、本番環境でのみ発生するパフォーマンスの問題を捉えることができたり、継続的にプロファイリングすることで問題が発生する前後の状態を比較することができます。

Goには pprof というプロファイリングのための標準パッケージがあり、プロファイリング自体は容易に行うことができますが、Continuous Profilingを実現するとなると、以下のような課題と向き合う必要があります。

  • 本番環境でオーバーヘッドが少なく安全にプロファイリングを実行できるか
  • どこにプロファイリング結果を保存するか
  • 保存したプロファイリング結果をどのようにして検索・抽出するか

今回はこれらの課題を解決するために、profefe というOSSを導入しました。

github.com

Continuous Profilingを支援するサービスとして、Cloud ProfilerDatadog Continuous Profilerといったサービスがありますが、データの保持期間に上限があったりするので、より柔軟な運用をしたいとなるとprofefeのようなOSSが選択肢に入ってくるかと思います。

profefeについて

profefeは、CollectorAgentという2つのコンポーネントから構成されています。

f:id:tatsumack:20201217105318p:plain
https://github.com/profefe/profefe/blob/master/DESIGN.md より引用

Collector

Collectorはプロファイルを受け取るサーバーです。Docker Imageが提供されているので、以下のコマンドで起動することができます。

$ docker run -d -p 10100:10100 profefe/profefe

以下のようにPOSTメソッドでプロファイルを送ると、profefeがプロファイルを保存します。

$ curl -X POST \
    "http://localhost:10100/api/0/profiles?service=<service>&type=cpu" \
    --data-binary @pprof.profefe.samples.cpu.001.pb.gz

プロファイルはpprofのフォーマットに従ってさえさえいれば良く、Go以外の言語でも使用することができます。

プロファイルを検索・抽出するためのAPIが提供されており、たとえば特定の期間のプロファイルをマージした結果を参照することができます。

$ go tool pprof \
   'http://localhost:10100/api/0/profiles/merge?service=<service>&type=<type>&from=<created_from>&to=<created_to>'

また、プロファイル保存時にlabelを指定することができ、プロファイルを検索するときの条件に指定することができます。例えば、labelにアプリケーションのversionを加えて、go tool pprofbaseオプションを利用してversion間のプロファイル差分を見ることができます。

$ go tool pprof \
   -base 'http://localhost:10100/api/0/profiles/merge?service=<service>&type=<type>&from=<created_from>&to=<created_to>&version=0.0.1' \
   'http://localhost:10100/api/0/profiles/merge?service=<service>&type=<type>&from=<created_from>&to=<created_to>&version=0.0.2'

プロファイルを保存するストレージは差し替えが可能になっており、Badger DBAWS S3Clickhouse DBを保存先として指定することができます。
ただ、今回はプロファイルをGoogle Cloud Storage(GCS)に保存したかったので、GCSをprofefeのストレージとして扱う実装を行いました。

profefeのストレージは以下のinterfaceを満たせばよく、integration testも用意されているので、さくっと実装することができました。手持ちの技術スタックに合わせて、ストレージを差し替えることができるのもprofefeの良い点だと思います。

type Storage interface {
    Writer
    Reader
}

type Writer interface {
    WriteProfile(ctx context.Context, params *WriteProfileParams, r io.Reader) (profile.Meta, error)
}

type Reader interface {
    FindProfiles(ctx context.Context, params *FindProfilesParams) ([]profile.Meta, error)
    FindProfileIDs(ctx context.Context, params *FindProfilesParams) ([]profile.ID, error)
    ListProfiles(ctx context.Context, pid []profile.ID) (ProfileList, error)
    ListServices(ctx context.Context) ([]string, error)
}

今回の実装はPRとして送っています。現時点ではまだマージされていませんが、"the change looks good to me."というコメントをいただいているので、そのうちマージされるかと思います。 マージされました 🎉 github.com

Agent

Agentは以下のようにアプリケーションに組み込んで使用します。

import "github.com/profefe/profefe/agent"

func main() {
    _, err := agent.Start("<profefe-url>", "<service-name>")
    ...
}  

Agentはgoroutineを起動し、定期的にプロファイリングを実行し、Collectorに送信します。プロファイリングにはruntime/pprofパッケージが使われています。

デフォルトでは1分おきに10秒間プロファイリングを実行します。Diagnostics - The Go Programming Language にもある通り、pprofは本番環境でも安全に実行できるとのことですが、オーバーヘッドはゼロではないので、プロファイリングの実行時間・間隔を調整して許容できる範囲を探ると良いと思います。ちなみにミラティブではデフォルトの設定のまま使用していますが、profefeの導入前後でCPU使用率に大きな変化はありませんでした。
また、異なるインスタンスで同時にプロファイリングが実行されてシステム全体の性能が劣化することがないように、ランダムにsleepを入れることで、インスタンス間でプロファイリングの実行タイミングを分散するような工夫がされていたりします。

このAgentは必ずしもアプリケーションに組み込む必要はありません。アプリケーションがnet/http/pprofを組み込んでいれば、cronなどで定期的にプロファイリングを実行してCollecterに送信する、といった使い方をすることも可能です。

f:id:tatsumack:20201217105150p:plain
https://github.com/profefe/profefe/blob/master/DESIGN.md より引用

おわりに

ミラティブでは本番環境にprofefeを導入して数週間経過しましたが、特に問題なく使うことができています。 profefeを導入してContinuous Profilingの基盤は整備できましたが、実運用としてどのように実践していくかはまだ固まっておらず、これから模索していくところです。今後知見が溜まってきましたら、またテックブログにて共有できればと思っております。

Continuous Profilingに関しては、GoogleのデータセンターのContinuous Profiling基盤に関する論文があるので、興味を持った方は読んでみると楽しめるかと思います。
Google-Wide Profiling: A Continuous Profiling Infrastructure for Data Centers – Google Research

また、profefeの作者の方がprofefeを作った背景をブログ記事に書いているので、こちらもオススメです。
Continuous Profiling and Go. There are lots of hidden details we… | by Vladimir Varankin | Medium

We are hiring!

ミラティブではサーバーエンジニアを募集中です!

  • Goで大規模サービスの開発をしたい
  • サーバーシステムの基盤の整備をしたい
  • ゲーム×ライブ配信サービスの開発をしたい

といった方のご応募をお待ちしております!

www.mirrativ.co.jp

speakerdeck.com

ミラティブのサーバサイドをGo + Clean Architectureに再設計した話

こんにちは、テックリードの夏です。

今年4月にCTOからテックリードに肩書が変わり、ガリガリコードを書くようになりました。 背景については、こちらをご覧ください。

www.wantedly.com

普段はプロダクト側の機能開発と、サーバ側の基盤開発を半々ぐらいの割合で仕事しています。 一口にサーバ側の基盤開発といっても定義が曖昧なのですが、基本的にはこんな感じのタスクをやっています。

  • インフラコストの最適化
  • 不正なアクセスからの防御
  • 障害の再発防止
  • 新技術の導入やアーキテクチャの整備

今回はこのうち「新技術の導入やアーキテクチャの整備」の中で、サーバサイドをGo + Clean Architectureで再設計したことについてお話したいと思います。

背景

ミラティブは2015年春頃に開発が始まり、同年8月にサービスがリリースされ、2020年8月で5周年を迎えました。 その過程で組織やプロダクトが成長するにつれ、サーバサイドには以下のような負債が溜まっていました。

  • アーキテクチャが崩れかけている
    • MVC + Serviceだが、依存関係がスパゲッティ
      • Service = Controllerの処理を共通化したレイヤー
    • 膨れ上がるModel
      • データ型 + 永続化の両方を担当
      • 場合によってはPresenter的な仕事も
    • Contextという名のもとにあらゆるレイヤーから密結合を黙認されているGodなクラス
  • query digestやslow queryなどで危険なSQLが洗い出されても、どこで発行されているのか調査に時間がかかる
    • method chainによる柔軟なSQLの組み立てのメリットがもはや負債
    • Modelが永続化も内包しているせいで、様々なレイヤーから実行時にSQLが発行される恐れアリ
      • 果てはViewからも。。。
  • 負債が溜まったテーブルを再設計しづらい
  • テストがすべてシナリオテストで書かれている
    • テストの実行時間が長い
    • シナリオテストは開発者によって書き方に差異が出やすい
    • エッジケースのテストを書くためのコストが大きい

また、ミラティブのサーバサイドは開発当時の事情によりPerlで書かれているのですが、OSSコミュニティでのプレゼンス低下なども踏まえてGoへの移行を検討し始めました。

そこで、サービス固有の歴史的経緯やインフラ構成に即したコードを表現できるか確認するために、Go言語とアーキテクチャの整備を同時に行うのではなく、 既存のPerl側のコードで上記の課題を改善し得るアーキテクチャを整備してから、Go移行を進めることになりました。 これにより、標準的なDBの負荷分散手法を抽象化できるか、トランザクションやロギングをどう表現するのか、チームに受け入れられるかどうかなどもGo移行に先んじて検証することができました。

半年くらいかけてPerl側のClean Architectureのプロトタイプを完成させ、1年かけてサーバチーム全体に浸透させました。現時点では、既存アーキテクチャのコードはフリーズしています。 また、Perl側のアーキテクチャ刷新と並走しながら、Goのプロトタイプ実装を進めてきました。

Go移行に関しては正直まだDaemonやBatchなど、移行しやすいコンポーネントしか本番投入出来ていません。しかし、テックブログを書くことで逆説的に社内への普及を加速させるためにも、 ミラティブのサーバサイドのGoコードのアーキテクチャをまとめてみようと思います。

Clean Architecture

アーキテクチャを再設計する上でClean Architectureを参考にすることにしました。 世の中のClean Architectureの文献を色々漁ってみても、コアとなる考え方は同じなのですが、細部に関してはいろいろな流派があるように見受けられます。 そこで、Clean Architectureとして正解を追うのではなく、過去の実装上の経緯を背負った上で、辛みポイントを解消できるようなアーキテクチャを設計しました。 Clean Architectureがどういうものなのかは参考記事に譲るとして、本記事ではミラティブで利用されているコードに近い形で、設計の詳細に入りたいと思います。 (本家本元のClean Architectureとは異なる場合がありますが、ご了承ください)

qiita.com qiita.com qiita.com www.m3tech.blog (「なぜ書くのか」に激しく同意)

再設計する上で大事にしたポイントは、「コンポーネントの依存性を一方向にする」の一点に集中するかなと思います。 これはなにも、ミドルウェアを差し替えた場合でも、内側のビジネスレイヤーを1行も変更したくないレベルの抽象化を目指したいわけではなく、 負債が溜まったMySQLのtableを再設計する際の影響範囲を最小限に留めようとか、外側の依存性を内側に注入することで、外部APIに依存する処理をテスト時だけモックを差し込みやすくすることなどが目的です。

f:id:mirrativ:20201127114557j:plain
あまりにも有名な例のあの図 The Clean Code Blog

ディレクトリ構造

├── entities
├── usecases
│   ├── inputport
│   ├── interactor
│   │   └── user
│   └── repository
├── gateways
│   ├── repository
│   │   ├── user
│   │   └── datasource
│   │       ├── dsmemcached
│   │       └── dsmysql
│   ├── datasource
│   │   ├── dsmemcachedimpl
│   │   └── dsmysqlimpl
│   └── infra
│       ├── infradns
│       ├── infralogger
│       ├── inframemcached
│       └── inframysql
├── controllers
│   ├── daemon
│   └── web
├── frameworks
│   ├── daemon
│   └── web
├── assets     // ここ以下のファイルはstatikによってバイナリに埋め込む
├── cmd        // アプリケーションの起動コマンドや、各種lint/generator/migrationコマンドが存在
│   └── wire   // DIライブラリ google/wire の定義ファイル
└── utils      // インフラレイヤーにもビジネスレイヤーにも該当しないutility群

Entities

オブジェクトでビジネスロジックを表現する責務を負っています。 ここでいうEntityは、DDDなどでのEntityとは違い、一意な識別子が存在しないものも定義しています。

これにより、Loggerのように全レイヤーから参照されるinterfaceなどもEntitiesに存在しています(実装はInfra層)。

package entity

type UserID uint64

type User struct {
    UserID UserID
    Name   string
}

type Logger interface {
    Error(ctx context.Context, err error)
    Log(ctx context.Context, level LogLevel, label string, payload ...interface{})
}

UseCases

Entity・Repositoryを使い、ユースケースを達成する責務を負っています。

└── usecases
     ├── inputport
     │   └── user.go
     ├── interactor
     │   └── user
     │       └── interactor.go
     └── repository
         └── user.go

ここでは usecases/inputport ディレクトリにinterfaceを定義し、

package inputport

import (
    "context"
    "time"
)

type User interface {
    UpdateRecommend(ctx context.Context, now time.Time) error
}

usecases/interactor ディレクトリにその実装を配置しています。 また、トランザクションのスコープを管理するのもInteractorのお仕事です。

package user

import (
    "context"
    "time"

    "server/entities/entity"
    "server/usecases/inputport"
    "server/usecases/repository"
)

type interactor struct {
    txm      repository.TransactionManager
    repoUser repository.User
}

func New(txm repository.TransactionManager, repoUser repository.User) inputport.User {
    return &interactor{
        txm:      txm,
        repoUser: repoUser,
    }
}

func (i interactor) UpdateRecommend(ctx context.Context, now time.Time) error {
    var recommendUserIDs []entity.UserID

    // おすすめユーザを計算

    return i.txm.Do(ctx, func(txns repository.Transactions) error {
        return i.repoUser.UpdateRecommend(ctx, txns, recommendUserIDs)
    })
}

usecases/repository ディレクトリにはInteractorが要求するRepositoryのinterfaceが定義されます。

package repository

import (
    "context"

    "server/entities/entity"
)

type User interface {
    ReadRecommend(ctx context.Context) ([]entity.User, error)
    UpdateRecommend(ctx context.Context, txns Transactions, recommendUserIDs []entity.UserID) error
}

Repository

データの集約、永続化の責務を負っています。 対応するDataSourceを活用し、UseCasesレイヤーが実際のテーブル構造などを把握しなくてもEntityの永続化を行える責務を負っています。

  • データの整合性が取れる最小単位
    • 例)MySQL側のDataSourceを更新したら、Memcached側のDataSourceも更新
  • DataSourceで取得したデータをEntityに変換
  • CRUDなinterfaceを提供
    • 命名規則もCreate/Read/Update/Deleteを強制
└── gateways
     └── repository
         ├── datasource
         │   ├── dsmemcached
         │   │   └── recommend_users.go
         │   └── dsmysql
         │        └── user.go
         ├── transaction
         │   └── repo.go
         └── user
             └── repo.go

usecases/inputport で定義されたRepositoryのinterfaceの実装が配置されています。

package user

import (
    "context"

    "server/entities/entity"
    "server/gateways/repository/datasource/dsmemcached"
    "server/gateways/repository/datasource/dsmysql"
    "server/usecases/repository"
)

type user struct {
    dsmemcachedRecommendUsers dsmemcached.RecommendUsers
    dsmysqlRecommendUser      dsmysql.RecommendUser
}

func New(dsmemcachedRecommendUsers dsmemcached.RecommendUsers, dsmysqlUser dsmysql.User) repository.User {
    return &user{
        dsmemcachedRecommendUsers: dsmemcachedRecommendUsers,
        dsmysqlRecommendUser:      dsmysqlRecommendUser,
    }
}

func (r user) ReadRecommend(ctx context.Context) ([]entity.User, error) {
    // dsmemcachedRecommendUsersからおすすめユーザを取得
    // なければdsmysqlUserから問い合わせ
    // 取得したDataSource固有の構造体をEntityへ変換
}

func (r user) UpdateRecommend(ctx context.Context, txns repository.Transactions, recommendUserIDs []entity.UserID) error {
    // dsmysqlRecommendUserで更新してから、dsmemcachedRecommendUsersを更新
}

gateways/repository 以下には、Repositoryが期待するDataSourceのinterfaceを定義しています。

package dsmysql

import (
    "context"

    "server/entities/entity"
)

type RecommendUsers interface {
    Update(ctx context.Context, txns repository.Transactions, users []*RecommendUserRow) error
    Select(ctx context.Context) ([]*RecommendUserRow, error)
}

Transaction

複数のInfra・DataSourceへのトランザクションを管理する責務を追っています。 (トランザクションスコープはInteractorで管理)

ミドルウェアを跨った厳密なトランザクションはサポートされていませんが、複数のMySQLのデータベースへの書き込みがある場合は、 すべての処理が完了してからのcommitやエラー時にすべてのsql.Txのrollbackなどを抽象化しています。

package repository

import "context"

// commitとrollbackができるものをTransactionと定義
type Transaction interface {
    Commit(ctx context.Context) error
    Rollback(ctx context.Context) error
}

// 複数のTransactionを抽象化し、同一データベースへのTransactionはキャッシュする
type Transactions interface {
    Get(key string, builder func() (Transaction, error)) (Transaction, error)
    Succeeded(f func() error) // cache更新などrollbackできない(厳密な整合性を担保しなくていい)処理などを登録し、全てのcommitが成功した場合のみ実行する
}

// トランザクションのスコープを管理するオブジェクト(dry-run時は最後に全てrollbackする)
type TransactionManager interface {
    Do(ctx context.Context, runner func(txns Transactions) error) error
}

DataSource

Infraを活用し、Repositoryが要求するデータの取得、永続化を達成する責務を負っています。

  • MySQLのtableや、Memcachedのkey、ElasticSearchのtypeと1:1の関係
  • 該当するミドルウェア固有の操作名に沿った命名規則
    • SQLであればSelect/Insert/Update/Delete
    • CacheであればGet/Set
└── gateways
     └── datasource
          ├── dsmemcachedimpl
          │   └── recommend_users.go
          └── dsmysqlimpl
               └── recommend_user.go

gateways/repository 以下で定義されたDataSourceのinterfaceの実装が配置されています。

package dsmysqlimpl

import (
    "context"

    "server/entities/entity"
    "server/gateways/infra"
    "server/gateways/repository/datasource/dsmysql"
    "server/usecases/repository"
)

type recommendUser struct {
    infraMySQL infra.MySQL
}

func NewRecommendUser(infraMySQL infra.MySQL) dsmysql.RecommendUser {
    return &recommendUser{infraMySQL: infraMySQL}
}

func (ds recommendUser) Update(ctx context.Context, txns repository.Transactions, users []*dsmysql.RecommendUserRow) error {
    txn, err := ds.infraMySQL.GetTxn(ctx, txns, "BASE_W") // BASE_W はデータベース系統の名前
    if err != nil {
        return nil
    }

    _, err = txn.ExecContext(ctx, "delete from recommend_user")
    if err != nil {
        return err
    }

    _, err = txn.ExecContext(ctx, "insert into recommend_user ...")
    return err
}

func (ds recommendUser) Select(ctx context.Context) ([]*dsmysql.RecommendUserRow, error) {
    return SelectRecommendUser(ctx, ds.infraMySQL, repository.DB_R)
}

このうち、 dsmysql.RecommendUserRow の構造体や SelectRecommendUser の処理などは、以下のような内製のテーブル定義から自動生成しています

kyleconroy/sqlc をオマージュしました)

recommend_user:
  columns:
    - name: user_id
      type: uint64
      foreign_key: user.user_id
    - name: name
      type: string
      collation: utf8mb4_bin
  primary_keys:
    - user_id
  queries:
    - sql: select * from recommend_user

このテーブル定義からDDLを生成し、 k0kubun/sqldef に食べさせることで、MySQLのマイグレーションなども行っています。

Infra

ミドルウェアとの実際の接続や入出力などを担当するレイヤーです。 内側のレイヤーが各ミドルウェアのI/Fを把握せずとも利用できる状態にする責務を負っています。

└── gateways
     └── infra
          ├── cache.go
          ├── config.go
          ├── db.go
          ├── dns.go
          ├── infradns
          │   └── infradnstest
          ├── infrahttp
          │   └── infrahttptest
          ├── infralogger
          │   └── infraloggertest
          ├── inframemcached
          │   └── inframemcachedtest
          ├── inframemorycache
          └── inframysql
               └── inframysqltest

gateways/infra 直下には各種ミドルウェアの入出力のinterfaceが定義しています。

package infra

import (
    "context"
    "database/sql"

    "server/go/usecases/repository"
)

type Transaction interface {
    repository.Transaction
    DB
}

type MySQL interface {
    Get(ctx context.Context, name string) (DB, error) // 複数系統のデータベースが存在するのでnameで指定する
    GetTxn(ctx context.Context, txns repository.Transactions, name string) (Transaction, error)
}

type DB interface {
    ExecContext(ctx context.Context, query string, args ...interface{}) (sql.Result, error)
    QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error)
}

そして、実際の実装はさらに一階層掘って定義しています。 そして、さらに一階層掘ったディレクトリにはtest用の実装が存在しています。 (loggerであればファイルに書き出さずに出力内容を変数として保持しておくとか)

Frameworks

外界からの入力をControllerへルーティングする責務を負っています。 ここでは時刻情報も外界の一部としてみなし、このレイヤー以外では現在時刻を取得しないように制限しています。 これにより特別なライブラリを用いずともテストを決定的にしたり、動作確認する際に任意の時刻への変更を行いやすくなります。

  • Web
    • HTTP RequestのURLなどを参照し、該当するControllerへRequestを渡す
      • Sessionの解決などもこのレイヤー
    • RequestやResponseがOpenAPIの定義通りかどうかを検証する
      • 実行速度が犠牲になるので開発環境のみ
    • 内部に引き回す時刻情報はリクエストを受け取った時刻
  • Daemon
    • queueベースで動作している非同期処理の場合は、該当のqueueからdequeue処理とControllerをつなげる
      • 内部に引き回す時刻情報はenqueueされた時刻
    • それ以外の非同期処理の場合は、実行間隔だけが指定されるので、指定された頻度でControllerを実行する
      • 内部に引き回す時刻情報はControllerが実行された時刻

Controllers

外界からの入力を、達成するユースケースが求めるインタフェースに変換する責務を負っています。 HTTP Request内のパラメータを取り出したり、queueの中から必要な情報を取り出して適切なInteractorに渡したりします。

また、ミラティブではPresenterを呼び出すのはInteractorではなくController内なので、 Interactorから返ってきたEntityをPresenterで変換し、外界が求める出力フォーマットに変更するのもControllerの責務です。

package user

import (
    "context"

    "server/controllers/web"
    "server/presenters/user"
    "server/usecases/inputport"
)

type Controller struct {
    user   inputport.User
}

func New(user inputport.User) *Controller {
    return &Controller{user: user}
}

func (c Controller) RecommendUsers(ctx context.Context, webCtx *web.Context) error {
    recommendUsers, err := c.user.ReadRecommendUsers(ctx)
    if err != nil {
        return err
    }

    return webCtx.RenderJSON(ctx, map[string]interface{}{
        "users": user.PresentUsers(recommendUsers),
    })
}

テスト戦略

もともとはすべてシナリオテストでカバーしていたのですが、アーキテクチャを再設計したタイミングで、 基本的なカバレッジ率の達成にはユニットテストを用い、統合テストでは正常系のユースケースのみ検証しています。

(ここに登場する話が身につまされたので、今後はTesting Pyramidに従おうと思います) testing.googleblog.com

テストのカバレッジ率は90%以上を目指しており、それ以下の場合はCIに怒られるように設定しています。 高めの数字を置いてはいますが、実は go tool cover で出力されるカバレッジ率は使っていません。 以下のようなテストを書くコストが見合わなさそうなブロックを除いてカバレッジ率を計算しているので、思ったほど酷な数字ではないと思います。

  • errorが存在していたら後続処理を行わずに、呼び出し元にreturnするだけのif文
  • panic
    • そもそも初期化時やアプリケーション実行中にどう足掻いても復旧できない場合のみpanicを使用しているため
  • delegateのように引数を一切加工せずにフィールドに渡すだけの関数

また、テストの実行速度は生産性に直結するため、すべてのテストにt.Parallelを指定することをLintで強制したり、 データベース名にユニークなsuffixを差し込むことでテスト同士で衝突しないようにしたり、Fixtureのロードを該当するtableへのSQLが実行された場合のみ行ったりと、小技を駆使しています。

今後の展望

現在、DaemonやBatchは本番投入済みで、Web側のいくつかエンドポイントもGo実装が完了しています。 しかし、モノリシックなPerlのWebサーバを移行していくのはそう簡単ではなく、 現在インフラチームと協力しながら、前段にProxyを挟みながら、特定のエンドポイントだけGo + Dockerコンテナで受けられるような仕組みを開発中です。

来期中にWebの本番投入と新機能の開発を全てGo化、半年後にはPerlコードのフリーズまでできると最高だなあと妄想しつつ、きっと色々ハマると思いますし、 アーキテクチャの整備に完成などないので、面白いことがあったらまたテックブログネタにしようかなと思います。

We are hiring!

ミラティブでは サービスや組織の成長に合わせて、生産性を最大化するためのより良いアーキテクチャを模索し続けられるサーバエンジニアを募集中です! meetup も開催しているため気軽にご参加ください!

www.mirrativ.co.jp

speakerdeck.com

Mirrativ の iOS アプリで使っているライブラリを紹介する!

こんにちは、iOSエンジニアのちぎらです。今回は Mirrativ の iOS アプリで使っているライブラリをご紹介します。

Mirrativ ではどんなライブラリを使用していますか?と質問されることが時々あります。設定画面のライセンス情報に一覧で表示はされているものの、ライブラリ名だけでは用途が分かりにくいものもあるので、説明を添えて一覧で確認できるようにしようというのが今回の趣旨です。

ライブラリ管理には CocoaPods、Carthage を使用しています。最新のライブラリに追従できるように、一部のライブラリでは CI(Bitrise)上で定期的にバージョン更新のためのプルリクを作成しています。Swift Package Manager はまだ導入していませんが、タイミングを見て集約していけたらいいですね。

続きを読む

【Unity】MirrativのEmbedding Unityを更新した話: 実践 Unity as a Library

こんにちは皆様いかがお過ごしでしょうか、10ヶ月ぶりくらいのポストになります、よこてです。今日は「Mirrativ の Unity は進化してるんだぞ」という記事を書いていきます。

tech.mirrativ.stream

Mirrativ は Swift/Kotlin によるネイティブアプリですが、3D/アバター部分は Unity で実現しています。いわゆる embedding unity で、 Unity 2018.3 からは Unity as a Library として公式サポートされています。前回記事で触れたように、Unity をネイティブアプリに組み込むこと自体は公式サポート以前にもできて、ミラティブでは Unity 2018.2(2018年8月頃)から使っています。

f:id:n0mimono:20201015194824p:plain

Mirrativ では今 Unity 2019.4 LTS を使っていて、8月から Mirrativ の機能としてリリースした「エモモRUN」(3Dアバター × ゲーム × ライブ配信)もこれを利用しています。公式としてサポートされたといってもハマりどころがあったりするので今日はそのあたりを中心に話をします。

Unity as a Library

Unity as a Library は読んで字のごとく「Unity を(アプリケーションでなく)ライブラリとしてつかう」方法です。Mirrativ がアバター機能を最初にリリースした2018年時点では、ググっても情報量皆無の認知度でしたが、今はそれなりにヒットする感じで1ミリずつ広がりを見せているんじゃないかと思います。

公式の説明から引用すると

Unity では、ランタイムライブラリの読み込み、アクティベーション、アンロードの方法とタイミングをネイティブアプリケーション内で管理するための制御機能を用意しています。その上、モバイルアプリの構築プロセスはほぼ同じです。Unity では iOS Xcode と Android Gradle のプロジェクトを制作できます。

もともと Unity は昔から、エンジン部分をライブラリとしてアプリケーション本体から切り離すような構成をしていました。具体的に Android では、エンジンのエントリーは libmain.so 、ビューとして Surface View (本体はVulkanあるいはGLSL)、ラッパーとしての UnityPlayerextends FrameLayout)があり、これを使うためのアプリケーションとして MainActivity がある、という構成です。

前回記事(Unity 2018.2)時点では、UnityPlayerを Unity が用意する MainActivity から切り離して使いました。ビルドという視点では、もともとアプリ用に準備されたプロジェクトをライブラリ用の設定にして、ライブラリとしてビルドするということをやっています。

  • アプリ用プロジェクト ← これをライブラリ用に書き換えてビルドする

Unity 2018.3 以降の Unity as a Library では、Unity 上で iOS/Android ビルドをした時点で Unity エンジン部分がプロジェクトとして初めから分離しています。Android の場合には、アプリケーションのプロジェクトの中に unityLibrary というサブプロジェクトが出力され、アプリのプロジェクトがこの unityLibrary に依存する構成になっています。このため unityLibrary をそのままビルドすれば他プロジェクトで利用するための .aar が取得できます。

  • アプリ用プロジェクト
    • ライブラリ用プロジェクト ← これをビルドする

出力されたプロジェクトをそのままネイティブ側のプロジェクトに組み込んでもよいですが、Mirrativ では一度ライブラリ(iOS の場合は .framework)としてビルドしています。

フレームワークのビルド(2020版)

そのままビルドすればいいといったものの、そのままビルドできません。。いくつかの hack を入れます。

iOS

Mirrativ では次の3つの処理を行っています。

  1. Xcodeプロジェクトの修正
  2. Info.plistの修正
  3. ネイティブコードの修正

すべてポストプロセスで処理するようにしています。コードの一部を抜粋すると

    public void OnPostprocessBuild(BuildReport report)
    {
        var outputPath = summary.outputPath;
        var overridePath = Application.dataPath + "/../Framework/iOS/build";
        EditProject(outputPath); // 1
        EditPList(outputPath); // 2
        Utility.RSync(overridePath, outputPath); // 3
    }

Utility.RSyncrsync -av overridePath outputPath と同じ処理を行うメソッドで、overridePath 以下にある全ファイルを outputPath 以下のファイルに上書きします。

Xcodeプロジェクトの修正は必須の処理になります。

    static void EditProject(string outputPath)
    {
        var projectPath = outputPath + "/Unity-iPhone.xcodeproj/project.pbxproj";

        var pbx = new PBXProject();
        pbx.ReadFromFile(projectPath);

        // Get UnityFramework Target
        var guidTarget = pbx.GetUnityFrameworkTargetGuid();

        // Add Public Header
        var guidHeader = pbx.FindFileGuidByProjectPath("Libraries/Plugins/iOS/UnityPlayerToIOS.h");
        pbx.AddPublicHeaderToBuild(guidTarget, guidHeader);

        // Add Data to Resources Build Phase.
        var guidData = pbx.FindFileGuidByProjectPath("Data");
        var guidResPhase = pbx.GetResourcesBuildPhaseByTarget(guidTarget);
        pbx.AddFileToBuildSection(guidTarget, guidResPhase, guidData);

        // Add BITCODE_GENERATION_MODE
        pbx.SetBuildProperty(guidTarget, "BITCODE_GENERATION_MODE", "bitcode");

        pbx.WriteToFile(projectPath);
    }

Unity が出力する Xcode のプロジェクトにはアプリ用の Target とフレームワーク用の Target が含まれます。フレームワーク用の Target に対して次の3つを行います。

  • iOS 用プラグインをプロジェクトに含める
  • Data(バンドルされるリソース郡)をプロジェクトに含める
  • bitcode を生成するようにする

この中で Data フォルダをリソースに指定するのは必須で、これがないと Unity が正常に動きません。Data フォルダはアプリ用 Target に含まれますが、フレームワーク用の Target には含まれないため、これを追加する処理を行います。プラグインと bitcode の対応は必須ではありませんが、Mirrativ では両方とも使用しているためこれを入れています。

Info.plist の修正は optional な処理になります。

    static void EditPList(string outputPath)
    {
        var plistPath = outputPath + "/UnityFramework/Info.plist";

        var plist = new PlistDocument();
        plist.ReadFromFile(plistPath);

        var root = plist.root;
        root.SetString("CFBundleShortVersionString", Application.version);

        plist.WriteToFile(plistPath);
    }

バージョンをフレームワークに入れています。Unity エディタ上で指定するバージョンはアプリの Info.plist に書かれますが、フレームワークの Info.plist には書かれないためこの処理を入れています。

ネイティブコードの修正は optional な処理になります。 Xcode が出力するコードを適当に書き変えたいときに Utility.RSync を利用して書き換えます。例えば、Mirrativ では UnityFramework.h とその周辺のファイルを書き換えていて

__attribute__ ((visibility("default")))
@interface UnityFramework : NSObject
{
}

...

- (void)setAudioSessionActiveUnsafe:(bool)active;
@end

main.mm で

- (void)setAudioSessionActiveUnsafe:(bool)active
{
    UnitySetAudioSessionActive(active ? 1 : 0);
}

という風にしています(この例だと絶対に必要というわけではありませんが。。)。

Android

ライブラリビルド用にポストプロセスを用意します。コードの一部を抜粋すると

    public void OnPostprocessBuild(BuildReport report)
    {
        var outputPath = summary.outputPath + "/unityLibrary";
        var overridePath = Application.dataPath + "/../Framework/Android/unityLibrary";

        ProcessorUtility.Rsync(overridePath, outputPath);
    }

Mirrativでは次のファイルを書き換えています。

  • unityLibrary
    • build.gradle
    • src
      • main
        • AndroidManifest.xml
        • jniLibs
          • x86
            • libmain.so
        • res
          • values
            • ids.xml
            • strings.xml

Unity には AndroidManifest.xml を上書きする仕組みがありますが、ライブラリ用には動いてくれないためポストプロセスで上書きします。

AndroidManifest.xml から application タグを消します。

<?xml version="1.0" encoding="utf-8"?>
<!-- GENERATED BY UNITY. REMOVE THIS COMMENT TO PREVENT OVERWRITING WHEN EXPORTING AGAIN-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.unity3d.player" xmlns:tools="http://schemas.android.com/tools">
  <uses-feature android:glEsVersion="0x00020000" />
  <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
  <uses-feature android:name="android.hardware.touchscreen" android:required="false" />
  <uses-feature android:name="android.hardware.touchscreen.multitouch" android:required="false" />
  <uses-feature android:name="android.hardware.touchscreen.multitouch.distinct" android:required="false" />
</manifest>

さらに UnityPlayer

    private static String a(Context var0) {
        return var0.getResources().getString(var0.getResources().getIdentifier("game_view_content_description", "string", var0.getPackageName()));
    }

というようにリソースにアクセスしているため、アプリのプロジェクトに含まれる strings.xml をライブラリに含むようにします。

<string name="game_view_content_description">Game view</string>

また必須の処理ではありませんが、x86 用の libmain.so を用意しています。UnityPlayer は static initializer で libmain.so を読み込みますが、Unity は x86 の端末(たとえばエミュレータ)をサポートしないため(libmain.soがないため)クラッシュを起こします。

    static {
        (new m()).a();

        try {
            System.loadLibrary("main");
        } catch (UnsatisfiedLinkError var1) {
            com.unity3d.player.g.Log(6, "Failed to load 'libmain.so', the application will terminate.");
            throw var1;
        }
    }

動作する x86 ビルドは用意できないしする必要もないので読み込めるだけのダミーの libmain.so を用意します。

フレームワークの利用

iOS

基本的には公式のサンプルコードを Swift で書き直すだけです。実際に使っているコードから一部抜粋します。

import UnityFramework

extension Unity {
    final class Framework {
        #if arch(i386) || arch(x86_64)
        public func load(argc: Int32, argv: UnsafeMutablePointer<UnsafeMutablePointer<Int8>?>!, launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
        }
        #else
        public func load(argc: Int32, argv: UnsafeMutablePointer<UnsafeMutablePointer<Int8>?>!, launchOptions: [UIApplication.LaunchOptionsKey: Any]?) {
            func unityFramewokLoad() -> UnityFramework? {
                let bundlePath = "\(Bundle.main.bundlePath)/Frameworks/UnityFramework.framework"
                let bundle = Bundle(path: bundlePath)
                if let bundle = bundle, !bundle.isLoaded {
                    bundle.load()
                }

                let ufw = bundle?.principalClass?.getInstance()
                if ufw?.appController() == nil {
                    var header = _mh_execute_header
                    ufw?.setExecuteHeader(&header)
                }
                return ufw
            }

            let ufw = unityFramewokLoad()
            ufw?.setDataBundleId("com.unity3d.framework")
            ufw?.runEmbedded(withArgc: argc, argv: argv, appLaunchOpts: launchOptions)
        }
        #endif
    }
}

シミュレータ用にダミーの関数を用意しています。

Android

こちらも同じく公式にサンプルがありますが、Mirrativ では OverrideUnityActivity を使わずに UnityPlayer を直接使っています。

class AnyUnityViewFragment : Fragment() {
    private val unityPlayer: UnityPlayer by inject()
}

UnityPlayer は子に SurfaceView をもつ FrameLayout ですが かなり問題児で 適当に扱うと割とクラッシュします。SurfaceView のサイズがなんらかの理由で変更されたときにフレームバッファを作り直す処理が走るため、Unity の処理がハングするのと、さらに処理が終わる前にサイズをさらに変更すると容易にクラッシュします。処理の完了を上手く拾えなかったため( onSurfaceChanged もあまり当てにならず、、)、アプリの方にサイズ変更を連発させないような処理を入れています。

おわりに

Unity as a Library を使うと Unity とネイティブのいいとこどりができるという側面もある一方制約も増えます。例えば、開発中のイテレーションを考えると、Unity ビルド → Xcode ビルド(Unity フレームワーク) → Xcode ビルド(iOS ビルド)となり単純にビルド時間が伸びます。また、アプリから利用する場合はアセットバンドルのビルドも考慮する必要が出てきます。

このような事情のため、Mirrativ の開発方針としては可能な限り Unity/iOS/Android が各々独立して動作可能になるように設計、運用しています。アセットフローという視点では、Unity 側では CI によってフレームワークをビルドし、アウトプット先として GitHub のリポジトリにフレームワークを push、iOS/Android 側のプロジェクトでは git submodule として扱う、という形で運用しています。

未解決の問題はそれなりにあって、例えばネイティブ/Unity間の通信と設計というトピックがあります。現状はネイティブ(iOS/Android)から Unity に情報を伝えるとき SendMessage を使って request を投げるような構成になっているのですが、iOS/Android のアプリ側はFluxなアーキテクチャになっているため、ネイティブ側としては state を Unity にわたして、ネイティブからは Unity が状態を持っていないように見える、、というのが良さげな設計かな、と考えています。今年もあと3ヶ月程度ですが、このあたりは年末までにやっつけていきたいですね。

We are hiring!

ミラティブでは ゲーム×ライブ配信のアプリを一緒に作ってくれる iOS/Android エンジニアを募集中です!meetup も開催しているため気軽にご参加ください!

www.mirrativ.co.jp

speakerdeck.com