Fastly VCL のベストプラクティス

https://www.fastly.com/documentation/guides/vcl/best-practices/

本記事では Fastly の VCL でコードを書く際のベストプラクティスについて [VCL best practices](https://www.fastly.com/documentation/guides/vcl/best-practices/) の内容を説明します。

### カスタムサブルーチンの利用
VCL サービスに不用意にロジックを追加していくと、メンテナンスが難しくなります。繰り返しの多いコードにはカスタムサブルーチンを使用しるようにし、カスタムサブルーチンの名前には処理内容がわかるようなものをつけましょう。


### コメント機能の利用

チケット番号やURL（Zendesk、JIRA、GitHubのissueなど）を示すコメントを追加して、将来コードを参照する人が文脈を理解しやすくします：

```
# https://mycompany.atlassian.net/browse/PROJECT-935
sub add_system17_auth_header {
  if (table.contains(credentials, "system17_token")) {
    set bereq.http.authorization = "Bearer " table.lookup(credentials, "system17_token");
  }
}

sub vcl_miss {
  call add_system17_auth_header;
}
sub vcl_pass {
  call add_system17_auth_header;
}
```
このコードでは vcl_miss と vcl_pass で同じ処理（認証用ヘッダの追加）を行うので、カスタムサブルーチンを作成して呼び出しています。
また、このカスタムサブルーチンを追加する理由についてコメントも利用しています。

### コードの追加元を統一する
Fastly では独自のロジックを Snippet と Custom VCL から追加することが可能です。ただしこれらを併用すると最終的に生成された Generated VCL を確認する際にどちらから追加されたコードなのかが分かりにくくなります。
例えば Custom VCL を利用するのであれば VCL のコードの追加は Custom VCL のみから行う方がよいでしょう。

### データの抽出に regsub を使わない


1つの文字列の中から一部を取得したい場合、例えば次のような値がクッキーの auth に入っていたとします。
`uid=12345:sess=01234567-89abcdef-012:name=sjones:remember=1`

実際にどのような値が含まれているかは関係なく、ポイントとしてはこの中から一つの値を取り出したいとします。よく使われる誤ったやり方として [regsub](https://www.fastly.com/documentation/reference/vcl/functions/strings/regsub/) を使用する方法があります。

```
set var.result = regsub(
  req.http.cookie:auth,
  "^.*?:name=([^:]*):.*?$", "\1"
);
```
この方法では `name=` から次のコロンまでの値を全体の値と入れ替えることにより name の値、上記のケースだと `sjones` を取り出します。
しかし、例えば `:` が3つ含まれていなかったり `name=` が含まれていないなど cookie のフォーマットが想定していないものであった場合、この処理では入力ソース(req.http.cookie)全体を返却します。これは意図していないデータが表示されてしまったりする可能性があり危険です。


#### 解決策 1: if を使用する

[if 関数](https://www.fastly.com/documentation/reference/vcl/functions/miscellaneous/if/)は、正規表現キャプチャを条件とした条件演算子として使用し、マッチが失敗した場合の処理を指定することが出来ます。

```
set var.result = if(
  req.http.cookie:auth ~ "^(?:.*:)?name=([^:]*):", # Expression
  re.group.1, # Value to return if true
  ""          # Value to return if false
);
```

正規表現がマッチしなかった場合、戻り値は明示的に宣言されたデフォルト値（上の例では空文字列 `""`）になります。文字列の先頭と末尾の両方にパターンを固定する必要がなくなったので、正規表現もシンプルになりました。


#### 解決策 2: subfield() を使用する
処理対象の文字列が Key-Value のペアの場合、[subfield](https://www.fastly.com/documentation/reference/vcl/functions/miscellaneous/subfield/) を利用することも可能です。

```
set var.result = subfield(req.http.cookie:auth, "name", ":");
```

上記の３パターンの結果は以下の Fiddle のサンプルコードで確認できます。
https://fiddle.fastly.dev/fiddle/7f5f2604


### `:` オペレーターを使用してクッキーやヘッダーの値にアクセスする

上記の例では : オペレーターを Cookie の auth の値を取得するのに利用しています。(req.http.cookie:auth)。


HTTP ヘッダや Cookie に含まれている値が一般的な書式(key=value, key=value)やセミコロン`;` で区切られているものであれば、ヘッダ名と `:` を組み合わせてサブフィールドにアクセスすることが出来ます。

これは Cache-Control ヘッダーなどで特定のヘッダーフィールドを追記するためにも使うことが出来ます。

```
set resp.http.Cache-Control:max-age = "3600";
```


Vary など key-value ではなくリストタイプのヘッダーに Key だけを追加することもできます。

Vary ヘッダーの値が "My-Header" だった場合、下の処理の後にヘッダーは
"My-Header, Accept-Encoding"になります。

```
set resp.http.Vary:Accept-Encoding = "";
```

### 空文字列は true を返却します

多くの開発言語で `0` や空白文字列は boolean に対して false となりますが、VCL では宣言されていない場合のみ false になります。例えば以下のようなコードがあったとします。

```
if (req.url.qs) {
  # Do something if the request has a query string
  # (but actually this will ALWAYS EXECUTE)
}
```
クエリストリングが設定されていな場合、`req.url.qs`は空白文字列ですが、BOOL の結果は true となります。そのため上記の if 条件は全てのリクエストで実行されることになります。

NULL や空白文字列を条件として拾いたい場合、[`std.strlen`](https://www.fastly.com/documentation/reference/vcl/functions/strings/std-strlen/) を使用することが出来ます。以下の条件は両方に対して false を返却します。

```
if (std.strlen(req.url.qs) > 0) {
  # Do something if the request has a query string
}
```

### 言語選択には地域情報ではなく Accept-Language を使用する

サービスが多言語対応している場合、クライアントに対して自動的に最適な言語のコンテンツを表示したいかもしれません。これを実現するために以下のようにクライアントのアクセス元の地域に応じてコンテンツを出し分けるコードを実装しているケースがあります。

```
# Assume badly that everyone in Mexico reads Spanish and everyone in
# the United Kingdom reads English
if (client.geo.country_code == "mx") {                  # Mexico
  set req.url = querystring.add(req.url, "lang", "es"); # ...Spanish
} else if (client.geo.country_code == "gb") {           # UK
  set req.url = querystring.add(req.url, "lang", "en"); # ...English
}
```

アクセス元の国から表示する言語を選択するのはほとんどのケースで問題ないかもしれませんが、サービスの利用ユーザーが旅行などでその国を訪れている場合は理解できない言語でコンテンツが表示されてしまうことになります。

多言語でのコンテンツを出し分ける場合は地域情報ではなく、ブラウザによって設定されている Accept-Language を利用することをお勧めします。また、Fastly は以下のように Accept-Language の正規化することが出来ます。

```
set req.url = querystring.add(
  req.url,
  "lang",
  accept.language_lookup("en:de:fr:nl:es", "en", req.http.Accept-Language)
);
```

このコードはエンドユーザのAccept-Languageヘッダを読み込み、あなたのサイトがサポートする言語のセット内で正規化し、最終的な選択肢をクエリストリングに追加します。

ただし、この方法でクエリ文字列を変更すると、言語ごとに別々のキャッシュキーが作成され、コンテンツをキャッシュから削除するのが難しくなります。言語によってコンテンツを変化させるより完全な解決策としては Varyヘッダーの使用を検討してください。


### vcl_fetch で `beresp.http.Cache-Control` を設定してもキャッシュの TTL に影響はありません

Fastly がオリジンからレスポンスを受け取ると、レスポンスヘッダーの内容に応じて TTL を決定し、最終的に TTL は `beresp.ttl` として保持されます。

例えば次のような VCL があった場合:

```sub vcl_fetch { ... }
set beresp.http.Cache-Control = "max-age=3600";
set beresp.ttl = 60s;
```

最初の Cache-Control を設定している行は Fastly のキャッシュの TTL には影響を与えません。
２行目で ttl は 60秒が設定されます。Fastly 上でのキャッシュの TTL を変更したい場合は Cache-control ヘッダーではなく、`beresp.ttl` を変更する必要があります。
また `beresp.ttl` に設定した値は下流（ブラウザやその他のプロキシサーバーなど）の TTL には影響を与えません。

### TTL や backend を上書き時はデフォルト設定が適用されないように注意

Fastly の設定では vcl_recv でbackend を設定し、vcl_fetch でデフォルトの TTL を指定しています。条件によって backend や TTL を変更した場合に、以下のようなコードになるケースがあります。


```
if (req.url ~ "^/some-path") {
  set req.backend = F_alternative_origin;
}
set req.backend = F_normal_origin;
```
このコードでは some-path に対して backend を指定していますが、その後でさらにデフォルトの backend で上書きされているためすべてのリクエストに `F_normal_origin` が設定されます。
"Show VCL" から生成された最終的な VCL を確認して、上記のように意図せず backend や TTL の上書きが発生していないことを確認しましょう。

### カスタムヘッダーによるステータスのチェック

何らかのステータスをカスタムヘッダーに保持して利用することがありますが、そのカスタムヘッダーがクライアントのリクエストから付与されていないかは注意が必要です。

```
if (req.http.Paywall-State == "allow") { # Not safe!
  return(lookup);
} else if (req.http.Paywall-State == "deny") {
  error 403;
} else {
  # Perform a paywall API call, set header, and restart
}
```

上記のコードの場合、クライアントがブラウザのエクステンションや何らかの方法で自らリクエストに `Paywall-State: allow` ヘッダーを付与すると Paywall のチェックがバイパスされてしまいます。

また、Fastly が付与する `CDN-Loop` や `Fastly-FF` など、一部のヘッダーは VCL で変更できません。
ただし、これらのヘッダーが Fastly に到達する前に設定されている場合、値は保持されます。そのため、これらのヘッダーを Fastly を経由したリクエストかどうかを判断するために利用することは出来ません。

```
if (!req.http.Fastly-FF) {
  call do_authentication;
  # (but we'll skip this if the end-user knows
  # to send a Fastly-FF header themselves!)
}
```

上記の例だと、`fastly-ff` が存在しない場合に認証処理を行いますが、エンドユーザーがこの設定を知っており、リクエストに `fastly-ff` ヘッダーを付与すると認証処理をスキップされてしまいます。

リクエストを受け付けた最初のタイミングで処理を一度だけ走らせたい場合、以下のような条件を使用することが出来ます。

```
if (fastly.ff.visits_this_service == 0 && req.restarts == 0) {
  # Here you are guaranteed to be dealing with a request
  # for the first time, and there is no way for an end user
  # to avoid hitting this condition, so it's a good place to
  # perform one-time validation, and to ensure you start
  # processing in a clean state by unsetting headers that
  # should not be in the inbound request.
  call perform_authentication;
  unset req.http.My_Custom_Header;
}
```

`fastly.ff.visits_this_service` は現在のリクエストが Fastly サービスで処理された回数が記録されています。また、リクエストが VCL で restart されている場合には処理が行われないように、restart も条件に追加しています。



### 処理が複数回実行されないように注意
Fastly の設定次第では、ひとつのリクエストに対して同じ VCL のコードが複数回実行される可能性があります。主な理由は `restart` やシールディングがあげられますが、同じコードが予期せず複数回実行されることで問題となることがあります。
シールディングを使用していない場合は問題とはなりませんが、将来的にシールドを有効化する可能性に備え、"シールドセーフ" な設定をしておいてもよいかもしれません。

##### ケース 1: リクエストの変更
S3 のバケットをバックエンドとして設定し、`/styles/main.css` を `/my-bucket-name/styles/main.css` に変更する必要があるとします。

その場合、以下のようなコードを書くかもしれません

```sub vcl_miss { ... }
Fastly VCL

set bereq.url = "/my-bucket-name" + bereq.url;
```
このコードはシールドを有効化するまでは問題なく動作しますが、シールディングを利用すると 403 や 404 の原因となります。
エラーの原因はこのコードがエッジ POP とシールド POP の両方で実行されるため、バックエンドの S3 へのリクエストパスが `/my-bucket-name/my-bucket-name/styles/main.css` となってしまうからです。

これを防ぐために、リクエストがオリジンサーバーに接続するためのものであるかどうかを確認する `req.backend.is_origin` 変数を利用します。

```sub vcl_miss { ... }
Fastly VCL

if (req.backend.is_origin) {
  set bereq.url = "/my-bucket-name" + bereq.url;
}
```

コードに実行条件を指定することで、コードが2回実行されても正しくパスが保持されるようにすることも可能です。この方がコードのメンテナンスがしやすいケースもあるかもしれません。

```sub vcl_miss { ... }
Fastly VCL

if (bereq.url !~ "^/my-bucket-name/") {
  set bereq.url = "/my-bucket-name" + bereq.url;
}
```

##### ケース 2: リクエストの restart
以下のようなコードでリクエストを `restart` することが出来ます。

```sub vcl_deliver { ... }
Fastly VCL

if (resp.status == 502) {
  restart;
}
```

このコードではリクエストに返却されたレスポンスコードが 502 ("Bad gateway") だった場合にリクエストを restart します。
ただし、リスタートしたリクエストへのレスポンスが再び 502 だった場合、再度 restart が発生し、結果的に "Max restarts limit reached" エラーになってしまう可能性があります。

このループを防ぐために、リクエストが restart した回数を記録している `req.restarts` 変数を利用することが出来ます。 

```sub vcl_deliver { ... }
Fastly VCL

if (resp.status == 502 && req.restarts == 0) {
  restart;
}
```

注意: restart の発生条件によっては `req.restarts` 変数のチェックではうまくいかないケースがあります。その場合は restart したことを明示的に示すフラグをヘッダーとして設定して対応することが出来ます。

```sub vcl_deliver { ... }
Fastly VCL

if (!req.http.restarted-for-502) {
  set req.http.restarted-for-502 = "1";
  restart;
}
```
この方法ではリクエストヘッダーを追加するため、vcl_miss と vcl_pass でヘッダーを削除しない限りオリジンサーバーへのリクエストにも追加したヘッダーが送られます。

##### ケース 3: レスポンスの変更

vcl_deliver サブルーチンで HTTP レスポンスヘッダーを変更することは一般的です。例えば `set-cookie` ヘッダーを追加したり、`Cache-Control` ヘッダーの値を変更したいかもしれません。

```sub vcl_deliver { ... }
Fastly VCL

add resp.http.Set-Cookie = "foo=bar; path=/; max-age=3600";
set resp.http.Cache-Control = "no-store, private";
```
シールディングが有効な場合、クライアントへのレスポンスだけでなく、シールド POP からエッジ POP へのリクエストにもヘッダーが追加されます。これは想定外の結果となる可能性があります。

Fastly のデフォルトの挙動ではレスポンスに `set-cookie` ヘッダーや `Cache-Control: private` が存在する場合、そのレスポンスはキャッシュされません。上記のコードでは、エッジ POP でオブジェクトがキャッシュされなくなります。

この問題を避けるためには `fastly.ff.visits_this_service variable` を使用します。


```sub vcl_deliver { ... }
Fastly VCL

# only run this on the first Fastly node
# (i.e., the deliver node of the edge pop)
if (fastly.ff.visits_this_service == 0) {
  add resp.http.Set-Cookie = "foo=bar; path=/; max-age=3600";
  set resp.http.Cache-Control = "no-store, private";
}
```

##### まとめ
コードをどこで実行したいのかを考慮し、正しく実行されるように注意することが重要です。

オリジンへのリクエストに対してのみコードが実行されるべきなのであれば `req.backend.is_origin` を使用する必要があります。

パスの変更などコードが一度だけ実行されることが必要な場合は、条件を追加しコードが一度しか実行されないようにします。

restart 発生時にもコードを一度しか実行したくない場合は、`req.restarts` を使用して restart のループが発生しないようにします。

リクエストをうけた最初の Fastly ノードで一度だけ処理を実行したい場合は `fastly.ff.visits_this_service` を使用します。