[Dd]enzow(ill)? with DB and Python

DBとか資格とかPythonとかの話をつらつらと

nginxでの静的ファイル配信時のキャッシュ(ETagやLast-Modifed)

f:id:denzow:20171126221155p:plain

ISUCON7の予選は、画像へのアクセスをいかに304で処理して帯域を節約できるかが肝でした。そこでnginxのETagやLast-Modified等の設定周りを見直してみます。

環境

以下の環境で試しました。

  • OS: Ubuntu 16.04
  • nginx: 1.10.3
  • Client: python-requests 2.8.1

初期設定

サーバ上の/public/sample.pngにサンプルの画像を配置し、http://host/sample.pngでアクセスできるようにnginx.confを設定します。以下で拡張子が*.css*.js*.pngの場合は/public配下より配信されます。

    server {
        listen 80;
        
        location ~* \.(css|js|png)$ {
            root /public;
        }
    }

今回はアクセスのテスト用にPythonのrequestsを使用したサンブルクラスを作成し、それを使って試していきます。

import requests

class SessionTest:
    def __init__(self):
        self.ses = requests.session()
        
    def head(self, url, headers=None):
        if not headers:
            headers={}
        print(headers)
        res = self.ses.head(url, headers=headers)
        self._print_response(res)
        return res
    
    def get(self, url, headers=None):
        if not headers:
            headers={}
        print(headers)
        res = self.ses.get(url, headers=headers)
        self._print_response(res)
        return res
        
    def post(self, url, headers=None):
        if not headers:
            headers={}
        print(headers)
        res = self.ses.post(url, headers=headers)
        self._print_response(res)
        return res
    
    @staticmethod
    def _print_response(res):
        print("status_code={}".format(res.status_code))
        print("status_history_code={}".format(res.history))
        print("=="*10)
        print("# headers")
        print("=="*10)
        for k, v in res.headers.items():
            print("{}=>{}".format(k, v))

実際にアクセスするとこんな感じのヘッダが帰ってきます。

ses = SessionTest()
_ = ses.get("http://devntu1/sample.png")

{}
status_code=200
status_history_code=[]
====================
# headers
====================
Server=>nginx/1.10.3 (Ubuntu)
Date=>Sun, 26 Nov 2017 07:46:39 GMT
Content-Type=>image/png
Content-Length=>81892
Last-Modified=>Sun, 26 Nov 2017 05:39:32 GMT
Connection=>keep-alive
ETag=>"5a1a5394-13fe4"
Accept-Ranges=>bytes

当たり前ですがレスポンスコードは200です。これが304になる仕組みをみていきます。

キャッシュ判定の仕組み

先程のレスポンスのヘッダは以下でした。

Server=>nginx/1.10.3 (Ubuntu)
Date=>Sun, 26 Nov 2017 07:46:39 GMT
Content-Type=>image/png
Content-Length=>81892
Last-Modified=>Sun, 26 Nov 2017 05:39:32 GMT
Connection=>keep-alive
ETag=>"5a1a5394-13fe4"
Accept-Ranges=>bytes

大事なのはLast-ModifiedETagです。それぞれ以下の意味です。

response header mean
Last-Modified ファイルの最終更新日
ETag ファイルとその更新情報をもとにしたハッシュ値のようなもの

たとえば、Last-Modified=>Sun, 26 Nov 2017 05:39:32 GMTとなっていますが、これはOS上でみたファイルの更新日です。

root@denzow-ubuntu:/public# ls -l sample.png
-rw-r--r-- 1 root root 81892 11月 26 14:39 sample.png ★GMTをJSTに直せば一致する更新時刻

クライアント側はこれらの値を付与してリクエストし、サーバ側はこれらの値が一致すればレスポンスコードを304(Not Modified)にして戻し、実際のデータはやりとせずに済ませます。

それぞれのレスポンスヘッダと対応するリクエストヘッダは以下です。

response header response header
Last-Modified If-Modified-Since
ETag If-None-Match

クライアント側は初回のアクセスで受け取ったヘッダー値を、それぞれ対応するヘッダにセットしてリクエストすることで2回目以降のアクセスを効率的に行えます。

