zshの標準エラー出力の色を赤くする

[追記]以下の方法は良くないようです。必ず、このエントリー最後の「stderredを使う」を参照してください[/追記]

最近stderrを赤くするように設定したら、コマンドの出力がかなり見やすくなりました。 f:id:itchyny:20171116233845p:plain

設定はこんな感じに書いてます。

zmodload zsh/terminfo zsh/system
color_stderr() {
  while sysread std_err_color; do
    syswrite -o 2 "${fg_bold[red]}${std_err_color}${terminfo[sgr0]}"
  done
}
exec 2> >(color_stderr)

fg_bold[red] のところを fg[red] とかbg_bold[red] とかするとスタイルを変更できます。 古いzshでは動かないらしいので、古い環境も気にしたい場合は is-at-least 4.3.4 でチェックするとよさそうです。

この設定の元ネタはcoloring stderr - was Re: piping stderrです。 大体はうまくいくし、普段使う分には大きな問題は起きていないのですが、わりと乱暴なことをやっているという自覚はあります。 元ネタのスレッドでも触れられていますが、 echo L1; echo L2 >&2; echo L3; echo L4 >&2 とやると L2L3 が逆に出力されたりします。 あと bash -i したあとに top を起動できないといったりします。

いくつか問題が起きるケースはありそうだけど、二週間ほど試してみて普段端末を触る範囲では困っていないのと、stderrに色が付くのがありがたいので使っています。もっといい方法があれば教えてください。

ついでに使っているPROMPTの設定はこんな感じです。終了コードを元に色を変更しています。

PROMPT="%(?.%{$fg[green]%}.%{$fg[blue]%})%B%~%b%{${reset_color}%} "
PROMPT2="%{$bg[blue]%}%_>%{$reset_color%}%b "
SPROMPT="%{$bg[red]%}%B%r is correct? [n,y,a,e]:%{${reset_color}%}%b "

stderredを使う

上の設定を使うと、 echo foobar | vim - が動かなくなります。これは結構困ります。端末で出力先を無理やり変える方法はやはり色々トラブルの元となるようです。 代わりにstderredを使うのをおすすめします。ビルドする必要はありますが、上の方法よりも直接的な方法なのでトラブルは起きないと思います。 github.com

if [ -f /usr/local/lib/libstderred.dylib ]; then
  export DYLD_INSERT_LIBRARIES="/usr/local/lib/libstderred.dylib${DYLD_INSERT_LIBRARIES:+:$DYLD_INSERT_LIBRARIES}"
  export STDERRED_ESC_CODE=$'\x1b[1;31m'
fi

Serverlessconf Tokyo 2017で『サーバレスアーキテクチャによる時系列データベースの構築と監視』という発表してきました

先日開催されたServerlessconf Tokyo 2017にスピーカーとして参加しました。

2017.serverlessconf.tokyo

Mackerelの今の時系列データベースは、マネージドサービスを組み合わせて作っています。 検証・実装・投入フェーズを終えて、運用・新機能開発フェーズに入っています。そんな中で、監視サービスを提供する私たちが、サーバーレスアーキテクチャで作ったミドルウェアをどのように監視しているかについてお話しました。 何かしら役に立つことや発想の元となるようなことをお伝えできていたらいいなと思います。

私も他の発表から様々なことを学びました。特に面白かった発表を挙げておきます。

真のサーバレスアーキテクトとサーバレス時代のゲーム開発・運用

ゲーム開発を支えるBaaSを開発されるなかで得られた様々な知見についてお話されていました。 プッシュ通知のために大量のコネクションを張りたい場面でAWS IoTを用いるといった話はとてもおもしろいと思いました。 アクセス権限の話でLambdaのメモリーにキャッシュを持つという話をされていて、そんな手があるのかと驚きましたが、物理的な限界がありスケールしないので、使いどころ (というかヒット率に基づくキャッシュの削除) が難しいなと思いました。

サーバーレスについて語るときに僕の語ること

軽快な語り口と盛り上げ方はランチセッションにふさわしく、うまいなぁと思いました。 サーバーレスというのは文字通りだと枠としては大きすぎるんですよね。 スケーラビリティーやイベント駆動により処理を繋げるアーキテクチャも包含する言葉がほしいなぁという気持ちになりました。 よっしゃサーバーレスやとかいってサービスを選んだ時に、その用途が本当にそのサービスの適した使い方なのかどうかはきちんと考えるべきですよね。

Open source application and Ecosystem on Serverless Framework

なるほど確かに、こういう方向に進んでいくよねという気持ちになりました。 サーバーレスミドルウェアという言葉が面白かったです。聞いた瞬間、様々なものが繋がった気がしました。 マネージドサービスを組み合わせて作るという流れはおそらくこれからも続いていくと思いますし、それを組み合わせることで1つの「ミドルウェア」として機能するものを作るのはよくあることだと思います。 そういうサーバーレスミドルウェアは一発で立ち上がるべきだし、利用者が実装内部 (どういうマネージドサービスを使っているか) を気にする必要なく使えるようになっているのが理想の形。 こういうミドルウェアって再利用可能な形でシェアできるといいし、それらを組み合わせてサービスを構築できるとかっこよさそうですよね。 そう、docker-composeみたいなやつが欲しいですね (あるのかしら、まだよく知らないです)。

最後に

私は対外発表は初めてこともあり、とてもよい経験になりました。 慣れていなくてストーリーを組み立てるのに苦労したり、資料を作るのに時間がかかったりしましたが、なんとか終わって今は安堵しています。 E2E監視というところを抽象化して、系全体として動いているかを見るというところに落とし所を見つけられたのは、発表の前日でした。 私たちが作ったのは時系列データベースの「サーバーレスミドルウェア」なんですよね、こういうまとめて見た時の概念を思いついていなかったのは正直くやしい。 k1LoWさんには発表や質疑応答の中でもdiamondに触れていただいて、親近感がわきました。

マネージドサービスを組み合わせてサービスを作ることは、セキュリティの問題やスケーラビリティー、運用コストの削減など様々な問題を解決してくれると思います。 ただし、各マネージドサービスにはそれぞれ使いどころや苦手なユースケースがありますので、他の成功事例だけを見て盲目的にコンポーネントを選ぶのではなく、適した使い方をしているかどうかをきちんと吟味しましょうということですね。 こういうアーキテクチャーに乗ったサービス開発はどんどん加速していくと思いますので、私も技術的に置いていかれないように精進していきたいと思います。

主催の吉田さん、運営スタッフのみなさま、本当にありがとうございました。

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

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

例えば、スマホアプリからバックグラウンドで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);
}