ぽよメモ

レガシーシステム考古学専攻

clapの使い方まとめ

はじめに

clapとはRustのCLI引数をパースするためのクレートです。Pythonで言うところのargparse、Goで言うところのcobra/spf13のような立ち位置のクレートで、CLIツールの実装をする場合お世話になることが多いでしょう。

docs.rs

自分が実装時にclapでこれはどういうやり方をするのか、などを調べても日本語では網羅的な情報があまり出てきませんでした。自分のためのまとめを書いていたらそれなりの文量になったので公開することにしました。

実際の実装は以下のリポジトリにあります。

github.com

インストール

cargo を使っているという前提で、Cargo.tomlに以下の行を足します。

[dependencies]
clap = { version = "4.0.11", features = ["derive"] }

使い方

基本形は以下の通りです。ここでは基本的にderive API*1を使ったやり方のみを紹介し、ビルダーパターンの方法は紹介しません。

use clap::Parser;

#[derive(Debug, Parse)]
struct Args {
    #[arg(help = "位置引数の説明")]
    pos_arg: String,
    #[arg(short, long, help = "オプション引数の説明")]
    opt_arg: String,
}

fn main() {
    let args = Args::parse();
    println!("opt_arg: {}", args.pos_arg);
    println!("opt_arg: {}", args.opt_arg);
}

structの各フィールドに書いたマクロでその引数の挙動を調整します。 arg の引数は key = value の形で渡すのですが、この key にはclapの Arg strutに実装されているものを指定できるので、詳しくは以下を見ると良いでしょう。

docs.rs

位置引数

arg マクロを使わなくとも、何も指定しない場合はデフォルトで位置引数になります。とはいえ、最低限ヘルプメッセージを実装しておくのが親切なので以下の様にします。

use clap::Parser;

#[derive(Debug, Parser)]
struct Args {
    #[arg(help = "位置引数の説明")]
    pos_arg: String,
}

この場合以下の様なヘルプメッセージが出力されます。

❯ cargo run -q -- --help
Usage: positional-arg <POS_ARG>

Arguments:
  <POS_ARG>  位置引数の説明

Options:
  -h, --help  Print help information

<POS_ARG> という表記が気に入らない場合、以下の様に任意の名前を指定できます。

use clap::Parser;

#[derive(Debug, Parser)]
struct Args {
    #[arg(value_name = "FILE", help = "位置引数の説明")]
    pos_arg: String,
}
❯ cargo run -q -- --help
Usage: positional-arg <FILE>

Arguments:
  <FILE>  位置引数の説明

Options:
  -h, --help  Print help information

エスケープされた位置引数

例えばcargoでは cargo run するとき、runした先に対して引数を渡せるように -- で区切って指定することができます。clapを使えば、これと同じ機能を自分のCLIでも実装できます。 使われ方は色々考えられますが、例えば cargo run のように他のコマンドのラッパーを書いた時に指定された引数をそのままラップしているコマンドに渡す、などを実装できます。

use clap::Parser;

#[derive(Debug, Parser)]
struct Args {
    #[arg(value_name = "FILE", help = "位置引数の説明")]
    pos_arg: String,
    #[arg(last = true, help = "ここはパースされない")]
    last_args: Vec<String>,
}
Usage: positional-arg <FILE> [-- <LAST_ARGS>...]

Arguments:
  <FILE>          位置引数の説明
  [LAST_ARGS]...  ここはパースされない

Options:
  -h, --help  Print help information

last = true を設定すると、 -- 以降の引数のみを消費するため、 -- を指定しない場合に該当する位置引数が無ければエラーになります。単に Vec<T> を指定した普通の位置引数は -- があってもなくても引数を消費します。

# これはいずれのケースでもエラーになる。実装されていないオプション引数として解釈されるため
positional-arg FILE --hoge --fuga

# last = true がある場合これはエラー。
positional-arg FILE Hoge Fuga
# -- の指定を以下の様に強制される。
positional-arg FILE -- Hoge Fuga
# -- 付きならオプション引数を指定してもエラーにならない
positional-arg FILE -- --hoge --fuga

# last = falseの場合、以下のいずれもエラーにならない。
# -- は引数として消費されないため last = trueの場合と得られる値は同じ。
positional-arg FILE Hoge Fuga
positional-arg FILE -- Hoge Fuga
positional-arg FILE -- --hoge --fuga

引数を取るオプション

例えばファイルパスやユーザ名のような文字列、リトライ回数のような整数などを取るオプションを作る場合は以下の様にします。

use clap::Parser;

#[derive(Debug, Parser)]
struct Args {
    #[arg(short = 'n', long = "name", help = "your name")]
    name: String,