実際に304が戻るか見てみる

If-Modified-Since

それではリクエストヘッダーとしてIf-Modified-Sinceを付与してレスポンスコードを確認してみます。

ses = SessionTest()
# 前回のLast-ModifiedをIf-Modified-Sinceにセットしてアクセス
_ = ses.get("http://devntu1/sample.png", headers={"If-Modified-Since": "Sun, 26 Nov 2017 05:39:32 GMT" })

結果はこうなりました

{'If-Modified-Since': 'Sun, 26 Nov 2017 05:39:32 GMT'}
status_code=304  # 期待通り304が戻っている
status_history_code=[]
====================
# headers
====================
Server=>nginx/1.10.3 (Ubuntu)
Date=>Sun, 26 Nov 2017 10:06:30 GMT
Last-Modified=>Sun, 26 Nov 2017 05:39:32 GMT
Connection=>keep-alive
ETag=>"5a1a5394-13fe4"

304が戻っていますね。

では、If-Modified-Sinceに過去の値を指定してみます。

ses = SessionTest()
# 26日 -> 25日
_ = ses.get("http://devntu1/sample.png", headers={"If-Modified-Since": "Sun, 25 Nov 2017 05:39:32 GMT" })
{'If-Modified-Since': 'Sun, 25 Nov 2017 05:39:32 GMT'}
status_code=200
status_history_code=[]
====================
# headers
====================
Server=>nginx/1.10.3 (Ubuntu)
Date=>Sun, 26 Nov 2017 11:21:18 GMT
Content-Type=>image/png
Content-Length=>81892
Last-Modified=>Sun, 26 Nov 2017 05:39:32 GMT
Connection=>keep-alive
ETag=>"5a1a5394-13fe4"
Accept-Ranges=>bytes

今度は200になりました。25日以降に変更があれば・・という意味ですので26日の変更日を持っていいる以上200が戻るのは正しい挙動でしょう。

続いて、未来の値(1h後)を指定した結果が以下です。

{'If-Modified-Since': 'Sun, 26 Nov 2017 06:39:32 GMT'}
status_code=200
status_history_code=[]
====================
# headers
====================
Server=>nginx/1.10.3 (Ubuntu)
Date=>Sun, 26 Nov 2017 11:27:40 GMT
Content-Type=>image/png
Content-Length=>81892
Last-Modified=>Sun, 26 Nov 2017 05:39:32 GMT
Connection=>keep-alive
ETag=>"5a1a5394-13fe4"
Accept-Ranges=>bytes

こちらも200になりました。(違和感があるのですが、調べきれませんでした)

If-None-Match

続いてリクエストヘッダーとしてIf-None-Matchを付与してレスポンスコードを確認してみます。

_ = ses.get("http://devntu1/sample.png", headers={"If-None-Match": '"5a1a5394-13fe4"' })
{'If-None-Match': '"5a1a5394-13fe4"'}
status_code=304
status_history_code=[]
====================
# headers
====================
Server=>nginx/1.10.3 (Ubuntu)
Date=>Sun, 26 Nov 2017 12:38:24 GMT
Last-Modified=>Sun, 26 Nov 2017 05:39:32 GMT
Connection=>keep-alive
ETag=>"5a1a5394-13fe4"

無事に304が返せています。地味にハマるのがETagは引用符込で指定する必要があるという点でした。つまり

_ = ses.get("http://devntu1/sample.png", headers={"If-None-Match": "5a1a5394-13fe4" })

はだめで

_ = ses.get("http://devntu1/sample.png", headers={"If-None-Match": '"5a1a5394-13fe4"'}) # ダブルクォテーションもデータに含める

でなければいけないということです。

もちろん誤ったETagを指定すると200が戻ります。

{'If-None-Match': '"99999999"'}  ★マッチしないETag
status_code=200   ★200が戻される
status_history_code=[]
====================
# headers
====================
Server=>nginx/1.10.3 (Ubuntu)
Date=>Sun, 26 Nov 2017 12:40:56 GMT
Content-Type=>image/png
Content-Length=>81892
Last-Modified=>Sun, 26 Nov 2017 05:39:32 GMT
Connection=>keep-alive
ETag=>"5a1a5394-13fe4"
Accept-Ranges=>bytes
ETagの生成方法

