👋

負荷テストツール「k6」入門

2024/02/08に公開

こんにちは。
PharmaX でエンジニアをしている諸岡(@hakoten)です。

この記事の概要

APIの負荷テストツールにGrafana Labs社が開発している「k6」というツールがあります。
k6はオープンソースのCLIツールですが、 「Grafana Cloud k6」というクラウドベースSaaSツールも提供されている便利なツールです。

https://k6.io/

https://grafana.com/products/cloud/k6/

ローカルのk6は、負荷テストの時に使ったことはあったのですが、真面目に負荷テストの設計をするにあたり、ちゃんと理解したかったため、改めて基本から調べてみました。k6の入門記事としてお役に立てれば嬉しいです。

インストール

Macでは、k6を「Homebrew」でインストールすることができます。

brew install k6

その他の環境については、以下の公式ページを参照ください。

https://grafana.com/docs/k6/latest/get-started/installation/

テスト実行の基本

k6を使ったテスト実行について、基本的な書き方について紹介します。

テストファイルを作成する

k6 new

k6 new コマンドでテストファイルのテンプレートを作成することができます。
k6 new <ファイル名> と指定することで、ファイル名を指定してテンプレートを作成することもできます。

k6 new hoge.js
出力されるテンプレート
hoge.js
import http from 'k6/http';
import { sleep } from 'k6';

export const options = {
  // A number specifying the number of VUs to run concurrently.
  vus: 10,
  // A string specifying the total duration of the test run.
  duration: '30s',

  // The following section contains configuration options for execution of this
  // test script in Grafana Cloud.
  //
  // See https://grafana.com/docs/grafana-cloud/k6/get-started/run-cloud-tests-from-the-cli/
  // to learn about authoring and running k6 test scripts in Grafana k6 Cloud.
  //
  // ext: {
  //   loadimpact: {
  //     // The ID of the project to which the test is assigned in the k6 Cloud UI.
  //     // By default tests are executed in default project.
  //     projectID: "",
  //     // The name of the test in the k6 Cloud UI.
  //     // Test runs with the same name will be grouped.
  //     name: "hoge.js"
  //   }
  // },

  // Uncomment this section to enable the use of Browser API in your tests.
  //
  // See https://grafana.com/docs/k6/latest/using-k6-browser/running-browser-tests/ to learn more
  // about using Browser API in your test scripts.
  //
  // scenarios: {
  //   // The scenario name appears in the result summary, tags, and so on.
  //   // You can give the scenario any name, as long as each name in the script is unique.
  //   ui: {
  //     // Executor is a mandatory parameter for browser-based tests.
  //     // Shared iterations in this case tells k6 to reuse VUs to execute iterations.
  //     //
  //     // See https://grafana.com/docs/k6/latest/using-k6/scenarios/executors/ for other executor types.
  //     executor: 'shared-iterations',
  //     options: {
  //       browser: {
  //         // This is a mandatory parameter that instructs k6 to launch and
  //         // connect to a chromium-based browser, and use it to run UI-based
  //         // tests.
  //         type: 'chromium',
  //       },
  //     },
  //   },
  // }
};

// The function that defines VU logic.
//
// See https://grafana.com/docs/k6/latest/examples/get-started-with-k6/ to learn more
// about authoring k6 scripts.
//
export default function() {
  http.get('https://test.k6.io');
  sleep(1);
}

テスト実行

テストの実行は、k6 run <ファイル名>で行うことができます。

