Article:

k6はjavascriptでシナリオが書ける負荷試験ツールですが、実際のところES6相当のjavascriptしかサポートされていません。 大抵の場合これで十分ではあるのですが、cryptoをはじめとした複雑な処理を行うNode.jsのモジュールが必要になった場合に困ってしまいます。

1つの解決策としてpolyfillを使ってNode.jsのモジュールをバンドルすることもできなくはないのですが、生成後のスクリプトのサイズが非常に大きくなり処理も遅くなってしまう為、負荷をかける側にとっては望ましくありません。

では、どうするのが良いのかというと、実はk6は拡張性が非常に高くxk6というビルドツールを使うことで独自のエクステンションをgo言語で記述し、それがインストールされたオリジナルのk6バイナリを簡単にビルドすることができます。

xk6のエクステンションは主に例えば負荷試験のカスタムメトリクスを取得したり、結果を独自のDBやAPIに送信するといったことに多く使われています。今では公式にバンドルされているようですが、例えばログをlokiに送信するといった処理も、はじめはこのxk6で開発されていたようです。

本記事では、そんなxk6のエクステンションの作例として、私がxk6を使ってjavascriptから呼び出し可能なGoのカスタム関数をk6に追加した方法を記録しておきます。

xk6のインストール

いつも通り、go installでインストールできます。

1
go install go.k6.io/xk6/cmd/xk6@latest

xk6の使い方

xk6はhelpコマンド等がなく何も知らずに触ると困惑するのですが、大きく分けて2つのモードがあります。

1. ビルドモード

xk6 buildからなるコマンドです。これを使うことで、k6にカスタムモジュールを追加したk6をビルドすることができます。

extensionはk6の公式ページで複数公開されており、例えば簡易的なダッシュボードのwebサーバーを建てることができるxk6-dashboardを含めたビルドを作成したい場合は、次のコマンドを使うことでビルドすることができます。

1
xk6 build --with github.com/grafana/xk6-dashboard

すごく簡単ですね。--withフラグを複数回書くことで、複数の拡張を追加することもできます。

2. ローカル開発モード

今回主に使うのはこのモードです。このモードはbuild以外の全てのサブコマンドがこちらのモードになります。

言い方が難しいのですが、build以外のサブコマンドでxk6を実行すると、まずカレントディレクトリのgoモジュールをプラグインとして読み込み、一度ビルドを行います。その後、自動的にxk6コマンドの引数を渡す形でビルドしたカスタムなk6を実行してくれます。

つまるところ、xk6 run test.jsというコマンドを実行することで、今自分が開発中のカスタムk6で、test.jsを実行することができるということです。

カスタム関数を追加する

プロジェクトディレクトリを切り、go mod init <好きな名前>で初期化します。

次にmodule.goを作成します。(なんでもよい)

module.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package module

import (
    "fmt"
	"go.k6.io/k6/js/modules"
)

// xk6がモジュールからinit()を名前で探して実行してくれる
func init() {
    fmt.Println("[totegamma] Registering MyModule")
	modules.Register("k6/x/mymodule", new(MyModule))
}

type MyModule struct {
}

func (m *MyModule) SayHello() {
    fmt.Println("[totegamma] Hello from MyModule!")
}

作成したら、go mod tidyでパッケージだけ準備しておいて、試しにxk6 versionを実行してみましょう。

実行結果
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
$ xk6 version
2024/08/18 20:48:14 [INFO] Temporary folder: /tmp/buildenv_2024-08-18-2048.1758456342
2024/08/18 20:48:14 [INFO] Initializing Go module
2024/08/18 20:48:14 [INFO] exec (timeout=10s): /home/totegamma/.asdf/shims/go mod init k6
go: creating new go.mod: module k6
2024/08/18 20:48:14 [INFO] Replace customk6 => /home/totegamma/customk6
2024/08/18 20:48:14 [INFO] exec (timeout=0s): /home/totegamma/.asdf/shims/go mod edit -replace customk6=/home/totegamma/customk6
2024/08/18 20:48:14 [INFO] exec (timeout=0s): /home/totegamma/.asdf/shims/go mod tidy -compat=1.17
go: warning: "all" matched no packages
2024/08/18 20:48:14 [INFO] Pinning versions
2024/08/18 20:48:14 [INFO] exec (timeout=0s): /home/totegamma/.asdf/shims/go mod tidy -compat=1.17
go: found customk6 in customk6 v0.0.0-00010101000000-000000000000
go: finding module for package golang.org/x/exp/slices
go: finding module for package github.com/nxadm/tail
go: found github.com/nxadm/tail in github.com/nxadm/tail v1.4.11
go: found golang.org/x/exp/slices in golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa
2024/08/18 20:48:14 [INFO] Writing main module: /tmp/buildenv_2024-08-18-2048.1758456342/main.go
2024/08/18 20:48:14 [INFO] exec (timeout=0s): /home/totegamma/.asdf/shims/go mod tidy -compat=1.17
2024/08/18 20:48:14 [INFO] Build environment ready
2024/08/18 20:48:14 [INFO] Building k6
2024/08/18 20:48:14 [INFO] exec (timeout=0s): /home/totegamma/.asdf/shims/go mod tidy -compat=1.17
2024/08/18 20:48:14 [INFO] exec (timeout=0s): /home/totegamma/.asdf/shims/go build -o /home/totegamma/customk6/k6 -ldflags=-w -s -trimpath
2024/08/18 20:48:15 [INFO] Build complete: ./k6
2024/08/18 20:48:15 [INFO] Cleaning up temporary folder: /tmp/buildenv_2024-08-18-2048.1758456342
2024/08/18 20:48:15 [INFO] Running [./k6 version]

