Nginx どハマりどころ N 選

食べログ DevOps チームの @weakboson です。
この記事は Advent Calendar の5日目の投稿です。

Nginx の設定は一見設定というよりプログラミング言語みたいで、複雑な要件を実装しやすそうですが……実は全然そんなことなくて、プログラムのつもりで書くとハマる落とし穴が満載だと思います。
私が「なんじゃそりゃ！」と思わず声に出した仕様をぱんぱかぱんと並べてゆきます。

この仕様とどう付き合ってゆくか……という話しは概ね9日目の Advent Calendar に持ち越します。
昔のドラゴンボール TV アニメが連載に追いついてしまってオリジナルエピソードで間をもたせたみたいなもんですね。

# 第1位: if and only if there are no 大杉

設定の継承を使った差分実装がほぼ無理です。どこにでも書けるディレクティブがあったとしても、 http ブロックに基本設定を、 server ブロックにバーチャルサーバ固有の設定を……と考えると100万回くらい死ねます。

オブジェクト指向言語じゃあるまいしなにゆえ Nginx でそんなことしたいのかと問われれば、こういうことだと答えましょう。

```nginx:nginx.conf
http {
  # リモートIPと元の Host は下流に必ず送りたいから http ブロックで設定するよ
  proxy_set_header X-Forwarded-For  $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Host $host;

  server {
    server_name example.com;
    # server_name example.com だけで X-Custom: a を送りたいよ
    proxy_set_header X-Custom a;
    location / {
      proxy_pass http://application_a;
    }
  }

  server {
    server_name example.net;
    # server_name example.net だけで X-Custom: b を送りたいよ
    proxy_set_header X-Custom b;
    location / {
      proxy_pass http://application_b;
    }
  }
}
```

この設定で proxy すると upstream のリクエストに `X-Forwarded-For`, `X-Forwarded-Host` ヘッダはセットされません。

