読者です 読者をやめる 読者になる 読者になる

$shibayu36->blog;

株式会社はてなでエンジニアをしています。プログラミングや読書のことなどについて書いています。

nginxのproxy設定ファイルも自動テストしよう

 最近nginxでリバースプロキシの設定を書いているんだけど、設定のたびに本番に緊張しながら反映していた。さらにその副作用として、nginxのファイルはリファクタリングされないという問題があった。

 そこで反映する前にバグ等が見つかるようにnginx設定のテストを書きたいと考えた。今回はperlでテストを書いた。

どういうテストをしたいか

 やり方によってnginxの設定ファイルの分割の方法は違うのだけど、例えば以下の様なnginxの設定があり、それが別のファイルのhttpコンテキストの中にincludeされているという分割で考える。この時、この設定ファイルに書かれた内容が正しく動いているかテストを書きたい。

upstream app1 {
    server app1.host;
}
upstream app2 {
    server app2.host;
}

server {
    listen 8080;
    location / {
        proxy_set_header Host 'dummy.host';
        proxy_pass http://app1;
    }

    location /app2 {
        proxy_pass http://app2;
    }

    location ~ ^/(html|css|images|js)/ {
        access_log off;
        root /path/to/repository/static;

        if ($arg_version) {
            expires max;
            add_header Last-Modified "Thu, 01 Jan 1970 00:00:00 GMT";
        }
    }
}

 この時

  • /にアクセスしたときにHostヘッダがリクエストヘッダにつくか
  • /app2にアクセスしたときに、app2 upstreamにリクエストが投げられるか
  • /html/sample.htmlなどの静的ファイルにアクセスしたときに、うまくキャッシュ用ヘッダが出力されるか

などのことをテストしたい。

テストの基本的な考え方

 基本的にはTest::TCPを利用して、テスト用に手元でnginxとそのupstreamとなるappサーバを立て、リクエストを投げ、そのリクエストやレスポンスをテストする。

 上に書いたテストしたい内容をチェックするためには、以下の図に書いてあるように、

  • nginx -> upstreamに投げられるリクエスト
  • nginx -> UserAgentに戻るレスポンス

の二つを検証すれば良い。

 そのため以下のようにテストできないかと考えた。

そこで

  1. Test::TCPを利用して、upstreamとなるappサーバを立ち上げる
    • この時nginxのリクエストの中でテストしたいものをresponseに入れる
  2. テストしたいファイルを読み込んだnginxをTest::TCPで起動
  3. 普通にLWP::UserAgentとかで立ち上がったnginxにリクエストして、返ってきた内容をテスト

という手順でテストしてみようと考えた。

upstreamとなるappサーバを立ち上げる

 これは以前Test::TCPを使ってテスト用にmemcached, app, nginxサーバを立てる - $shibayu36->blog;にも書いた。

 appサーバを立ち上げるときに、テストしたいリクエストをレスポンスに詰めておく。2台のapp serverを立てるサンプルコードは以下。

# テスト用app serverを立てるutility
sub start_http_server {
    my ($app) = @_;
    return Test::TCP->new(
        code => sub {
            my $port = shift;

            my $server = HTTP::Server::PSGI->new(
                host    => "127.0.0.1",
                port    => $port,
            );

            $server->run($app);
        },
    );
}

my $app1 = start_http_server(sub {
    my $env = shift;
    my $req = Plack::Request->new($env);
    my $headers = { map { $_ => $req->header($_) } $req->headers->header_field_names };

    # リクエストheaderをテストするために、responseに入れておく
    [ 200, [ 'Content-Type' => 'text/plain' ], [ encode_json $headers ] ];
});

my $app2 = start_http_server(sub {
    [ 200, [ 'Content-Type' => 'text/plain' ], [ 'Hello App2' ] ];
});

 これでnginxのテストに必要なupstreamのdummy serverが出来た。

テスト用nginxを起動

 これもTest::TCPを使ってテスト用にmemcached, app, nginxサーバを立てる - $shibayu36->blog;に、Test::TCPを使ってnginxを起動するという話だけ書いた。

 上の内容に追加して、テストするためにはいくつかの問題を解決する必要がある。

  • テストしたいファイルを指定して読み込める
  • テスト用に、設定内のportやrootのディレクトリを書き換える

 そのためのコードが以下。

