Django Static file เรื่องนิ่งๆ ที่ไม่นิ่ง

วันนี้จะมาพูดถึง Static file บน Django กันครับ ว่าบน production ที่ TipMe เราทำยังไงกันอยู่

Django Static

ใครที่เคยเขียน Django น่าจะทราบเป็นอย่างดีว่า Django มีระบบจัดการ static file มาให้แล้ว ซึ่งก็ใช้งานได้ค่อนข้างดีเลย โดยการทำงานก็คือเราเอาไฟล์ยัดไว้ใน app/static/ แล้วเวลา deploy ก็สั่ง python manage.py collectstatic ไฟล์ทั้งหมดก็จะมากองรวมกันใน folder เดียวที่กำหนดใน STATIC_ROOT

เรื่องวุ่นๆ กับ Docker

ปัญหาแรกคือแล้วเราจะ serve static file ยังไงเมื่อเราอยู่บน Docker? เพราะ

  1. เราไม่ควรเอา application มากกว่า 1 ตัวรันใน 1 container
  2. เราไม่ควรให้ Gunicorn serve static เพราะมันช้า ซึ่งถ้าอ่าน docs WhiteNoise ก็จะบอกเลยว่าถ้าจะทำก็ควรจะมี CDN ดักหน้า

ฉะนั้นวิธีที่ “คิดว่า” work ที่สุดคือการ collectstatic ออกมาแล้วรัน web server แยกไปเลย มี reverse proxy ดักหน้าตัวนึงเพื่อแยก request วิ่งเข้า Gunicorn หรือ static server

ใน Docker Compose ที่เราใช้อยู่ก็จะประมาณนี้ครับ

version: "2.2"
services:
  tmstreamlabs:
    restart: unless-stopped
    image: asia.gcr.io/...
    init: true
    env_file:
      - config.env
    volumes:
      - static:/static
volumes:
  static: {}

ซึ่งใน config เราก็จะตั้งให้ collectstatic เก็บไฟล์เข้า /static แล้วเวลา deploy ก็มี shell script ให้รัน collectstatic เป็นอันเรียบร้อย

สำหรับการ serve ก็ mount volume static เข้าไปที่อีก container นึงที่รัน Caddy ให้มัน serve static โดยเซต caching ให้ยาวๆ และมี nginx ชี้เข้าหา Caddy/Gunicorn เป็นอันเสร็จ

(จริงๆ ใช้ Caddy ตัวเดียวก็จบแล้ว แต่ว่าในเครื่องมีแอพอื่นๆ ที่แชร์ service กันด้วยเลยอยากได้ความ flexible พอสมควร ไว้ระบบใหม่อาจจะเหลือ Caddy อย่างเดียว)

คำถามที่หลายๆ คนน่าจะถามคือใช้ local volume มันจะ scale ได้หรอ? มีโปรเจกท์อีกอันหนึ่งของลูกค้าที่ใช้ Kubernetes อยู่ก็จะทำคล้ายๆ กันครับ

apiVersion: apps/v1beta1
kind: Deployment
metadata:
  name: regist
spec:
  replicas: 1
  revisionHistoryLimit: 3
  template:
    metadata:
      labels:
        app: regist
    spec:
      containers:
      - name: regist
        image: asia.gcr.io/app
        resources:
          requests:
            cpu: 10m
            memory: 100Mi
        envFrom:
          - configMapRef:
              name: regist-config
        ports:
        - containerPort: 80
        volumeMounts: &mount
          - name: static
            mountPath: /static
      - name: static
        image: asia.gcr.io/nginx-static
        resources:
          requests:
            cpu: 10m
            memory: 30Mi
        ports:
        - containerPort: 81
        volumeMounts: *mount
      initContainers:
        - name: collectstatic
          image: asia.gcr.io/app
          command: ['python', 'manage.py', 'collectstatic', '--no-input']
          resources:
            requests:
              cpu: 10m
              memory: 100Mi
          envFrom:
          - configMapRef:
              name: regist-config
          volumeMounts: *mount
      volumes:
        - name: static
          emptyDir: {}

ก็คือเวลา spawn pod นี้มาก็จะมีการรัน container 3 ตัว ตัวหนึ่งคือแอพ อีกตัวคือ nginx และตอนบูทจะมีอีก container ที่รัน collectstatic เพื่อส่งให้ nginx ผ่าน shared volume (เป็นฟีเจอร์ใหม่ของ Kubernetes)