k6 run script.js
テスト実行のサンプル
k6 run script.js

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

     execution: local
        script: script.js
        output: -

     scenarios: (100.00%) 1 scenario, 10 max VUs, 1m0s max duration (incl. graceful stop):
              * default: 10 looping VUs for 30s (gracefulStop: 30s)

     data_received..................: 2.9 MB 94 kB/s
     data_sent......................: 28 kB  895 B/s
     http_req_blocked...............: avg=18.94ms  min=2µs      med=10µs     max=509.55ms p(90)=17.1µs   p(95)=55.74µs
     http_req_connecting............: avg=7.97ms   min=0s       med=0s       max=217.76ms p(90)=0s       p(95)=0s
     http_req_duration..............: avg=207.13ms min=172.76ms med=201.73ms max=420.72ms p(90)=219.72ms p(95)=251.84ms
       { expected_response:true }...: avg=207.13ms min=172.76ms med=201.73ms max=420.72ms p(90)=219.72ms p(95)=251.84ms
     http_req_failed................: 0.00%  ✓ 0250
     http_req_receiving.............: avg=8.81ms   min=33µs     med=160.5µs  max=210.73ms p(90)=266.6µs  p(95)=897.19µs
     http_req_sending...............: avg=46.04µs  min=7µs      med=43µs     max=403µs    p(90)=60.2µs   p(95)=80µs
     http_req_tls_handshaking.......: avg=8.07ms   min=0s       med=0s       max=219.54ms p(90)=0s       p(95)=0s
     http_req_waiting...............: avg=198.27ms min=172.62ms med=200.7ms  max=277.44ms p(90)=216.02ms p(95)=219.71ms
     http_reqs......................: 250    8.010172/s
     iteration_duration.............: avg=1.22s    min=1.17s    med=1.2s     max=1.72s    p(90)=1.22s    p(95)=1.41s
     iterations.....................: 250    8.010172/s
     vus............................: 2      min=2      max=10
     vus_max........................: 10     min=10     max=10

running (0m31.2s), 00/10 VUs, 250 complete and 0 interrupted iterations
default ✓ [======================================] 10 VUs  30s

実行オプション

k6では様々なテストケースに対応するために実行オプションが数多く用意されています。
オプションの指定方法には次のようなものがあります。

  • スクリプトファイルの option オブジェクト に書く
  • CLIのオプションとして渡す

他にも環境変数で指定することもできます。

スクリプトファイルで指定する場合は、以下のoptionオブジェクトをテストファイルのJavascriptで定義してください。

export const options = {
  vus: 10,
  duration: '30s',
};

CLIのオプションで渡す場合は、次のようにコマンドのパラメタとして渡します。

k6 run --vus 10 --duration 30s script.js

主要なオプションと使い方

ここではいくつかの主要なoptionについて、紹介します。

VUs

vusオプションは、同時に実行される仮想ユーザー(VUs)の数を指定します。このオプションは、テストの負荷レベルを設定する際に使用されます。

import http from 'k6/http';

export const options = {
    vus: 10, // 10のVUsを使用
    duration: '30s',
};

export default function () {
    http.get('<http://test.k6.io>');
}

この例では、10のVUsを30秒間実行するテストを定義しています。

Duration

durationオプションは、テストの総実行時間を指定します。このオプションは、テストがどのくらいの時間実行されるべきかを定義する際に使用されます。

import http from 'k6/http';

export const options = {
    vus: 10,
    duration: '1m', // 1分間実行
};

export default function () {
    http.get('<http://test.k6.io>');
}

この例では、10のVUsを1分間実行するテストを定義しています。

Stages・Target

stagesオプションは、テストの実行中にVUsの数を段階的に増減させるために使用されます。これにより、テストの負荷を時間の経過とともに徐々に増加させたり、ピークに達した後に徐々に減少させたりすることができます。targetはそのステージでの VUsを指します。

import http from 'k6/http';

export const options = {
    stages: [
        { duration: '30s', target: 20 }, // 最初の30秒でVUsを0から20まで増加
        { duration: '1m', target: 10 },  // 次の1分間でVUsを20から10まで減少
        { duration: '30s', target: 0 },  // 最後の30秒でVUsを10から0まで減少
    ],
};

export default function () {
    http.get('<http://test.k6.io>');
}

この例では、テストの負荷を段階的に増減させるシナリオを定義しています。

Thresholds

thresholdsオプションは、テストの成功条件を定義するために使用されます。しきい値は、特定のメトリックが満たすべき条件を指定します。

import http from 'k6/http';

export const options = {
    thresholds: {
        http_req_duration: ['p(95)<500'], // 95%のリクエストが500ms未満で完了すること
    },
};

export default function () {
    http.get('<http://test.k6.io>');
}

この例では、95%のリクエストが500ミリ秒未満で完了することをテストの成功条件として定義しています。