    // shortやlongは値を省略するとフィールドの値から勝手に埋めてくれる
    // でもドキュメントが見つからない……
    #[arg(short, long, help = "a 32bit integer")]
    count: i32,
}

これらのパラメータが必須かどうか、複数取れるかどうかなどはフィールドの型で決まります。↑の例ではいずれも必須かつそれぞれ一つしか取れません。以下のリンクに対応表があります。

https://docs.rs/clap/latest/clap/_derive/index.html#arg-types

よくあるオプショナルな引数を作るには Option<T> 、複数の値を取れるものは Vec<T> を使えば良いです。

use clap::Parser;

#[derive(Debug, Parser)]
struct Args {
    #[arg(short, long, help = "optional value")]
    opt: Option<String>,

    #[arg(short, long, help = "multiple inputs")]
    inputs: Vec<String>,
}

複数取る場合、これは必須では無くなる(0以上の要素が許容される)ので注意が必要です。必須にしたい場合は明示的に required = true が必要になります。

引数を取らないオプション

例えば verbose フラグのような、ある機能の有効無効を決定するオプションを実装する場合に使います。単に型を bool にすればフラグになります。

#[derive(Debug, Parser)]
struct Args {
    #[arg(short, long)]
    verbose: bool,
}

bool なオプションはデフォルトで required = false になり、デフォルト値が false になります。

デフォルトを true 、指定したら false になるフラグを作りたい場合は明示的に action を指定する必要があります。

#[derive(Debug, Parser)]
struct Args {
    #[arg(short, long, action = clap::ArgAction::SetFalse)]
    non_verbose: bool,
}

環境変数と紐付ける

env featureを有効にすると使えるようになります。

https://docs.rs/clap/latest/clap/builder/struct.Arg.html#method.env

特にコンテナなどでコマンドラインフラグではなく環境変数で挙動を変えたいケースは多々あります。引数に対して環境変数を紐付け、自動で取得させることが出来ます。

#[derive(Debug, Parser)]
struct Args {
    #[arg(short, long, env, help = "Value from env")]
    from_env: String,
}

デフォルトではフィールドのアッパースネークケースがそのまま環境変数として使われますが、自分で指定することもでき、その場合は env = "ENV_VAR_NAME" とします。

ヘルプメッセージにどういう環境変数が使えるかが表示されるのが結構良いですね。

❯ cargo run -q -- --help                
Usage: env-var --from-env <FROM_ENV>

Options:
  -f, --from-env <FROM_ENV>  Value from env [env: FROM_ENV=]
  -h, --help                 Print help information

値を入れるとhelpメッセージにもそれが表示されます。

FROM_ENV=value cargo run -q -- --help                
Usage: env-var --from-env <FROM_ENV>

Options:
  -f, --from-env <FROM_ENV>  Value from env [env: FROM_ENV=value]
  -h, --help                 Print help information

ただしなんらかのクレデンシャルなどを環境変数経由で渡したいときに、そのまま出てくると困るケースがあります。こういう場合は以下の様に値が隠れるようにオプションを指定すると良いでしょう。

#[derive(Debug, Parser)]
struct Args {
    #[arg(short, long, env, hide_env_values = true, help = "Any credential")]
    credential: String,
}
FROM_ENV=value CREDENTIAL=password cargo run -q -- --help                
Usage: env-var --from-env <FROM_ENV> --credential <CREDENTIAL>

Options:
  -f, --from-env <FROM_ENV>      Value from env [env: FROM_ENV=value]
  -c, --credential <CREDENTIAL>  Any credential [env: CREDENTIAL]
  -h, --help                     Print help information

そもそも環境変数で指定できることを隠したいケース(そういうケースあるの……?)では hide_env = true とすれば [env: XXX] のような表示も消すことができます。

一つのフラグで複数の値を取る

どういう事かというと、カンマ区切りの値を受け取ってそれをカンマでsplitして Vec<T> として扱います。

use clap::Parser;

#[derive(Debug, Parser)]
struct Args {
    #[arg(
        short,
        long,
        env,
        value_delimiter = ',',
        help = "comma separated values are allowed"
    )]
    multi: Vec<String>,
}

fn main() {
    let args = Args::parse();
    println!("{:?}", args.multi);
}
❯ cargo run -q -- --multi hoge,fuga
["hoge", "fuga"]

MULTI=hoge,fuga のように環境変数経由でも Vec<T> にできます。

ただしこれはナイーブに , でsplitしているだけなので、クォートなどを解釈してくれるわけではありません。例えば hoge, fugapiyo を渡したくなって以下の様にしても期待する動作にはなりません。

❯ cargo run -q -- --multi '"hoge, fuga",piyo'
["\"hoge", " fuga\"", "piyo"]

