負荷を均すための『時間軸シャーディング』という考え方

ウェブアプリケーションを作っていると、負荷を分散させるために「タイミングをばらけさせる」場面に時々遭遇します。 データの更新、キャッシュのフラッシュ、バッチ処理など様々な問題で、同じ構造が見られます。

例えば、スマホアプリからバックグラウンドで1時間ごとに何らかの情報をサーバーに送りたいとします。 愚直に毎時0分に更新処理を行うようにすると、すべてのユーザーから同じタイミングでリクエストが来てしまいます。 ですから、リクエストのタイミングをユーザーごとにばらして負荷を均す必要があります。

他のケースを考えます。 5分ごとにジョブを投入して何らかの更新を行うタスクがあるとします。 本来ならデータベースに更新を行いたいのですが、データベースのハードウェアの限界が近いので、更新データをまずキャッシュに乗せるようにしました。 何らかのタイミングでキャッシュからデータベースにフラッシュする必要があります。 データベースに書き込むタイミングをジョブによってばらして負荷を均したいという要求が出るのは自然なことです。

負荷を均すときに考える基本的なことは、何が時間軸上でばらけるかということです。 例えばインストール時刻がばらけるならば、インストール時刻から1時間ごとに更新するという処理でユーザーごとにばらけるでしょう。 そんな都合のいいタイミングがない場合や、ストレージに情報を持てない場合には、id番号のようにすでにばらけているものから処理する時刻を決めるのが有効です。 例えば5分毎のジョブ投入のケースの場合、id番号のmod 12を取った値が分を5で割った値と同じになったタイミングで処理を行うようにすると、重い処理を1時間に一回にしてかつ負荷を分割することができます。

func shouldUpdate(job: Job) bool {
    return (job.ID % 12) == (time.Now().Minute() / 5)
}

idが文字列の場合は文字コードの和をとる、SHA-1を取って文字コード和をとる、CRCをとるなど、とにかくmodでばらけるような数字を作れたら成功です。

本来なら毎回行う更新処理を数回に一回にしてばらけさせる、あるいはリクエストの集中を避けるためにタイミングをばらけさせる。 このように、負荷を分割して均す目的で時間軸上でばらけさせる処理は、時間軸方向のシャーディングと言うことができます。

一般にシャーディングというと、データベースやストリームをスケールアウトさせて負荷を分散させる、空間軸方向のシャーディングを指します。 一つのデータベースに集中させていたものを、ハードウェアの限界などのためにデータベースを分割して負荷を軽減させるのがよくあるケースです。 何らかのハッシュ関数のmodからノードを決定してデータを書き込む。 ハッシュ値のmodが均等に分散すれば、各ノードの負荷は1/(ノード数)に軽減されます。

更新リクエストを時間軸上で分散させるときも、idから計算したハッシュ値のmod (単純なケースではidのmod) を取ります。 1時間を5分毎に分割するときは、ノード数が12ですからmod 12を取ってノードを決定します。 15ノードに分けても30ノードに分けても構いません。 一日一回の更新タスクを24分割するならmod 24を取って時刻と比較するとか、96分割してその日の経過した分を15で割った数字と比較するなど、分割方法はいくらでもあります。 ノードの分割方法は、タスクの投入の仕方、どれくらいの失敗が許容されるか、短い期間で同じ処理を行なっても大丈夫かなど、問題の性質によって変わってきます。 いずれにしても、idや名前などの固定値からハッシュ値を作ることが大切です。

もっと古典的なユースケースとして、重いバッチ処理をmodで分割するのも、時間軸のシャーディングと言えます。 偶数番号と奇数番号でバッチを分割するというのは最も単純なシャーディングです。

これまで「タイミングをばらけさせる」と言っていたものを「時間軸のシャーディング」という言葉で表現することで、空間軸のシャーディングの用語を類推して用いることができます。 例えば「シャードが偏る」という言葉がありますが、時間軸シャーディングの場合で偏る場合はだいたいハッシュ関数か元の値の選び方が悪いでしょう。 「シャードを分割する」場合は、特定の一つのシャードを分割するよりも、10分毎のノードを5分毎に分割するという風に時間軸上のノード数を増やすのが有効だと思います。 空間軸シャーディングとは違って、時間軸シャーディングの場合はデータ移行を考えなくて良いので、ハッシュ関数やノードの分割方法を変えて「リシャーディング」を行うことでうまくいく場合が多いと思います。

上記で書いた内容は、特に新しいことはやっていませんし、タイミングをいい具合にばらけさせるコードを書いたことがある人はたくさんいると思います。 そういう処理を時間軸のシャーディングと捉えることで、言葉を類推して用いることができるようになり、空間軸シャーディングと対比して利点と欠点を比較し、より良い方を選ぶことができるようになると思います。

「更新が多くてハードウェアの限界に来てますが、仕様としてもう少し更新回数減らしてよさそうですね。ただ、タイミングがばらばらになるとよさそう」のようなあやふやな言葉で伝えていたものを、「時間軸シャーディングするとよさそうですね。1時間を12ノードに分けましょう。1時間に一回の更新で仕様上は問題はありません。空間軸シャーディングはこれでだめになった時にまた考えましょう」のようにはっきりと伝えられるとかっこいいですね。

追記: ジョブキュー使いましょうというのは真っ当なご意見ですし、負荷のパターンと工数によってジョブキューを作るべき場面もあるでしょう。もともと時間軸シャーディングを考えたのは負荷が一定でスパイクのない系で工数をかけずにタイミングをバラすというものだったので、負荷を均す一つの解法にすぎないことは把握しております。

Mackerelのプラグインを書く楽しみ ― Rustでプラグインを書くためのライブラリを作りました!

Mackerelは「エンジニアをワクワクさせる」ツールであることをサービスの大事な考え方の一つとして捉えています。 一体どういう場面でエンジニアはワクワクするのでしょうか。 簡単にインストールできるmackerel-agentや、直感的で触りやすい画面、チャットツールとの連携は大事な機能です。 しかし、監視ツールとしてもっと重要なのは、ミドルウェアのメトリックをどのように可視化し、何を監視するかということです。