[totegamma] Registering MyModule
k6 v0.53.0 (go1.22.5, linux/amd64)
Extensions:
  customk6 (devel), k6/x/mymodule [js]

$

Extensionsに自前のモジュールが登録されていることがわかり、また27行目にinit()が呼ばれた際のログが出力されていることが確認できますね。

(余談ですが、最近こういうログに自分の名前を入れると、目のカクテルパーティー効果(あるのか?)で見つけやすいことに気づきました。おすすめです。)

カスタム関数を呼び出す

カスタム関数は登録した際のパッケージ名で引くことができます。次の通りです。

test.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import mymodule from 'k6/x/mymodule';

export const options = {
    vus: 1,
    iterations: 3,
}

export default function() {
    mymodule.sayHello();
}

これを実行すると、次のように出力されます。

実行結果
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
$ xk6 run test.js
~中略~
2024/08/18 20:57:46 [INFO] Running [./k6 run test.js]

[totegamma] Registering MyModule

          /\      |‾‾| /‾‾/   /‾‾/
     /\  /  \     |  |/  /   /  /
    /  \/    \    |     (   /   ‾‾\
   /          \   |  |\  \ |  ()  |
  / __________ \  |__| \__\ \_____/ .io

     execution: local
        script: test.js
        output: -

     scenarios: (100.00%) 1 scenario, 1 max VUs, 10m30s max duration (incl. graceful stop):
              * default: 3 iterations shared among 1 VUs (maxDuration: 10m0s, gracefulStop: 30s)

[totegamma] Hello from MyModule!
[totegamma] Hello from MyModule!
[totegamma] Hello from MyModule!

     data_received........: 0 B 0 B/s
     data_sent............: 0 B 0 B/s
     iteration_duration...: avg=25.78µs min=13.39µs med=15.66µs max=48.31µs p(90)=41.78µs p(95)=45.04µs
     iterations...........: 3   19951.451468/s


running (00m00.0s), 0/1 VUs, 3 complete and 0 interrupted iterations
default ✓ [======================================] 1 VUs  00m00.0s/10m0s  3/3 shared iters
> 08/18 20:58 ~/customk6$

Hello from MyModule!がiterationsで指定した通り3回出力されていますね。どうやらカスタム関数が正常に登録され、実行されているようです。

もう少し発展的な例

ありそうなケースとして、例えばパスワードのハッシュ化を行う関数を追加してみましょう。

module.go
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
package module

import (
    "fmt"
	"go.k6.io/k6/js/modules"
    "crypto/md5"
)

// init()
func init() {
	modules.Register("k6/x/mymodule", new(MyModule))
}

type MyModule struct {
}

func (m *MyModule) Hash(data, salt string) string {
    return fmt.Sprintf("%x", md5.Sum([]byte(data + salt)))
}
test.js
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import mymodule from 'k6/x/mymodule';

export const options = {
    vus: 1,
    iterations: 3,
}

export default function() {
    const mypassword = `mypassword${__VU}:${__ITER}`;
    console.log(mymodule.hash(mypassword, "mysalt"));
}

これを実行すると、次のように出力されます。

実行結果
1
2
3
INFO[0000] 81acdcc3c7c3cc945dd2e7ef3ccc565c              source=console
INFO[0000] 1c41f3ba0bb749c20e601bdbeaad2f79              source=console
INFO[0000] 60527d1e53740a5425f2249eb206b501              source=console

このように、非常に直感的な形でjavascript<->go間でデータのやり取りができることが分かりました。