一応自前で引数のパース処理を書くことが出来るので、TypedValueParserを実装することでこうしたケースをカバーすることも可能なはずです(未検証)。

https://docs.rs/clap/latest/clap/builder/struct.Arg.html#method.value_parser

https://docs.rs/clap/latest/clap/builder/trait.TypedValueParser.html

排他オプション

オプションが排他であるとは、option Aが指定されたとき、別のoption Bは指定できなくなる(指定するとエラー)というような関係を指します。これは例えばオプション引数で入力を受け付ける --input INPUT と、指定されると標準入力から入力を受け付ける --input-from-stdin フラグのような同じリソースを指定する2つの異なるオプションや、同時に指定すると矛盾が発生するオプションなどに適用されます。

clapでは2つの方法を使ってこれを実装できます。

  • 各argに対して confricts_withconfricts_with_allexclusive を設定して他のコマンドとの排他性を宣言する
  • ArgGroup を設定してそのうちの一つのみを許可するようにする

前者のやり方はどちらのオプションにこれを設定するかなどが問題になることから、基本的には後者でこれを行うこととします。 ArgGroup のようなtraitはないためやり方がよくわかっていませんでしたが、tutorialに書かれていました。

docs.rs

use clap::{ArgGroup, Parser};

#[derive(Parser)]
#[command(group(ArgGroup::new("how_to_input").required(true).args(["input", "input_from_stdin"])))]
struct Args {
    #[arg(long, help = "input from arg")]
    input: Option<String>,

    #[arg(long, help = "input from stdin")]
    input_from_stdin: bool,
}

fn main() {
    let args = Args::parse();
    println!(
        "input: {}, input_from_stdin: {}",
        args.input.unwrap_or("not specified".to_string()),
        args.input_from_stdin
    );
}
❯ cargo run -q -- --help 
Usage: exclusive-option <--input <INPUT>|--input-from-stdin>

Options:
      --input <INPUT>     input from arg
      --input-from-stdin  input from stdin
  -h, --help              Print help information

❯ cargo run -q -- --input-from-stdin          
input: not specified, input_from_stdin: true

❯ cargo run -q -- --input hoge
input: hoge, input_from_stdin: false

❯ cargo run -q -- --input hoge --input-from-stdin
error: The argument '--input <INPUT>' cannot be used with '--input-from-stdin'

Usage: exclusive-option <--input <INPUT>|--input-from-stdin>

For more information try '--help'

ここで勘違いしてはいけないのは、ArgGroupへ設定した required と各 arg の required は両立してしまうということです。 bool なフラグはデフォルトで false が指定されるため気付きづらいのですが、それ以外の型でデフォルト値なしもしくは Option なしでargを設定すると、それは rquired = true になってしまいます。今回の例では --inputOption<String> ではなく String にすると、 —-input-from-stdin のみを指定したときに error: The following required argument was not provided: input というエラーを見ることになるでしょう。

もう一つ気をつけなくてはいけないのは、デフォルトでは各 arg がそれぞれ ArgGroup を作ってしまう点です。通常同じstruct内では同じ名前のfieldを宣言できないのでこれらが重複することはありませんが、自分で ArgGroup を作ると同じ名前で作ることができてしまいます。これはエラーになるので、異なる名前になるようにしなければいけません。

候補から選択するオプション

予め取り得る値がいくつか決まっており、そのうち一つまたは一つ以上を選択して指定するオプション。Pythonのargparseだと choices とかで指定できたものを指します。

よくある例としてはログレベルです。いくつか定義されたレベルがあり、そのうち一つを選びます。以下はデフォルト値を設定していますが、 default_value_t を消せば指定が必須のパラメータになります。もちろん Option<LogLevel> にすればオプショナルにもできます(が、ログレベルではデフォルト値の方が普通の挙動でしょう)。

use clap::{Parser, ValueEnum};
use std::fmt::{Display, Formatter, Result};

#[derive(Debug, Parser)]
struct Args {
    #[arg(long, value_enum, default_value_t = LogLevel::Info, help = "Log level")]
    log_level: LogLevel,
}

#[derive(Debug, Clone, ValueEnum)]
enum LogLevel {
    Debug,
    Info,
    Warn,
    Error,
    Critical,
}

impl Display for LogLevel {
    fn fmt(&self, f: &mut Formatter) -> Result {
        let raw = format!("{:?}", self);
        write!(f, "{}", raw.to_uppercase())
    }
}

fn main() {
    let args = Args::parse();
    println!("log_level: {}", args.log_level);
}

N個の選択肢からM個を選ぶ場合は以下の様に Vec<T> を使えば良いです。

use clap::{Parser, ValueEnum};
use std::fmt::{Display, Formatter, Result};