Mackerelは公式のプラグインリポジトリに各種プラグインを揃えています (contributorの皆様ありがとうございます)。 これらはすべてGo言語で書かれています。 しかし、MackerelのプラグインはGo言語で書かなければいけない、ということはありません。 例えばカスタムメトリックのヘルプページのサンプルプラグインRubyで書かれていますし、メタデータのヘルプのサンプルスクリプトPerlで書かれています。

Mackerelのプラグインはどんな言語で書いていただいても構いません。 Rubyで書いてもシェルスクリプトで書いてもHaskellで書いても構いません。 世の中にはいろいろなミドルウェアがありますから、公式プラグインのなかに要件を満たせない場合もあるかもしれません。 自分の好きな言語でミドルウェアのメトリックを可視化する、そして監視をかけて、異常をいち早く察知する、つまり自らコードを書いて監視を作ること、これはすごくワクワクすることなのです。

こういうMackerelのhackableな部分はすごく大事だと思っていますし、これからも大事にしていきたいと思います。 初期のデザインドキュメントを見てみても、APIで様々な操作を行えることを最初から重要視していたことが伺える記述が見つかります。 そのまま実行したらグラフを確認できるcurlコマンドサンプルが置かれているのも、このあたりの考えが反映さています。 f:id:itchyny:20171005000230p:plain

ところで、私はRustという言語がとても気に入っています。 家ではだいたいRustのことを考えていて、Rustで書かれたプロダクトのコードを読んだり、いろいろ作ったりして楽しんでいます。 好きな言語があるとMackerelのAPIを叩いてみたくなるわけで、mackerel-client-rsmackerel-client-hsを作ってきました。 ただ、APIクライアント作りってやることが淡々としていて、正直飽きてくるんですよね。 まだすべてのAPIに対応できていないのでがんばらないといけないわけですが、いかんせんモチベーションが上がりにくいわけです。

一方で、Mackerelのメトリックプラグインを作るのは、APIクライアントを作るよりも格段におもしろいわけです。 そもそもメトリックのとり方はいろいろありますし、サーバーの変化を時系列データとして可視化できると、これがすごく楽しい。 ひょっとして、MackerelプラグインをRustで書いたらもっと楽しいんじゃない?

本題です。 RustでMackerelのプラグインを書くためのヘルパーライブラリ、mackerel-plugin-rsを作りました。 公式のgo-mackerel-pluginのRust版です。

github.com

まだ作ったばかりで、差分計算など一部の機能をまだ足りていませんが、今はモチベーションがすごく高いので、すぐに実装されると思います。 上記ライブラリを使って、手始めにloadavgプラグインuptimeプラグインを書いてみました。 f:id:itchyny:20171005005452p:plain topやuptimeコマンドで3つの数字が並んだ状態のloadavgはよく目にしますが、実際にグラフにしてみると激しく変化するloadavg1、それに上下するloadavg5、よりなだらかなloadavg15というメトリックの特徴がよく出ていて、すごく美しいわけです。 f:id:itchyny:20171005005912p:plain プラグインのコードはこういう感じです。

extern crate libc;
#[macro_use]
extern crate mackerel_plugin;

use std::collections::HashMap;
use mackerel_plugin::*;

pub struct LoadavgPlugin {}

#[inline]
fn get_loadavgs() -> Result<[f64; 3], String> {
    let mut loadavgs: [f64; 3] = [0.0, 0.0, 0.0];
    let ret = unsafe { libc::getloadavg(loadavgs.as_mut_ptr(), 3) };
    if ret == 3 {
        Ok(loadavgs)
    } else {
        Err("failed to get load averages".to_string())
    }
}

impl Plugin for LoadavgPlugin {
    fn fetch_metrics(&self) -> Result<HashMap<String, f64>, String> {
        let mut metrics = HashMap::new();
        let loadavgs = get_loadavgs()?;
        metrics.insert("loadavg.loadavg1".to_string(), loadavgs[0]);
        metrics.insert("loadavg.loadavg5".to_string(), loadavgs[1]);
        metrics.insert("loadavg.loadavg15".to_string(), loadavgs[2]);
        Ok(metrics)
    }

    fn graph_definition(&self) -> Vec<Graph> {
        vec![
            graph! {
                name: "loadavg",
                label: "Load averages",
                unit: "float",
                metrics: [
                    { name: "loadavg15", label: "loadavg15" },
                    { name: "loadavg5", label: "loadavg5" },
                    { name: "loadavg1", label: "loadavg1" },
                ]
            },
        ]
    }
}

プラグインを作るとき、まずどのようにしてメトリックをとるかを調べなければいけません。 これが普段ウェブアプリケーションを書くのとは違っていて実に楽しいわけです。 loadavgの取得方法について調べていくと、getloadavg(3)LinuxBSDの両方で同じように使えることが分かりました。 mackerel-plugin-loadavgはlibc::getloadavgを使うことで、macOSUbuntuの両方で同じようにコンパイルして動作することを確認しています。 一方でuptimeのportableな取得する方法は難しく、LinuxではsysinfoBSDではsysctlboottimeを取得してから計算する必要がありました。

今回プラグインを書くためのライブラリを書く上で、公式のgo-mackerel-pluginをかなり読んで挙動を確認しました。 歴史的な経緯でinconsistentな挙動となってしまっていたり、プラグイン側でやったほうがよさそうな余計な機能があったり、エラーハンドリングを丁寧にやったほうがよさそうなところなど、よろしくないところがいくつかあることに気が付きました。 mackerel-plugin-rsを作る時間にはお給料は出ていませんが、go-mackerel-pluginへの理解が深まり、問題点が把握できたのはよかったなと思います。 いくつか良くない処理は直そうと思っています。 また、ヘルプの文言も不正確な部分は既にいくつか修正しています。

Mackerelのプラグインを知ることは、その監視対象を知ることです。 Redisのプラグインを作ろうと思ったら、redis-cli infoの各項目が何を表しているか、正確に把握する必要があります。 Linuxのメトリックを取得するということは、Linuxに対する理解を深めるということです。 Rustはそういう領域が得意な言語ですので、loadavgをとるには→man 3 getloadavgを見る→libcライブラリのドキュメントでgetloadavgを探すというようにすぐに目的の関数にたどり着くことができます。