ซึ่งที่ TipMe ก็มีแผนจะย้ายไป Kubernetes อยู่ก็อาจจะใช้ตามนี้ครับ แต่ก็กำลังมองหาวิธีที่ดีกว่านี้อยู่ เพราะ static web server เราไม่ได้โดน hit บ่อย (ไปติดที่ CDN เกือบหมด) ถ้าไม่ต้องรัน nginx เยอะเท่ากับแอพก็จะประหยัดแรมไปอีก

Cache busting

ปัญหาในการใช้บน Production คือเนื่องจากเราทำ caching ยาวๆ เวลาอัพเดตฝั่ง client อาจจะติด cache ซึ่งก็แก้ได้ด้วยการทำ Cache busting

ใน Django เองก็จะมี support เรื่องนี้อยู่แล้วด้วย StaticFilesStorage โดยเมื่อมีการ collectstatic มันจะเปลี่ยนชื่อไฟล์ให้มี MD5 ของไฟล์นั้นๆ อยู่ด้วย (และ rewrite CSS file ให้ไปอ้างอิง URL ใหม่นี้แทน) ซึ่งจะมีสองเวอร์ชั่นคือ

  1. CachedStaticFilesStorage จะเก็บชื่อไฟล์ใหม่ไว้ใน cache backend
  2. ManifestStaticFilesStorage จะเก็บชื่อไฟล์ใหม่ไว้ใน staticfiles.json ด้านใน folder static

ซึ่งในช่วงแรกเราก็จะใช้ ManifestStaticFilesStorage ครับ

CDN

ต่อมาผมได้ข่าวจาก @icez ว่า ByteArk นั้นเติมเงินขั้นต่ำแค่ 300 บาท ก็เลยมีความคิดว่าไหนๆ static file มันก็นิ่งๆ อยู่แล้ว เราก็น่าจะ push static file ไปเก็บไว้ใน ByteArk Storage เลยสิ จะได้ไม่ต้องรัน static server เอง

วิธีที่เราทำคือสลับ backend เป็น Django-Storages S3 (ByteArk มีบริการ Storage ที่เป็น S3-compatible API) แล้วใส่ ManifestFilesMixin เข้าไปด้วยทำให้ได้ผลเหมือนใช้ ManifestStaticFilesStorage

ผลปรากฏว่า deploy ปุ๊บ Sentry ระบบ error reporting เด้งไม่ยั้ง เพราะว่าเดิมที collectstatic รัน 2 วินาทีเสร็จ กลับต้องรออัพโหลดไฟล์เป็นนาที แถมเวลา deploy เสร็จต้อง restart web server อีกเพื่อให้มันอ่าน staticfiles.json ใหม่

ระหว่างนี้ request ที่เข้ามาก็ error หมด

แล้วอย่าลืมว่า staticfiles.json อยู่ใน static server นั่นแปลว่าทุกครั้งที่ web server boot จะต้อง download ไฟล์นี้มาอ่าน

แบบนี้ดูท่าไม่ค่อยดีแน่ ก็เลยไปลอง CachedFilesMixin ดูบ้าง พบว่ามันไม่ error แต่จะคืนชื่อไฟล์ version เก่ามาแทนทำให้เว็บไม่ล่ม แถมมันไม่ cache คำตอบ ทำให้ไม่ต้อง restart web server ก็เลยเป็น solution ที่ดี สุดท้ายแล้วเว็บก็ใช้วิธีนี้อยู่หลายเดือน

แต่ปัญหายังไม่หมดครับ ใช้ไปสักพักก็พบว่าถ้าเพิ่ม static file ใหม่บางที user จะเข้ามาระหว่าง deploy ก่อนที่ file นั้นจะโดนอัพโหลด ก็จะได้ error อยู่ดี สุดท้ายก็เลยตัดสินใจถอด ByteArk Storage ออกแล้วใช้บริการ CDN ให้โหลด static จากเครื่องเราแทนเพื่อให้ collectstatic รันได้เร็วที่สุด

Brotli & Zopfli

ผ่านไปหลายเดือน เว็บเริ่มมาใช้ React แล้วไฟล์ JavaScript ก็ใหญ่ขึ้นเรื่อยๆ ผมก็เลยมีความคิดว่าน่าจะบีบไฟล์ให้มันเล็กๆ ซึ่งตอนนี้เทคโนโลยีบีบอัดใน browser ที่เล็กที่สุดคือ Brotli และ Zopfli จาก Google ซึ่ง

  • Zopfli เป็นเทคนิคการบีบอัดที่ compatible กับ Deflate/GZip แต่บีบได้เล็กกว่า ทำให้ browser ทุกตัวสามารถอ่านไฟล์ที่ใช้ Zopfli ได้เลย
  • Brotli เป็นระบบบีบอัดใหม่ซึ่ง Edge/Chrome/Firefox รองรับ โดยอาศัย dictionary ที่เก็บข้อความที่ปรากฏบ่อยๆ ในหน้าเว็บเพื่อให้บีบอัดได้เล็กลงอีก

