Go Conference 2025 Capture The Flag 模範解答

バックエンドチームの近松です。

カンムでは、Go Conference 2025 開催前に Capture The Flag (CTF) を公開していました。皆さん、お楽しみいただけたでしょうか?

本記事では、CTF の模範解答および作問にまつわる裏話を紹介します。

作問担当者

今回の CTF では、過去に作問経験がある knee を筆頭に、今回初めて作問を担当する kshun、近松(nchika)の合計3名で作問しました。

  • knee(CTO):エグゼクティブプロデューサー担当
  • kshun(セキュリティチーム):作問担当
  • 近松(nchika)(バックエンドチーム):作問、実装担当

余談ですが、近松は不正対策の業務を担当しているため、2025年4月〜7月の間に CTF を通してセキュリティの勉強をしてきました。例えば、ハッキングラボの作り方HackerOne で、CTF の経験を積んできました。

その流れで、今回は作問担当として立候補しました。

想定ターゲットユーザー

CTF 初心者や未経験者を想定しました。

フラグ一覧

今回、レシピサイトに埋め込んだフラグ(注:明示的にフラグと書いていなかったものを含む)は、以下の3点です。

  • 他人のレシピを閲覧(権限管理の不備)
  • ログインフォームから全ユーザー名、全パスワードを取得(不適切なSQL
  • 複数のバイナリを結合し、画像を生成(観察力の要求)

「他人のレシピを閲覧」に関する解説

まず、CTF 初心者や未経験者は、ログインフォームから素直にログインするだろうと想定しました。ログイン後は、下図が示すようにダッシュボード画面からレシピ詳細画面(ぎょうざ、いくらとポテト、ピザ)を順番に確認する人が多いと想定しました。

レシピサイトのトップページ

レシピ詳細画面に遷移すると、特に API を実行できそうなボタンがありません。存在するのは、作問者が作った料理の写真と調理手順だけです。

ピザの詳細

ページから目を離してみましょう。

注意深く URL を観察すると、「レシピ詳細画面は連番を振られて管理されていそうだ」「権限の不備があれば、他のレシピ詳細ページにアクセスできそうだ」と予想できます。作問者としてはそのように予想して欲しかったので、1, 2, 3 と番号を振らずに、意図的に2, 3, 5と欠番がある状態で URL に番号を振って実装しました。

/recipe/4にアクセスすると、さしみ料理のページに辿り着きます。

さしみ料理

おめでとうございます🎉このページはフェイクです。

さしみ料理のページは、フラグをゲットしたと錯覚させるために用意しました。実際のところはもう一つ理由があり、料理の画像が余っていたので、フェイクページで画像を消費しようと考えました。なお、正解のページは、/recipe/13に仕込んであります。若い番号(= /recipe/4)で隠されたページを見つけたけれども、特にヒントがなくて困る人が出てくるといいな、と思いながら実装しました。

別解としては、curl などのコマンドを利用して、HTTP ステータスが404以外のページを集める方法を想定していました。私が過去に解いた CTF にはこのパターンがありました。

ログインフォームから全ユーザー情報を取得

次に怪しいのは、ログインフォームです。

CTF で自由入力できるテキストエリアを見ると、SQL インジェクションが決まる可能性を考える人がいることでしょう。そのような方達のために、今回はツール(例:sqlmap)を使わなくてもお手軽に SQL インジェクションできるログインフォームを作りました。

ログインフォーム

このログインフォーム(正確には認証処理)は凄まじく脆弱で、以下のような実装になっています。ユーザーから受け取ったユーザー名とパスワード(入力値そのまま)で DB を検索します。パスワードは、平文で DB に格納されています。怖いですね。

query := fmt.Sprintf("SELECT username, password FROM users WHERE username='%s' AND password='%s'", username, password)

このSQLクエリは、プレースホルダーを用いていません。プレースホルダーがある場合は、

  1. プリペアードステートメントによって SQL の構文が事前確定
  2. プレースホルダー部分を置き換えて、SQL 実行

の流れになります。言い換えると、ユーザーが不正な入力値を渡して SQL 構文が変わった場合、SQL 実行時にエラーとなります。

さて、SQL インジェクションの模範解答を以下に示します。例えば、ユーザー名に kanmu' OR '1'='1' --、パスワードに任意文字列を入力します。この例では、WHERE 句が`username='kanmu' OR '1'='1'となり、OR '1'='1'は常に true なので、全ユーザーの情報が取得できます。--部分でパスワード条件をコメントアウトしています。

SQL インジェクションが決まると、全ユーザーの情報が綺麗に整形されて表示されます。

SQLインジェクション

全ユーザー情報

なお、SQL インジェクションを問題に組み込んだ理由は、CSV に SQL を実行できる自作ライブラリ(nao1215/filesql)がサーバーでキチンと動作するかを試してみたかったからです。

バイナリ結合による画像生成

SQL インジェクションで取得した他ユーザー情報でログインすると、ステーキソースのレシピが表示されます。ステーキソースのページは、先ほど説明した正解のページ(/recipe/13)です。

ステーキソースのページ

ステーキソースのレシピ詳細画面の下部には、フラグが示されています。このフラグボタンを押すと、flag.zipのダウンロードが始まります。

ダウンロードフラグ

flag.zipは、パスワードがかかっています。ここでのパスワード突破方法は、以下の2パターンを想定していました。

  1. SQL インジェクション結果を利用(zip ユーザーのパスワードが、flag.zipのパスワード)
  2. ZIP パスワードをツールでクラック(例:John the ripper の利用)

解答者が zip ユーザーと zip ファイルを結びつけられない可能性を考え、ツールで簡単にクラックできる脆弱なパスワード("qwerty123456")としました。 zip ファイルを展開すると、以下のファイルが入っています。

  • apple
  • garlic
  • mirin
  • onion
  • soy_sauce
  • README.md

README には、以下の文章が書かれています(読みやすいように改行を加えました)。

$ cat README.md
## Go Conference 2025 ノベルティの受け取り方

本ディレクトリ内にある食材情報の中から、
ノベルティ(ステーキソース)に含まれる隠し味を
カンム社員に伝えてください。

見事正解された方には、ノベルティを進呈します。

この README を読み、「ステーキソースのページ(/recipe/13)に何かヒントが書かれているな」と推測してもらうことを期待しました。ステーキソースの調理手順を確認すると、以下の食材が使われています。

ステーキソースの作り方

この調理手順を読むと、「zip ファイルに入っていたファイル(食材名称が英語で書かれたファイル)」と「調理手順に書かれている食材」が一致しています。しかし、何をすれば良いか分からないで詰まってしまった人がいると思われます。ヒントは、調理手順に書かれている「全てが混ざり合い」の部分で、この文章はファイルを結合すると何かが表れることを示唆しています。

もう一つのヒントは、ファイル自体から情報を得ることです。バイナリアンであればバイナリを覗いたかもしれませんが、作問者としては file コマンドの利用を想定しました。以下のように file コマンドを実行すると、どうやら PNG ファイルが含まれていることが分かります。

$ file apple
apple: data

$ file garlic
garlic: data

$ file mirin
mirin: data

$ file onion
onion: PNG image data, 100 x 272, 8-bit/color RGBA, non-interlaced

$ file soy_sauce
soy_sauce: Clarion Developer (v2 and above) help data

ここまでの流れで、「ステーキソースの調理手順通りにファイルを連結すると、PNG 画像になりそうだ」と予想できます。もう一度、ステーキソースの調理手順を確認して、ファイルを結合してみましょう!

$ cat onion apple garlic soy_sauce mirin > restored.png

隠し味(フラグ)が現れました!お疲れ様でした!

隠し味

作問にまつわる裏話

CTF の作問は、kshunが「問題の答えは、ステーキソースの原材料のうちのどれかにしておく」と発言したことをキッカケに進んでいきました。

作問のキッカケ

この発言を受けて、ノベルティ(ステーキソース)の隠れていない隠し味を答えにしようと、近松は考えました。

隠れていない隠し味に対する誰かのツッコミ

その後は、おおよそ以下のような流れです。

  1. kshun :「バイナリファイルから画像(フラグ)を作ればよい」と発案
  2. 近松:「ファイル名が原材料名のバイナリファイルを複数渡して、結合させる」と発案
  3. 近松:分割したファイルをシェルスクリプトに埋め込む案を実装
  4. 近松シェルスクリプトでは可搬性の問題があるので、Go で再実装

バイナリファイルを分割してから結合し直す案は、「料理は混ぜる工程がある」、「ファイルを食材に見立てよう。分割ファイルを結合して戻すことは、分霊箱(horcrux)が実証している」と考えて、発案しました。ここで分霊箱に発想が至った理由は、私自身分かりません。

初期実装でシェルスクリプトを利用した理由は、画像を5分割してから Go 製のバイナリに埋め込んで、取り出すのが大変だったからです。「バイナリを操作するコマンド」や「バイナリからファイルを取り出す binwalk コマンド」を使って、正確にファイルを取り出せないので「これは殆どの人が対応できない」と判断しました。そこで、シェルスクリプトbase64 エンコードしたバイナリを埋め込んで、解答者に取り出してもらう方法で検証を進めました。

しかし、途中で「シェルスクリプトでは、LinuxMac で動作保証するのが大変。Windows で保証するのはもっと厳しい」と気づきました。この辺りの大変さは、Software Design 2024年12月号 第1特集第4章に書いてあります(著者:近松)。

可搬性であれば、Go を採用すべきです。しかし、Go 製のバイナリから分割ファイルを取り出せません。どうにか binwalk でファイルを抽出できないかと考えた時に、「zip にまとめれば取り出せる」と気づきました。ここからの実装はとても早く、 Go であれば Web アプリも簡単に実装できるので、フラグが想定より増えました。そう、SQL インジェクションや権限不備のフラグは、近松がノリで実装しました。ノリノリで実装した結果、バイナリに埋め込んだレシピ画像が多すぎて、binwalk で zip ファイルを取り出せなくなりました。泣く泣く、ダウンロードボタンを設けて、zip ファイルをダウンロードする仕様にしました。

最後に

カンムでは一緒に働けるメンバを募集しています。私と一緒に CTF を作問しましょう。

team.kanmu.co.jp

Go Conference 2025 Capture The Flag 公開

バックエンドチームの近松です。
株式会社カンムは、Go Conference 2025 に Silver スポンサーとして参加します!

gocon.jp

カンムは例年、プログラミングクイズや Capture The Flag(CTF) を Go Conference 前に公開しています。直近では① 脆弱性を利用してリバースプロキシを突破② バイナリの探索③ ISO 8583 メッセージのパース④ 標準ライブラリの利用ミスに関する脆弱性含むアプリケーションを公開し、皆さんに楽しんでいただきました。

2025年は、レシピサイトをモチーフにした CTF を公開します。

CTF 問題

GitHub に CTF を公開しています。

github.com

CTF 用のサーバーを起動すると、レシピサイトが立ち上がります。 また、Webサイトとしても公開しています。

gocon2025-ctf.dev.kanmu.jp

皆さんは、この脆弱なレシピサイトからフラグを探し当ててください。模範解答や作問意図は、Go Conference 2025(9/27、9/28)終了後に本テックブログで公開します。

CTF レシピサイト

CTF 正解者にはノベルティ

CTF の解答(フラグ)にたどり着いた方には、カンムからノベルティを差し上げます。

  • ノベルティの受け取り方:Go Conference 2025でカンムブースにいる社員にフラグ内容を伝え、回答が正しいこと

今回の CTF にチャレンジせずに Go Conference 2025 へ参加された方向けに、会場でクイズを準備しています。クイズに正解された方にも、ノベルティを差し上げます。

CTF に関するハッシュタグ

今回の CTF に関するご感想やご意見は、X(旧 Twitter)のハッシュタグ#kanmu2025ctf」でお待ちしております。

また、ネタバレを含むコメント、次回への要望がある方は、専用の Issue を用意してあります。こちらでご歓談ください。

【CTF出題予定!】カンムは Go Conference 2025 にシルバースポンサーとして参加します

こんにちは、サクっと資金調達 開発チーム ソフトウェアエンジニアの hata です。

カンムは、2025年9月に開催される Go Conference 2025 にシルバースポンサーとして参加します。

Go Conference には例年関わってきましたが、今年も引き続き、スポンサーという形で Go のコミュニティを応援できることを嬉しく思います。

Go は弊社のプロダクト開発においても主要な言語のひとつであり、エンジニアが日々の業務で触れている技術です。Go を取り巻く技術的な挑戦や知見は、我々にとっても常に関心のあるテーマであり、カンファレンスでの情報交換や登壇の機会を大切にしています。

セッションに登壇します

私は 「panicと向き合うGo開発 - nilawayで探る見逃されるnil参照とその対策」 をテーマに9月28日(日) 15:30 - 15:50のセッションに参加します。

発表では、Go の開発現場でよくある panic を題材に、参加者の皆さんが Go の nil に対する言語仕様や、安全性を支える設計思想への理解を一段深めることを目指します。

gocon.jp

ブース企画として CTF を出題します

当日カンムは会場にブースを出展予定です。今年のカンムのブース企画はオンラインからオフラインにまたがる取り組みを予定しています。

以前の Go Conference でも好評いただいていた CTF 19月17日 に投稿するテックブログにて出題します。 CTF を解けた方はその回答をブースにお持ちいただき、正解の場合はカンムの特製ノベルティをプレゼントします🎁

ぜひ奮ってご参加ください!

当日、Abema Towersでお会いしましょう!

当日カンムからは時間帯に応じて CTOの knee、私 hataGenki SugawaraShuhei Katsumata がブースにいる予定です。

会場でお会いできるのを楽しみにしています。

team kanmu https://team.kanmu.co.jp/

zenn https://zenn.dev/p/kanmu_dev

カンムテックブログ https://tech.kanmu.co.jp/


  1. Capture The Flag, 情報セキュリティ分野では、専門知識や技術を競い合うハッキングコンテストのことを指す

jackc/pgxのErrBadConnリトライ・target_session_attrs

プラットフォームチームの菅原です。

GolangPostgreSQLドライバ jackc/pgxについて最近まで知らなかった機能があったので紹介します。

driver.ErrBadConnでのリトライ

データベースの再起動などで切断されたコネクションをコネクションプールから引き当ててエラーになる問題について、SetConnMaxLifetime()を設定して、定期的にコネクションをリフレッシュするしかないと思っていたのですが、こちらの記事でdriver.ErrBadConnのときにリトライしてくれることを知りました。

たしかにドキュメントには

ErrBadConn should be returned by a driver to signal to the database/sql package that a driver.Conn is in a bad state (such as the server having earlier closed the connection) and the database/sql package should retry on a new connection.

と書いてあり、database/sqlのコードを読むとfunc (db *DB) retry()でリトライ処理を行っています。

// go/src/database/sql/sql.go

// maxBadConnRetries is the number of maximum retries if the driver returns
// driver.ErrBadConn to signal a broken connection before forcing a new
// connection to be opened.
const maxBadConnRetries = 2

func (db *DB) retry(fn func(strategy connReuseStrategy) error) error {
    for i := int64(0); i < maxBadConnRetries; i++ {
        err := fn(cachedOrNewConn)
        // retry if err is driver.ErrBadConn
        if err == nil || !errors.Is(err, driver.ErrBadConn) {
            return err
        }
    }

    return fn(alwaysNewConn)
}

pgxのコードではSafeToRetry()がtrueを返すときにErrBadConnを返していました。特定のエラーや、エラー発生時にデータ送信がなかった場合などにリトライが許可されるようです。

cf. https://github.com/search?q=repo%3Ajackc%2Fpgx%20SafeToRetry&type=code

// pgx/stdlib/sql.go

    if err != nil {
        if pgconn.SafeToRetry(err) {
            return nil, driver.ErrBadConn
        }
        return nil, err
    }

以下のコードで動作を確認してみました。

package main

import (
    "database/sql"
    "fmt"
    "net/url"
    "time"

    _ "github.com/jackc/pgx/v5/stdlib"
    "github.com/mattn/go-tty"
)

func main() {
    url := &url.URL{
        Scheme: "postgres",
        User:   url.UserPassword("postgres", "xxx"),
        Host:   "xxx.ap-northeast-1.rds.amazonaws.com:5432",
        Path:   "postgres",
    }

    db, err := sql.Open("pgx", url.String())

    if err != nil {
        panic(err)
    }

    defer db.Close()
    db.SetConnMaxLifetime(0)
    db.SetConnMaxIdleTime(0)
    db.SetMaxIdleConns(1)
    db.SetMaxOpenConns(1)

    tty, err := tty.Open()

    if err != nil {
        panic(err)
    }

    defer tty.Close()

    for {
        // キー入力を待つ
        tty.ReadRune()

        var n int
        err = db.QueryRow("select 1").Scan(&n)

        if err != nil {
            fmt.Println(err)
            continue
        }

        fmt.Printf("select 1 => %d\n", n)
    }
}
// database/sql/sql.go

func (db *DB) retry(fn func(strategy connReuseStrategy) error) error {
    for i := int64(0); i < maxBadConnRetries; i++ {
        err := fn(cachedOrNewConn)
        // retry if err is driver.ErrBadConn
        if err == nil || !errors.Is(err, driver.ErrBadConn) {
            return err
        }
        // リトライ時の出力を追加
        fmt.Printf("[INFO] retried with error: %s\n", err)
    }

    return fn(alwaysNewConn)
}

キー入力でselect 1を実行しながら途中でデータベースを再起動してみると、リトライされていることが確認できました。

select 1 => 1
select 1 => 1
select 1 => 1
# ここでデータベースを再起動
[INFO] retried with error: driver: bad connection
select 1 => 1

target_session_attrs

こちらは別のブログ記事で知ったのですが、go-sql-driver/mysqlにはrejectReadOnlyというパラメーターがあり、Auroraがフェイルオーバーした際に降格したreaderノードに書き込みを行う問題を回避できるようになっていました。

pgxでも同様の機能がないか調べたところtarget_session_attrsというパラメーターで接続するノードの種別を指定できるようになっていました。

cf. https://github.com/jackc/pgx/blob/70f7cad2226dc12406b105f8bb5be9c62780aaf7/pgconn/config.go#L402-L417

   switch tsa := settings["target_session_attrs"]; tsa {
    case "read-write":
        config.ValidateConnect = ValidateConnectTargetSessionAttrsReadWrite
    case "read-only":
        config.ValidateConnect = ValidateConnectTargetSessionAttrsReadOnly
    case "primary":
        config.ValidateConnect = ValidateConnectTargetSessionAttrsPrimary
    case "standby":
        config.ValidateConnect = ValidateConnectTargetSessionAttrsStandby
    case "prefer-standby":
        config.ValidateConnect = ValidateConnectTargetSessionAttrsPreferStandby
    case "any":
        // do nothing
    default:
        return nil, &ParseConfigError{ConnString: connString, msg: fmt.Sprintf("unknown target_session_attrs value: %v", tsa)}
    }

libpqにある機能ですがpgxも独自に実装しているようです。

// ValidateConnectTargetSessionAttrsReadWrite is a ValidateConnectFunc that implements libpq compatible
// target_session_attrs=read-write.
func ValidateConnectTargetSessionAttrsReadWrite(ctx context.Context, pgConn *PgConn) error {

以下のコードで動作を確認してみました。

package main

import (
    "database/sql"
    "fmt"
    "net/url"
    "time"

    _ "github.com/jackc/pgx/v5/stdlib"
)

func main() {
    params := url.Values{}
    // params.Add("target_session_attrs", "read-write")

    url := &url.URL{
        Scheme:   "postgres",
        User:     url.UserPassword("postgres", "xxx"),
        Host:     "xxx.ap-northeast-1.rds.amazonaws.com:5432",
        Path:     "postgres",
        RawQuery: params.Encode(),
    }

    db, err := sql.Open("pgx", url.String())

    if err != nil {
        panic(err)
    }

    defer db.Close()
    db.SetConnMaxLifetime(0)
    db.SetConnMaxIdleTime(0)
    db.SetMaxIdleConns(1)
    db.SetMaxOpenConns(1)

    for {
        time.Sleep(1 * time.Second)

        r, err := db.Exec("insert into test values ($1)", time.Now().String())

        if err != nil {
            fmt.Println(err)
            continue
        }

        n, _ := r.RowsAffected()
        fmt.Printf("RowsAffected: %d\n", n)
    }
}

target_session_attrsを設定しないコードを動かしてフェイルオーバーを行うと、切り替え後にERROR: cannot execute INSERT in a read-only transactionが発生しつづけてしまいます。

RowsAffected: 1
RowsAffected: 1
RowsAffected: 1
unexpected EOF
failed to connect to `user=postgres database=postgres`:
    xxx.xxx.xxx.xxx:5432 (xxx.ap-northeast-1.rds.amazonaws.com): dial error: dial tcp xxx.xxx.xxx.xxx:5432: connect: connection refused
    xxx.xxx.xxx.xxx:5432 (xxx.ap-northeast-1.rds.amazonaws.com): dial error: dial tcp xxx.xxx.xxx.xxx:5432: connect: connection refused
...
ERROR: cannot execute INSERT in a read-only transaction (SQLSTATE 25006)
ERROR: cannot execute INSERT in a read-only transaction (SQLSTATE 25006)
ERROR: cannot execute INSERT in a read-only transaction (SQLSTATE 25006)
...

target_session_attrs=read-writeを設定した場合には、切り替え後に検証が行われ書き込み可能なコネクションに接続することを確認できました。

RowsAffected: 1
RowsAffected: 1
RowsAffected: 1
unexpected EOF
failed to connect to `user=postgres database=postgres`:
    xxx.xxx.xxx.xxx:5432 (xxx.ap-northeast-1.rds.amazonaws.com): dial error: dial tcp xxx.xxx.xxx.xxx:5432: connect: connection refused
    xxx.xxx.xxx.xxx:5432 (xxx.ap-northeast-1.rds.amazonaws.com): dial error: dial tcp xxx.xxx.xxx.xxx:5432: connect: connection refused
...
failed to connect to `user=postgres database=postgres`:
    xxx.xxx.xxx.xxx:5432 (xxx.ap-northeast-1.rds.amazonaws.com): ValidateConnect failed: read only connection
    xxx.xxx.xxx.xxx:5432 (xxx.ap-northeast-1.rds.amazonaws.com): ValidateConnect failed: read only connection
RowsAffected: 1
RowsAffected: 1
RowsAffected: 1
...

まとめ

MySQLの話からなんとなく調べてみただけだったのですが有用な機能を知ることができました。

再接続やフェイルオーバー時のノード選択などの機能はライブラリに実装されず自前でライブラリを拡張することも多いのですが、このようにライブラリ側で実装されているとデータベースを運用する立場としてはとてもありがたいです。

自分の知らない機能はまだまだある気はするので、時間のあるときにでもこまごま深掘りできたらと思っています。

Go 1.24 で map が30%以上高速化!Swiss Tableとは?

Go1.24 がついに公開されましたね。その中でとても興味深い改善内容がありました。

簡単にいうと以下のような内容です。

  • map処理が30%+の高速化
  • CockroachDB1のチームで高性能なSwiss Tableを開発

なんとGo 1.24ではMapに関する処理が30%+の高速化しているそうです! それを実現しているSwiss Tableとは何かを少しみてみたいと思います。

初学者が勉強的に読んでいるので、誤った解釈などあれば教えて下さい 🙏

Go の従来の map

Go 1.23 以前の map は、バケットとオーバーフローバケット を備えた従来のハッシュテーブルが使用されていたそうです。

この方式の課題は、キャッシュの非効率性とポインタ追跡に課題があったそうです。

その問題を解決すべく選ばれたのは Swiss Table でした

cockroachdbのswissリポジトリのreadmeを読むと以下のようなドキュメントやライブラリもあるので、興味があれば覗いてみて下さい!

Swiss Table2とは?

高速なオープンアドレス方式3のハッシュテーブル実装であり、ハッシュ値の分割とSIMD(Single Instruction Multiple Data)4 命令の活用により、従来のハッシュテーブルよりも効率的な操作を実現しています。

ハッシュ値の分割:H1とH2

Swiss Tableでは、キーから生成された64ビットのハッシュ値を以下のように分割します。

  • H1(上位57ビット):テーブル内のグループ(バケット)の開始位置を決定するために使用

  • H2(下位7ビット)メタデータとして保存され、キーのハッシュシグネチャとして機能

この分割により、H1はデータの格納場所を特定し、H2はキー比較の前に候補を絞り込むためのフィルタとして機能します。

SIMD(Single Instruction Multiple Data)の活用

SIMDは、単一の命令で複数のデータを同時に処理する技術です。Swiss Tableでは、SIMD命令を使用して、メタデータの複数のバイトを一度に比較することで、検索や挿入操作の効率を大幅に向上させています。

具体的な手順

  1. メタデータの読み取り:H1で特定されたグループのメタデータ(複数のコントロールバイト)をSIMDレジスタに読み込み
  2. H2との比較SIMD命令を使用して、読み込んだメタデータとターゲットのH2を同時に比較
  3. 結果の解析:比較結果から、有効な候補の位置を特定し、実際のキーと値の比較

この方法により、複数のスロットを一度に検査でき、キャッシュの局所性を高め、分岐予測のミスを減らすことで、全体的なパフォーマンスが向上します。

これらの工夫により、Swiss Tableは高い効率性とパフォーマンスを実現しているようです。

CockroachDBのSwiss Table は何が違うのか

では、CockroachDBのSwiss Tableはどう実装されていることで、30%以上のパフォーマンス向上を果たしているのかを見てみましょう。

基本的には、GoogleのSwiss Tableの設計を踏襲しているようですね。

  • オープンアドレス方式とメタデータ配列
    • ここで、各スロットに1バイトのコントロールメタデータ)を持たせ、空・削除済み・使用中を示す点が説明されています。

https://github.com/cockroachdb/swiss/blob/main/map.go#L1468-L1473:embed:lang=go https://github.com/cockroachdb/swiss/blob/main/map.go#L25-L38:embed:lang=go

  • SIMD(Single Instruction Multiple Data)の活用
    • ARM向けのSWAR(SIMD Within A Register)による処理についても記載されており、複数スロットを一括でチェックする仕組みが解説されています。

https://github.com/cockroachdb/swiss/blob/main/map.go#L35-L38:embed:lang=go

  • 探索戦略(プロービング)
    • H1(上位57ビット)を使ってグループの開始位置を決定し、グループ内のコントロールバイト(H2)を調べることで候補を絞り込む方法が説明されています。
    • また、probeSeq 型の実装部分にも、グループごとの線形探索とグループ間の二次探索(quadratic probing)の流れが示されています。

https://github.com/cockroachdb/swiss/blob/main/map.go#L52-L59:embed:lang=go

  • バケットサイズとキャッシュ最適化
    • Google版ではメタデータとスロットが分離しているのに対し、CockroachDB版(この実装)では8個のコントロールバイトと8個のスロットをグループ化することで、同一キャッシュライン内でアクセスできるようにしている点が説明されています。

https://github.com/cockroachdb/swiss/blob/main/map.go#L40-L50:embed:lang=go

  • 削除処理の最適化
    • Tombstone(削除済みのフラグ)を使い、かつ隣接スロットの状態をチェックすることで、プロービングの不整合を防ぎながら効率的に削除を行う方法が述べられています。

https://github.com/cockroachdb/swiss/blob/main/map.go#L61-L69:embed:lang=go

https://github.com/cockroachdb/swiss/blob/main/map.go#L71-L93:embed:lang=go

ざっくりとまとめるとこんな感じでしょうか?

Google Abseil(C++ CockroachDB swiss(Go)
メタデータ管理 メタデータとスロットを分離 メタデータとスロットを同じキャッシュラインに配置
キャッシュ最適化 N-1スロット + N+groupSizeメタデータ 8スロット + 8メタデータでキャッシュ効率を向上
探索アルゴリズム グループ単位の線形探索 線形 + 二次探索を組み合わせたハイブリッド探索
リサイズ バケットを一斉にリサイズ 拡張可能なハッシュで局所的にリサイズ
削除処理 Tombstone利用 Tombstoneに加え、隣接スロットのチェックで最適化
SIMD最適化 x86: SIMD / ARM: SWAR x86: SIMD / ARM: SWAR + メタデータ最適化

今後の改善余地は?

このツイートを読むと、SIMDの使用率の改善、削除処理の向上、サイズ変更戦略の変更でより改善の余地を残しているそうです!


  1. CockroachDB githubの文言を引用しますが、

    CockroachDBは、トランザクション処理と強い一貫性を持つキーバリューストア上に構築された分散型SQLデータベースです。水平スケーリングが可能で、ディスク、マシン、ラック、さらにはデータセンターの障害にも最小限の遅延で対応し、手動の介入を必要としません。強い一貫性を持つACIDトランザクションをサポートし、データの構造化、操作、およびクエリのための馴染みのあるSQL APIを提供します。

    普段、分散型SQLデータベースを作成しているチームがコントリビュートしてくれていたのですね。 そのチームが開発したSwiss Tableが今回の改善に大きく貢献しているようです。

  2. SwissTable: A High-Performance Hash Table Implementationを参考
  3. オープンアドレス
  4. SIMD 【Single Instruction/Multiple Data】

Goのコネクションプーリングまわりのメトリクス収集

プラットフォームチームの菅原です。

Goでデータベースを使う場合には、以下のメソッドでコネクションプーリングまわりの設定を調整することが多いと思います。

MaxOpenConnsを設定してアプリケーションからの接続がデータベースのリミットを超えないようにしたり、MaxIdleConnsを設定してアイドルコネクションを保持し接続が都度発生しないようにしたりしますが、サービスにデプロイされた後はその設定が正しいか調べるためメトリクスが必要になります。

func (db *DB) Stats()はそれらコネクションプーリングの統計データを返すメソッドで、以下のような構造体を返します。

type DBStats struct {
    MaxOpenConnections int // Maximum number of open connections to the database.

    // Pool Status
    OpenConnections int // The number of established connections both in use and idle.
    InUse           int // The number of connections currently in use.
    Idle            int // The number of idle connections.

    // Counters
    WaitCount         int64         // The total number of connections waited for.
    WaitDuration      time.Duration // The total time blocked waiting for a new connection.
    MaxIdleClosed     int64         // The total number of connections closed due to SetMaxIdleConns.
    MaxIdleTimeClosed int64         // The total number of connections closed due to SetConnMaxIdleTime.
    MaxLifetimeClosed int64         // The total number of connections closed due to SetConnMaxLifetime.
}

カンムのサービスではモニタリング・ログ収集にDatadog、Goアプリからのログ出力にzerologを使っているので、アプリ起動時に以下のようなgoroutineを実行してコネクションプーリングの統計データをログ出力するようにしてみました。

// goroutine
func LogDBStats(db *sql.DB, host string, interval time.Duration, logger *zerolog.Logger) func() {
    ticker := time.NewTicker(interval)
    done := make(chan struct{})

    go func() {
        for {
            select {
            case <-done:
                return
            case <-ticker.C:
                logger.Info().
                    Str("log_type", "db_stats").
                    Str("host", host).
                    Interface("db_stats", db.Stats()).
                    Msg("Database statistics")
            }
        }
    }()

    return func() {
        ticker.Stop()
        close(done)
    }
}
// アプリ側
if cfg.DBStatsIntervalSec > 0 {
    interval := time.Duration(cfg.DBStatsIntervalSec) * time.Second
    cleanup := utils.LogDBStats(db, cfg.DBHost, interval, &logger)
    defer cleanup()
}

こうするとDatadogのログ管理でコネクションプーリングの統計データを見られるようになります。

またログの属性値からグラフを作成することも可能です。

これでコネクションプーリングまわりをモニタリングできるようになって便利になりました…

…と、思っていたのですが、dd-trace-goを調べてみたらv1.63.0でDB Statsを収集するための変更が入っていました。

github.com

なのでcontrib/database/sqlsqltrace.Open()/OpenDB()を使っている場合、WithDBStats()を使えば普通にメトリクスを収集することができます。

db := sqltrace.OpenDB(connector, sqltrace.WithServiceName("my-service"), sqltrace.WithDBStats())

Datadogのログ管理でメトリクスを収集する場合、メトリクスの保持期間がログの保持期間と同じなってしまうので、長期的にメトリクスを保持したい場合はWithDBStats()を使った方が良さそうです。

データベースの固定パスワードをなくす

プラットフォームチームの菅原です。

カンムのサービスで使われている各種アプリケーション(Goアプリ・管理アプリ・Redash等)では、データベースに接続する場合に一般的なパスワード認証を使っていることが多いです。

しかし、パスワード認証はパスワード漏洩のリスクやパスワード管理の手間があり、また要件によっては定期的なパスワードの変更も必要になってきます。 単純な方法で安全にパスワードをローテーションしようとすると、新しいDBユーザーを作成し、アプリケーションの接続ユーザーを変更し、さらに必要であれば元のDBユーザーのパスワードを変更して、接続ユーザーを元に戻す…などのオペレーションが必要になります。

そこで、AWS RDS(PostgreSQL)の「Secrets Managerによるマスターユーザーパスワードのパスワード管理」と「IAMデータベース認証」を利用してシステムから固定パスワードをなくすようにしてみました。

Secrets Managerによるマスターユーザーパスワードの管理

docs.aws.amazon.com

「Secrets Managerによるマスターユーザーパスワード管理」はRDSのマスターユーザーパスワードをSecrets Managerに管理させて定期的にパスワードをローテーションさせる機能です。 パスワードを管理するSecretは自動的に作成されるので、RDS側の設定を変更するだけで機能は有効になります。

terraformでの設定は以下のようになります。

resource "aws_rds_cluster" "my_db" {
  cluster_identifier = "my-db"
  # ...
  manage_master_user_password = true

  # aws secretsmanager get-secret-value \
  #   --secret-id $(
  #    aws rds describe-db-clusters \
  #      --db-cluster-identifier my-db \
  #      --query 'DBClusters[0].MasterUserSecret.SecretArn' \
  #      --output text
  #   ) \
  #   --query SecretString --output text
}

Secret自体は自動作成されるのですがローテーション間隔やポリシーは管理したいので、作成されたSecretをterraformにインポートします。

resource "aws_secretsmanager_secret_rotation" "my_db" {
  secret_id = aws_rds_cluster.my_db.master_user_secret.0.secret_arn

  rotation_rules {
    automatically_after_days = 7
  }
}

resource "aws_secretsmanager_secret_policy" "my_db" {
  secret_arn = aws_rds_cluster.my_db.master_user_secret.0.secret_arn

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "secretsmanager:GetSecretValue"
        Condition = {
          ArnNotEquals = {
            "aws:PrincipalArn" = "(アクセスを許可するIAMロール)"
          }
        }
        Effect    = "Deny"
        Principal = "*"
        Resource  = "*"
      }
    ]
  })
}