Linuxや各種ミドルウェアへの理解を深めることは、エンジニアとしてすごくワクワクすることです。 監視対象に詳しくなると同時に、プログラムを書く楽しさを改めて教えてくれるのです。

mackerel.io

Mackerelはただのサーバー監視ツールではありません。 プラグインを作るワクワクを、みなさんも是非実感してみてください。

本日10/5はMackerel Dayを開催します。 私も会場にいるので、是非お声がけください。

mackerelio.connpass.com

Mackerel開発チームのメンバーが書いた公式の入門書、出ました。

Mackerel サーバ監視[実践]入門

Mackerel サーバ監視[実践]入門

はてなでは、ワクワクしたいエンジニアに限らず、ディレクターやCRE (Customer Reliability Engineer) も募集しております。 hatenacorp.jp

ptraceシステムコール入門 ― プロセスの出力を覗き見してみよう!

他のプロセスを中断せずに、その出力をミラーリングして新しくパイプで繋ぐ、そんなことはできるのでしょうか。 straceやgdbといったコマンドは一体どういう仕組みで動いているのでしょうか。 ptraceシステムコールを使い、プロセスが呼ぶシステムコールを調べて出力を覗き見するコマンドを実装してみたいと思います。

ptraceシステムコール

Linuxを触っていると、いかにプロセスを組み合わせるか、組み合わせる方法をどれだけ知っているかが重要になってきます。 パイプやリダイレクトを使ってプロセスの出力結果を制御したり、コードの中からコマンドを実行して、終了ステータスを取得したりします。 プロセスツリーやプロセスグループを理解し、シグナルやnohupコマンドを使ったりします。

プロセスの扱いに慣れると疑問に持つのがstracegdbの仕組みです。 プロセスの実行しているシステムコールを出力したり、メモリーを書き換えたりできるこれらのコマンドは、まるで魔法みたいです。 一体全体どんな仕組みで動いているのでしょうか。 誰に許しを得て他のプロセスのメモリーにアクセスしたりしているのでしょうか。 これらのコマンドは、標準出力を読んだりプロセスにシグナルを送ったりするのとは全く別の次元のことをやっているかのようです。

ptraceシステムコールは、straceやgdbのようなコマンドの実装の中で核となるシステムコールです。 このシステムコールを使うと、実行中の他のプロセスの動作を覗き見したり、メモリーを書き換えたりすることができます。 魔法を使っているんじゃなかったんですね。 ちょっぴり残念な気分です。

[試して理解]Linuxのしくみ ~実験と図解で学ぶOSとハードウェアの基礎知識

[試して理解]Linuxのしくみ ~実験と図解で学ぶOSとハードウェアの基礎知識

  • 作者:武内 覚
  • 発売日: 2018/02/23
  • メディア: 単行本(ソフトカバー)

procoutコマンド

使っているシステムコールがわかれば、それを使って何かを作りたくなってきます。 ptraceシステムコールを使うと、原理上はstraceやgdbを自前で実装することができます。 しかし、最初から複雑なデバッガを目指そうとすると大変です。

私はまず手始めに、procoutというコマンドを作ってみました。

中断して途中から再開したりできないプロセスの出力が端末に流れている時に、後からgrepしたりteeしたりしたくなることがあると思います。 そんな時にプロセス自体を中断せず、またコードの変更も行わずに、別のターミナルからプロセスの出力を流すことができるコマンドです。 プロセスの出力を覗き見るという感じなので、procoutと名付けてみました。

次のように、引数にpidを渡して実行します。

 $ sudo procout [pid]

そうすると、対象となるプロセスにアタッチし、コマンドの標準出力をそのまま真似して出力してくれます。 エディタのプロセスに対して使うと、まるで端末がミラーリングされているかのような挙動になります。

f:id:itchyny:20170731003438g:plain

procoutコマンドは、コマンド自体の便利さやおもしろさよりも、それ自体を実装することに意義があります。 ptraceシステムコールの基礎に触れることができるからです。 ptraceの最初の練習問題として、そしてLinuxシステムコールがどのように呼ばれているかについて理解するために、このエントリーではprocoutコマンドを一緒に作っていきたいとおもいます。

免責: 私は一週間前にptraceシステムコールについて学びはじめた素人です。素人だからこそ、わからないところを一つずつ潰しながらこの記事を書きました。もし誤っている記述があれば、お気軽にコメントいただければと思います。

この記事のコード及び上記procoutコマンドは、以下の環境で動作確認をしています。macOSでは動きませんが、仮想マシン上でも簡単に試せますので、是非挑戦していただけたらと思います。

vagrant@vagrant-ubuntu-trusty-64:~/$ uname -srmo
Linux 3.13.0-125-generic x86_64 GNU/Linux

プロセスにアタッチしてみよう

ptraceシステムコールは、引数によって様々なことを行うことができます。 man 2 ptraceでマニュアルを引いてみましょう。

NAME
       ptrace - process trace

SYNOPSIS
       #include <sys/ptrace.h>

       long ptrace(enum __ptrace_request request, pid_t pid,
                   void *addr, void *data);

第一引数にはプロセスに対してどういうptraceリクエストを行うか、第二引数にはプロセスのpidを指定します。 リクエストの内容によって、第三・四引数の意味は変わってくるので、これらについてはおいおい見ていきましょう。 他にはsys/ptrace.hをincludeすること、返り値がlongであることがわかりました。

まずは、ptraceの基本であるアタッチ・デタッチから始めましょう。

#include <stdio.h>
#include <stdlib.h>
#include <sys/ptrace.h>

int main(int argc, char *argv[])
{
  long ret;
  if (argc < 2) {
    fprintf(stderr, "specify pid\n");
    exit(1);
  }

  pid_t pid = atoi(argv[1]);
  printf("attach to %d\n", pid);

  ret = ptrace(PTRACE_ATTACH, pid, NULL, NULL);
  if (ret < 0) {
    perror("failed to attach");
    exit(1);
  }
  printf("attached to %d (ret: %ld)\n", pid, ret);
  sleep(5);

  ret = ptrace(PTRACE_DETACH, pid, NULL, NULL);
  if (ret < 0) {
    perror("failed to detach");
    exit(1);
  }
  printf("detached from %d (ret: %ld)\n", pid, ret);

  return 0;
}