ซึ่งทั้งคู่ขึ้นชื่อว่ามันบีบอัดได้ช้าครับ แต่ไม่ใช่ปัญหาอยู่แล้วเพราะ static file มันก็ static ตามชื่อ เราสามารถบีบอัดล่วงหน้าไว้ได้เลย

คำถามคือแล้วจะบีบอัดตอนไหน?

คำตอบแรกในหัวคือตอนนี้ในเว็บใช้ webpack/Gulp build แล้ว (ตามสไตล์ของเว็บที่ใช้ React) ก็คิดว่าจะให้ Gulp บีบให้ แต่วิธีนี้ใช้ไม่ได้แน่ๆ เพราะอย่างที่บอกไปข้างบนว่า StaticFileStorage ใน Django จะแก้ URL ที่ปรากฏในไฟล์ CSS เราอีกที Gulp เลยไม่ได้แตะไฟล์สุดท้าย

ฉะนั้นมีคำตอบเดียวที่ถูกคือ ไปแก้ให้ collectstatic บีบไฟล์ให้

วิธีการก็ไม่ซับซ้อนครับ เขียน Mixin มาอีกตัวหนึ่งที่ไปถาม CachedFilesMixin ว่าชื่อไฟล์ปลายทางคืออะไร เสร็จแล้วก็อ่านไฟล์นั้นมาโยนใส่ library Brotli/Zopfli เป็นอันเสร็จ พอรัน collectstatic ปุ๊บก็จะได้ 4 ไฟล์เลย คือ ไฟล์ต้นฉบับ, ไฟล์ที่โดน rename แล้ว, ไฟล์ที่โดน rename และบีบ Brotli แล้ว (.br) และไฟล์ที่บีบด้วย Zopfli (.gz) ซึ่งก็ปล่อยมาให้ลองเล่นกันดูครับที่ django-static-compress

แต่พอไปใช้ใน production จริงปัญหาเดิมกลับมาอีกแล้วครับ นั่นคือพอ collectstatic รันช้าเพราะมัวแต่บีบอัดไฟล์อยู่ คนเข้าเว็บก็ได้ 500 รัวๆ ฉะนั้นเราจึงหนีความจริงไม่ได้แล้วต้องหาทางแก้ใหม่

Build time static

สุดท้ายทางออกที่ใช้คือกลับมาใช้ ManifestStaticFilesStorage แต่เราจะเปลี่ยนวิธีใหม่ คือไป collectstatic ไว้ใน container เลย ฉะนั้นตอน container boot ปุ๊บจะอ่าน staticfiles.json ได้เลย ส่วนการ deploy ก็จะแค่ใช้โปรแกรม copy พื้นๆ ย้ายไฟล์ข้าม container ไปได้เลย ฉะนั้นทำให้ไม่มี downtime เลย และ deploy static เสร็จในเวลาไม่ถึงวินาที

วิธีการก็ประมาณนี้ครับ ใน Dockerfile ก็จะเพิ่มคำสั่งเก็บ static และลง rsync:

