Using cURL to Test Origin Server Responses
あるトピックが繰り返し話題に出ることって、たまにありますよね。私は Fastly の Sales Engineer としてお客様のシステムの構築やデプロイを手掛けていますが、先週さまざまな場面で同じテーマが何度も繰り返し話題に上がることがありました。
先日、cURL というツールを使ってオリジンサーバーのレスポンスをテストしていました (例えば、アプリがおかしなエラーメッセージを返して応答している場合などのテストに便利なツールです)。結構ニッチなユースケースだと思っていたのですが、これがなぜか私の周りで何度も繰り返し話題になったのです。cURL に関してあまり詳しくない同僚や、デプロイのトラブルシューティングをしているお客様にこのプロセスについて説明する機会がありましたが、皆さんにとっても参考になるかと思い、ブログ記事を書くことにしました。では、早速 cURL についてご説明します。
### cURL について
クライアント URL (通称 cURL または curl) は、[1997年に Daniel Stenberg 氏によってリリース](https://en.wikipedia.org/wiki/CURL)されました。公開後も、Daniel Stenberg 氏は cURL プロジェクトのメンテナンスに熱心に取り組み続けています。もともとは、通貨為替レートのフェッチを自動化するために IRC ユーザー向けに開発された cURL ですが、現在ではあらゆる形の URL フェッチに幅広く使用されるようになりました。開発以来、多くのプロジェクトにとってバックボーンとなった cURL に、絶え間なく新たな機能を開発・追加し続けている Daniel Stenberg 氏は、開発者の鏡です。
cURL は、URL に HTTP リクエストを送信し、結果を受信することを可能にするコマンドラインユーティリティです。多くの Linux ディストリビューションや MacOs などのオペレーティングシステムにデフォルトで含まれています。HTTP 中心の仕組みが多いインターネットで、Web ページや API など、HTTP インターフェイスを利用しているものにアクセスするのに便利なツールです。
今回のデモでは、cURL を使用して Web ページをリクエストする際のブラウザ体験をシミュレートしてみます。これによってリクエストを完全にコントロールし、トラブルシューティングをより容易に行うことが可能になります。
以下は、MacBook のターミナルアプリケーションから実行したシンプルな cURL のコマンドです。Fastly.com のホームページをリクエストするこのコマンドでは、HTML 全体が表示されます。これによる出力は冗長ですので、後ほどクリーンアップします。
```
curl https://www.fastly.com/
```
### フラグ
cURL の優れたところは、フラグを使用してリクエストを操作できるところです。例えば、`-I` をパスすることで、レスポンスに表示する内容を、コンテンツではなくリモートサーバーからのヘッダーのみに制限することができます。
私がほぼ全ての cURL コマンドで使っているフラグは、`-svo` (silent モード (s)、 verbose モード (v)、writing to a file (o)) で、これを `/dev/null` に追加します。コマンド全体は `curl -svo /dev/null https://www.fastly.com` になります。こうすることで、レスポンスボディのノイズを排除し、リクエストの詳細に集中することができます。また、ヘッダーには送信済み、または受信済みのフラグが付くので便利です。SSL ネゴシエーションは、メインのリクエストの前に表示されるようになっています。
以下では、記述されたコマンド ($)、接続と SSL ネゴシエーション (*)、リクエスト (>)、そしてレスポンス (<) が確認できます。
```shell
$ curl -svo /dev/null https://www.fastly.com/
* Trying 199.232.77.57...
* TCP_NODELAY set
* Connected to www.fastly.com (199.232.77.57) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
* CAfile: /etc/ssl/cert.pem
CApath: none
* TLSv1.2(OUT), TLS handshake, Client hello (1):
} [228 bytes data]
* TLSv1.2(IN), TLS handshake, Server hello (2):
{ [102 bytes data]
* TLSv1.2(IN), TLS handshake, Certificate (11):
{ [2828 bytes data]
* TLSv1.2(IN), TLS handshake, Server key exchange (12):
{ [300 bytes data]
* TLSv1.2(IN), TLS handshake, Server finished (14):
{ [4 bytes data]
* TLSv1.2(OUT), TLS handshake, Client key exchange (16):
} [37 bytes data]
* TLSv1.2(OUT), TLS change cipher, Change cipher spec (1):
} [1 bytes data]
* TLSv1.2(OUT), TLS handshake, Finished (20):
} [16 bytes data]
* TLSv1.2(IN), TLS change cipher, Change cipher spec (1):
{ [1 bytes data]
* TLSv1.2(IN), TLS handshake, Finished (20):
{ [16 bytes data]
* SSL connection using TLSv1.2/ ECDHE-RSA-CHACHA20-POLY1305
* ALPN, server accepted to use h2
* Server certificate:
* subject: C=US; ST=California; L=San Francisco; O=Fastly, Inc.; CN=www.fastly.com
* start date: Mar 3 21:56:03 2021 GMT
* expire date: Apr 4 21:56:03 2022 GMT
* subjectAltName: host "www.fastly.com"matched cert's "www.fastly.com"
* issuer: C=BE; O=GlobalSign nv-sa; CN=GlobalSign RSA OV SSL CA 2018
* SSL certificate verify ok.
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x7fe1f980aa00)
> GET / HTTP/2
> Host: www.fastly.com
> User-Agent: curl/7.64.1
> Accept: */*
>
* Connection state changed (MAX_CONCURRENT_STREAMS == 100)!
< HTTP/2 200
< alt-svc: h3=":443";ma=86400,h3-29=":443";ma=86400,h3-27=":443";ma=86400
< etag: "5c770df920f8c90e4c4532c32aea6ec3"
< content-type: text/html
< accept-ranges: bytes
< date: Mon, 09 Aug 2021 15:38:35 GMT
< x-served-by: cache-pwk4963-PWK
< x-cache: HIT
< x-cache-hits: 2
< x-timer: S1628523515.208976,VS0,VE0
< vary: Accept-Encoding
< x-xss-protection: 1; mode=block
< x-frame-options: DENY
< x-content-type-options: nosniff
< cache-control: max-age=0, private, must-revalidate
< server: Artisanal bits
< strict-transport-security: max-age=31536000
< content-length: 777219
<
{ [1113 bytes data]
* Connection #0 to host www.fastly.com left intact
* Closing connection 0
```
### URL と SSL 名
上記のシナリオでは、SSL 証明書名、Host ヘッダー、DNS 名のすべての値が `www.fastly.com` として設定されています。もちろん、これで十分な場合もあります。今回は、これらを分けることで、異なる目的のトラブルシューティングで各要素を検査できるようにします。
今使用されている `https://www.fastly.com/` では、リクエストされている URL が表示されています。URL 内で使用されているホスト名 (`www.fastly.com` など) が、cURL が SSL 証明書を検証するために使用する値となります。これは、TLS が正常に動作していること、そして正しい証明書によってサイトが保護されていることを確認するために重要です。以下の例をご覧ください。
$ curl -svo /dev/null https://www.fastly.com/
* Trying 199.232.77.57...
* TCP_NODELAY set
* Connected to www.fastly.com (199.232.77.57) port 443 (#0)
* ALPN, offering h2
* ALPN, offering http/1.1
* successfully set certificate verify locations:
* CAfile: /etc/ssl/cert.pem
CApath: none
* TLSv1.2(OUT), TLS handshake, Client hello (1):
} [228 bytes data]
* TLSv1.2(IN), TLS handshake, Server hello (2):
{ [102 bytes data]
* TLSv1.2(IN), TLS handshake, Certificate (11):
{ [2828 bytes data]
* TLSv1.2(IN), TLS handshake, Server key exchange (12):
{ [300 bytes data]
* TLSv1.2(IN), TLS handshake, Server finished (14):
{ [4 bytes data]
* TLSv1.2(OUT), TLS handshake, Client key exchange (16):
} [37 bytes data]
* TLSv1.2(OUT), TLS change cipher, Change cipher spec (1):
} [1 bytes data]
* TLSv1.2(OUT), TLS handshake, Finished (20):
} [16 bytes data]
* TLSv1.2(IN), TLS change cipher, Change cipher spec (1):
{ [1 bytes data]
* TLSv1.2(IN), TLS handshake, Finished (20):
{ [16 bytes data]
* SSL connection using TLSv1.2/ ECDHE-RSA-CHACHA20-POLY1305
* ALPN, server accepted to use h2
* Server certificate:
* subject: C=US; ST=California; L=San Francisco; O=Fastly, Inc.; CN=www.fastly.com
* start date: Mar 3 21:56:03 2021 GMT
* expire date: Apr 4 21:56:03 2022 GMT
* subjectAltName: host "www.fastly.com" matched cert's "www.fastly.com"
* issuer: C=BE; O=GlobalSign nv-sa; CN=GlobalSign RSA OV SSL CA 2018
* SSL certificate verify ok.
--->{ 読みやすさのために省略されています }<---
### パス
元の URL に残るものとして、HTTP vs HTTPS などのスキームと、リクエストされた特定のリソースへのパスもあります。URL が目的地、その他すべてはその目的地にたどり着く方法や経路として考えると分かりやすいでしょう。
### ホストヘッダー
典型的な Web サーバーは、`blog.example.com` や `docs.example.com` などの異なるドメイン名を持つ複数のサイトをホストすることが可能です。このような同じシステム内のサイトでも、ソースコードや URL パスが異なる場合があります。
ホストヘッダーを変更したい場合、cURL 内でヘッダーを明示的に定義することでパスさせることができます。ヘッダーは、`--header` または略して `-H` フラグで宣言することができます。すると値が引用符によってカプセル化され、ヘッダー名が定義されます。cURL コマンドは以下のようになります。
```shell
curl -svo /dev/null https://www.fastly.com/-H “host: blog.fastly.com”
```
このリクエストでは、`www.fastly.com` の証明書とロケーションを使用して、`blog.fastly.com` のホストが要求されています。これは、`fastly.com` と `www.fastly.com` など、ドメインの Apex の違いを確認したい場合に特に便利です。以下では `www` に対して正常に 301 を返していることが分かります。
$ curl -svo /dev/null https://www.fastly.com/-H "host: fastly.com"
* Trying 199.232.77.57...
* TCP_NODELAY set
* Connected to www.fastly.com (199.232.77.57) port 443 (#0)
--->{ 読みやすさのために省略されています }<---
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x7fa15480aa00)
> GET / HTTP/2
> Host: fastly.com
> User-Agent: curl/7.64.1
> Accept: */*
>
* Connection state changed (MAX_CONCURRENT_STREAMS == 100)!
< HTTP/2 301
< retry-after: 0
< accept-ranges: bytes
< date: Mon, 09 Aug 2021 16:26:07 GMT
< x-served-by: cache-pwk4938-PWK
< x-cache: HIT
< x-cache-hits: 0
< cache-control: max-age=0, private, must-revalidate
< server: Artisanal bits
< strict-transport-security: max-age=31536000
< location: https://www.fastly.com/
< content-length: 0
<
{ [0 bytes data]
* Connection #0 to host www.fastly.com left intact
* Closing connection 0
### 名前解決
cURL 内のフラグの中で最も過小評価されているのが、`--resolve` です。上記の cURL すべてにおいて、最初に DNS サーバーでドメインの名前解決を行うために `Trying X.X.X.X` として IP アドレスが表示されているのがお分かりでしょうか。しかし、場合によってはヒットしたいターゲットが DNS 名とは異なることがあります。例えば、初期デプロイにおいて、DNS での変更を反映させる前にサービスが正しく解決することができるか、テストを行う場合がそうです。またはリバースプロキシが連鎖しており、パブリック DNS は連鎖の始めでしか名前解決が行えない状況で、オリジン自体のレスポンスの確認が必要な場合もあります。
これらの問題を回避するために、cURL 向けにドメインの名前解決を行い、使用したい IP アドレスを指定することができます。そのためには、2つのステップがあります。
1. 宛先の IP が明確でない場合、ホストの DNS 解決を行い、IP を取得する必要がある場合があります。この場合、コマンドラインから DNS クエリを実行する標準的なユーティリティ、Dig を使うと便利です。
```shell
$ dig www.fastly.com +short
prod.www-fastly-com.map.fastly.net.
151.101.185.57
```
2. 次に --resolve オプションを使って、置き換えるドメイン名、ポート、使用したい IP を指定し、HTTP リクエストを行う際に cURL が使うべき IP を割り当てます。最終的に以下のようになります。
$ curl -svo /dev/null https://www.fastly.com/-H "host: fastly.com"--resolve www.fastly.com:443:151.101.185.57
* Added www.fastly.com:443:151.101.185.57 to DNS cache
* Hostname www.fastly.com was found in DNS cache
* Trying 151.101.185.57...
* TCP_NODELAY set
* Connected to www.fastly.com (151.101.185.57) port 443 (#0)
--->{ 読みやすさのために省略されています }<---
### connect-to オプション
上記の resolve の代わりに `--connect-to` を使用し、接続する IP またはホストを指定することもできます。この方法の方が簡単ですが、ホストを使用する場合に精度的な問題が発生する場合があります。connect-to によって DNS で名前解決を行うことができますが、正しい IP が使用されない場合があります。ホストと IP のマッピングが確実に一致する場合は、問題ありません。しかし、複数の A レコードがあり、すべての IP を試したい場合、もしくはホストの DNS ベースのロードバランシングにより、ロケーションなどによって異なる結果が出る場合は、問題になり得る可能性があります。そのため、`--resolve` オプション、または `--connect-to` で IP を指定する方法が確実です。この方法であれば、誰でもこの cURL を使って同じ結果を再現することができます。
また connect-to のメリットは、resolve とは違い、DNS で名前解決するドメイン名やポートを明示的に定義しなくても良いところです。つまり、`::151.101.185.57` または `::target.host.fastly.com` を使用することができます。
```shell
$ curl -svo /dev/null https://www.fastly.com/-H "host: fastly.com"--connect-to ::151.101.185.57
* Connecting to hostname: 151.101.185.57
* Trying 151.101.185.57...
* TCP_NODELAY set
* Connected to 151.101.185.57 (151.101.185.57)port 443 (#0)
--->{ 読みやすさのために省略されています }<---
```
### まとめ
HTTP リクエストを実行する方法は、沢山あります。cURL にはさまざまなオプションがあり、それぞれの方法にメリットやデメリットがあります。今回ご紹介したのはあくまでも私が気に入っている手法で、使いやすいと感じるやり方は人それぞれだと思います。
cURL では、/dev/null を使い、ホストヘッダーを操作し、resolve オプションを使用することで、リクエストの宛先、マッチしたい SSL 証明書、サーバーが使用すべきホストを指定し、予測可能で管理しやすい出力が得られます。おかげでトラブルシューティングが楽になるので、ストレスも大幅に削減することができると思います。ぜひお試しください!
こちらは、分かりやすく色分けしたスニペット全体です。ぜひ参考にしてください。
curl -svo /dev/null https://www.certificate-name.com/path/to/resource/ -H "host: www.expected-host.com" --resolve www.certificate-name.com:443:1.2.3.4