#[derive(Debug, Parser)]
struct Args {
    #[arg(long, value_enum, value_delimiter = ',', help = "Fruits")]
    fruits: Vec<Fruit>,
}

#[derive(Debug, Clone, ValueEnum)]
enum Fruit {
    Apple,
    Orange,
    Banana,
}

impl Display for Fruit {
    fn fmt(&self, f: &mut Formatter<'_>) -> Result {
        write!(f, "{:?}", self)
    }
}

fn main() {
    let args = Args::parse();
    println!("fruits: {:?}", args.fruits);
}

ヘルプメッセージにはちゃんとデフォルト値や取り得る値が表示されます。

❯ cargo run -q -- --help
Usage: choices [OPTIONS]

Options:
      --fruits <FRUITS>   Fruits [possible values: apple, orange, banana]
  -h, --help              Print help information

サブコマンド

CLIツールを作る場合には基本的にサブコマンドも作ることが多いでしょう。clapではサブコマンドもderiveで実装出来ます。基本的に以下の様に実装するようです。

  1. ルートコマンドの引数のパーサーを作る( #[derive(Parser)]
  2. サブコマンドのenumを作る( #[derive(Subcommand)]
  3. ルートコマンドに2のenumを埋め込む( #[command(subcommand)]

実際のサブコマンドの実行は、2で作ったenumでmatchさせることで処理を分岐するようです。

use clap::{Args, Parser, Subcommand};

#[derive(Debug, Parser)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Debug, Subcommand)]
enum Commands {
    #[command(about = "help for hoge")]
    Hoge {
        #[arg(short, long)]
        opt: String,
    },
    #[command(about = "help for fuga")]
    Fuga(FugaArgs),
}

#[derive(Debug, Args)]
struct FugaArgs {
    #[arg(short, long)]
    opt: String,
}

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Commands::Hoge { opt } => {
            println!("hoge {}", opt);
        }
        Commands::Fuga(fuga) => {
            println!("fuga {}", fuga.opt);
        }
    }
}
❯ cargo run -q -- --help
Usage: subcommand <COMMAND>

Commands:
  hoge  help for hoge
  fuga  help for fuga
  help  Print this message or the help of the given subcommand(s)

Options:
  -h, --help  Print help information

❯ cargo run -q -- hoge --opt 1
hoge 1

グローバルなオプション

verbose フラグだったりログレベルのようなオプションはどのコマンドにも付けたいケースがあります。全てのサブコマンドにこれを毎回実装するのは手間ですが、ルートコマンドの直後でしか verbose を許容できないのも使い勝手が悪く、全コマンドに --verbose を付けられるようにしたいです。

そこで global というフラグを使うと、ルートコマンドのオプションをそのサブコマンドでも使えるようになります。

use clap::{Args, Parser, Subcommand};

#[derive(Debug, Parser)]
struct Cli {
    #[arg(short, long, global = true, help = "Global flag")]
    verbose: bool,

    #[command(subcommand)]
    command: Commands,
}

#[derive(Debug, Subcommand)]
enum Commands {
    #[command(about = "help for hoge")]
    Hoge {
        #[arg(short, long)]
        opt: String,
    },
    #[command(about = "help for fuga")]
    Fuga(FugaArgs),
}

#[derive(Debug, Args)]
struct FugaArgs {
    #[arg(short, long)]
    opt: String,

    #[arg(short, long, global = true, help = "global option for fuga")]
    global: bool,

    #[command(subcommand)]
    command: Option<SubCommands>,
}

#[derive(Debug, Subcommand)]
enum SubCommands {
    #[command(about = "help for nested")]
    Nested {
        #[arg(short, long, help="opt for nested")]
        opt: String,
    }
}

fn main() {
    let cli = Cli::parse();
    println!("verbose: {}", cli.verbose);
    match cli.command {
        Commands::Hoge { opt } => {
            println!("hoge {}", opt);
        }
        Commands::Fuga(fuga) => {
            println!("fuga {}", fuga.opt);
            println!("global {}", fuga.global);
            match fuga.command {
                Some(c) => {
                    match c {
                        SubCommands::Nested { opt } => {
                            println!("nested {}", opt);
                        }
                    }
                }
                None => {
                    println!("sub command is not specified");
                }
            }
        }
    }
}

単に arg(global = true) にするだけで、そのオプションはそのコマンド以下全てのサブコマンドで有効になります。ただし、サブコマンドの Args から急にそのパラメータが生えてくるわけではなく、あくまで global = true を設定した struct に値は入るため、ログレベルの変更などはそこで行わなければなりません。

❯ cargo run -q -- --verbose hoge --opt 1
verbose: true
hoge 1

❯ cargo run -q -- hoge --opt 1 --verbose
verbose: true
hoge 1

❯ cargo run -q -- fuga --opt 1 --verbose       
verbose: true
fuga 1
global false
sub command is not specified

「そのサブコマンド以下全て」であり、全体ではないのでこの例のように fuga サブコマンドで global なオプションを実装しても、それは hoge サブコマンドでは使えません。

❯ cargo run -q -- fuga --opt 1 --global 
verbose: false
fuga 1
global true
sub command is not specified

❯ cargo run -q -- hoge --opt 1 --global
error: Found argument '--global' which wasn't expected, or isn't valid in this context

  If you tried to supply '--global' as a value rather than a flag, use '-- --global'

Usage: subcommand hoge <--opt <OPT>>

For more information try '--help'

テスト

ここでは、単に引数のパースに関するテストのみを主眼とし、ツール全体の試験については考えないこととします。今回の実装では雑に src/main.rsユニットテストを記述していますが、 src/lib.rs ないしその他ライブラリクレートに突っ込む方が普通のやり方だと思います。

Argsに実装されている try_parse_from を使うことで任意の引数を処理させることができます。そのパース結果はResultで返ってくるので、エラーになる場合のテストなども書くことが出来ます。

例えば一つの位置引数を取る場合のテストを書いてみます。

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn one_arg() {
        // 0番目の値は実行するプログラム自身を指す。何でも良いのでここでは空文字を入れておく
        let args = Args::try_parse_from(["", "hoge"]);
        assert!(args.is_ok());
        assert_eq!(args.unwrap().pos_arg, "hoge".to_string());
    }
}

