$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の設定ファイル群をリファクタリング出来たし、結構成功した気がする。ぜひお試しください。

サンプルコード全体

社内Cartonコードリーディング会第二回のメモ

社内Cartonコードリーディング会第一回のメモ - $shibayu36->blog; に引き続き、第二回を開催したので、その時の内容をメモしておきます。そのままメモを公開しただけなのでかなり雑です。間違ってることも多いと思います。

今回の趣旨

今回は、cpanfile.snapshotがどのように作られるのか、という点に絞ってコードリーディングをしていきました。

cpanfile.snapshotとMYMETA.json, install.jsonの関係

  • 各モジュールのMYMETA.jsonやinstall.jsonを使ってcpanfile.snapshotを作成している
  • MYMETA.json にはそのモジュールの cpanfile とか pod とかから得られそうな情報が載ってる
    • モジュール名、作者、build requires、runtime requires、test requires、etc.
  • install.json にはそのモジュールが提供するパッケージの情報が載ってる。
    • 例えば DateTime モジュールなら DateTime、DateTime::Duration、DateTime::Helpers、etc.
  • これらのファイルは誰が作っている?
    • おそらくcpanm?
  • cpanfile.snapshot の provides には install.json から得られる情報が、requirements には MYMETA.json から得られる情報が載っているっぽい?

cartonがcpanfile.snapshotに必要なモジュールを集める手順

  1. local/以下にモジュールがインストールされているはずなので、ディレクトリをrecursiveにたどり、入っているモジュールのメタデータを集める
  2. そのなかでCPAN::Meta::Requirementsなどを利用し、snapshotに利用するモジュールを抽出する

snapshotに利用するモジュールを抽出する

https://github.com/miyagawa/carton/blob/master/lib/Carton/Snapshot.pm#L134..L146 このあたりの処理がやっている

accepts_module

my $bool = $req->accepts_modules($module => $version);

...
For modules that do not appear in the requirements, this method will return true.
  • また複数versionに対する依存があった時は、条件を満たす一番最新のモジュールを選択する


以上の条件から

  • cpanfileに記述されたモジュールはsnapshotに含まれる
  • 依存の依存などの関係から複数バージョンのメタデータが見つかった場合は、cpanfileを満たすversion rangeの中で最新の物が選択される
  • ただしlocalにこれまでインストールされてしまっていて、cpanfileに書かれていないものや、cpanfileに書かれたもののさらなる依存などは、CPAN::Meta::Requirementsに登録されていないので、snapshotに含まれてしまう
    • そのため依存を消した時などはlocalを一度消してinstallし直さないと厳密なsnapshotが作れない
    • 環境によっては少しずつ違うsnapshotが出来てしまう

さらなる疑問点

  • snapshotの前段階で、carton installがどういうロジックでlocal以下にcpanモジュールを入れていくか
  • carton installのときにglobalに入っているモジュールによって、インストールされるモジュールが変わったりするのか
    • globalというのはlocal/libではなく、使っているperlのsite_perl的なとこに入っているモジュール

次回見ようと思っているところ

 次回はcarton installとcpanmの関係で、どのようなロジックでモジュールを入れていくかについて見ようと思っています。