關於cahce的這幾天

事情的來龍去脈:

目前我架設的部落格是使用靜態網站生成器(SSG)搭配AWS服務(S3、Cloudfront),因為我們不希望使用者每次拜訪我們的網站都連線到單一伺服器(例如亞洲人瀏覽美國網站),我們希望使用者在最小的距離下瀏覽我們的網站,因此我們使用CDN(Cloudfront)服務將SSG生成出的靜態網站copy到世界各地,然而這麼做卻造成每次發布新文章都會面臨舊內容快取的問題,雖然我們可以直接將CDN的快取壽命(TTL)設定為0來解決這個問題,但這麼做會失去CDN的優點,造成每個request都必須從CDN去請求來源伺服器,這可以說是違背了CDN的初衷。

解決方案一:

使用CDN服務內建的cahce invalidation功能,可以在你部署新內容時無效化CDN edge的舊內容,對於解決快取問題非常的輕鬆愉快,小缺點是要等待無效化的處理時間,但最大的缺點是這種無效化的操作其實是昂貴的,畢竟每次操作都要勞師動眾地訪問全球據點(edge),雖然Cloudfront每個月都有1000個invalidatoin path的免費額度(free tier),但只要超過1000個之後的每一個path都要0.005美金,假設這個月使用了2000個path,那麼你的AWS帳單將多出$5美金,雖然對於一般產量不大的部落格來說是綽綽有餘了,但心理還是不太舒坦,總有一種不能自由自在發文以及修改版面的束縛感,而且也覺得這一定不是處理快取問題的所謂的best practice,於是輾轉研究另一個方案。

解決方案二:

使用cache-controlheader,在S3上要添加response header有很多方法,我用的是AWS Lambda@Edge,在使用者發出瀏覽請求的過程中有4個時機點可以呼叫Lambda@Edge Function,分別是:

  1. Viewer request
  2. Origin request
  3. Origin response
  4. Viewer response

一開始我按照網路上查到的部落格文章的指示,將Lambda@Edge function設定在Origin response時觸發下面這段函數。

exports.handler = (event, context, callback) => {
  const request = event.Records[0].cf.request;
  const response = event.Records[0].cf.response;
  const headers = response.headers;

  if (request.uri.startsWith('/static/')) {
    headers['cache-control'] = [
      {
        key: 'Cache-Control',
        value: 'public, max-age=31536000, immutable'
      }
    ];
  } else {
    headers['cache-control'] = [
      {
        key: 'Cache-Control',
        value: 'public, max-age=0, must-revalidate'
      }
    ];
  }

  [
    {
      key: 'Strict-Transport-Security',
      value: 'max-age=31536000'
    },
    {
      key: 'X-Content-Type-Options',
      value: 'nosniff'
    },
    {
      key: 'X-Permitted-Cross-Domain-Policies',
      value: 'none'
    },
    {
      key: 'Referrer-Policy',
      value: 'no-referrer'
    },
    {
      key: 'X-Frame-Options',
      value: 'deny'
    },
    {
      key: 'X-XSS-Protection',
      value: '1; mode=block'
    },
    {
      key: 'Content-Security-Policy',
      value:
        "default-src 'none' ; script-src 'self' 'unsafe-inline'; " +
        "style-src 'self' 'unsafe-inline' ; img-src 'self' data:; " +
        "font-src 'self' ; manifest-src 'self' ; " +
        'upgrade-insecure-requests; block-all-mixed-content; ' +
        'report-uri https://ximedes.report-uri.com/r/d/csp/enforce;'
    }
  ].forEach(h => (headers[h.key.toLowerCase()] = [h]));

  callback(null, response);
};

當時對於整個運作流程還不熟悉,由於我沒有移除Cloudfront cache invalidation的指令,然後Lambda@Edge設定完畢之後,我以為我的Lambda@Edge function有成功幫我清除快取,但後來才發現不對啊! 我的cache invalidation還在作用中,每次部署都會執行invalidate,一切都還是cache invalidation的功勞…

移除了invalidation的指令後發現我的function根本沒把cache control header加上去,之後又研究了很久,試著把Lambda@Edge改在Viewer response觸發,成功增加response header! 本以為順利完成,沒想到更可怕的事情發生了!

我的Gatsby壞掉啦!!!

正常的Gatsby擁有SPA(Single Page Application)的瀏覽體驗,點擊網頁上的任何連結都不會造成頁面reloading(原理參考) 而我現在壞掉的Gatsby點擊頁面上任何連結都會reloading,每次請求都會轉圈圈,真的會氣死,然後又研究了超久,好險身為一個不屈不撓的專業人士,後來在chrome DevTool console上發現一個關鍵字csp,我就知道是header出問題啦! 清理function裡的header,清到剩下cahce control,畢竟太多安全標頭,我還不清楚到底是哪個安全標頭破壞了Gatsby的運行。 然而這次換成header被快取了,無論怎麼清除快取都還留有那些舊的安全標頭…

後來經過各種嘗試,又把function移回Origin response,後來才又想到由於我沒有發布新內容,以至於根本沒經過Origin response,整個請求都在Cloudfront回傳,沒有被轉送到Origin server(S3)。

透過發佈新文章或是修該內容之後,觸發了Origin response,順利地增加了沒有安全標頭的response header。

本以為終於解決所有問題,但我使用的Gatsby Starter有使用到支持離線瀏覽的plugingatsby-plugin-offline,這個plugin使用了Service Worker這東西,這是可以讓網頁離線瀏覽的一種技術,也理所當然的造成了一些舊內容的快取問題,這plugin在The offline cookbook中所使用的模式是Stale-while-revalidate,就是先返回快取的內容,下次再透過Service Worker向伺服器請求更新的內容,在gatsby-plugin-offline中可以透過browser apionServiceWorkerUpdateFoundonServiceWorkerUpdateReady來處理頁面reload或是提示用戶有更新是否重新載入,甚至是AJAX請求。

結語

關於這幾天遇到的快取問題其實似乎沒那麼困難,只是由於不熟悉而造成錯亂,其實很多東西都是這樣透過摸索來實際學習,個人會被問題糾纏太久可能就是自己一直鬼打牆,想要找到別人是否也有相同問題,而一直google卻沒有自己靜下來好好思考原理及解決方案。

About the author

崇尚新潮技術的自學者,對世界的運作充滿好奇,
導致各種IT相關技術都有所涉略(Web🌎| Mobile📱| Desktop💻| Game🎮)
日後將發布各種小知識與技術細節的文章,歡迎聯繫與討論😉