Web パフォーマンス チューニング ISUCON

Chapter3 負荷試験の準備

負荷試験の準備

nginx のアクセスログを集計

Nginx の初期状態のアクセスログのフォーマットは Nginx のデフォルトの combined と呼ばれる形式。

/etc/nginx/nginx.conf
log_format comibined '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent"';

このままではリクエストの処理にかかった時間(レスポンスタイム)が含まれていないため、ログに含め、かつ処理しやすい JSON 形式で出力するように設定を修正。

アクセスログを JSON 形式で出力

/etc/nginx/nginx.conf
log_format json escape=json '{"time":"$time_local",'
                                '"host":"$remote_addr",'
                                '"forwardedfor":"$http_x_forwarded_for",'
                                '"req":"$request",'
                                '"status":"$status",'
                                '"method":"$request_method",'
                                '"uri":"$request_uri",'
                                '"body_bytes":$body_bytes_sent,'
                                '"referer":"$http_referer",'
                                '"ua":"$http_user_agent",'
                                '"request_time":$request_time,'
                                '"cache":"$upstream_http_x_cache",'
                                '"runtime":"$upstream_http_x_runtime",'
                                '"response_time":"$upstream_response_time",'
                                '"vhost":"$host"}';
access_log /var/log/nginx/access.log json;
変数名 意味
$request_time リクエスト処理に要した時間(秒)
$upstream_response_time リバースプロキシとして動作する場合に、プロキシ先からのレスポンスを得るまでの時間(秒)

クライアントから nginx までのネットワークが十分低レイテンシで帯域が広い場合には、通常この2つの値はほぼ等しくなる。しかし、ネットワークのトラブル時やクライアント側の回線にボトルネックがある場合などは、request_timeとupstream_response_time の差が大きくなることがある。

  1. nginx の設定に文法的な誤りがないかどうか検証
$ nginx -t
  1. nginx の設定を適用するために再起動
$ systemctl reload nginx

alp を使ったログ解析方法

alp は、JSON 形式や LTSV 形式で保存されたアクセスログを解析ツール。
alp で JSON 形式のログを解析するためには、alp json コマンドを使用し、標準入力から JSON 形式のログを与えられるか、--file オプションでファイル名を指定。
何もオプションで指定しない場合、alp はデフォルトで以下の JSON のキーの値を集計に使用。

キー名 説明
method リクエストの HTTP Method
uri リクエストの URI
status レスポンスの HTTP Status Code
body_bytes レスポンスタイムのサイズ(bytes)
response_time レスポンスタイム

alp は、デフォルトでは URI のクエリ文字列(?以降)は無視して集計する。無視したくない場合、-q, --query-string オプションを指定。クエリ文字列の値だけを同一視したい場合、-q と同時に--qs-ignore-values を指定。

URI に/diary/entry/1234 と/diary/entry/5678 のように、パスの一部としてパラメータが入っている場合、デフォルトでは別々の URI として集計される。これを同一視したい場合、-m, --matching-groups+PATTERN オプションを指定。

ベンチマーカーによる負荷試験の実行

アクセスログのローテーション

負荷試験の実行ごとにログがひとつのファイルに記録されるようにして、1回の試験に対応するログファイル全体を解析するようにする。この操作をログの**ローテート(rotate)**という。

$ mv /var/log/nginx/access.log /var/log/nginx/access/log.old

ただし、nginx が動作中の場合は、単に出力中のログファイルを別の名前に変更してもログは新ファイルに出力されず、別名に変更された後のファイルの末尾に追記されてしまう。ファイルが切り替わったことを知らせるためには、

  1. nginx を再起動もしくはリロード
$ systemctl restart nginx
# or
$ systemctl reload nginx

再起動にはわずかながらダウンタイムが伴うため、本番で運用中の nginx に対しては気軽には実行できない。

  1. ログを開きなおすためのシグナルを送信
$ nginx -s open

パフォーマンスチューニング 最初の一歩

MySQL のボトルネックを発見する準備

スロークエリログには、クエリの実行に要した経過時間(Query_time)、ロックを取得する時間(Lock_time)、クエリの実行結果としてクライアントに送信した行数(Rows_sent)、クエリを実行するために MySQL 内部で読み取った行数(Rows_examined)などが記録されている。