オンラインの設定変更でも特にダウンタイムが発生するようなこともなく、簡単にマスターユーザーパスワードのローテーションを自動化することができました。

IAMデータベース認証

docs.aws.amazon.com

「IAMデータベース認証」はパスワードの代わりに一時的な認証トークンを生成して許可されたIAMロールからデータベースに接続できるようにする機能です。認証トークンの有効期限は15分で、データベースに接続できれば基本的にコネクションは維持されます。

マスターユーザーパスワードと違ってアプリケーションで使われているので、アプリケーション側の修正も必要になります。

RDS・IAMの設定

「IAMデータベース認証」を有効にするには、まずRDSの設定を変更します。

resource "aws_rds_cluster" "my_db" {
  cluster_identifier = "my-db"
  # ...
  iam_database_authentication_enabled = true
}

PostgreSQLの場合、接続するDBユーザー(ロール)にrds_iamロールを付与します。

GRANT rds_iam TO app_db_user;

rds_iamを付与するとIAM認証以外では接続できなくなるので注意が必要です。 IAM認証を有効化する際には、IAM認証用のユーザーを新しく作成して、アプリケーションの接続ユーザーをそちらに変更するようにしました。

DBに接続するEC2インスタンスやECSタスクのIAMロールにはrds-db:connectの権限を付与します。