こんな感じで、引数を入力して出力をチェックする普通のユニットテストとして記述できます。

おわりに

一応一通り自分で実装して動かした物ですが、非効率な書き方をしているかもしれません。改善点などあれば指摘ください。

公式のtutorialやcoolbookにはこれ以外の例や、より詳細な実装もあるのでそちらも参照してみてください。

docs.rs

docs.rs

壁を感じたISUCON12本戦

TL; DR

  • 実力不足を痛感した
  • なんとなく計測した気になってはいけない
  • 予め用意したものはとても役に立った
    • 更にブラッシュアップしていきたい

はじめに

この記事はチームツナ缶 としてISUCON12本戦の記録です。
予選同様、下記の様な分担で参加していました。

  • pudding(自分):インフラ担当
  • taxio:アプリ担当
  • shanpu:アプリ担当

もし興味があれば、ぜひ予選のときの記事も読んでみてください。

poyo.hatenablog.jp

参加前の準備

ISUCON12予選ではデプロイの自動化などはうまく回っていましたが、反面計測周りに関しての体験が良くありませんでした。具体的には以下の様なフローになっていました。

  1. アプリやインフラへの変更をデプロイ(自動化されておりコマンド一つ)
  2. 全てのログをローテート(自動化されておりコマンド一つ)
  3. ベンチを回す
  4. 手元でpprofのコマンドを用意してポートフォワード越しに実行(もちろん実行者しか見れない)
  5. 終わったらサーバに入ってIssueに貼っておいた解析コマンドを実行してコピー
  6. まとめてIssueなりPRなりSlackで報告

問題は3つあります。

  • 手作業が多い
  • 実行者しかpprofの結果をみれない
  • 結果が一箇所にまとまっておらず、毎回どこにあるか探すことになる

これらを一挙に解決すべく、Ansibleでベンチ前後の処理を自動化しました。make bench すると以下の様な処理を実行するようになっています。

  1. スロークエリログを有効化
  2. 全てのログをローテート
  3. ユーザのインタラクションを待つ(ベンチリクエスト待ち)
  4. Enterが押されたらアプリケーションホスト上でpprofでプロファイリング
  5. ユーザのインタラクションを待つ(ベンチ終了まで待ち)
  6. Ansibleのinventoryに従いDBグループならpt-query-digestの結果を、nginxグループならalpの結果を取得し、レポートのMarkdownを生成しローカルにダウンロード
  7. pprofのダンプファイルをローカルにダウンロード
  8. ダウンロードしたレポートを一つのMarkdownにマージし、ローカルホストからghコマンドでIssueに投稿

これにより以下の様なレポートがIssueに投稿されます。本文が長すぎて投稿出来なかった場合でも、ローカルに同じ内容のMarkdownのファイルが残るのでそれを見ることが出来ます。

https://github.com/pddg/isu12f/issues/1#issuecomment-1229147907

レポートの例