# Query_time: 0.0006620 Lock_time: 0.000097 Rows_sent: 20 Rows_examined: 100034

スロークエリログを出力するために、/etc/mysql/my.cnf の[mysqld]セクションに

[mysqld]
slow_query_log=1
slow_query_log_file=/var/log/mysql/mysql-slow.log
long_query_time=0
log_queries_not_using_indexes=1
設定 意味
slow_query_log スロークエリログを有効にする
slow_query_log_file スロークエリログの出力先ファイル名
long_query_time 指定した秒数以上かかったクエリのみログに出力する

設定ファイルに記述した設定を反映するためには、MySQL の再起動が必要。

$ systemctl restart mysql

スロークエリログを解析

  1. mysqldumpslow コマンドでスロークエリログを集計

集計ツールとして、MySQL 自体に付属する mysqldumpslow コマンドを利用。

$ mysqldumpslow /var/log/mysql/mysql-slow.log
Count: 1  Time=1.94s (1s)  Lock=0.00s (0s)  Rows=1.0 (1), isuconp[isuconp]@localhost
  SELECT COUNT(*) AS count FROM `comments` WHERE `post_id` IN (N, N, N, N, N, N, N, N, N, N, N, N, N, N)

Count: 1  Time=1.67s (1s)  Lock=0.00s (0s)  Rows=1.0 (1), isuconp[isuconp]@localhost
  SELECT COUNT(*) AS count FROM `comments` WHERE `post_id` IN (N, N, N, N, N, N, N, N, N, N)

Count: 1  Time=1.54s (1s)  Lock=0.00s (0s)  Rows=1.0 (1), isuconp[isuconp]@localhost
  SELECT COUNT(*) AS count FROM `comments` WHERE `post_id` IN (N, N, N, N, N, N, N, N, N, N, N, N, N, N, N, N)

Count: 1499  Time=1.47s (2208s)  Lock=0.00s (0s)  Rows=2.9 (4316), isuconp[isuconp]@localhost
  SELECT * FROM `comments` WHERE `post_id` = N ORDER BY `created_at` DESC LIMIT N

Count: 1  Time=1.32s (1s)  Lock=0.00s (0s)  Rows=1.0 (1), isuconp[isuconp]@localhost
  SELECT COUNT(*) AS count FROM `comments` WHERE `post_id` IN (N, N, N, N)

msqldumpslow コマンドはログ中の平均クエリ時間が長いクエリから順に表示。

  1. ボトルネックのクエリをスロークエリログで見る

ボトルネックのクエリを見つけたら、そのクエリをスロークエリーログを見つける。

# Query_time: 0.0006620 Lock_time: 0.000097 Rows_sent: 20 Rows_examined: 100034
SELECT * FROM `comments` WHERE `post_id` = 9995 ORDER BY `created_at` DESC LIMIT 3;

このクエリではクライアントに3行を返すために内部で10万行程度の処理を必要としている。
実行計画を確認する。

mysql> SELECT * FROM `comments` WHERE `post_id` = 9995 ORDER BY `created_at` DESC LIMIT 3;
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type  | possible_keys | key     | key_len | ref   | rows | filtered | Extra |
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | comments | NULL       | ALL | NULL       | NULL | NULL       | NULL |    99193 |   10.00 | Using where; Using  filesort  |
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+

key の値が NULL になっていることからインデックスは使用されず、rows から約10万行を読み取る実行計画が採用されていることがわかる。

チューニングの成果を確認する負荷試験

スロークエリログが前回実行時のものと混ざらないように削除する。
ログファイルを削除したり名前を変更した場合は、mysqladmin flush-logs を実行してファイルが更新されていることを MySQL に伝える必要がある。

$ rm /var/log/mysql/mysql-slow.log
$ mysqladmin flush-logs

ベンチマーカーの並列度

サーバーの処理能力を全て使えているか確認

top コマンドでは刻一刻と表示が切り替わってしまうため、時系列で CPU 使用率を観察するために dstat コマンドを使用

$ dstat --cpu
--total-cpu-usage--
usr sys idl wai stl
  0   0 100   0   0
 44  11  45   0   0
 50   9  42   0   0
  0   0 100   0   0

idl と表示されている列は CPU が idle 状態、つまり処理を行なっておらず、CPU が暇な状態である割合を示している。

なぜ CPU を使いきれていないのか