このあたりを見る感じ、ETagはLast-ModifiedとContent-Lengthで生成しているようです。

Content-Length=>81892
Last-Modified=>Sun, 26 Nov 2017 05:39:32 GMT

から

ETag=>"5a1a5394-13fe4"

を求めてみます。

どうやら-を境に前方がLastModifedで後方がContent-Lengthのようです。まずは前者から求めます。GMTをJSTにしてエポックタイムにして16進数にしてみます。

from datetime import datetime
import time
# +9時間しておく
d = datetime(2017, 11, 26, 14, 39, 32)
# epochタイムに変換
epoch_time = time.mktime(d.timetuple())
hex(int(epoch_time))

結果は0x5a1a5394となり期待した値でした。

続いて、Content-Length側を求めます。こちらは単に16進数にすればよいでしょう。

hex(81892)

結果は0x13fe4ですのでこれでETagが求まりました。この結果よりnginxのETagはファイルの更新日とデータサイズに依存していることが確認できました。Apache等ではファイルのi-node番号等も用いているらしく、複数サーバに分散していると同じファイルでもETagが変化してしまうことがあるそうですが、nginxではその心配はなさそうです。

両方指定した場合

では両方指定した場合の挙動を確認します。まずは両方ともあっている場合です。

_ = ses.get("http://devntu1/sample.png", headers={"If-Modified-Since": "Sun, 26 Nov 2017 05:39:32 GMT", "If-None-Match": '"5a1a5394-13fe4"'})
{'If-Modified-Since': 'Sun, 26 Nov 2017 05:39:32 GMT', 'If-None-Match': '"5a1a5394-13fe4"'}
status_code=304
status_history_code=[]
====================
# headers
====================
Server=>nginx/1.10.3 (Ubuntu)
Date=>Sun, 26 Nov 2017 13:03:00 GMT
Last-Modified=>Sun, 26 Nov 2017 05:39:32 GMT
Connection=>keep-alive
ETag=>"5a1a5394-13fe4"

当然ですが304が帰りますね。続いて、If-None-Matchが誤っている場合です。

{'If-Modified-Since': 'Sun, 26 Nov 2017 05:39:32 GMT', 'If-None-Match': '"9999-9999"'}
status_code=200
status_history_code=[]
====================
# headers
====================
Server=>nginx/1.10.3 (Ubuntu)
Date=>Sun, 26 Nov 2017 13:04:18 GMT
Content-Type=>image/png
Content-Length=>81892
Last-Modified=>Sun, 26 Nov 2017 05:39:32 GMT
Connection=>keep-alive
ETag=>"5a1a5394-13fe4"
Accept-Ranges=>bytes

200が返りました。

続いて、If-Modified-Sinceがキャッシュの効かない値を指定している場合です。

{'If-Modified-Since': 'Sun, 27 Nov 2017 05:39:32 GMT', 'If-None-Match': '"5a1a5394-13fe4"'}
status_code=200
status_history_code=[]
====================
# headers
====================
Server=>nginx/1.10.3 (Ubuntu)
Date=>Sun, 26 Nov 2017 13:05:36 GMT
Content-Type=>image/png
Content-Length=>81892
Last-Modified=>Sun, 26 Nov 2017 05:39:32 GMT
Connection=>keep-alive
ETag=>"5a1a5394-13fe4"
Accept-Ranges=>bytes

こちらも200が戻りました。両方指定がある場合は両方が一致しないといけなさそうです。しかし、ここらへんは他のサイトの情報と一致しないのでもう少し確認しないといけなさそうです。 ※If-None-Matchが優先される的な情報が多かった…

まとめ

とりあえず、ざっくりとnginxに於けるETagやLast-Modifedの挙動を確認してみました。まだまだ理解できていないところもありますが、とりあえずまとめてみて少し理解が深まったと思います。