# テスト用nginxを立てるutility
sub start_nginx_server {
    my %opts = @_;
    my $app1_port = $opts{app1_port};
    my $app2_port = $opts{app2_port};
    my $conf_file = $opts{conf_file};

    return Test::TCP->new(
        code => sub {
            my $port = shift;

            my $temp_dir = File::Temp::tempdir;

            my $nginx_conf = file($conf_file)->slurp;

            # ---- 設定ファイルの書き換えを行う ----
            # listenの番号書き換え
            $nginx_conf =~ s{listen 8080}{listen $port}g;

            # upstreamをdummy serverに書き換え
            $nginx_conf =~ s!upstream app1 {.*?}!upstream app1 { server localhost:$app1_port; }!s;
            $nginx_conf =~ s!upstream app2 {.*?}!upstream app2 { server localhost:$app2_port; }!s;

            # ファイルパスを現在のディレクトリに書き換え
            my $current_directory = Cwd::getcwd();
            $nginx_conf =~ s{/path/to/repository}{$current_directory}g;

            # 設定ファイルを書き換えた内容をテストしやすいwrapperにくるんで
            # nginxにそのまま渡せる形に
            my $conf = <<"EOS";
daemon off;

error_log $temp_dir/error_log crit;
lock_file $temp_dir/lock_file;
pid $temp_dir/nginx.pid;

events {
    worker_connections  1024;
}

http {
    client_body_temp_path $temp_dir/client_body_temp;
    proxy_temp_path $temp_dir/proxy_temp;

    $nginx_conf
}
EOS

            my $fh = Path::Class::file("$temp_dir/nginx.conf")->openw;
            print { $fh } $conf;
            close $fh;

            # 起動
            exec "nginx -c $temp_dir/nginx.conf -p $temp_dir";
        },
    );
}

# 立ちあげたapp1, app2を利用して、nginxを立てる
# proxy.nginx.confのテストをしたい
my $nginx = start_nginx_server(
    app1_port  => $app1->port,
    app2_port  => $app2->port,
    conf_file  => 'proxy.nginx.conf',
);

これでテスト用nginxの起動まで出来た。

立ち上がったテスト用サーバにリクエストを送ってテスト

 あとは立ち上がったnginxサーバにリクエストを送ることでテストが出来る状態になっている。テストしたい内容を振り返ると

  • /にアクセスしたときにHostヘッダがリクエストヘッダにつくか
  • /app2にアクセスしたときに、app2 upstreamにリクエストが投げられるか
  • /html/sample.htmlなどの静的ファイルにアクセスしたときに、うまくキャッシュ用ヘッダが出力されるか

だったので、それに対応するようなテストを書いたのが以下。

my $ua = LWP::UserAgent->new;
subtest '/へのリクエスト' => sub {
    my $data = decode_json($ua->get('http://localhost:' . $nginx->port)->content);
    # app1のリクエストヘッダがレスポンスに含まれるはずなのでそれを検証
    is $data->{Host}, 'dummy.host', 'Hostヘッダがリクエストに付く';
};

subtest '/app2へのリクエスト' => sub {
    my $content = $ua->get('http://localhost:' . $nginx->port . '/app2')->content;
    is $content, 'Hello App2', 'app2にリクエストがいく';
};

subtest '/html/sample.htmlへのリクエスト' => sub {
    # responseヘッダは普通に取得できるはずなのでそれを検証
    subtest 'versionというクエリがついた時、キャッシュ用ヘッダが出力' => sub {
        my $res = $ua->get('http://localhost:' . $nginx->port . '/html/sample.html?version=123');
        ok $res->header('Expires'), 'ヘッダが出力される';
    };

    subtest 'クエリがつかない時、Expiresのヘッダが出ない' => sub {
        my $res = $ua->get('http://localhost:' . $nginx->port . '/html/sample.html');
        ok ! $res->header('Expires'), 'ヘッダが出力されない';
    };
};

done_testing();

まとめ

 今回はnginxの設定もテストしたいということで、perlによるテストの実装方法について書いた。今回のサンプルコードの全体は下に貼り付けておく。

 社内ではこういうコードをベースにさらにユーティリティを作っていて、もう少し簡単にいろんなテストを出来るようにしている。このへんももし公開できるならうまく公開していきたい。またnginxをテスト用に立ち上げる部分は汎用化出来るような気もするので、可能だったらCPANに上げたい気もする。

 最近は基本的に何か問題があったら、テストで解決できないかと考えるようにしているのだけれど、やはり今回nginxのテストを書いてみると、一気にnginxの設定ファイル群をリファクタリング出来たし、結構成功した気がする。ぜひお試しください。

サンプルコード全体