resource "aws_iam_role_policy" "app_db_connect" {
  role = aws_iam_role.app.name
  name = "db-connect"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = "rds-db:connect"
        Resource = "arn:aws:rds-db:ap-northeast-1:123456789012:dbuser:${aws_rds_cluster.my_db.cluster_resource_id}/app_db_user"
      }
    ]
  })
}

アプリケーション側の変更

Goアプリ

database/sqlsql.Open()はコネクションプールを返すので、sql.Open()を呼び出すタイミングと実際にデータベースに接続するタイミングは異なります。IAM認証を使う場合、実際にデータベースに接続するタイミングで認証トークンを生成するように設定する必要があります。

PostgreSQL用のドライバjackc/pgxではstdlib.GetConnector()にオプションを渡すことでデータベース接続のコールバックを設定することができます。

実際のコードは以下のような感じになりました。

func ConnectDB(dsn *url.URL, iamAuth bool) (*sql.DB, error) {
    opts := []stdlib.OptionOpenDB{}

    if iamAuth {
        host := dsn.Hostname()
        port := dsn.Port()
        username := dsn.User.Username()

        if !strings.HasSuffix(host, ".rds.amazonaws.com") {
            var err error
            host, err = net.LookupCNAME(host)

            if err != nil {
                return nil, err
            }

            host = strings.TrimSuffix(host, ".")
        }

        // import "github.com/jackc/pgx/v5/stdlib"
        opts = append(opts, stdlib.OptionBeforeConnect(func(ctx context.Context, cc *pgx.ConnConfig) error {
            awscfg, err := config.LoadDefaultConfig(ctx)

            if err != nil {
                return err
            }

            // import "github.com/aws/aws-sdk-go-v2/feature/rds/auth"
            token, err := auth.BuildAuthToken(ctx, host+":"+port, awscfg.Region, username, awscfg.Credentials)

            if err != nil {
                return err
            }

            cc.Password = token
            return nil
        }))
    }

    cfg, err := pgx.ParseConfig(dsn.String())

    if err != nil {
        return nil, err
    }

    // import "github.com/jackc/pgx/v5/stdlib"
    connector := stdlib.GetConnector(*cfg, opts...)
    db := sql.OpenDB(connector)
    return db, nil
}