unicorn というアプリケーションサーバーは、1プロセスで1リクエストを処理するアーキテクチャになっている。
1プロセスで1リクエストを処理するアーキテクチャで1プロセスしか稼働していないということは、つまり同時に処理できるリクエストは1リクエストのみということ。

一般的に1プロセスで1リクエストを処理するアーキテクチャの場合、worker プロセスは CPU 数より大きく(典型的には CPU 数の数倍に)するのが一般的。

適切な worker プロセス数

リクエストの処理時間にはアプリケーションサーバーが CPU を使うだけでなく、MySQL などのデータベースと通信する時間も含まれる。worker プロセス数を CPU コア数と同一に設定すると、複数のプロセスが同時に通信待ちの状態になった場合、そのタイミングで空いている CPU を使用できるプロセスがいない状態になりがちになる。プロセス外部のミドルウェアとの通信が多い典型的な Web アプリケーションの場合、CPU コア数の5倍程度を設定するのが適切な場合が多くある。

ただし、worker プロセス数は多くすればするほどよいかというとそうではない。動作するプロセスが増えればそれだけメモリも消費するし、CPU の割り込みやコンテキストスイッチと呼ばれる処理も増加する。

同時マルチスレッディング(SMT)の影響

CPU によっては、同時マルチスレッディング(Simultaneous Multi-Threading、SMT)というアーキテクチャが採用されている。SMT は、実際に存在している物理 CPU コア数よりも多いスレッドを同時に稼働させることで、物理コア数以上の個数の CPU が論理的に存在しているように見せる技術のこと。

SMT は CPU コアが遊んでいる部分を使ってあたかも複数コアが稼働しているように見せているものなので、CPU 使用率が高くなっていくと、処理能力は線形に向上しなくなる。SMT が有効な CPU の使用率が 50%を超えている場合、実際の余力はそんなにない。
アプリケーションサーバーにオートスケールを設定して、負荷に応じて台数を増減させるときは、CPU 使用率が基本的に 50%を超えないようにキャパシティプランニングを行う。

Chapter5 データベースのチューニング

データベースとリソースを効率的に利用する

プリペアドステートメントと Go 言語における接続設定

ADMIN PREPAREADMIN CLOSE STMTは、プリペアドステートメントというデータベースの機能に使われる。プリペアドステートメントでは、データベースを使うクライアントから、まず変数を埋め込み可能な形の SQL を発行し、データベース側でそれを解析しキャッシュしておく。そして、クライアントから変数のみを送って SQL を実行する。同じ SQL を何度も発行する場合、実行計画をキャッシュしておくことでデータベースの効率が上がり、クエリとパラメータを分離することで SQL インジェクションなどのセキュリティ対策になる。
しかし、Web アプリケーションでは発行するクエリの種類も多く、作成したプリペアドステートメントのキャッシュが効率よくキャッシュされない。結果として SQL を発行するごとにプリペアドステートメントを準備する、PREPARE と作ったステートメントを解放する CLOSE のクエリが必要になり、通信の回数が増えてしまい、効率が落ちてしまいがち。

プリペアドステートメントを無効にするには、interpolateParams というパラメータをtrueに設定。

db, err := sql.Open("mysql", "isucon:@tcp(127.0.0.1:3306)/isuconp?interpolateParams=true")

データベースとの接続の永続化と最大接続数

アプリケーションサーバーからデータベースへの接続は TCP や Unix domain socket を介して行われる。TCP の接続はコストが高い処理。サーバー、クライアント間で TCP 接続を行う場合、通信を開始するまでに 3 回のパケットやりとりをする3ウェイハンドシェイクが行われる。TLS による暗号化ではさらにパケットのやりとりが追加される。

通信を効率化するために、一度接続したコネクションをすぐに切断せずに永続化して使い回すことが考えられる。

db.SetMaxOpenConns(8)
db.SetMaxIdleConns(8)

MaxOpenConns はアプリケーションからデータベースへの最大コネクション数であり、デフォルトは0(無限大)である。MaxIdleConns は利用していない(idle 状態)の接続の保持件数で、デフォルトは2。
MaxOpenConns のデフォルトは無限であるので、アプリケーションサーバーへの同時リクエスト数が増えれば増えるほど、データベースへの接続が増えていく。データベース側では最大の接続数があり、それを超えると接続エラーが発生する。MySQL ではmax_connectionsというパラメータで MySQL の同時接続数が設定されている。