結果としてベンチ前後の処理が大幅に楽になりました。
(pushしていなかったりしてcommitがない場合もあるので必ずでは有りませんが)現在のHEADを指すコミットハッシュをレポートに入れているのでどの時点の結果なのかよくわかるというのも良い点でした。 またpprofの実行結果を全ホストからダウンロードする方法も1コマンドで用意し、他のメンバーの実行したpprofでも簡単に見ることができるようになった点も大きかったです。

今回の出題内容

クッキーの代わりに椅子を生産するクッキークリッカーみたいなゲームに関する問題でした。

youtu.be

ユーザは椅子生産を効率化するアイテムでより大量の椅子を生産し、ログインボーナスを獲得してガチャを回してより強いアイテムを……みたいなサイクルを回すゲームになっていました。
アプリケーションの実態としては単なるWebアプリですが、フロントエンドのアプリケーションが運営が用意したCloudFrontから配信されていたのが少し驚かされる点でした。過去問再現が難しそう……

また、サーバが5台用意されていたところもこれまでとは違う点でした。幸いAnsibleではinventoryに2つホストを足すだけで良かったので、対応は難しくありませんでした。

やったこと

自分はインフラ担当なので主にその立場でやったことになります。

2022/8/28 20:14 追記
一時的にコードを非公開にしました。実行中のインスタンスが停止次第、再度公開する予定です。

github.com

10:00~11:00 初期セットアップ

nginx・MySQL・Go製のアプリケーションとほぼいつも通りの構成であり、迷うところはありませんでした。
alpやpt-query-digest、netdataなどを入れて、nginxやMySQLの設定をバックアップ、nginxのログをLTSVにしたりMySQLのbinlogを無効にしたりしていました。
あとはmitigations=offを入れたりしていました。ちなみに/proc/cpuinfoを見たらAMD EPYC 7763だったのでちょっとびっくりしました*1

初期スコアは600くらいでした。

11:00~12:00 作戦会議とか

アプリの仕様の共有をして、取得されたalpの結果やスロークエリログからとりあえず方針を決めました。 まずサーバが5台あり、1人1台割り当てても2台余ることから、初手でDBを別ホストに分けました。 後はインデックス貼ったりN+1潰したりをやっていこうという方針で行きました。

このあたりは何かやるだけスコアが上がるのでとても楽しかったです。

12:00~13:00 obtainItemの闇に触れる

obtainItemという関数があるのですが、こいつはobtainという名前に反して内部では状態の更新をしており返り値はどこからも利用されていませんでした。
ヤバすぎる関数なのでなんとかしたかったのですが、これの調査をしているだけでかなり時間を持って行かれたのが痛かったです。同時期の他の変更とのコンフリクトを避けるため、ひとまず内部の処理を関数に分けるリファクタを加えました。

13:00~14:00 admin関連のテーブルを別DBに分離しようとしてベンチが落ちる

DBの負荷が相変わらずなので、一部のデータだけでも分離できないかと考え、ひとまず他から参照されて無さそうなadmin_usersとadmin_sessionsのみ別DBに分離しようとしました。
use isu4 as Admin DB host by pddg · Pull Request #19 · pddg/isu12f · GitHub

が、ベンチがコケるので諦めました。この時点で20000点くらいは出ておりこのままいければそれなりに順調だった(と思う)のですが、このあたりから何をしてもスコアが伸びなくなり、焦り始めます。

14:00~15:00 generateIDで重複する

単に現在の時間をナノ秒単位で返していただけの実装がduplicate entryと言われるようになってしまったので乱数に変えられないか調べていました。
ORDER BY id ASCしているテーブルは自分たちで採番していなかったので乱数でイケると判断してrand.Int63()に変えました。

このあたりでRedis欲しいかもと言われたので、余っていた4台目にRedisを入れ設定し、環境変数にホストやポートなどを入れました。
Introduce redis by pddg · Pull Request #24 · pddg/isu12f · GitHub

15:00~16:00 alpの結果がおかしい

行き詰まってきてしまい、色々見直そうということでプロファイリング結果を見ていたのですが、叩かれているはずのエンドポイントがalpで観測出来ていませんでした。
明らかに今回最大のミスで、alpのオプションを初期のログから目grepで組み立てて運用していたのが良くないポイントでした。

明らかにこれによって解像度は上がりました(が、結局スコアに繋がる改善ができず……)。

16:00~17:00 泥沼にはまる

スコアがこの時点で14時辺りから変わっておらず、打開策を見つけられなくなっていました。

17:00~18:00 とにかく再起動試験を通す

スコアは残したいので再起動周りに手を入れ始めました。 DBが起きてくるまでアプリ側は無限ループしたり、pprof切ってログ切ってnetdataをアンインストールして……みたいな感じで計測系をひたすら外していました。