topコマンドを実行し、そのプロセスに対してアタッチしてみましょう。

f:id:itchyny:20170730182701g:plain

あら、アタッチできませんでした。 Operation not permittedと表示されていることから想像がつきますが、ptraceで他のプロセスにアタッチするには、root権限が必要です。 そうですよね、一般ユーザーで他のプロセスを自由に操作できたら怖いですよね。

f:id:itchyny:20170730182642g:plain

うまく動きました。 プロセスにアタッチしてsleepしている間、左側のtopコマンドが停止しているのがわかります。

なぜtopコマンドは止まってしまったのでしょうか。 man 2 ptraceでは次のように説明されています。

While being traced, the tracee will stop each time a signal is delivered, even if the signal is being ignored. (An exception is SIGKILL, which has its usual effect.) The tracer will be notified at its next call to waitpid(2) (or one of the related "wait" system calls); that call will return a status value containing information that indicates the cause of the stop in the tracee.

tracerがptraceするプロセスで、traceeがptraceされるプロセスです (employerとemployeeと同じ)。 ptraceされるプロセスはシグナル毎にいちいち止まるから、ptraceするプロセスはwaitpidを使ってねと書かれています。

straceのようなptraceの典型的な用途では、システムコールが呼ばれるところで処理を行います。 PTRACE_SYSCALLを使ってプロセスを再開すると、次のシステムコールでプロセスが停止し、ptraceするプロセスはwaitpidを使ってその停止を検知することができます。

#include <stdio.h>
#include <stdlib.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>
#include <sys/syscall.h>

int main(int argc, char *argv[])
{
  int status;

  if (argc < 2) {
    fprintf(stderr, "specify pid\n");
    exit(1);
  }

  pid_t pid = atoi(argv[1]);
  printf("attach to %d\n", pid);

  if (ptrace(PTRACE_ATTACH, pid, NULL, NULL) < 0) {
    perror("failed to attach");
    exit(1);
  }

  while (1) {
    waitpid(pid, &status, 0);
    if (WIFEXITED(status)) {
      break;
    } else if (WIFSIGNALED(status)) {
      printf("terminated by signal %d\n", WTERMSIG(status));
    } else if (WIFSTOPPED(status)) {
      printf("stopped by signal %d\n", WSTOPSIG(status));
    }

    ptrace(PTRACE_SYSCALL, pid, NULL, NULL);
  }

  return 0;
}

f:id:itchyny:20170728000028g:plain

大量に表示されるsignal 5とはどういう意味でしょうか。 プロセスにシグナルを送るコマンドであるkillに聞いてみましょう。

 $ kill -l
 1) SIGHUP       2) SIGINT       3) SIGQUIT      4) SIGILL       5) SIGTRAP
 6) SIGABRT      7) SIGBUS       8) SIGFPE       9) SIGKILL     10) SIGUSR1
11) SIGSEGV     12) SIGUSR2     13) SIGPIPE     14) SIGALRM     15) SIGTERM
16) SIGSTKFLT   17) SIGCHLD     18) SIGCONT     19) SIGSTOP     20) SIGTSTP
...

5番のシグナルがSIGTRAPだということがわかりました。 PTRACE_SYSCALLで再開するとSIGTRAPによって止まるという動作が確認できました。 これは期待されている動作なのでしょうか。 man 2 ptraceを引き、適当に検索しながら該当しそうな記述を探します。

Syscall-enter-stop and syscall-exit-stop are observed by the tracer as waitpid(2) returning with WIFSTOPPED(status) true, and WSTOPSIG(status) giving SIGTRAP.

From the tracer's perspective, the tracee will appear to have been stopped by receipt of a SIGTRAP.

実際の挙動を確かめながら、manを読み込み少しずつ知識を蓄えていくことは楽しいことですね。

レジスタの状態を取得してみよう

プロセスの出力を覗き見するには、プロセスが呼ぶwriteシステムコールの引数を解析する必要があります。 システムコールが呼ばれるときのレジスタの中身を見てみましょう。 ptraceの引数にPTRACE_GETREGSを使ってみます。

  struct user_regs_struct regs;

  while (1) {
    waitpid(pid, &status, 0);

    if (WIFEXITED(status)) {
      break;
    } else if (WIFSTOPPED(status)) {
      ptrace(PTRACE_GETREGS, pid, NULL, &regs);
      printf("%lld %lld %lld %lld\n", regs.orig_rax, regs.rsi, regs.rdx, regs.rdi);
    }

    ptrace(PTRACE_SYSCALL, pid, NULL, NULL);
  }

f:id:itchyny:20170728000012g:plain

なんだか出力が賑やかになってきました。 他のプロセスにアタッチし、システムコールが呼ばれる時のレジスタを出力しているだけですが、これはすでに簡易straceのようなものです。

一番左に出力したorig_raxは、システムコールの番号を表します。 システムコール番号はsys/syscall.hで定義されています。

#include <stdio.h>
#include <sys/syscall.h>

int main(int argc, char const* argv[])
{
  printf("%d\n", SYS_write);
  return 0;
}

私の手元ではSYS_write1でした。

上記のコードでは、システムコール番号の他にrsi, rdx, rdiを表示しています。 システムコールが呼ばれる瞬間、各レジスタには何が入っているのでしょうか。 x86_64 syscall registersなどでググって調べてもいいのですが、簡単なコードのアセンブリを見るという方法もあります。

#include <stdio.h>
#include <unistd.h>

void main() {
  write(STDOUT_FILENO, "Hello, world!", 13);
}
 $ gcc -O0 -S write_regs.c
 $ cat write_regs.s
 .file "write_regs.c"
    .section  .rodata
.LC0:
    .string   "Hello, world!"
    .text
    .globl    main
    .type main, @function