mysql> SELECT @@max_connections;

max_connectionsのデフォルトは 151 である。
一時的に変更する場合、

mysql> SET GLOBAL max_connections=256;

設定を永続化したい場合、

[mysqld]
max_connections = 500

MySQL はスレッドモデルで実装されており、1つの接続に対して1つのスレッドが割り当てられる。スレッドあたりのメモリ使用量は比較的小さく、接続しただけの idle 状態では CPU への負荷はほぼないため、max_connections はアプリケーションサーバーが複数台あるような環境では、数千以上の大きな数が設定される。しかし、小規模なリソースが限られた環境で大量の接続を行ってしまうと、メモリ不足に陥る可能性がある。

読み取りの高速化 - データサイズの確認・Buffer Pool の活用

一般的な OS にはディスクキャッシュと呼ばれる機構があり、一度ファイルから呼び出したデータはメモリ上にキャッシュとして確保され、次回のアクセスから高速に読み取りが行えるようになっている。

MySQL でも、読み込んだデータおよびインデックスをメモリ上に確保する InnoDB Buffer Pool という機能がある。データベース専用に確保することで、高速なアクセスを表現する。この領域のサイズを決めるのがinnodb_buffer_ppol_sizeである。MySQL8.0 では、128MB がデフォルトとなる。アプリケーションサーバと共用しないデータベース専用のサーバーが用意される場合は、物理メモリの 80%程度を割り当てると良いとされている。

innodb_buffer_pool を活用する場合は、OS によるディスクキャッシュと二重でメモリを確保しないよう、データベースのファイルを読み書きする際に、O_DIRECTというフラグを有効にする。MySQL では、innodb_flush_methodで指定する。

innodb_flush_method=O_DIRECT

更新の高速化 - パフォーマンスとリスクのバランス

MySQL では、一度コミットしたデータを失わないように様々な工夫がされている。そのうちの一つが fsync である。fsync はディスクキャッシュ上に書いたデータを、ストレージデバイスに同期させる OS の命令。
更新を高速化するためには、同期的な fsync をやめ、OS が行う非同期のフラッシュ操作に任せることが考えられる。ただし、OS による同期は数秒から数十秒おきに行われるため、コミットからフラッシュが行われる間に電源を失ったり、別の原因で OS がダウンしたりするとデータを失うことになる。
コミット時の動作は MySQL では、innodb_flush_log_at_trx_commitという設定で制御。

innodb_flush_log_at_trx_commit = 2

デフォルトは1であり、コミット毎に更新データをログに書き、ログをフラッシュする。0にすると更新データを1秒おきにログに書きますが、フラッシュはしません。2にするとコミットのたびにログに書き、1秒ごとにログをフラッシュする。0または2ではクラッシュ時に最大 1 秒間のデータを失う可能性がありますが、パフォーマンスを優先する設定ができる。

MySQL8.0 では更新ログ(バイナリログ)がデフォルトで有効になっている。バイナリログは、MySQL において複数のサーバに非同期的にデータを複製し、リードレプリカを作成するレプリケーションおよび高可用性構成に必要不可欠である。冗長化構成が不要な場合、無効化することでストレージへの書き込み量を減らすことができる。

disable-log-bin=1

バイナリログの記録が必要な環境の場合、sync_binlog という設定を変更することで I/O 処理を軽減できる。

sync_binlog=1000

Chapter6 リバースプロキシの利用

リバースプロキシを利用するメリット

PHP や unicorn を利用した Ruby で動いているアプリケーションサーバーはマルチプロセス・シングルスレッドで動いており、クライアントからの1リクエストを1プロセスが処理を行なっている。プロセスは処理を行なっている間に他のリクエストを処理できない。そのためプロセス数と同時に処理できるリクエスト数が一致する。クライアントによっては回線が細いなどの理由で、クライアントにレスポンスを送り切るまで1プロセスが占有される。
また、Web アプリケーションの場合、1プロセスあたり数十 MB〜数百 MB 程度のメモリを消費する。なぜなら、異なるプロセス同士では通常メモリが共有できないため、使用するメモリが多くなる。そのため、大量のプロセスを起動できない。