他のオプションについては公式リファレンスを参照ください。

https://grafana.com/docs/k6/latest/using-k6/k6-options/reference/

メトリクスの見方

k6をの実行結果として表示される主要メトリクスについて説明します。

テスト結果のメトリクスサンプル
run script.js

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

     execution: local
        script: script.js
        output: -

     scenarios: (100.00%) 1 scenario, 10 max VUs, 1m0s max duration (incl. graceful stop):
              * default: 10 looping VUs for 30s (gracefulStop: 30s)

     data_received..................: 2.9 MB 94 kB/s
     data_sent......................: 28 kB  899 B/s
     http_req_blocked...............: avg=16.83ms  min=2µs      med=10µs     max=436.21ms p(90)=15µs     p(95)=31.54µs
     http_req_connecting............: avg=7.88ms   min=0s       med=0s       max=211.37ms p(90)=0s       p(95)=0s
     http_req_duration..............: avg=208.75ms min=178.49ms med=193.19ms max=432.13ms p(90)=221.67ms p(95)=363.42ms
       { expected_response:true }...: avg=208.75ms min=178.49ms med=193.19ms max=432.13ms p(90)=221.67ms p(95)=363.42ms
     http_req_failed................: 0.00%  ✓ 0250
     http_req_receiving.............: avg=13.57ms  min=35µs     med=165µs    max=202.37ms p(90)=361µs    p(95)=179.68ms
     http_req_sending...............: avg=43.51µs  min=10µs     med=39.5µs   max=927µs    p(90)=57µs     p(95)=63.55µs
     http_req_tls_handshaking.......: avg=8.29ms   min=0s       med=0s       max=212.13ms p(90)=0s       p(95)=0s
     http_req_waiting...............: avg=195.13ms min=178.27ms med=191.69ms max=246.19ms p(90)=209.63ms p(95)=220.17ms
     http_reqs......................: 250    8.042534/s
     iteration_duration.............: avg=1.22s    min=1.17s    med=1.19s    max=1.64s    p(90)=1.35s    p(95)=1.4s
     iterations.....................: 250    8.042534/s
     vus............................: 1      min=1      max=10
     vus_max........................: 10     min=10     max=10

主なメトリクスとしては次のようなものがあります。

指標名 意味
checks チェック関数の成功割合
http_req_duration HTTPリクエストの完了にかかった合計時間
http_req_failed 失敗したHTTPリクエストの割合
iteration_duration 1回のイテレーションを完了するのにかかった時間
vus アクティブな仮想ユーザー(VU)の数
vus_max 最大VU数
http_reqs テスト中に生成されたHTTPリクエストの総数
http_req_blocked リクエスト開始前にブロックされている時間
http_req_connecting TCP接続の確立にかかった時間

他にも、様々なメトリクスを計測することができます。詳しくは公式リファレンスをご参照ください。

https://grafana.com/docs/k6/latest/using-k6/metrics/reference/

Check

k6のテストでは実行結果の値に対して、チェック処理を書くことができます。

このチェック処理は、ソフトウェアテストのアサートと似ていますが、条件が満たせなくてもエラーにはなりません。テスト結果のメトリクスにチェックが通っていないという出力が表示されます。

次の例では、HTTPレスポンスコードが200かどうかのチェックをしています。

import { check } from 'k6';
import http from 'k6/http';

export default function () {
  const res = http.get('http://test.k6.io/');
  check(res, {
    'is status 200': (r) => r.status === 200,
  });
}


全てのチェックが通った場合


チェックが通っていないリクエストがある場合

しきい値(Thresholds)

optionsにthresholdsを定義することでテストの実行結果(メトリクス)にしきい値を設定することができます。

例えば、リクエストの成功率, レスポンス時間などのしきい値を指定し、しきい値を満たせない場合はエラーを表示することができます。

thresholdsの指定方法

export const options = {
  thresholds: {
    <ここにしきい値を設定する>
  },
};

設定例: リクエスト時間

例えばリクエストの合計時間が200msを下回るように設定するには、次のように書きます。

import http from 'k6/http';

export const options = {
  thresholds: {
    http_req_duration: ['p(95)<200'], // 95%のリクエストが200msを下回る
  },
};

