負荷試験ツールのk6が良さそう

またもやisucon本から。
前回はこちら(アクセスログの集計ツールのalpが良い )。

本題

負荷テストツールとしては、Apache JMeterApache Bench(ab)辺りをよく使っていましたが、 isucon本で紹介されているk6の感触が良かったので、ご紹介です。 installationはmacだとbrewでサクッと入るので省略。

使い方

シナリオをjsで書いて、コマンドで実行するだけです。
本書で紹介されていたab的な使い方だと、以下のような感じです。

import http from "k6/http";

const BASE_URL = "http://localhost";

export default function() {
  http.get(`${BASE_URL}`);
}
# 並列度1で30秒実行
$ k6 run --vus 1 --duration 30s ab.js

実行結果。 Requests per secondにあたるのが、http_reqs。Time per request(レスポンスタイム)にあたるのが、http_reeq_durationのavg

もう少し複雑な例

ログイン、画面遷移、コメントをPOSTするような処理も以下のように書けます。

// comment.js
import http from "k6/http";
import { check } from "k6";
import { parseHTML } from "k6/html";

import { url } from "./config.js";

export default function() {
  // ログイン
  const login_res = http.post(url("/login"), {
    account_name: "terra",
    password: "terraterra",
  });

  check(login_res, {
    "is status 200": (r) => r.status === 200,
  });

  // ユーザー画面へ遷移しResponseを取得
  const res = http.get(url("/@terra"));
  const doc = parseHTML(res.body);
  const token = doc.find('input[name="csrf_token"]').first().attr("value");
  const post_id = doc.find('input[name="post_id"]').first().attr("value");

  // コメント
  const comment_res = http.post(url("/comment"), {
    post_id: post_id,
    csrf_token: token,
    comment: "Hello k6!",
  });
  check(comment_res, {
    "is status 200": (r) => r.status === 200,
  });
}
// config.js
const BASE_URL = "http://localhost";

export function url(path) {
  return `${BASE_URL}${path}`;
}
$ k6 run --vus 1 comment.js

check() を使ってますが、これがあると試行回数と結果が出力されるようになります。

default ✓ [======================================] 1 VUs  00m01.6s/10m0s  1/1 iters, 1 per VU

     ✓ is status 200

     checks.........................: 100.00% ✓ 2        ✗ 0

感想

JMeterとか独特のUIですし、こういう風にscirptでチャチャっと書いていけるのは、なかなか良いです。
実際に利用するとなると環境用意するのが若干面倒だったりするので、sassで提供されてると嬉しいよなと思ってググってみると、やはりありました。 Plan & Pricing | k6 Cloud
お値段は、安いやつだと $89/mo なので結構現実的かもしれません。

See Also

アクセスログの集計ツールのalpが良い - rochefort's blog

アクセスログの集計ツールのalpが良い

新年あけましておめでとうございます。 年末にかかった風邪がようやく治りかけてきたので、isucon本で気になるところを手元で試したりしています。

こちらの本、トップレビューは酷評されてますが、isucon対策本として読むには面白かったです。
 

alp

本書で紹介されているalpが非常に良いです。
アクセスログをコマンド1発で集計してくれる優れものです。
昔はawkとか使って集計してましたよね。便利。

使い方

# nginx の log_formatをjsonで出力
log_format json escape=json '{"time": "$time_iso8601",'
  '"host": "$remote_addr",'
  '"port": "$remote_port",'
  '"method": "$request_method",'
  '"uri": "$request_uri",'
  '"status": "$status",'
  '"body_bytes": "$body_bytes_sent",'
  '"referer": "$http_referer",'
  '"ua": "$http_user_agent",'
  '"request_time": "$request_time",'
  '"apptime": "$upstream_response_time"}';