データベースのエンドポイントにはCNAMEのエイリアスをつけているので、net.LookupCNAME()で実際のエンドポイントを取得しています。

Djangoアプリ

管理用のDjangoアプリはlabd/django-iam-dbauthで対応しました。

DATABASES = {
    "default": {
        "HOST": "<hostname>",
        "USER": "<user>",
        "NAME": "<db name>",
        "ENGINE": "django_iam_dbauth.aws.postgresql",
        "OPTIONS": {
            "use_iam_auth": True,
            "sslmode": "require",
            "resolve_cname_enabled": True,
        }
    }
}

CNAMEの解決機能が実装されているのが便利です。

DBマイグレーション

GoアプリのDBマイグレーションにはAlembicを使っています。 データベースのパスワードは環境変数経由でAlembicに渡されます。

環境変数で認証トークンを渡す場合、AWS CLIで生成することが多いのですが「AWS CLI一式をDockerイメージに入れたくない」「ホストやユーザー名の渡し方を簡潔にしたい」「CNAMEの解決をしたい」といった理由から、認証トークンを生成するCLI rdsauthを作成しました。

github.com

rdsauthはDB URLを渡すと認証トークンを生成します。また-eオプションをつけることでexport PGPASSWORD=...の形式で出力することもできます。

rdsauthを使って、以下のようにDBマイグレーションを実行するようにしました。