export default function () {
  http.get('https://test-api.k6.io/public/crocodiles/1/');
}


200ms以上かかるとエラーが表示される

テストライフサイクル

ここでは、k6のテストライフサイクルについて説明します。
k6には、次の4つのライフサイクルステージが存在します。

ステージ 目的
init テストの開始前に一度だけ実行されるステージです。グローバル変数の宣言やテスト全体で使用する設定の初期化などを行います。
setup テストのdefault関数(VU code)が実行される前に一度だけ実行されるステージです。テストデータの準備や外部からのデータの取り込みなど、テスト実行に必要な前処理を行います。
VU Code 各VU(仮想ユーザー)によって実行されるメインのテストスクリプトです。HTTPリクエストの送信やカスタムメトリクスの計測など、テストの核となるアクションが含まれます。
teardown テストのメイン部分の実行が終了した後に一度だけ実行されるステージです。テストで使用したリソースのクリーンアップや結果の後処理などを行います。

ライフサイクルは次のように関数で定義します。

// 1. init: 関数外のスコープは全てinitになる

export function setup() {
  // 2. setup: テスト実行(`k6 run`)1回につき1度テスト実行前に呼び出される
}

export default function() {
  // 3. VU code: 実際のテストシナリオ
}

export function teardown() {
  // 4. teardown code: テスト実行(`k6 run`)1回につき1度テスト実行後に呼び出される
}

initは、関数に定義しないスコープを指しています。

https://grafana.com/docs/k6/latest/using-k6/test-lifecycle/

initはMaxVUs + 3回呼ばれる

initのライフサイクルは少し変わった挙動をしているようでしたので備忘録として残しておきます。
ライフサイクルの実際の挙動を確認するために次のコードを実行してみました。

import http from 'k6/http';

console.log('Lifecycle: init');

export const options = {
  vus: 1,
  duration: '1s',
};

export function setup() {
  console.log('Lifecycle: setup');
}

export default function () {
  console.log('Lifecycle: VU code');
  http.get('http://test.k6.io');
}

export function teardown() {
  console.log('Lifecycle: teardown');
}

結果は次のようになります。

実行結果
k6 run lifecycle.js

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

INFO[0000] Lifecycle: init                               source=console
     execution: local
        script: lifecycle.js
        output: -

     scenarios: (100.00%) 1 scenario, 1 max VUs, 31s max duration (incl. graceful stop):
              * default: 1 looping VUs for 1s (gracefulStop: 30s)

INFO[0000] Lifecycle: init                               source=console
INFO[0000] Lifecycle: init                               source=console
INFO[0000] Lifecycle: setup                              source=console
INFO[0000] Lifecycle: VU code                            source=console
INFO[0001] Lifecycle: init                               source=console
INFO[0001] Lifecycle: teardown                           source=console
INFO[0001] Lifecycle: init                               source=console

     █ setup

     █ teardown

     data_received..................: 17 kB 16 kB/s
     data_sent......................: 546 B 493 B/s
     http_req_blocked...............: avg=336.09ms min=225.37ms med=336.09ms max=446.81ms p(90)=424.66ms p(95)=435.73ms
     http_req_connecting............: avg=216.68ms min=212.57ms med=216.68ms max=220.79ms p(90)=219.97ms p(95)=220.38ms
     http_req_duration..............: avg=217.27ms min=213.16ms med=217.27ms max=221.38ms p(90)=220.56ms p(95)=220.97ms
       { expected_response:true }...: avg=217.27ms min=213.16ms med=217.27ms max=221.38ms p(90)=220.56ms p(95)=220.97ms
     http_req_failed................: 0.00% ✓ 0        ✗ 2
     http_req_receiving.............: avg=210µs    min=80µs     med=210µs    max=340µs    p(90)=314µs    p(95)=327µs
     http_req_sending...............: avg=110.5µs  min=29µs     med=110.5µs  max=192µs    p(90)=175.7µs  p(95)=183.85µs
     http_req_tls_handshaking.......: avg=112.94ms min=0s       med=112.94ms max=225.88ms p(90)=203.29ms p(95)=214.59ms
     http_req_waiting...............: avg=216.95ms min=212.8ms  med=216.95ms max=221.11ms p(90)=220.28ms p(95)=220.69ms
     http_reqs......................: 2     1.804891/s
     iteration_duration.............: avg=369.07ms min=8.87µs   med=34.25µs  max=1.1s     p(90)=885.74ms p(95)=996.45ms
     iterations.....................: 1     0.902446/s
     vus............................: 1     min=1      max=1
     vus_max........................: 1     min=1      max=1