再起動して何回かベンチ回してコケないことを確認して、最高スコアの22000あたり(うろ覚えだった)が出たところで終了にしました。

結果

各チームの最終スコア

チームツナ缶はスコアを残すことに成功し、最終スコアは21710点でした。得点のあるチームの中では上から15番目(精一杯好意的な解釈)でした*2
失格するチームも多い中得点を残せたのは、flakyなfailを潰すために何度もベンチを回したり*3、丁寧に再起動周りをケアして、自分で再起動回してベンチがfailしないことを確認したおかげかなと思います。

振り返り

alpのオプションミス

明らかにこれは痛かったです。正規表現が間違っており、分離されるべきパスが全て集約されてしまったことで誤ったエンドポイントが遅いように見えていました。
アプリを見る前にログを目grepしてオプションを組み立てていたのですが、アプリのコードがそこにあるのだからそれを見て組み立てるべきでした。

シャーディングの観点の欠如

今回はユーザ同士のインタラクションがなく、完全にそれぞれ独立しているためシャーディングをすれば余っているサーバを活用できました*4。IDの採番のロジックも握っているのでどうにでもなったはずです。
が、そもそもこの発想に至っていませんでした。予選がSQLiteで複数サーバにシャーディングできなくて困った~~って話をやったばかりなのに、一体何を学んでいたんでしょうか……?

アプリケーションログの軽視

あまり良い手立てを思い浮かんでいなかったのでアプリのログは愚直にjournalctlで見ていました。
が、他のメンバーはあまり慣れておらず効率的なログの閲覧ができていませんでした。これによりデッドロックに気付くのが遅れたり、作業が遅くなったりしており、大いに改善の余地があります。

nonyleneくんがやっていたように、ログとトレーシングの基盤を用意するのがいいのかな……と考えていますが、来年の僕に期待します。 nonylene.hatenablog.jp

デッドロックの解消の知見が乏しい

/login という重いエンドポイントがありました。ここではログイン処理(パスワードチェックとかsession周りとか)に加えてログインボーナスの処理などをやっており、非常に複雑なエンドポイントになっていました。
alpで見ると全アクセス中このエンドポイントは500のケースがかなり多く、ベンチの結果にも出ていましたがそこまで気にしていませんでした。

が、実はデッドロックを引き起こしておりこれを解消しなければそもそも遊んでくれるユーザが入って来れないという状況になっていたようです。
終盤はtaxioがずっとこれを見ていてくれたのですが、何がデッドロックしているのか、どうすれば解消できるのかが掴めず最後までどうにもなりませんでした。ここがスコアを伸ばすポイントだったのに、みんな目先のN+1やスロークエリにとらわれてしまっていました。

また、自分がDBのデッドロックに詳しくなく、あまり手が出せなかったのも良くなかったです。とはいえギャップロックとかネクスキーロックくらいの知見はあるのだから、分離レベルをREAD COMMITTEDにしてみるとか*5それくらいは出せたはずでしたが、競技終了後に思い出しました。

来年に向けて

他のチームのスコアを見ると、自分たちのチームと見えている世界が違いすぎ、壁を感じました。ただし「また新しい知識を吸収して来年こそは本戦でも爪痕残すぞ」という気持ちになってきたのは良かったと思います。

幸い数日間、感想戦としてポータルやインスタンスを提供して頂けるとのことなので、冷静になった頭で色々ためしてみたいなと思います。

運営の皆さん、ありがとうございました!優勝のNaruseJunチームはおめでとうございます!
来年また対戦よろしくお願いします。


*1:勝手にXeonだと思っていました

*2:最下位だとLINEのまた会いま賞を受賞できたので惜しかったなとちょっと思ったのは秘密です

*3:合計121回回したらしい。全体で、上から4番目でした。これは明らかにベンチ周りに関して自分が手を入れた成果が出ていると感じました。

*4:ただし後述するデッドロックは解消できていなかった可能性が高く、これでもスコアは伸びなかったかもしれません

*5:これで解決する気はしませんが……

Mozilla Foundationへの寄付を始めた

TL; DR

  • WebやOSSの発展に対して継続的な支援がしたくなった
  • 自分が一番世話になっているのはブラウザなのではないかと気付いた
  • FireFoxには生き残って欲しいのでMozilla Foundationへ毎月一定額を寄付することにした

はじめに

これは後から謎の請求を見て自分が混乱しないように残しているポエムです。寄付というものは性質上強制されるものでも奨励される物でも無いと思っているので、そういう書き方はしないように心がけています。Mozillaの回し者ではないです。
意図せずそういう表現になっていたらこっそり教えて下さい。

OSSと持続可能性