このアーキテクチャでリクエストを捌くには、CPU が処理をするプロセスを切り替えるためにコンテキストスイッチを実行する必要がある。コンテキストスイッチを実行した直後は CPU 上のキャッシュが新しいプロセス用のキャッシュではないため、キャッシュを切り替える必要がある。キャッシュの切り替えが大量に発生すれば、無視できないレベルでパフォーマンスが落ち、クライアント数が1万を超えたあたりでパフォーマンスが極端に落ちると言われている。この問題はC10K問題と呼ばれている。

nginx とは

server {
  listen 80

  client_max_body_size 10m;
  root /home/isucon/pribate_isu/webapp/public;

  location /css/ {
    root /home/isucon/private_isu/webapp/public/;
  }

  location /js/ {
    root /home/isucon/private_isu/webapp/public/;
  }

  location / {
    proxy_set_header Host $host;
    proxy_pass http://localhost:8080;
  }
}

client_max_body_sizeはリクエストボディの最大許容サイズを指定。nginx ではデフォルトが 1MB になっているため、大きな画像ファイルなどを扱う場合大きくする。
proxy_set_headerはアップストリームサーバーに送るリクエストの HTTP ヘッダーを変更や追加したい場合に利用。
rootを使用することで公開するディレクトリを指定できる。root で指定されたディレクトリのパスが URL のパスにそのままマッピングされる。

nginx のアーキテクチャ

nginx は基本的にマルチプロセス・シングルスレッドで動作するが、イベント駆動のアーキテクチャを採用しており、各ワーカープロセスが複数のクライアントからのリクエスト・レスポンスを並行して扱うことができる。
nginx は、マスタープロセスとその子プロセスであるワーカープロセスの2種類のプロセスが起動している。リクエストを受け付けるのはワーカープロセスであり、マスタープロセスはワーカープロセスの制御と管理をしている。
nginx はリクエスト・レスポンスに伴う I/O 処理を並行かつ高速に扱うため、多重 I/O やノンブロッキング I/O を活用している。

一般的にはファイルの内容を読み込む場合、プログラムはデータの到着を待つ。書き込みも同様に書き込みが完了するまで待つ。この I/O 処理が完了するまで待つ状態をブロッキングという。ノンブロッキング I/O はこのブロッキングを避けることができる。その代わり処理がブロックした場合、エラーが発生するため適切にリトライする必要がある。

多重 I/O は、複数のファイルディスクリプタを同時に渡すことができ、いずれかのファイルディスクリプタが I/O 可能になった時に通知を受け取ることができる。

nginx はスレッドを利用していないため、1つのワーカープロセスが使用できる CPU は1コアのみである。そのため、ワーカープロセスは複数起動することが一般的。ワーカープロセス数は nginx の worker_processes という設定を指定することで変更可能。デフォルトは1で、auto と指定することで CPU のコア数を自動で指定できる。

nginx による転送時のデータ圧縮

リクエストの HTTP ヘッダーに Accept-Encoding: gzip が付与されていれば、レスポンスのボディを gzip 圧縮してレスポンスサイズを小さくできる。HTTP レスポンスを gzip 圧縮することで大体 1/5 程度のサイズになる。

gzip on;
gzip_types text/css text/javascript application/javascript application/x-javascript application/json;
gzip_min_length 1k;

gzip_typesは gzip 圧縮する MIME タイプを指定。JPEG や PNG などの画像は既に圧縮されているファイル形式のため、それ以上圧縮できない。しかし、HTML・JSON・CSS・JavaScript ファイルなどは圧縮できるため、gzip 圧縮を利用したいファイル形式。HTML の MIME タイプである text/html はデフォルトで利用できる。
gzip_min_lengthは gzip 圧縮の対象となる最小ファイルサイズを指定。nginx はレスポンスボディサイズが入っている Content-Length ヘッダーを見て gzip 圧縮の対象か判断する。小さいサイズのファイルは gzip 圧縮すると、元のファイルよりサイズが大きくなる。デフォルトは 20 で小さいので 1k に指定する。

ngx_http_gzip_static_moduleを使用することで、事前に gzip 圧縮したファイルを nginx から配信できる。Google 社が開発した Zopfli を使用すると、通常よりも高い圧縮率の gzip ファイルを用意できる。しかし、圧縮処理の時間は長くなるため、リクエストされたタイミングで動的に圧縮する用途には不向き。