running (01.1s), 0/1 VUs, 1 complete and 0 interrupted iterations
default ✓ [======================================] 1 VUs  1s

この結果から、「initのログが4回記録されている」ことがわかります。「initは1VUごとに1回だけ実行される」と理解していたため、疑問に思い調べてみました。

https://community.grafana.com/t/why-init-called-4-times/97808

このQAによれば、initは「最初の1回目でoptionsを取得するため」「setupとteardownのそれぞれで1回ずつ」「各VUコードが実行されるたびに1回」の計3つのタイミングで呼ばれるようです。つまり、「MaxVUs + 3回(最初の1回、setup、teardown)」が呼び出される回数となるようです。

setup関数の戻り値はVU codeとterdownから参照可能

setup関数では、戻り値を返すことができます。この値はVU codeのdefaul関数とterdown関数に引数として渡されるため参照することができます。

https://grafana.com/docs/k6/latest/using-k6/test-lifecycle/#use-data-from-setup-in-default-and-teardown

import http from 'k6/http';

export const options = {
  vus: 1,
  duration: '1s',
};

export function setup() {
  return { variable: 'setup data' };
}

export default function (data) {
  http.get('http://test.k6.io');
  // setupで返したデータを参照できる
  console.log('VU code:', data);
}

export function teardown(data) {
  console.log('Lifecycle: teardown');
  // setupで返したデータを参照できる
  console.log('teardown:', data);
}
実行結果
k6 run lifecycle.js

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

     execution: local
        script: lifecycle.js
        output: -

     scenarios: (100.00%) 1 scenario, 1 max VUs, 31s max duration (incl. graceful stop):
              * default: 1 looping VUs for 1s (gracefulStop: 30s)

INFO[0001] VU code: {"variable":"setup"}                 source=console
INFO[0001] Lifecycle: teardown                           source=console
INFO[0001] teardown: {"variable":"setup"}                source=console

     █ setup

     █ teardown

     data_received..................: 17 kB 17 kB/s
     data_sent......................: 546 B 540 B/s
     http_req_blocked...............: avg=314.89ms min=237.46ms med=314.89ms max=392.32ms p(90)=376.83ms p(95)=384.58ms
     http_req_connecting............: avg=208.18ms min=191ms    med=208.18ms max=225.37ms p(90)=221.93ms p(95)=223.65ms
     http_req_duration..............: avg=190.19ms min=186.83ms med=190.19ms max=193.54ms p(90)=192.87ms p(95)=193.2ms
       { expected_response:true }...: avg=190.19ms min=186.83ms med=190.19ms max=193.54ms p(90)=192.87ms p(95)=193.2ms
     http_req_failed................: 0.00% ✓ 0        ✗ 2
     http_req_receiving.............: avg=183µs    min=170µs    med=183µs    max=196µs    p(90)=193.4µs  p(95)=194.7µs
     http_req_sending...............: avg=142µs    min=62µs     med=142µs    max=222µs    p(90)=206µs    p(95)=214µs
     http_req_tls_handshaking.......: avg=100.58ms min=0s       med=100.58ms max=201.16ms p(90)=181.04ms p(95)=191.1ms
     http_req_waiting...............: avg=189.86ms min=186.6ms  med=189.86ms max=193.12ms p(90)=192.47ms p(95)=192.79ms
     http_reqs......................: 2     1.977066/s
     iteration_duration.............: avg=336.93ms min=1.45µs   med=63.66µs  max=1.01s    p(90)=808.6ms  p(95)=909.66ms
     iterations.....................: 1     0.988533/s
     vus............................: 1     min=1      max=1
     vus_max........................: 1     min=1      max=1