main:
.LFB0:
    .cfi_startproc
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6, -16
    movq %rsp, %rbp
    .cfi_def_cfa_register 6
    movl $13, %edx
    movl $.LC0, %esi
    movl $1, %edi
    call write
    popq %rbp
    .cfi_def_cfa 7, 8
    ret
    .cfi_endproc
.LFE0:
    .size main, .-main
    .ident    "GCC: (Ubuntu 4.8.4-2ubuntu1~14.04.3) 4.8.4"
    .section  .note.GNU-stack,"",@progbits

文字列のアドレスはesiに、長さ (writeの第三引数) がedxに、出力先であるfd (=STDOUT_FILENO=1) はediに書かれていることがわかります。 siレジスタは文字列操作のためのSource index, diレジスタDestination indexであることからその名がついていることを思い出すと、それぞれに文字列のアドレスとfdが入ってるというのは自然な挙動です。

やりたいことは「プロセスの出力を覗き見する」だったので、regs.orig_rax == SYS_writeのときにプロセスのメモリーから文字列を読み取り、出力すれば完成です。

syscall-enter-stopとsyscall-exit-stop

これまで「システムコールが呼ばれる時」とごまかしてきましたが、実はこの言い方は正確ではありません。 レジスタの値を出力して様子を見てみましょう。

 $ sudo ./main 24358
attach to 24358
1 6545952 434 1
1 6545952 434 1
1 6545952 1140 1
1 6545952 1140 1
1 6545952 821 1
1 6545952 821 1
1 6545952 1136 1
1 6545952 1136 1
# orig_rax rsi rdx rdi

左からシステムコール番号 (SYS_write), 文字列のアドレス, 書き込んだバイト長, fdです。 同じ値の行が二回ずつ表示されていることがわかります。 これは同じ引数で二回システムコールが呼ばれているのではなく、システムコールが呼ばれる直前と直後の二回表示されているのです。

さらに理解を深めるために、orig_raxレジスタに加えてraxレジスタも表示してみます。

 $ sudo ./main 24358
attach to 24358
1 -38 6545952 1278 1
1 1278 6545952 1278 1
1 -38 6545952 1262 1
1 1262 6545952 1262 1
1 -38 6545952 821 1
1 821 6545952 821 1
1 -38 6545952 1122 1
1 1122 6545952 1122 1
# orig_rax rax rsi rdx rdi

システムコールが呼ばれると、その返り値がraxレジスタに入ります。 writeシステムコールの返り値は書き込んだバイト数ですから、writeの第三引数であるrdxraxが同じ行はシステムコールが呼ばれた後ということになります。 raxレジスタの値が-38となっている行は、システムコールが呼ばれる前の状態ということになります。 この-38が何の値なのかは後で説明します。

PTRACE_SYSCALLによりシステムコールをトラップすると、システムコールに入ったとき (syscall-enter-stop) と終わった時 (syscall-exit-stop) の二回停止するようになっています。 manを見てみましょう。

If the tracee was restarted by PTRACE_SYSCALL or PTRACE_SYSEMU, the tracee enters syscall-enter-stop just prior to entering any system call (中略). No matter which method caused the syscall-entry-stop, if the tracer restarts the tracee with PTRACE_SYSCALL, the tracee enters syscall-exit-stop when the system call is finished, or if it is interrupted by a signal. (That is, signal-delivery-stop never happens between syscall-enter-stop and syscall-exit-stop; it happens after syscall-exit-stop.).

プロセスの出力文字列を覗き見するのは、入ったときでも終わったときでもどっちでも構いません。 ただ二回表示されると困るので、ここではシステムコールに入った時だけ処理を行うようにしましょう。 では、syscall-enter-stopsyscall-exit-stopを区別するにはどうすればいいのでしょうか。 manを順番に読んでいくと、次のような記述に愕然とします。

Syscall-enter-stop and syscall-exit-stop are indistinguishable from each other by the tracer. The tracer needs to keep track of the sequence of ptrace-stops in order to not misinterpret syscall-enter-stop as syscall-exit-stop or vice versa.

関連する記述をmanから抜き出してまとめてみました。

  • syscall-enter-stopの直後はsyscall-exit-stopとは限らない。PTRACE_EVENTによる停止かもしれないし、終了しているかもしれない。
  • PTRACE_O_TRACESYSGOOD を使うと、syscall-{enter,exit}-stopかそれ以外かは区別できる。
  • x86において、syscall-enter-stopではraxレジスタ-ENOSYS (この値が-38) になる。しかし、何らかのシステムコールが同じ値を返すこともあり、rax == -ENOSYS だからといってsyscall-exit-stopではないとは言い切れない。
  • syscall-enter-stopとsyscall-exit-stopは単体で見た時に区別することはできない。前の状態を保持しておいて調べるしかない。