社会人になり、ソフトウェアエンジニアとしてのキャリアをスタートさせて2年と少し経ちました。世の中は大きく変わりましたが、自分の仕事は相も変わらずよくわからないソフトウェアを書いて、メンテナンスして、バグを出したり直したりしています。

さて、自分は学生の頃からOSSが好きで、自分で公開したりissueを投稿したりコントリビューションしたりしてきました。例えば、修士のときにコントリビューションしたtextlintのLaTeXプラグインを今でも時々メンテナンスしていたりします*1

github.com

この程度の規模だとたまに来るIssueに対応したり時折依存関係の更新をしたりする程度で済んでいるため、それほど大きな負担があるわけではありません。積極的に機能開発をしているわけでも、大量のユーザがいるわけでもない、極々一般的なオープンソースソフトウェアです。

一方で、世の中には多数のユーザを抱え非常にクリティカルな箇所で使われるにもかかわらず、無償ないし寄付に頼ってメンテナンスが行われているソフトウェアというものが多数あります。 2021年の冬にはLog4j2の脆弱性が話題となり、無償でメンテナンスをしているメンテナーと、そのソフトウェアに強く依存する大量の営利企業の構図が比較的大きく取り扱われ、様々な議論を呼んでいました。

最近ではGitHub Sponsorsなどもありソフトウェアのメンテナを金銭的に支援する方法は増えてきたように思います*2。これによって本当にOSSというものが持続可能性を手に入れることが出来るのかは分かりませんが、少なくとも個人レベルで何らかの支援が出来る/受けられるようになってきたことはありがたい限りだなと思っています。

何を支援したらいいのか問題

現代では様々なところでOSSが利用されており、日々たくさんのソフトウェアの世話になっています。さて、自分が何かOSSを応援したい場合、どうすべきなのでしょうか?

  • コントリビューションする→これももちろんできるならやりたい
  • 全部にたくさん寄付→破産
  • 全部にちょっとずつ寄付→手数料にも満たなさそう
  • 特定のものに絞る→これが難しい

自分が世話になっている自覚のあるOSSが多岐にわたりすぎて、全部に寄付すると破産するし一つに選ぶのも難しいしかといってメンテ出来るほど詳しくもない……ということで、近年は大変そうだな〜と思ったところにまとめて寄付するようにしていました。例えば昨年はLog4j2のメンテナに少額ですが寄付させて頂きました。

自分が一番世話になっているのはブラウザではないか

今年に入ってからもう少し継続的な支援がしたいという気持ちが強くなってきたため、やっと寄付対象をまともに考え始めました。

  • The Linux Foundation
    • Linuxでメシ食わせてもらっていると言っても過言ではない
  • Free Software Foundation
    • GNUにはいつも世話になっているため
  • Let's Encrypt
    • OSSとはちょっと違うけどHTTPSの敷居を下げてくれた偉大な存在
  • Debian Project・Canonical
    • Ubuntuには仕事でもプライベートでも世話になっているため
  • Mozilla Foundation
    • 就職してからプライベートではFireFoxをメインブラウザにしているため

会社でWebブラウザセキュリティという本の輪読会に参加し、Webブラウザの担う役割の大きさに気付かされたのもあり、ここしばらく自分が最も直接的に恩恵を受けているのはブラウザなのではという思いが強くなってきました。macOSでもWindowsでもLinuxでもFireFoxを使っていますし、自分の重要な情報はプライベートでも仕事でもありとあらゆるものがブラウザを経由しているように思えます。

Webブラウザセキュリティ ― Webアプリケーションの安全性を支える仕組みを整理するwww.lambdanote.com

現代のWebブラウザが当然考慮しているであろう様々な複雑な事情・ユーザの利便性・プライバシー保護を考えると頭が上がりません。また特定のベンダーによって独占される市場は大抵ろくなことになりません。Internet Explorerが退場し、EdgeがChromiumの仲間になった今、一人のユーザとして支持するべきはMozillaなのではないかという思いが強くなってきました。

寄付の仕方

以下のリンクから可能です。PayPal、もしくはクレジットカードが選択できます。

donate.mozilla.org

Mozilla Foundationの寄付画面

自分はとりあえず560円/月から始めることにしました。いつも最新のWebブラウザを使えるというサービスのサブスクリプションを契約しているような感じで良いかなと。
収入が増えたらもう少し額を増やすか、寄付対象を増やそうと思っています。

おわりに

めちゃくちゃ少額ですが、少しでも何かの助けになると嬉しいです。

*1:ほんとはフルスクラッチでv2を作って今ある課題をどうにかしたいという野望はあるのですが、なかなか難しいですね

*2:自分が最初にそういったシステムを知ったのは確かOpen Collectiveでした。こういう方法で支援をする仕組みがあるのだなぁと驚いたのを覚えています。