running (01.0s), 0/1 VUs, 1 complete and 0 interrupted iterations
default ✓ [======================================] 1 VUs  1s

ただし、次のような注意点があるようです。

  • 渡せるデータは、JSONのみ
  • データを途中で加工することはできない

シナリオを使ったテスト

scenarios オプションを使うと、複数のk6のシナリオを統合した不可テストを実行できます。
シナリオを使うことで、いくつもの負荷テストシナリオを結合した、統合的な負荷テストを書くことも可能です。

(シナリオテストのサンプル)

import step1 from './step1.js';
import step2 from './step2.js';
import step3 from './step3.js';

// NOTE: step1, step2, step3 を export する必要がある
export { step1, step2, step3 };

export const options = {
  scenarios: {
    step1_scenario: {
      executor: 'per-vu-iterations',
      // NOTE: シナリオを実行する関数名を指定する
      exec: 'step1',
      vus: 1,
      iterations: 1,
      startTime: '0s', // テスト開始時にすぐに実行
    },
    step2_scenario: {
      executor: 'per-vu-iterations',
      exec: 'step2',
      vus: 1,
      iterations: 1,
      startTime: '1m', // step1の後、1分後に実行
    },
    step3_scenario: {
      executor: 'per-vu-iterations',
      exec: 'step3',
      vus: 1,
      iterations: 1,
      startTime: '2m', // step2の後、さらに1分後に実行
    },
  },
};

// メインの実行関数は空でOK
export default function () {}

ポイントは、importした関数名を統合シナリオを書いたファイルでexportする必要がある ところです。exportしないと、k6に認識されず、エラーになってしまいます。

Grafana k6 Browser Recorderを使ってテストシナリオを作成する

https://chromewebstore.google.com/detail/fbanjfonbcedhifbgikmjelkkckhhidl

「Grafana k6 Browser Recorder」というツールを使うと、ブラウザの操作をそのままk6のシナリオに変換することができます。

このツールをつかうためには、Grafana Cloudのアカウントが必要ですが、テストのスクリプトを作成するだけであれば無料で行えます。

このツールを使うことで、シナリオ作成が画面操作から行うことがでるため、アクションが多いテストではテスト作成工数を削減できます。


chrome拡張機能からレコードボタンを押下


chrome拡張機能からStopボタンを押下


Grafana Cloud上で Script editorを選択してSave

※ Test builderを選択するとGUIでのビルダー形式に変換されます。

詳細はこちらも参照ください。

https://grafana.com/docs/grafana-cloud/k6/author-run/browser-recorder/

har-to-k6を使って、HARファイルをk6のシナリオに変換する

HAR(HTTP Archive File)ファイルとは、ブラウザの通信を記録したファイルで、chrome developer toolなどから作成することができます。


Developer toolからHARをダウンロードできる

前述した「Grafana k6 Browser Recorder」を使わなくとも、このHARからk6のテストファイルへ変換するツールをGrafana Labs社が提供してくれています。

このツールを使うと、Grafana Cloudに登録しなくても、ブラウザの通信記録からK6ファイルが作成でき、汎用性が高い便利なツールです。

(インストール)

npm install -g har-to-k6

(HARからk6テストの作成)

har-to-k6 myfile.har -o loadtest.js

https://github.com/grafana/har-to-k6

詳細はこちらを参照ください。

https://grafana.com/docs/k6/latest/using-k6/test-authoring/create-tests-from-recordings/using-the-har-converter/

終わりに

以上、k6の基本的な使い方について入門記事を書きました。実際の運用事例なども別の機会させていただきます。

個人的には「Grafana k6 Browser Recorder」や「har-to-k6」などのサブツールはあまり活用できていなかったので、改めてk6の便利さをしれて勉強になりました。今後はツールをつかった運用ノウハウなども公開していきたいです。

PharmaX では、様々なバックグラウンドを持つエンジニアの採用をお待ちしております。
もし、興味をお持ちの場合は、私の X アカウント(@hakoten)や記事のコメントにお気軽にメッセージいただけますと幸いです。まずはカジュアルにお話できれば嬉しいです!

PharmaXテックブログ

Discussion

ログインするとコメントできます