以上を踏まえて、syscall-enter-stopでのみレジスタ値を表示するように実装してみました。

  ptrace(PTRACE_SETOPTIONS, pid, NULL, PTRACE_O_TRACESYSGOOD);

  int is_enter_stop = 0;
  long prev_orig_rax = -1;
  while (1) {
    waitpid(pid, &status, 0);

    if (WIFEXITED(status)) {
      break;
    } else if (WIFSTOPPED(status) && WSTOPSIG(status) == (SIGTRAP | 0x80)) {
      ptrace(PTRACE_GETREGS, pid, NULL, &regs);
      is_enter_stop = prev_orig_rax == regs.orig_rax ? !is_enter_stop : 1;
      prev_orig_rax = regs.orig_rax;
      if (is_enter_stop && regs.orig_rax == SYS_write) {
        printf("%lld %lld %lld %lld %lld\n", regs.orig_rax, regs.rax, regs.rsi, regs.rdx, regs.rdi);
      }
    }

PTRACE_SETOPTIONSはptraceリクエストの1つで、PTRACE_O_TRACESYSGOODを指定することで、システムコールによる停止かどうかを正確に判定できるようになります。 システムコール番号が前から変化したときにenter-stopだと判定するようにしました。 ずっと同じシステムコールが呼ばれ続けるならば、ずっとexit-stopで出力する可能性は否定できませんが、多くの現実的なコマンドそういうことはなさそうですし、仮にそうなったとしても出力を覗き見するコマンドとしての動作には影響しません。 だんだん精度良くシステムコールをトレースできるようになってきましたね。

文字列をメモリーから読み取ろう

プロセスがwriteシステムコールを呼ぶ時の引数から、出力されているバイト列を読み取ることができます。 PTRACE_PEEKDATAを使ってptraceを呼ぶと、プロセスの管理しているメモリーの値を取得することができます。

        peek_and_output(pid, regs.rsi, regs.rdx, (int)regs.rdi);

/* ... */

void peek_and_output(pid_t pid, long long addr, long long size, int fd)
{
  if (fd != 1 && fd != 2) {
    return;
  }
  char* bytes = malloc(size + sizeof(long));
  int i;
  for (i = 0; i < size; i += sizeof(long)) {
    long data = ptrace(PTRACE_PEEKDATA, pid, addr + i, NULL);
    if (data == -1) {
      printf("failed to peek data\n");
      free(bytes);
      return;
    }
    memcpy(bytes + i, &data, sizeof(long));
  }
  bytes[size] = '\0';
  write(fd == 2 ? 2 : 1, bytes, size);
  fflush(fd == 2 ? stderr : stdout);
  free(bytes);
}

ptrace(PTRACE_PEEKDATA, pid, {addr}, NULL)の返り値が、そのアドレスにある値です。 標準エラー出力ならエラー出力に出すように実装してみました。

さっそく実行してみましょう。

やったー! topコマンドのプロセスにアタッチすると、まるでこちらでもtopコマンドを打ったような動きになりました。 もちろん、topコマンドでなくてどんなコマンドに対しても使えます。 Vimのプロセスにアタッチしてみましょう。 Vimの画面がミラーリングされていておもしろい!

今回実装するprocoutコマンドはここまでとします。 ただ、ここから様々な発展したコマンドが実装できると思います。 上のコードでは諸事情でfd = 1, 2のみ扱っていますが、この処理の必然性はありません (こう制限しないとエディタにアタッチした時にゴミが出力される)。 open, readなど、対応するシステムコールを増やしていくと、より便利なデバッガとなるでしょう。

straceやgdbそのものをそっくり実装しようとする必要はありません。 既にこれらのコマンドはあるじゃないですか、うまく動いているじゃないですか。 それでもなお、これらのコマンドの仕組みを理解することは重要な意義があると考えています。 ptraceシステムコールについて学ぶこと、それを使って実際に動くコマンドを作ってみること、簡単なデバッガを書いてみること。 そして、何をどのように実装すればstraceやgdbなどを作れるかをイメージできるようになること。 コマンドやツールの使い方を学ぶだけで満足するのではなく、それらの仕組みを深く知り、実装方法をイメージできるようになると、技術者としての知識がより幅広くそして深くなり、エンジニアリングを楽しめるようになっていくのだと思います。

Rustで書き直そう

Linuxシステムコールについて学ぶためには、C言語が最適の言語だと思います。 しかし、それらを組み合わせて大きなプログラムを組んだり、複雑なTUIを作ったりする必要が生じたときにふさわしい言語であるかどうかはかなり疑わしいと思います。

Rustはシステムプログラミングを学ぶのに適した言語です。 冒頭でご紹介したprocoutコマンドも、Rustで実装しています。 実は、先にRust版を書いてから、ブログのためにCで書き直しているのが実情です。 ブログを書く時にCを選んだのは、システムコールについて学ぶ時にRustを選ぶことが時に遠回りになりうるとわかったからです。

Programming Rust: Fast, Safe Systems Development

Programming Rust: Fast, Safe Systems Development

Rustを書いていると、実行時のメモリー安全性や、型チェックの厳格さから、「コードの正しさ」をコンパイルのチェックに委ねがちになります。 しかし、低レイヤーを触るとこれはかなり様子が変わってくるのがわかります。 当然のことですが、コンパイルが通っても、システムコールの使い方が適切でなければ全く動きません。 システムコールの呼ぶ順番が間違っていたら、コンパイルが通ったとしても、やってることは全くトンチンカンかもしれません。 また、Rustで書くこととコードがportableかどうかもイコールではありません。 結局のところ、誰かがportabilityの高い素晴らしいライブラリ (ただし中身は涙ぐましい努力で書かれている) を用意しないといけないのです。

Rustで書くことは、メモリー管理と型チェックに関してコードの安全性と大きな安心感をもたらしてくれます。 例えば、ptraceの2つの引数を誤って逆に書いてしまい、数分悩むといったことは起こりえないでしょう (記事を書く過程でやらかしました…)。 Rustの洗練されたエラーのハンドリングや、豊富なライブラリなどエコシステムの恩恵も受けられます。 ただ、低レイヤーを触るときは「正しくないコードはコンパイルが通ったとしても正しく動かない」という当たり前のことを教えてくれます。

さて、procoutのRust実装ですが、これは「読者の課題」としたいと思います。 大した行数じゃないので、簡単に移植できると思います。 私もコードをGitHubにあげていますので、実装できたらコードを見比べてみるとおもしろいかもしれません。 ここではRustで書いたことで得られた知見を簡単に書いておきます。

  • rust-lang/libcnix-rust/nixを使えばだいたいのことはできる。
  • nixパッケージにもmacOSで動くptraceは実装されていない。システムプログラミングでportableにすることは難しい。システムコールの番号すらプラットフォームによって異なる。
  • nixパッケージのptraceはまだ機能が揃っていない。getregsなんかは欲しい。まだ未熟なので、プルリクエストを送ったら簡単に取り込まれるかもしれません。

まとめ

ptraceシステムコールを使い、他のプロセスが呼ぶシステムコールを調べたり、メモリーを読み取ることができるのを確認しました。 プロセスの呼ぶwriteシステムコールの引数を使い、プロセスの出力を覗き見するコマンドprocoutをRustで実装しました。

当初は、普段使っているmacOS上で動くものを作ろうとしたのですが、nixライブラリのptraceのコードを見た時に諦めました。 VagrantUbuntuを立てて、その中で動作確認を行なっています。 普段Cを書くことがほとんどないので、自分にとって貴重な経験になりました。 /usr/includeから素早くファイルを開いて実装を確認するのにも慣れました。 portableなstraceを作るにはかなり大変だということもわかりました。

Rustは書いていてとても楽しい言語です。 今回だと、メモリーからバイト列を読み取って結合するコードはきれいに書けたと思います。 低レイヤーを触るときは何よりもまず、システムコール自体を正しく理解していることが大事です。 実際に動作するCのコードを書けることを確認しておくとよいでしょう。

straceやgdbってどうやって動いているのだろう。 この小さな疑問が浮かんだのが、一週間前のことです。 システムコールについて学び、それを使ったコマンドツールを作るのは、とても楽しい経験でした。 既存のツールの仕組みを調べることで「何を使えば何ができる」というレパートリーを増やし、それらがアイディアの源泉となって、便利なコマンドラインツールを作っていけたらいいなと思います。

参考にしたサイトは以下のとおりです。勉強させていただきました、ありがとうございます。

おまけ: ptraceを使いこなせるようになると、GitHub - nelhage/reptyr: Reparent a running program to a new terminalのような発想が出てくるわけです。いやはや、これはすごいですね。

ソースコード

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>
#include <sys/syscall.h>

void peek_and_output(pid_t, long long, long long, int fd);

int main(int argc, char *argv[])
{
  int status;
  struct user_regs_struct regs;

  if (argc < 2) {
    fprintf(stderr, "specify pid\n");
    exit(1);
  }

  pid_t pid = atoi(argv[1]);
  printf("attach to %i\n", pid);

  if (ptrace(PTRACE_ATTACH, pid, NULL, NULL) < 0) {
    fprintf(stderr, "failed to attach\n");
    exit(1);
  }
  ptrace(PTRACE_SETOPTIONS, pid, NULL, PTRACE_O_TRACESYSGOOD);

  int is_enter_stop = 0;
  long prev_orig_rax = -1;
  while (1) {
    waitpid(pid, &status, 0);

    if (WIFEXITED(status)) {
      break;
    } else if (WIFSTOPPED(status) && WSTOPSIG(status) == (SIGTRAP | 0x80)) {
      ptrace(PTRACE_GETREGS, pid, NULL, &regs);
      is_enter_stop = prev_orig_rax == regs.orig_rax ? !is_enter_stop : 1;
      prev_orig_rax = regs.orig_rax;
      if (is_enter_stop && regs.orig_rax == SYS_write) {
        peek_and_output(pid, regs.rsi, regs.rdx, (int)regs.rdi);
      }
    }

    ptrace(PTRACE_SYSCALL, pid, NULL, NULL);
  }

  return 0;
}

void peek_and_output(pid_t pid, long long addr, long long size, int fd)
{
  if (fd != 1 && fd != 2) {
    return;
  }
  char* bytes = malloc(size + sizeof(long));
  int i;
  for (i = 0; i < size; i += sizeof(long)) {
    long data = ptrace(PTRACE_PEEKDATA, pid, addr + i, NULL);
    if (data == -1) {
      printf("failed to peek data\n");
      free(bytes);
      return;
    }
    memcpy(bytes + i, &data, sizeof(long));
  }
  bytes[size] = '\0';
  write(fd == 2 ? 2 : 1, bytes, size);
  fflush(fd == 2 ? stderr : stdout);
  free(bytes);
}

音量を調節できるCLIツールをGo言語で作りました!

volumeコマンドを作りました。

音量の調整ってコマンドからどうやるんだろう、ポータブルな形でコマンドラインツールがあれば便利なのでは… と思ったので作りました。 macOSUbuntuで動作確認をしています。

インストール

go get github.com/itchyny/volume-go/cmd/volume

getは今の音量を返します。set (0-100) で音量を設定できます。

 $ volume get
25
 $ volume set 20
 $ volume get
20

up, down, mute, unmuteなど、直感的に使えるサブコマンドを揃えています。

 $ volume down
 $ volume status
volume: 14
muted: false
 $ volume up
 $ volume status
volume: 20
muted: false
 $ volume mute
 $ volume status
volume: 20
muted: true
 $ volume unmute
 $ volume status
volume: 20
muted: false

簡単!OS起動時に音量を下げたり、アプリケーションを切り替えるたびに音量を調節したりするなどのオートメーションに使えて便利ですね。おわり!

コマンドラインがさらに便利になるfillinコマンドを作りました!

fillinというコマンドラインツールを作成しました。

コマンドの一部を変数化して、別の履歴に保存しておけるツールです。 ステージング環境と本番環境のように、同じコマンドで複数の環境を切り替えるのに便利です。

zshの本 (エッセンシャルソフトウェアガイドブック)

zshの本 (エッセンシャルソフトウェアガイドブック)

  • 作者:広瀬 雄二
  • 発売日: 2009/06/17
  • メディア: 単行本(ソフトカバー)

どうして作ったの

コマンド履歴って便利ですよね。 私はよくコマンド履歴からコマンドを選んで実行しています。 シェルに付属しているデフォルトの履歴を使っている方もおられるでしょうし、fzfやpecoのようなインタラクティブな絞り込みを行なっている方もいるでしょう。

私が一番困っていたのが、認証キーの扱いです。 webアプリを作っていてcurlで素早く確認するときに、認証キーやアクセストークンを打つことがあります。 アクセストークンのようなランダムな英数字は、fzfのようなfuzzy searchとかなり相性が悪いものです。 最近fzfに乗り換えてインターフェースは気に入っていたのですが、トークンを入れてcurlする癖を直さないといけないなぁと思っていました。

いや、トークンを直に打つなよ…っていうご意見はごもっともです。 ライブコーディングする前に履歴消さないといけませんし。 Basic認証ならば標準入力で指定できるんですけど、ヘッダーは (たぶん) できませんよね。 認証キーは何かしらの履歴には残っていて簡単に呼び出したいけど、シェルの履歴には入れたくない… この程度のことにシェルスクリプトを書くのも面倒だし、ファイルに一個ずつ保存していちいち展開するのもかわいくない…

トークンをコマンドに直接書きたくない以外にも、コマンドの一部を別管理したいと思うことはたくさんあります。 データベースに繋ぐ時、データベースのホスト名とデータベース名はセットで管理したいですよね。 awsコマンドのプロファイルだってシュッと切り替えて同じコマンドを実行したくなることもあります。

コマンドの一部を「変数」にして、それを「展開して実行」してくれるコマンドがあれば良いのではないか? シェルの履歴が汚れることはないし、履歴をfuzzy searchしてもおかしなことにはなりにくい。 変数に埋めた値の履歴をローカルに保存して矢印キーとかで呼び出せれば便利そう…

はい、それがfillinです。

brew install itchyny/tap/fillin

または

go get github.com/itchyny/fillin

でインストールできます。

導入編

fillinコマンドの使い方はとても簡単です。 いつも打つコマンドにfillinとつけて、「変えたいところ」をテンプレートにしてあげるだけです。 例えば、次のように打ってみましょう。

 $ fillin echo {{message}}
message: 

messageは何かと聞かれました。適当に打ってみます。

message: こんにちは、世界!
こんにちは、世界!

こんにちは! fillinコマンドは、messageの場所をユーザーに入力してもらい、その値を埋めてコマンドを実行するだけです。

そうです、{{message}}というところを「埋めて (fill-in)」から実行する、それがfillinコマンドです。

もちろん変数はいくつも使うことができます。

 $ fillin echo {{foo}} {{bar}} {{baz}}
foo: 変数を
bar: こういうふうに
baz: 入力するよ
変数を こういうふうに 入力するよ

かわいい。

普段使っているコマンドをテンプレート化してfillinとつけるだけなので、例えば環境変数を含んだコマンドも実行できます。

 $ fillin LANG={{lang}} date
lang: en_US
Mon Jun 12 08:22:55 JST 2017
 $ fillin LANG={{lang}} date
lang: ja_JP
2017612日 月曜日 082252秒 JST

便利!

実践編

いつも打つコマンドの中で、ホスト名のように環境によって切り替えるところをテンプレート化することができます。

 $ fillin psql -h {{psql:hostname}} -U {{psql:username}} -d {{psql:dbname}}
[psql] hostname: localhost
[psql] username: example-user
[psql] dbname: example-db

psql (9.6.3)
Type "help" for help.

example-db=>

各値を入力して決定すると、いつものようにコマンドが実行されます。 入力した値はまとめて履歴に残るので、同じコマンドから上矢印キーで呼び出すことができます。

 $ fillin psql -h {{psql:hostname}} -U {{psql:username}} -d {{psql:dbname}}
[psql] hostname, username, dbname: localhost, example-user, example-db

もちろん、ステージング環境用、本番用のように複数の設定を残すことができます。 psql:という同じ「スコープ」を持つ変数は、組になって履歴を呼び出すことができるのが便利なところです。

webアプリのAPIの動作確認でcurlを使う人ならば、次のようにbase-urlaccess-tokenを管理することができます (認証方法はサンプルです)。

 $ fillin curl {{api:base-url}}/api/info -H 'Authorization: Bearer {{api:access-token}}'
[api] base-url, access-token: example.com, accesstokenabcde012345

この例でもapi:というスコープをつけたので、ホストと認証キーがステージングと本番でごっちゃになることはありません。

何が直交したデータなのかということを考えるのはとても大事なことです。 ローカルの環境、ステージング環境、本番環境によって、ベースURLと認証キーは異なります。 そして、API/api/info, /api/service, /api/account ... といくつかあります (URLは適当です)。 どのAPIも、どの環境に対しても叩くことができます。 つまり環境とAPIのエンドポイントは直交した概念です。

「このAPIのエンドポイントにcurlする」ということはシェルの履歴に残します。 「ステージングの認証キーや本番のキー」といった情報は、fillinの履歴に残します。 これによって、シェルにn*m個の履歴が残ることなく、コマンド履歴を探すのが楽になります。 新しいAPIのエンドポイントを作ったときも、開発時にローカル環境に対して打っておけば、後は同じコマンドでステージング、本番と切り替えるだけで動作確認できます。

こういう話はデータベース、API開発以外にもたくさんあると思います。 例えばawsコマンドの--profile引数ですね。

 $ fillin aws --profile {{aws:profile}} ec2 describe-instances
[aws] profile: aws-profile-example

EC2 (など) に何をするか×アカウントのプロファイルが直交概念ですね。 他にも応用が効く考え方だと思うので、是非よい使い方を考えてみてください。

Go言語による並行処理

Go言語による並行処理

まとめ

コマンドには「どこに」「何を」するかの二軸があります。 「どのホストのどのポートに」「Redis CLIでログインする」とか、「AWSのどのプロファイルの管理している」「EC2のインスタンスを一覧する」、「ステージング環境 (あるいは本番環境) の」「APIを叩いてレスンポンスを調べる」といった具合です。 fillinを用いると、この2つをきれいに分離し、コマンド履歴の管理が簡単になります。

コマンドには「何をするか」を打ちます。

ホスト名、ベースURL、認証キー、プロファイルといった「環境 (どこに)」はfillinで切り替えます。

このように運用すると、コマンドを打つのがとても楽になります。 いや、楽になりそうです… 前の木曜にアイディアを思いついて、この土日でようやく動いたばかりなので、まだがっつりは使ってないんです… でも、きっと便利なはずなので、よかったら使ってみてください。 私もしばらくfillin運用すると思います。 沢山の人に使っていただくと私がとても喜びます。

実際に実行されたコマンドが履歴に残らないのは困る? ご安心ください。 fillinコマンドは実行したコマンドをタイムスタンプ付きで~/.config/fillin/.fillin.histfileに保存しています (この場所が適切なのかという話はありそうだけど、まぁとりあえず…)。 zshのコマンド履歴を真似して出力しているので、fillinをやめたいとなったら (そんな…><)、.histfilegrep -v fillinし、.fillin.histfile.histfileに追記しソートするといい感じになるはずです。 よかったですね。

宣伝

はてなでは、開発時に不便なことがあったらツールを自分で作って解消する、そんな情熱あふれるエンジニアを募集しています。

エンジニア以外にも様々な職種のご応募お待ちしております。

こちらの記事もどうぞ。私です。