# ログの例
{"time": "2023-01-01T11:48:37+00:00","host": "172.18.0.1","port": "61388","method": "GET","uri": "/","status": "200","body_bytes": "35624","referer": "","ua": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36","request_time": "10.961","apptime": "10.963"}
...

こんな感じで簡単に解析してくれます。

$ cat logs/nginx/access.log | alp json
+-------+-----+-----+-----+-----+-----+--------+------------------+--------+--------+--------+--------+--------+--------+--------+--------+-------------+-------------+-------------+-------------+
| COUNT | 1XX | 2XX | 3XX | 4XX | 5XX | METHOD |       URI        |  MIN   |  MAX   |  SUM   |  AVG   |  P90   |  P95   |  P99   | STDDEV |  MIN(BODY)  |  MAX(BODY)  |  SUM(BODY)  |  AVG(BODY)  |
+-------+-----+-----+-----+-----+-----+--------+------------------+--------+--------+--------+--------+--------+--------+--------+--------+-------------+-------------+-------------+-------------+
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /                | 10.961 | 10.961 | 10.961 | 10.961 | 10.961 | 10.961 | 10.961 | 0.000  | 35624.000   | 35624.000   | 35624.000   | 35624.000   |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/10000.png | 0.088  | 0.088  | 0.088  | 0.088  | 0.088  | 0.088  | 0.088  | 0.000  | 1056749.000 | 1056749.000 | 1056749.000 | 1056749.000 |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9998.jpg  | 0.082  | 0.082  | 0.082  | 0.082  | 0.082  | 0.082  | 0.082  | 0.000  | 61656.000   | 61656.000   | 61656.000   | 61656.000   |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9995.jpg  | 0.101  | 0.101  | 0.101  | 0.101  | 0.101  | 0.101  | 0.101  | 0.000  | 123364.000  | 123364.000  | 123364.000  | 123364.000  |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9997.jpg  | 0.116  | 0.116  | 0.116  | 0.116  | 0.116  | 0.116  | 0.116  | 0.000  | 176404.000  | 176404.000  | 176404.000  | 176404.000  |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9996.jpg  | 0.138  | 0.138  | 0.138  | 0.138  | 0.138  | 0.138  | 0.138  | 0.000  | 374805.000  | 374805.000  | 374805.000  | 374805.000  |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9999.jpg  | 0.146  | 0.146  | 0.146  | 0.146  | 0.146  | 0.146  | 0.146  | 0.000  | 89928.000   | 89928.000   | 89928.000   | 89928.000   |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9994.jpg  | 0.068  | 0.068  | 0.068  | 0.068  | 0.068  | 0.068  | 0.068  | 0.000  | 105154.000  | 105154.000  | 105154.000  | 105154.000  |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9993.jpg  | 0.055  | 0.055  | 0.055  | 0.055  | 0.055  | 0.055  | 0.055  | 0.000  | 85546.000   | 85546.000   | 85546.000   | 85546.000   |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9991.jpg  | 0.067  | 0.067  | 0.067  | 0.067  | 0.067  | 0.067  | 0.067  | 0.000  | 153465.000  | 153465.000  | 153465.000  | 153465.000  |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9990.jpg  | 0.066  | 0.066  | 0.066  | 0.066  | 0.066  | 0.066  | 0.066  | 0.000  | 102371.000  | 102371.000  | 102371.000  | 102371.000  |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9989.jpg  | 0.048  | 0.048  | 0.048  | 0.048  | 0.048  | 0.048  | 0.048  | 0.000  | 107460.000  | 107460.000  | 107460.000  | 107460.000  |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9988.jpg  | 0.063  | 0.063  | 0.063  | 0.063  | 0.063  | 0.063  | 0.063  | 0.000  | 111515.000  | 111515.000  | 111515.000  | 111515.000  |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9987.jpg  | 0.086  | 0.086  | 0.086  | 0.086  | 0.086  | 0.086  | 0.086  | 0.000  | 367388.000  | 367388.000  | 367388.000  | 367388.000  |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9986.jpg  | 0.094  | 0.094  | 0.094  | 0.094  | 0.094  | 0.094  | 0.094  | 0.000  | 86624.000   | 86624.000   | 86624.000   | 86624.000   |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9985.jpg  | 0.093  | 0.093  | 0.093  | 0.093  | 0.093  | 0.093  | 0.093  | 0.000  | 83264.000   | 83264.000   | 83264.000   | 83264.000   |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9983.jpg  | 0.093  | 0.093  | 0.093  | 0.093  | 0.093  | 0.093  | 0.093  | 0.000  | 98082.000   | 98082.000   | 98082.000   | 98082.000   |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9982.jpg  | 0.087  | 0.087  | 0.087  | 0.087  | 0.087  | 0.087  | 0.087  | 0.000  | 149874.000  | 149874.000  | 149874.000  | 149874.000  |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9981.jpg  | 0.077  | 0.077  | 0.077  | 0.077  | 0.077  | 0.077  | 0.077  | 0.000  | 81386.000   | 81386.000   | 81386.000   | 81386.000   |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9980.jpg  | 0.037  | 0.037  | 0.037  | 0.037  | 0.037  | 0.037  | 0.037  | 0.000  | 95919.000   | 95919.000   | 95919.000   | 95919.000   |
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /image/9979.jpg  | 0.040  | 0.040  | 0.040  | 0.040  | 0.040  | 0.040  | 0.040  | 0.000  | 66941.000   | 66941.000   | 66941.000   | 66941.000   |
+-------+-----+-----+-----+-----+-----+--------+------------------+--------+--------+--------+--------+--------+--------+--------+--------+-------------+-------------+-------------+-------------+

使い方の詳細

ここに詳細が記載されていますが、フィルターやマージも可能です。GETだけとか、特定日以降とかでも絞れます。
alp/usage_samples.md at main · tkuchiki/alp

以下、日時でフィルターして、画像をひとまとめにする例。

$ cat logs/nginx/access.log | alp json -m "/image/*" --filters "Time > '2023-01-01T11:48:36+00:00'"
+-------+-----+-----+-----+-----+-----+--------+----------+--------+--------+--------+--------+--------+--------+--------+--------+-----------+-------------+-------------+------------+
| COUNT | 1XX | 2XX | 3XX | 4XX | 5XX | METHOD |   URI    |  MIN   |  MAX   |  SUM   |  AVG   |  P90   |  P95   |  P99   | STDDEV | MIN(BODY) |  MAX(BODY)  |  SUM(BODY)  | AVG(BODY)  |
+-------+-----+-----+-----+-----+-----+--------+----------+--------+--------+--------+--------+--------+--------+--------+--------+-----------+-------------+-------------+------------+
| 1     | 0   | 1   | 0   | 0   | 0   | GET    | /        | 10.961 | 10.961 | 10.961 | 10.961 | 10.961 | 10.961 | 10.961 | 0.000  | 35624.000 | 35624.000   | 35624.000   | 35624.000  |
| 20    | 0   | 20  | 0   | 0   | 0   | GET    | /image/* | 0.037  | 0.146  | 1.645  | 0.082  | 0.116  | 0.138  | 0.146  | 0.028  | 61656.000 | 1056749.000 | 3577895.000 | 178894.750 |
+-------+-----+-----+-----+-----+-----+--------+----------+--------+--------+--------+--------+--------+--------+--------+--------+-----------+-------------+-------------+------------+

補足

本書には、集計に利用するJSONキーとして、以下が必要と記載されてました。
method, uri, status, body_bytes, response_time
キー名が異なる場合は、optionで設定可能です。

ただ、ドキュメントのどこにも見当たらなかったので、ソースを見てみると、確かにそれっぽいことをやっていました。
alp/alp.go at main · tkuchiki/alp

今更だけどRailsの基本認証について

手元で作ったRailsアプリを軽くデプロイして確認したかったので、基本認証入れて確認しようとしていましたが、 似たようなメソッドがいくつもあってどれだっけとなったり、良くない実装が検索結果に出てきたので、改めて整理しておきます。

ググる

以下のようなコードが紹介されています。

class ApplicationController < ActionController::Base
  before_action :basic_auth

  def basic_auth
    authenticate_or_request_with_http_basic do |username, password|
      username == ENV["BASIC_AUTH_USER"] && password == ENV["BASIC_AUTH_PASSWORD"]
    end
end

== での比較は timing attacks の脆弱性があります。

secure_compare使おう

secure_compare を使うというのが解なのですが、こちらの記事がめちゃくちゃ詳しく載っててるので割愛します。
 
参考)機密情報に関わる文字列の比較は == ではなく secure_compare を使おう

やり方おさらい

やり方1. http_basic_authenticate_with

simpleなやつ。こんな感じで使います。簡単で良いですね。

class PostsController < ApplicationController
  http_basic_authenticate_with name: "dhh", password: "secret", except: :index

内部的には、before_actionで http_basic_authenticate_or_request_with を呼ぶ実装になっている。

# https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/http_authentication.rb#L76
def http_basic_authenticate_with(name:, password:, realm: nil, **options)
  raise ArgumentError, "Expected name: to be a String, got #{name.class}" unless name.is_a?(String)
  raise ArgumentError, "Expected password: to be a String, got #{password.class}" unless password.is_a?(String)
  before_action(options) { http_basic_authenticate_or_request_with name: name, password: password, realm: realm }
end

そして、http_basic_authenticate_or_request_with では、ActiveSupport::SecurityUtils.secure_compare を利用している。

# https://github.com/rails/rails/blob/v7.0.4/actionpack/lib/action_controller/metal/http_authentication.rb#L83
def http_basic_authenticate_or_request_with(name:, password:, realm: nil, message: nil)
  authenticate_or_request_with_http_basic(realm, message) do |given_name, given_password|
  # This comparison uses & so that it doesn't short circuit and
  # uses `secure_compare` so that length information isn't leaked.
  ActiveSupport::SecurityUtils.secure_compare(given_name.to_s, name) &
    ActiveSupport::SecurityUtils.secure_compare(given_password.to_s, password)
  end
end

やり方2.http_basic_authenticate_or_request_with

独自の処理を噛ませたい場合は、当然ながら「1」で呼んでいる http_basic_authenticate_or_request_with を利用することで実現できる。

class ApplicationController < ActionController::Base
  before_action :set_account, :authenticate

  private
    def authenticate
      # do something
     http_basic_authenticate_or_request_with(name: "dhh", password:  "secret")
    end

やり方3. authenticate_or_request_with_http_basic

また、当然ながら2で呼んでる authenticate_or_request_with_http_basic を呼ぶ実装でも良い。

# https://github.com/rails/rails/blob/8015c2c2cf5c8718449677570f372ceb01318a32/actionpack/lib/action_controller/metal/http_authentication.rb#L92
def authenticate_or_request_with_http_basic(realm = nil, message = nil, &login_procedure)
  authenticate_with_http_basic(&login_procedure) || request_http_basic_authentication(realm || "Application", message)
end

def authenticate_with_http_basic(&login_procedure)
  HttpAuthentication::Basic.authenticate(request, &login_procedure)
end

やり方4. authenticate_with_http_basic

またまた当然ながら3で呼んでる authenticate_with_http_basic を呼ぶ実装でも良い。 この場合は、基本認証を要求するレスポンスが自動で返らないので、必要に応じて request_http_basic_authentication (後述)を呼び出したりする必要がある。

Advanced Basic exampleとしてRailsのドキュメントにはこの例が記載されている。

class ApplicationController < ActionController::Base
  before_action :set_account, :authenticate

  private
    def set_account
      @account = Account.find_by(url_name: request.subdomains.first)
    end

    def authenticate
      case request.format
      when Mime[:xml], Mime[:atom]
        if user = authenticate_with_http_basic { |u, p| @account.users.authenticate(u, p) }
          @current_user = user
        else
          request_http_basic_authentication
        end
      else
        if session_authenticated?
          @current_user = @account.users.find(session[:authenticated][:user_id])
        else
          redirect_to(login_url) and return false
        end
      end
    end
end

request_http_basic_authentication

基本認証を要求するレスポンスを返す処理。

# https://github.com/rails/rails/blob/8015c2c2cf5c8718449677570f372ceb01318a32/actionpack/lib/action_controller/metal/http_authentication.rb#L100
def request_http_basic_authentication(realm = "Application", message = nil)
  HttpAuthentication::Basic.authentication_request(self, realm, message)
end
# https://github.com/rails/rails/blob/8015c2c2cf5c8718449677570f372ceb01318a32/actionpack/lib/action_controller/metal/http_authentication.rb#L100
def authentication_request(controller, realm, message)
  message ||= "HTTP Basic: Access denied.\n"
  controller.headers["WWW-Authenticate"] = %(Basic realm="#{realm.tr('"', "")}")
  controller.status = 401
  controller.response_body = message
end

まとめ

色々あってややこしいので軽くまとめておく。

No. メソッド名 secure_compareの利用の有無 認証不可時に基本認証を要求するレスポンスを返すかどうか 備考
1 http_basic_authenticate_with 返す                               単に基本認証するだけならこちらがおすすめ。
2 http_basic_authenticate_or_request_with 返す 独自の処理を追加したいのであれば、こちらがおすすめ。認証不可の場合は、 基本認証を要求するレスポンスを返す。
3 authenticate_or_request_with_http_basic 返す secure_compare が不要なケース。
4 authenticate_with_http_basic 返さない ブラウザからの入力を期待しないケース。もしくは、別途任意のタイミングでrequest_http_basic_authenticationを呼んで入力させたい場合。