gzip に対応していない HTTP クライアントも存在するため、ngx_http_gzip_static_module を使用する場合は gzip 圧縮したファイルと無圧縮のファイルの両方を用意する必要がある。しかし、ngx_http_gunzip_moduleを使用するとサーバー上に gzip 圧縮したファイルのみを配置するだけでよくなる。gzip 対応していない HTTP クライアントへレスポンスを返す際に gzip を展開する必要があるが、gzip に対応していないクライアントはほとんど存在しない。

圧縮レベル

圧縮レベルの低い方が圧縮にかかる時間は短くなりますが、容量が大きくなる。Zlib(ngix などが gzip 圧縮に使用しているライブラリ)のデフォルトの圧縮レベルは6である。nginx の場合、gzip_comp_levelという設定で圧縮レベルを1から9まで設定可能。デフォルトは1。

nginx とアップストリームサーバーのコネクション管理

nginx のデフォルトでは、アップストリームサーバーとのコネクションを都度切る設定になっている。コネクションを保持して使いまわしたい(keepalive)場合は HTTP/1.1 を利用することと、Conncetion ヘッダーにから文字を設定する必要がある。

location / {
  proxy_htttp_version 1.1;
  proxy_set_header Connection "";
  proxy_pass http://app;
}

キープアライブを利用することで、アップストリームサーバーへの接続処理を減らすことができる。keepalive を利用するとキープアライブするコネクション数を指定でき、keealive_requests を利用するとコネクションを閉じるまで受け付ける最大のリクエスト数を指定できる。

upstream app {
  server localhost:8080;

  keepalive 32;
  keepalive_requests 10000;
}

更なる nginx 高速化

sendfile と tcp_nopush はデフォルト無効ですが、基本的に両方とも有効にした方が良い。
sendfileを有効した場合、ファイルの読み込みとレスポンス送信に sendfile システムコールを利用することで、カーネル空間からユーザ空間へのメモリのコピーをせずに効率よくファイルの送信をおこなれるようになる。
tcp_nopushは sendfile を有効にしたときのみ有効にできる。送信するパケット数を減らして効率よくファイルの送信が行える。

sendfile on;
tcp_nopush on;

Chapter8 押さえておきたい高速化手法

外部コマンド実行ではなく、ライブラリを利用

アプリケーションから外部コマンドを実行すると、アプリケーションとは別のプロセスを起動する必要がある。別のプロセスを起動するコストがかかるし、起動したプロセスがメモリを消費するため、メモリの消費量も増える。

また、OS コマンドインジェクション脆弱性は、bash などのシェルを起動して、その起動されたシェルが外部コマンドを実行する際にプログラムから渡された文字列をシェルとして解釈することで発生。

標準ライブラリを利用する実装に変えられないか検討する。

out, err := exec.Command("/bin/bash", "-c", `printf ...`)

外部コマンド呼び出しでシェルを起動するべきではない?

外部コマンド呼び出しを exec と呼ぶことがありますが、システムコール的には fork と exec の2つを呼び出す。Linux ではプロセスを新しく作る方法はなく、プロセスのコピーを作る fork が新しいプロセスを作る唯一の方法。
exec システムコールを呼び出すと実行するバイナリをメモリにロードし、そのプロセスのメモリの内容を書き換えてプログラムを実行する。fork せずに exec システムコールだけを実行するとそのプロセスのメモリが書き換えられてしまい、後続の処理を実行できなくなる。そんとあめ、基本的に exec システムコールだけを実行することはなく、fork システムコールを実行した後に子プロセス上だけで exec システムコールを実行する。

外部コマンドを起動する際にシェルを経由すれば、*やパイプなどのシェルが提供する機能が使える。Go の exec.Command は fork した後に子プロセスで exec システムコールをシェルを経由せずに直接を呼び出す。exec.Command は実行するバイナリは一つしか指定できず、引数も文字列として解釈される。

開発用の設定で冗長なログを出力しない

運用では大量のログを出力すればファイル書き込みなどが実行されるため、パフォーマンスに影響がある。また、ログを外部サービスに保管している場合、コストもかさむ。

デバッグモードを無効にしたり、ログレベルを変更する必要がある。

HTTP クライアントの使い方