RUN apt-get update \
    && apt-get install -y rsync \
    && rm -rf /var/lib/apt/lists/* \
    && STATICFILES_STORAGE=tmstreamlab.storage.compress.CompressedManifestStaticFilesStorage python manage.py collectstatic --no-input --link

สังเกตว่าจะใช้ --link เพื่อให้ collectstatic สร้าง symlink จะได้ไม่เปลืองที่ใน image (แต่สำหรับไฟล์ที่โดนแก้ชื่อก็จะเป็นไฟล์ใหม่อยู่ดี) ส่วน rsync นี่จริงๆ จะใช้ cp ธรรมดาก็ได้ครับ แต่เดี๋ยวจะบอกว่าเอาไปใช้ทำอะไร

สำหรับตอน Deploy ก็จะใช้ script ประมาณนี้

sudo docker-compose exec --user root tmstreamlabs rsync -PhvrL --delete --delete-after --exclude=/staticfiles.json /app/staticcollect/ /static/

ก็คือให้ใช้ rsync copy จาก /app/staticcollect/ (ซึ่งผมใส่ไว้ใน settings STATIC_ROOT) ไปที่ /static/ (ซึ่งเป็น mounted volume ที่แชร์กับ Caddy — ดู compose file ผมได้ที่หัวข้อแรกครับ)

จะเห็นว่าพอใช้ rsync เราจะสั่งให้มันทำอะไรพิเศษหน่อย คือ

  • ให้ copy เนื้อหาใน symlink แทนที่จะ copy symlink ไป ซึ่งข้าม container กันจะอ่านไม่ได้
  • ให้ลบไฟล์ static เก่าๆ ออกให้ด้วย ซึ่ง rsync ทำได้ดีกว่า collectstatic -c ซะอีก เพราะอันนั้นมันจะลบไฟล์ออกหมดแล้ว copy ใหม่ แต่ rsync จะลบเฉพาะไฟล์ที่หายไปจริงๆ แถมยังเลือกให้ลบทีหลัง copy file ใหม่เสร็จแล้วได้ด้วย
  • ให้ไม่ก๊อป staticfiles.json ไป นั่นแปลว่ารายชื่อไฟล์ static เราจะไม่หลุดเป็น public

Brotli in production

เวลา Deploy จริงๆ ก็ยังมีปัญหาอื่นๆ อีกครับ

เรื่องแรกคือ web server ที่ใช้จะต้องสามารถ serve precompressed files ได้ ซึ่งถ้าใช้ nginx ก็จะต้องเซตเพิ่มเติม (อ่านในเอกสารของ django-static-compress ได้) แต่เนื่องจากเราใช้ Caddy อยู่ก็ง่ายเลย เพราะมันสามารถ serve .br และ .gz ได้ในตัว

และเรื่องถัดมาคือห้าม serve Brotli ให้ client ที่ไม่รองรับเด็ดขาด ไม่งั้นเค้าจะเปิดอะไรไม่ได้เลย ซึ่ง browser เองจะส่ง Header Accept-Encoding: br, deflate, gz มาอยู่แล้วถ้ารองรับ Brotli ตัว web server เราก็จะต้องดูแล้วส่งให้ถูกต้อง ซึ่งไม่ค่อยเป็นปัญหาเท่าไร แต่ปัญหาจะไปอยู่ที่ caching ครับ

เนื่องจาก static เราจะถูก cache ที่ CDN ก็จะต้องตรวจดูด้วยว่า CDN cache มั่วหรือหรือเปล่า วิธีการก็ประมาณนี้ครับ

  1. เปิดหน้า static เราด้วย Browser ซึ่งตอนนี้ CDN ควรจะ cache หน้าที่ compress แล้วเข้าไป
  2. ใช้ curl เปิดหน้านี้ ซึ่งค่าปกติของ curl จะไม่ขอไฟล์บีบอัด (เพราะไม่ได้ใส่ --compress) ถ้าขึ้นเป็น binary content ก็แปลว่า CDN ส่งไฟล์บีบอัดมาให้เรา

ซึ่งในเคสเราก็พบว่าทาง ByteArk ไม่ได้สนใจ header Vary ฝั่งเรา (header นี้จะบอกว่าเนื้อหาในหน้าจะเปลี่ยนไปตาม header ที่ client ส่งมายังไง ซึ่งเราส่ง Vary: Accept-Encoding ไปแล้ว) เลยต้องแจ้งให้ทาง support แก้ไขให้ซึ่งก็ใช้เวลาไม่นาน

สรุป

ตอนนี้คิดว่าวิธีการ collectstatic ตอน build แล้วไป rsync ใน production เป็นวิธีที่ดีที่สุดแล้วครับ แต่ก็จะมีวิธีอื่นๆ ที่มี tradeoff น่าสนใจอีกคือ

  • ใช้ WhiteNoise ก็ได้ แต่ต้องมั่นใจว่า CDN cache ให้เราอยู่
    • วิธีนี้ TipMe ใช้ไม่ได้แล้วเพราะเราไม่ได้ใช้ WSGI แต่ใช้ ASGI (Django-Channels)
  • Copy static ขึ้น S3 ตอน build
    • วิธีนี้ผมไม่ค่อยชอบเพราะมันจะผูก build กับ deploy ไม่อยากให้รวมกัน แต่ถ้ามองเรื่องฟีเจอร์แล้วน่าจะทำใหัชัวร์มากๆ ว่าพอมันขึ้น production แล้ว user จะไม่โดน 404 ตอนโหลด static แน่นอน