例: [Module ngx_http_proxy_module#proxy_set_header - Nginx.org](http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_set_header)

> These directives are inherited from the previous level if and only if there are no proxy_set_header directives defined on the current level.

抄訳 これらのディレクティブは同レベルに `proxy_set_header` の定義がないときに限り、前のレベルから継承する。

Nginx はこの if and only if there are no がめっちゃ多い！
いま "if and only if there are no" site:nginx.org で Google 検索したら 325 件もヒットしたよ……

何回も呼べるディレクティブはほとんどこのルールだと思っていいです。

# 第2位: 同値の `^~` location は書けない

記述順に評価される修飾子は `^~` だけです。
ほとんどの修飾子で記述順が動作に影響しないというのはとても宣言的であると思います。Nginx はこういう仕様の機能が多いですね。

| 強さ | 修飾子 | 仕様 |
| :---: | ---- | ---- |
| 1 | `=` | 完全一致 |
| 2 | `^~` | 前方一致、記述順で適用される。マッチしたら適用して以降の locaion は評価しない |
| 3 | `~`, `~*` | 正規表現。最長マッチが適用される |
| 4 | 無印 | 前方一致。最長マッチが適用される。一番弱く、他の修飾子にマッチしなかったとき評価 |

[nginx実践入門(技術評論社)](http://gihyo.jp/book/2016/978-4-7741-7866-0) 第4章の優先度順である表では `^~` は一番下だが特別扱いというように見えますが、実質的に正規表現よりも強いと考えてよいと思います。

`^~` は他の修飾子に横取りされることがほぼなくて使い勝手がよいと思っていたのですが、同一の値があると emerge Error になり起動しません。

```nginx:nginx.conf
server {
  server_name example.com;
  location ^~ /images/ {
    # こちらを優先して common.conf を無視してくれるような都合のよい動作はしない
    root /var/com_specific_images;
  }
  include location.d/common.conf;
}
server {
  server_name example.net;
  include location.d/common.conf;
}
```

```nginx:location.d/common.conf
location ^~ /images/ {
  root /var/common_images;
}
```

```shell
sudo service nginx configtest
nginx: [emerg] duplicate location "/images/" in /etc/nginx/location.d/common.conf:1
nginx: configuration file /etc/nginx/nginx.conf test failed
```

普通はそんなに困らないような気もしますが、上記の例のように共通的な location をスニペットにして複数の server ブロックで include する方式を考えたものだからハマりました。全バーチャルサーバの location を概ね書いてから気がついて大変な目に会いました。

# 第3位: try_files で内部 redirect すると、最初の location 内の設定は評価されない

内部 redirect ということを忘れてミスディレクションしてしまったのですが、ハマるのは私くらいですかね。

例えば静的ファイルが何箇所かに分散していて、リクエストを受けたホストにファイルがなかったら別のホストに proxy したいとします。

```nginx:nginx.conf
server {
  server_name example.com;
  expires 0;

  location ~ \.(css|js|jpe?g|gif|png|svg|eot|ttf|woff|ico)$ {
    # アセットは Expires を1年後にしたい
    expires 1y;
    # もしこのサーバになかったらもう1台のサーバに proxy したい
    try_files $uri
              @another_asset;
  }

  @another_asset {
    proxy_pass http://another_asset;
  }
}
```

```nginx:nginx.conf
  @another_asset {
    # Q. さて、 $uri のファイルがなくてこちらに内部 redirect されたとき
    # Expires はどうなるのかな？
    #
    # A. server ブロックで設定した 0 です
    proxy_pass http://another_asset;
  }
```

雑な設定書いて横着するなってことですかね。
このあたりで Nginx との付き合い方がなんとなくわかってきます。黄金の風のキング・クリムゾン！。途中の時間 (過程) は消し飛ばされます。

# 第4位: set 以外の Lua Hook は1リクエストで一番詳細な1つだけ有効

通常 Nginx 設定の `if and only if there are no` と似た感じで、同じ Lua Hook があったときはより下層の1つだけが評価されます。 

```nginx:nginx.conf
http {
  access_by_lua_file global_acl.lua; # ヨシ？

  server {
    server_name example.com;
    access_by_lua_file com_domain_acl.lua; # ヨシ！
  }

  server {
    server_name example.net;
    access_by_lua_file net_domain_acl.lua; # ヨシ！

    location ^~ /private/ {
      access_by_lua_file very_strict_acl.lua; # ヨシ！
    }
  }
}
```

|url|適用される Hook|
|----|----|
|http://example.com|com_domain_acl.lua のみ|
|http://example.net|net_domain_acl.lua のみ|
|http://example.net/private/|very_strict_acl.lua のみ|

またしてもキング・クリムゾン！
……いや、global_acl.lua ニ到達スルコトハ決シテナイ！のでゴールド・エクスペリエンス・レクイエムかな。

なお同一レベルに set 以外の同一 Hook を2つ以上書くとわかりやすく emerge Error になり起動しません。

# 第5位: `geo` ディレクティブのブロックに書ける `include` は他の基本ブロックとは別物

[Module ngx_http_geo_module](http://nginx.org/en/docs/http/ngx_http_geo_module.html)

glob が使えるとはどこにも書いてない。この `include` は geo ディレクティブの special parameters です。

# 第6位: gzip_vary on にすると Vary ヘッダをカスタマイズできない

[Module ngx_http_gzip_module#gzip_vary](http://nginx.org/en/docs/http/ngx_http_gzip_module.html#gzip_vary)

SEO 目的でコンテンツにより Vary ヘッダに `User-Agent` を含めるかどうかを切り替えたいことがあるのですが、このとき `gzip_vary: on` にするともうどうやっても `Vary: Accept-Encoding` にしかなりません。自前で Vary ヘッダを出力しようとすると2個に増えます。

Lua Hook で上書きできないかな……と試みましたがダメでした。まあここまでやったら Lua の else に入れちゃえばよいのでいいんですけど。

```nginx:nginx.conf
  gzip on;
  gzip_vary on;
  # Vary に User-Agent を追加したいとき vary_ua = 1 にする
  map $uri $vary_ua {
    default                  1;
    # css, js, image なんかは User-Agent に依存しない……つもり
    ~\.(css|js)$             0;
    ~\.(jpe?g|gif|png)$      0;
    ~\.(svg|eot|ttf|woff)$   0;
    ~\.ico$                  0;
  }
  header_filter_by_lua_file vary_filter.lua;
```

```lua:vary_filter.lua
if ngx.var.vary_ua == "1" then
  -- gzip_vary の動作を上書きしたいのだが
  -- 期待したようには動作せず、ヘッダが2個に増える……
  ngx.header["Vary"] = "Accept-Encoding,User-Agent"
end
```


# 第7位: DNS 解決は reload 時のみ

有名なので逆にハマることはありませんかね！
upstream が DNS ラウンドロビンのロードバランサだと悲惨なことになります。 reload 時に確定してそれっきり。

# 第8位: upstream の server が DNS 解決できないと起動しない

DNS 障害と reload が重なったとき「実は reload できてない」というわかりづらいトラブルを招きそうです。経験はありませんが。
あと upstream ホスト・DNS設定が準備できてないが、reverse proxy を先に用意しておくか……と計画すると困ります。こっちは経験しましたともさ。

# 第9位: geo で cidr が重なるとより厳密な1つだけが採用される

```nginx:nginx.conf
http {
  geo $client_location {
    10.255.241.0/24 monitor;
    10.255.192.0/24 proxy;
    10.0.0.0/8      intra;
    172.16.0.0/12   intra;
    192.168.0.0/16  intra;
    default         outer;
  }
  server {
    server_name example.com;
    # ...
  }
  server {
    server_name "" default;
    access_by_lua_file intra_acl.lua;
    location = /status {
      stub_status on;
    }
    location = /healthcheck {
      root /internal;
    }
  }
```

```lua:intra_acl.lua
if ngx.var.client ~= "intra" then
  ngx.exit(ngx.HTTP_FORBIDDEN)
end
```

いい感じにどれかマッチなんて動作してくれないかなーと期待する方が悪いですね。
こんな書き方をすると monitor, proxy からのリクエストが内部向けのつもりのバーチャルサーバでアク禁喰らいます。

# 第10位: upstream に proxy_pass したときの Host が妙

誰が嬉しいんだこれ……と最初思いましたが、勝手に Host ヘッダが切り替わるので同一ホストを複数の役割を持つ upstream にしたいとき `proxy_set_header` を書かなくて済みます。

# 第11位: ログのパスに変数は使えない

```nginx:nginx.conf
http {
  geo $client_location {
    10.0.0.0/8     intra;
    172.16.0.0/12  intra;
    192.168.0.0/16 intra;
    default        outer;
  }

  server {
    server_name example.com;
    access_log /var/log/nginx/$client_location/access.log ltsv;
    # ...
  }
}
```

こういう書き方ができたらちょっと嬉しかったのですけれど。

access_log ディレクティブの if パラメータを使うと決めておいたいくつかのログを出し分けることはできます。
実際のやり方は9日目の Advent Calendar で……

# 第12位: Syntax エラーがあるとき reload してもコマンドは正常終了する

reload と言っても master プロセスに `HUP` シグナルを送るだけなので、シグナルを送った PID のプロセスさえ存在すればコマンド自体は必ず成功します。
`nginx -c nginx.conf -t` で Syntax check してから reload しましょう。

# 第13位: プロセスが client_body_temp_path に書き込みできないとけったいな現象に遭遇する

OS 標準パッケージで install した後に設定変更して worker プロセスの実行ユーザを変更すると罠にはまるかもしれません。
CentOS だとデフォルトでは以下のように `nginx:nginx` しか read も write もできないディレクトリになってます。

```
drwx------. 3 nginx nginx 4096 10月 31 21:39 2016 /var/lib/nginx
```

この状態で Nginx の実行ユーザを nginx から nobody に変更したりすると…… `client_body_buffer_size` を越えたときに初めてエラーになります。こんな感じ。

```
2018/12/03 21:28:48 [crit] 60837#0: *152 open() "/var/lib/nginx/tmp/proxy/5/04/0000000045" failed (13: Permission denied) while reading upstream, client: 10.109.118.75, server: , request: "GET /ui/assets/consul-ui-43ebfc393b33bc22c406d75e4490b9fc.css HTTP/1.1", upstream: "http://127.0.0.1:8500/ui/assets/consul-ui-43ebfc393b33bc22c406d75e4490b9fc.css", host: "example.com", referrer: "http://example.com/ui/"
```

Chrome Dev Tools なんかで見ると HTTP Status 5xx ではなく `ERR_CONTENT_LENGTH_MISMATCH` なんて分かりづらいエラーになります。

以上、なんか知見の共有というか愚痴みたいになってしまいました。
金田に「ピーキー過ぎてお前にゃ無理だよ」と言われた鉄雄みたいな気分です。

明日、6日目 は @sadashi による「自分なりのKotlin Coding Tips」です。