マイクロサービスでは、1つの Web アプリケーションであるマイクロサービス同士の通信が非常に多くなるため、HTTP クライアントの扱い方もパフォーマンス面で非常に重要。

  • 同一ホストへのコネクションを使い回す
  • 適切なタイムアウトを設定する
  • 同一ホストに大量のリクエストを送る場合、対象ホストへのコネクション数の制限を確認

同一ホストへのコネクションを使い回す

HTTP クライアントが通信先ホストとの TCP コネクションを使いまわせない場合、HTTP リクエストを送信するたびに TCP のハンドシェイクが必要となる。そうなると、サーバー間の必要な通信回数が増えるし、ローカルポートを大量に消費する。できる限り、TCP コネクションを使いまわした方がパフォーマンスが上がる。

Go の場合、標準パッケージである http.Client の変数を使い回すことで、TCP コネクションと TLS コネクションをできる限り使い回すことができる。Go はマルチスレッドで動作する言語のため、通常一つの変数を複数スレッドから使用する場合は不整合が発生しないか確認する必要がある。しかし、http.Client は内部的に対策がされているため、並行に使用しても問題ない。そのため、http.Client の変数を一つ作って、常に使い回すようにプログラムを記述するべき。

Go の http.Get 関数はグローバル変数の http.DefaultClient を内部的に使い回しているので、コネクションの件については問題ない。

Go はレスポンスの Body の Close を忘れると TCP コネクションが再利用されないため、res.Body.Close()を必ず実行するようにする。
また、res.Body.Close()を実行していても、レスポンスの Body を Read せずに Close すると都度 TCP コネクションが切断される。

res, err := http.DefaultClient.Do(req)
if err != nil {
  log.Fatal(err)
}
defer res.Body.Close()

_, err := io.ReadAll(res.Body)
if err != nil {
  log.Fatal(err)
}

基本的にこれらのコネクションを保持するのは OS のプロセス単位である。そのため、マルチプロセスで動く言語の場合、1プロセスにつき最低1コネクションを保持して使い回すことになる。

適切なタイムアウトを設定する

タイムアウトが設定されていなかったり、設定されていても非常に長い設定になっていれば、外部 Web サービスが障害になりレスポンスが返ってこなくなった時に、Web アプリケーションサーバーの1リクエストの処理にかかる時間が非常に長くなってしまう。

http.DefaultClient には、Timeout が設定されていないため、本番環境で利用することは非推奨。
Timeout を設定した http.Client を用意することを推奨。

cli := http.Client{
  Timeout: 5 * time.Second,
}

同一ホストに大量のリクエストを送る場合、対象ホストへのコネクション数の制限を確認

ライブラリやネットワークのシステムによっては外部サービスへ負荷をかけにくいようにしたいなどの理由で、対象ホストへのコネクション数が制限されていることがある。
Go の http.Client の場合、同一ホストへのコネクション数のデフォルトは http.DefaultMaxIdlePerHost の2に制限されている。
この設定を変えたい場合は、http.Client の内部で保持している http.Transport の設定を変更。

cli := http.Client{
  Timeout: 5 * time.Second,
  Transport: &http.Transport{
    MaxIdleConns: 500,
    MaxIdleConnsPerHost: 200,
    IdleConnTimeout: 120 * time.Second,
  }
}

MaxIdleConnsPerHost は同一ホストへのコネクション数の設定でデフォルトは2。
MaxIdleConns は通信している全体(すべてのホスト)で保持できるコネクション数の上限でデフォルトは100。
IdleConnTimeout は通信していない(アイドル状態)コネクションをいつまで保持するかの設定でデフォルト値は 90 秒。

静的ファイル配信をリバースプロキシから直接配信する

アプリケーション上では拡張子から Content-Type ヘッダを付与する処理を入れる必要があるが、nginx の場合はファイルの拡張子から/etc/mime.types ファイルの内容に従って Content-Type を付与する。
よって、ファイルの拡張子を適切につけることで Content-Type を付与する処理を行えるので、nginx で実装する必要はない。

try_files はパラメータに指定したファイルパスを前から順番にチェックし、ファイルがあればそのファイルの内容をレスポンスとして返し、どのファイルも存在しなかった場合は最後に指定した転送先 URI へ内部リダイレクトを行う設定である。

location /image/ {
  root /home/isucon/private_isu/webapp/public/;
  try_files $uri @app;
}

location @app {
  internal;
  proxy_pass http://localhost:8080;
}