export DB_USER=...
export DB_HOST=...
export DB_PASSWORD=$(rdsauth postgres://${DB_USER}@${DB_HOST})

if [ "$MIGRATE" = "true" ]; then
  python manage.py migrate
else
  python manage.py showmigrations
fi

AlembicをIAM認証に対応させる上で少しつまずいたのがconfigparserのエスケープです。 env.pyで以下のようにしてDB接続情報を渡すことがあると思うのですが、configは内部的にconfigparserを使っているため%エスケープが必要になります。

# dbpass = os.environ.get("DB_PASSWORD") # NG
dbpass = os.environ.get("DB_PASSWORD").replace("%", "%%")
config.set_section_option("alembic", "DB_PASSWORD", dbpass)

connectable = engine_from_config(
    config.get_section(config.config_ini_section),
    # ...
)

psql

基本的にpsqlで直接データベースに接続することはないのですが、どうしてもpsqlでの作業が必要になることがあります。 そのための作業用ユーザーもIAM認証で接続するようにしました。

psqlの接続情報は.pg_service.confで管理されており、$(rdsauth -e postgres://..) ; psql service=my-dbと実行すればデータベースに接続することができるのですが、rdsauthの接続情報は.pg_service.confから自動的に取得してほしかったので簡単なラッパースクリプト pxを作成しました。

#!/bin/bash
PC_SERVICE_CONF=~/.pg_service.conf

if [ $# -eq 0 ]; then
  echo "usage: px <service> [extra-args ...]"
  echo -e "\nservice:"
  grep '^\[' $PC_SERVICE_CONF | tr -d '[]' | sed 's/^/  - /'
  exit 0
fi

set -eo pipefail

NAME=$1
shift

JSON=$(ini2json $PC_SERVICE_CONF | jq --arg name "$NAME" '.[$name]')

if [ "$JSON" = "null" ]; then
  echo "error: service not found - $NAME"
  exit 1
fi

DB_HOST=$(echo "$JSON" | jq -r '.host')
DB_PORT=$(echo "$JSON" | jq -r '.port')
DB_USER=$(echo "$JSON" | jq -r '.user')

$(rdsauth -e "postgres://${DB_USER}@${DB_HOST}:${DB_PORT}")

psql service="$NAME" "$@"

px my-dbを実行することでIAM認証を意識せずにpsqlでデータベースに接続できます。

Terraform(PostgreSQL)

PostgreSQLのロールはterraform(cyrilgdn/terraform-provider-postgresql)で管理しているので、これもIAM認証で接続するようにしました。

プロバイダーにはrdsauthを使って環境変数経由で認証トークンを渡すこともできるのですが、aws_rds_iam_authというオプションを有効にすればプロバイダー内部で認証トークンを生成してくれます。

terraform {
  required_providers {
    postgresql = {
      source  = "cyrilgdn/postgresql"
      version = ">= 1.25"
    }
  }
}

variable "aws_rds_iam_auth" {
  type    = bool
  default = false
}

provider "postgresql" {
  # ...
  aws_rds_iam_auth = var.aws_rds_iam_auth
}

Redash

Redashは、Redash本体のデータベースへの接続とPostgreSQLデータソースの接続でIAM認証をすることになりますが、残念ながら今のところどちらもIAM認証には対応していません。

しかし、修正自体はそれほど難しくはないのでPull Requestを作成しました。マージされることを祈るばかりです。

github.com github.com

まとめ

AWS RDSの機能を使って固定パスワードをなくす話を書きました。

今までは積極的にこれらの機能を使ってこなかったのですが、やってみると特に問題なく導入することができました。 パスワードのローテーション作業は気をつかう手間のかかる作業なので、今後はなるべくデフォルトで有効にして運用コストを下げていきたいと考えています。