画像を配信する際にアプリケーションでの認証を要求したい場合、nginx では X-Access-Redirect という特殊なヘッダを使用できる。アプリケーション上で認証した後、アプリケーションから X-Access-Redirect ヘッダを返すことで、nginx 内で別の path に内部リダイレクトを行うことができる。

アプリケーションの使用に画像ファイルの変更が含まれる場合は、URL も同時に変更することを考える。画像ファイルは CDN などのキャッシュを返すことも多かったり、ブラウザ上でキャッシュを有効にするのが普通。

クエリ文字列を使用して、クライアント側のキャッシュを無効にする

ファイル名を変更できない場合にキャッシュを無効にする手法として、クエリ文字列に日付や何らかの文字列を付与することもよく使われる。ブラウザはクエリ文字列も含めた URL が一致しているかどうかでキャッシュを使えるかどうかを判定。

nginx は proxy_cache_key の設定で判定。デフォルトでは$scheme$proxy_host$request_uriという設定で、$request_uri にはクエリ文字列も含まれるため、URL スキームとクエリ文字列を含めた URL 全体をキャッシュのキーとして利用する。このような手法をキャッシュバスター法と呼ばれる。この手法で気をつけることは、

  • サーバーのキャッシュキーにクエリ文字列が含まれている
  • ファイルが変更されたら必ずクエリ文字列に渡す値を更新する
  • ファイルが変更されていないなら同じクエリ文字列を使い続ける

HTTP ヘッダを活用してクライアント側にキャッシュさせる

サーバーで配信しているファイルがブラウザなどのクライアントが保持しているコンテンツと同一のものか判定するために「HTTP 条件付きリクエスト」と呼ばれるリクエストが存在する。

  • 初回もしくはキャッシュが存在しない場合、リクエストは通常通り送る
    • レスポンスとして Last-Modified・ETag ヘッダのいずれかもしくは両方が返ってくるのでブラウザはその値を保存しておく
    • Last-Modified には最終更新時刻が、ETag にはリソース固有のユニークな文字列が入っている
  • キャッシュが期限切れをした後のリクエストでは、リクエストのヘッダーとして If-Modified-Since・If-None-Match ヘッダを付与する
    • If-Modified-Since ヘッダには保存しておいた Last-Modified、If-None-Match ヘッダーには保存しておいた ETag ヘッダの内容をそれぞれ付与する
    • コンテンツに変化がなければ、レスポンスとしてレスポンスボディは空で、HTTP のレスポンスステータスコードとして 304 NOT MODIFIED を返す
    • コンテンツに変化があれば、レスポンスとして新しいコンテンツデータと更新された Last-Modified・ETag ヘッダをそれぞれ返す

Cache-Control ヘッダは、このキャッシュをそれぞれのシステムがいつまで有効かを記述できる HTTP ヘッダである。Cache-Controle: max-age=86400というヘッダを返すと、1 日キャッシュが有効になる。Cache-Controle ヘッダでキャッシュを使用している静的ファイルを変更する場合、ファイル名を変更するか、クエリ文字列を利用して古いファイルが利用されないようにする。

304 NOT MODIFIED のレスポンスはレスポンスボディを空に出来、クライアント側のキャッシュを利用するようにするものなので、実際にファイルを配信する時に比べると転送量を大幅に減らすことができる。

location /image/ {
  root /home/isucon/private_isu/webapp/public/;
  expires 1d
  try_files $uri @app;
}

location @app {
  internal;
  proxy_pass http://localhost:8080;
}

expires 1d;と設定することで Cache-Control というヘッダを返す。nginx でファイルを配信する場合、Last-Modified ヘッダはファイルの更新時刻(mtime)から、Etag ヘッダはファイルの更新時刻とファイルサイズから計算して自動で付与される。そのため、expires の設定をすることで HTTP 条件付きリクエストを使用できる。

複数台構成でサーバーによって異なる Last-Modified・ETag ヘッダを返すようになっているとクライアントが適切にキャッシュを利用できない。nginx でファイルを配信する場合はファイルの更新時刻が同じなら同じ Last-Modified、ETag ヘッダーの値が生成できる。

Last-Modified・ETag ヘッダは、どちらか片方が存在すれば十分。Last-Modified ヘッダのみを有効する。nginx の場合、etag: off;とすることで無効にできる(デフォルトでは有効)。