ปิดเว็บแค่ 15 นาที ก็ย้าย TipMe ขึ้น Kubernetes ได้

ช่วงเดือนที่ผ่านมาที่ TipMe เจอปัญหากับ provider เราเยอะมากครับ

  • เค้าบอกว่าระบบจะล็อค inter 1 ชั่วโมงถ้ามีการใช้ data transfer ออกต่างประเทศเกิน (วัตถุประสงค์คงจะไว้กันโดนยิง ถ้าใช้งานปกติไม่ควรจะติด)
  • แต่ปรากฏว่าผมโดนล็อคอย่างปริศนาบ่อยมากๆ ยังไม่รู้สาเหตุ
  • สัปดาห์ที่แล้ว inter ผมโดนล็อคแทบจะทันทีที่ docker pull
  • พอสอบถามไปพบว่าทางผู้ให้บริการได้ bandwidth เพิ่มมาทำให้เน็ตเร็วขึ้นแต่ cap เท่าเดิม
  • ผมรอไปสามสี่วันก็ไม่แก้ จนต้องทวงไปอีกรอบแล้วเค้าถึงยอมปลด threshold ให้ไปก่อน
  • ซึ่ง docker pull ไม่ได้ผมถือว่าสำคัญมากเพราะทำให้ deploy อะไรไม่ได้เลย
  • หลังปัญหานั้น ช่วงสัปดาห์นี้ก็ยังเจอปัญหาอีกที่ ISP ว่าขาออก inter packet loss 75% ซึ่งเป็นหลายครั้งมาก เมื่อวันศุกร์ที่ผ่านมาก็เจอ

ซึ่งการที่ออก inter ไม่ได้นี่เป็นเรื่องใหญ่สำหรับเว็บมากครับ เพราะว่าทั้ง Streamlabs และ True Wallet ที่เราใช้ API อยู่บน AWS ทั้งหมด พอล่มปุ๊บกลายเป็นว่าเราตัดบัตรได้แต่ยิง alert ไม่ได้

ที่สำคัญคือเป็นเรื่องเงินๆ ทองๆ เก็บเงินเค้ามาแล้วระบบ error ผู้ใช้บริการก็คงไม่ไว้วางใจ

สุดท้ายแล้วมันเลยเกิดอาการ

ต้องหน้าเนรุด้วยนะ

Boku wa Iyada!!

(นี่เขียนข้างบนยืดเยื้อมาเพราะจะเล่นมุกนี้เลยนะ)

ก็เลยคิดว่าถึงเวลาย้ายออกแล้วล่ะ ส่วนเครื่องเดิม (ซึ่งก็คือเครื่องที่รันบล็อคนี้) ก็คงยังมีอยู่แต่จะลดสเปคลงไป

สำหรับผู้ให้บริการใหม่ก็เลือก Google Cloud ไว้นานแล้วครับ เพราะว่า

  • Docker registry ของเว็บอยู่บน Google Cloud Container Registry ตั้งแต่ตั้งตั้งเว็บแล้ว
  • DNS ของเว็บตอนนี้ก็ใช้ Google Cloud DNS เพราะจะใช้ DNSSEC (ตอนนี้เป็น private Alpha อยู่)
  • Interface Google Cloud ใช้งานง่ายมากๆ เมื่อเทียบกับ AWS
  • Offering ของ Google จะ implement แล้วมีฟีเจอร์น่าใช้กว่าหลายอย่าง ตัวอย่างเช่นสามารถเพิ่ม user และ ssh key จากหน้าเว็บได้ตลอดเวลา ในขณะที่ cloud อื่นๆ จะต้องเซตตอนสร้างเครื่องเท่านั้น
  • ราคาจะถูกกว่า AWS นิดหน่อย แต่แลกมากับการที่ bandwidth แพงกว่า
    • ตรงนี้ Google บอกว่าเพราะ peering ของ Google ดีมากๆ คือผู้ใช้เกิน 90% peer จาก ISP เข้า Google ตรงๆ เลย และ network ภายในของ Google ก็เป็น private network เชื่อมถึงกันทั้งโลก
    • ซึ่งมันจะดีจริงหรือเปล่าก็ไม่รู้หรอกแต่หวังว่าจะมันจะไม่ล่มให้ต้องร้อง Boku wa Iyada

และส่วนของการ setup นั้นก็จะขยับไปใช้ Kubernetes เพราะว่า

  • มันเป็น managed ไม่ต้องไปยุ่งกับ OS แค่กดสร้างแล้วใส่ config เข้าไปได้เลย
  • ทำ Infrastructure as Code ง่าย
  • อยากได้ฟีเจอร์ zero downtime deployment
  • Security ค่อนข้างดีจากการที่ใช้ Container optimized OS ที่มีขนาดเล็ก
  • Google Cloud ไม่คิดค่ารัน Kubernetes Controller สำหรับคลัสเตอร์ขนาดเล็ก ซึ่่งพอแยก Controller ออกไปแล้วทำให้สามารถปิด ssh ไม่ให้เข้าเครื่องได้เลย จะทำอะไรก็ไปทำที่ Controller หมดซึ่ง IP จะเป็นความลับ และมีระบบ login ที่ปลอดภัยผ่าน IAM
    • ปกติถ้าจะทำแบบนี้อย่างน้อยๆ ก็จะต้องซื้อ IP เพิ่ม แต่อันนี้ทำให้ secure ขึ้นได้โดยไม่ต้องเสียเพิ่มสักบาท

โจทย์

สิ่งที่อยากจะทำในรอบนี้คือ

  1. ย้ายเว็บจากเครื่องเดิมไปเครื่องใหม่โดยทำให้ downtime น้อยที่สุด
  2. ย้าย database จาก MySQL เป็น PostgreSQL เพราะอยากได้ฟีเจอร์ schema change แล้ว rollback transaction ได้
  3. เปลี่ยน web server เป็น Caddy ทั้งระบบ
  4. ใช้เครื่องแค่ 1 เครื่อง เพื่อประหยัดค่าใช้จ่าย (เดิมก็ใช้เท่านี้อยู่ ยังไม่คิดว่า load เยอะจนต้องขยาย)

ติดตั้ง Reverse proxy

เนื่องจากว่าเว็บจะมีส่วนของ application server (ใช้ Daphne) และ static file server ก็เลยจะต้องติดตั้ง reverse proxy เพื่อ routing เข้าให้ถูกที่

สำหรับ server ที่เลือกใช้ก็คือ Caddy ครับ ที่เลือก Caddy เพราะ

  1. Caddy สามารถออกใบรับรองจาก Let’s Encrypt ได้ในตัวเลย
    (แต่ตอนหลังผมพบว่ามันออก subdomain ละใบ ไม่ได้ทำแบบใบเดียวหลายๆ ชื่อ ซึ่งอาจจะเสี่ยงติดลิมิตได้)
  2. Caddy config ง่ายมาก และ default config มาให้ค่อนข้างปลอดภัยอยู่แล้ว ซึ่งเดิมเว็บก็ใช้รัน static server อยู่ตลอดไม่เคยมีปัญหาเลย

โดยเจ้า Caddy ก็จะลงไว้ใน Container แล้วก็จะมี shell script ที่ generate Caddyfile อีกทีหนึ่งจาก environment variable

สำหรับ Caddyfile ก็จะหน้าตาประมาณนี้

http://tipme.in.th {
    header / {
        Strict-Transport-Security "max-age=31536000"
    }
    limits 10KB
    redir / https://{host}{uri} 301
}

https://tipme.in.th {
    header / {
        Strict-Transport-Security "max-age=31536000"
    }

    limits {
        header 4KB
        body 10MB
    }

    timeouts 0

    log stdout
    push
    root /documents

    errors {
        413 413.html
        502 502.html
        504 504.html
    }

    proxy / $REMOTE_BACKEND {
        websocket
        transparent
        except /static
    }

    proxy /static $STATIC_BACKEND {
        without /static
    }
}

จะสังเกตว่า config สะอาดมากๆ และจบในไฟล์เดียวด้วย สิ่งที่มันทำคือ

  • ถ้าเข้าเว็บเป็น HTTP มา ให้ redirect เข้า HTTPS เอง ซึ่งตรงนี้ปกติ Caddy จะทำให้อยู่แล้วไม่ต้องเซต แต่มันไม่ได้ใส่ header Strict-Transport-Security ก็เลยต้องเซตเอง
  • พอมี request มาให้ proxy เข้า backend ไป โดยให้รองรับ WebSocket และ set header ระบุ IP ที่เข้ามาด้วย
  • กำหนด limit header + body size ไว้ป้องกัน application server โดน overload
  • เนื่องจากเว็บใช้ WebSocket เลยจำเป็นจะต้องปิด timeout
    • ก่อนย้ายเข้ามาใช้ nginx จะไม่สามารถปิด timeout ได้แต่ขยายเวลาออกไปแทน กลายเป็นว่าพอครบเวลาแล้ว WebSocket จะหลุด
    • คนใช้ฟีเจอร์ Alert ของเว็บน่าจะเปิด WebSocket ค้างหลายชั่วโมงอยู่แล้ว ก็เลยคิดว่า limit 1 ชั่วโมงก็ไม่พอ ถ้ายาวกว่านั้นก็ไม่ต่างกับไม่มีลิมิตแล้ว

เสร็จแล้วก็จะติดตั้งใน Kubernetes ซึ่งตรงนี้ก็จะเจอปัญหาคือ Kubernetes เชียร์ให้ expose service ผ่าน Load balancer ทั้งหมด ไม่ว่าจะใช้ Service type LoadBalancer (Layer 4) หรือ Ingress (Layer 7) แต่ถ้าซื้อ Load balancer ของ Google Cloud นั้นก็จะแพงกว่าเครื่องที่ใช้อยู่ซะอีก ฉะนั้นเลยจะต้องให้เปิด port ในเครื่องที่รันอยู่

วิธีการทำก็พอมีคนพูดถึงใน ServerFault อยู่ คือการใช้ ClusterIP แล้วระบุ IP เครื่องลงไป แต่พอทดสอบดูก็พบว่ามันทำให้ IP คนที่เข้ามากลายเป็น IP ของเครื่องแม่หมดเลย แบบนี้ระบบ rate limit ของเว็บก็พังหมด

นึกอยู่สองวันก็คิดได้ว่าใช้ Host networking ได้ ก็จะได้ Config ใน Kubernetes ประมาณนี้

apiVersion: apps/v1beta1
kind: Deployment
metadata:
  name: caddy
spec:
  replicas: 1
  template:
    metadata:
      labels:
        app: caddy
    spec:
      hostNetwork: true
      containers:
      - name: caddy
        image: asia.gcr.io/project-id/caddy
        env:
        - name: REMOTE_BACKEND
          value: $(TMSTREAMLABS_SERVICE_HOST):80
        - name: STATIC_BACKEND
          value: $(STATIC_SERVICE_HOST):80

สังเกตว่าเราจะต้องระบุ IP ของ backend service ลงไปตรงๆ แล้ว เนื่องจากเวลาระบุ hostNetwork: true จะทำให้เครื่องใช้ DNS ของ host ด้วย ไม่สามารถ resolve service จาก DNS ของ Kubernetes ได้

ทดสอบ Caddy

ไหนๆ ใช้ของใหม่แล้วก็คงต้องลองเล่นหน่อยครับว่าทำอะไรได้บ้าง

Slowloris

Slowloris เป็นการยิง DoS ประเภทหนึ่งที่ไม่ต้องใช้ bandwidth เยอะเลย เพียงแค่เปิด connection ค้างไว้เยอะๆ ทำให้ web server thread pool เต็ม ซึ่งวิธีแก้ไขก็คือใส่ timeout ให้เรียบร้อย แต่ในเคสนี้เราปิด timeout ไปแล้วก็เลยจะต้องทดสอบดูว่าจะไม่โดนยิง

สำหรับวิธีการเทสก็จะใช้ Goloris ยิงดู ซึ่งลองปล่อยไปสัก 5,500 connections แล้วก็ไม่เกิดอะไรขึ้นเลย ก็แสดงว่าน่าจะรอด

SSLLabs

ปกติแล้วเวลา setup web server สิ่งที่ผมจะต้องทำคือใส่ Mozilla SSL Config ไปด้วยเพื่อความปลอดภัย แต่ Caddy นี่มันเน้นย้ำใน docs ว่าไม่ต้องปรับ

มันจะเทพขนาดนั้นเลยหรอ? ก็เลยต้องวัดกันหน่อยด้วย SSLLabs

ก็แปลกใจดีว่าตอนเรียน Network security นี่ข้อสอบ midterm ให้เซต Apache ให้ได้ A+ นะ ใช้ Caddy นี่ลงเสร็จไม่ต้องปรับอะไรเลยได้ A แล้ว ใส่ HSTS นิดเดียวได้ A+ เลย

ย้าย DNS

เรื่องถัดมาที่กังวลอยู่คือ หลายๆ เว็บเวลาจะย้ายเว็บต้องขึ้นบอกผู้ใช้งานว่า DNS ย้ายแล้วนะ รอ update หน่อย

แล้ว DNS TTL ของเว็บเราคือ 1 สัปดาห์ซะด้วย

ฉะนั้นอย่างแรกที่ทำคือลด DNS TTL ของเว็บให้เหลือแค่ 10 นาที จะได้ไม่โดน cache แล้วค่อยไปปรับกลับตอนย้ายเว็บแล้ว

หลังจากนั้นหลายวันในหัวเริ่มมีแผนชั่วครับ ถ้าเราย้าย server ก่อนซะเลยล่ะ?

วิธีการก็คือเราจะตั้งให้ Caddy proxy เข้า server ตัวเดิม เสร็จแล้วก็แก้ DNS ชี้มาเครื่องใหม่ ฉะนั้นจะเข้าเครื่องไหนก็จะเหมือนกัน แต่ latency จะเยอะกว่าเท่านั้นเอง

ปัญหาอย่างเดียวคือ HTTPS… เว็บไม่มี HTTPS ไม่ได้ เพราะเซต HSTS ไว้ ถ้าขาดปุ๊บ browser จะบล็อคทันที

วิธีที่ตรงไปตรงมาที่สุดคือ certificate ข้ามจากเครื่องเดิมมา แต่วิธีนี้ยังไม่โอเคเท่าไรเพราะกลัวจะมีปัญหาในการต่ออายุ (เนื่องจากใช้ account key คนละอันกัน) ฉะนั้นให้ Caddy ออกใบใหม่ไปเลยดีกว่า แต่จะใช้วิธีปกติคือ setup web server มาแล้วขอ Let’s Encrypt ไม่ได้ เพราะจะมีช่วงที่มันยังไม่ได้ certificate แล้วคนจะเข้าไม่ได้

นึกอยู่นานก็ได้วิธีว่าจะใช้ DNS challenge ครับ ซึ่ง Caddy ก็สามารถขอ Let’s Encrypt ผ่านการ verify ด้วย DNS record ก็ได้ ก็เลยจะให้ Caddy ใช้วิธีนี้โดยเซตประมาณนี้

tipme.in.th {
    tls {
        dns googlecloud
    }

    proxy / https://madoka.whs.in.th {
        websocket
        header_upstream Host tipme.in.th
        header_upstream X-Forwarded-For {remote}
    }
}

(ตัดส่วน security ต่างๆ ออกจะได้ไม่รก)

ก็หมายความว่า ว่าการออกใบรับรองนั้นให้ Verify ผ่าน Google Cloud DNS (อย่าลืมโหลด plugin นี้ด้วยตอนโหลด Caddy)

เวลา Deploy จะต้องเซตข้อมูล Google Cloud ให้กับ Caddy ใน Environment ด้วย:

  • GCE_PROJECT เซตเป็น Project ID ที่เก็บ Zone ของเว็บอยู่
  • GOOGLE_APPLICATION_CREDENTIALS เซตเป็นพาธของ service account JSON ที่แก้ไข DNS ได้ ซึ่งตรงนี้ผมก็เอาไฟล์ใส่ไว้ใน image เลย เพราะใช้ชั่วคราวเท่านั้น
    • ถ้าใช้ถาวรแนะนำให้ใช้ Kubernetes secret แล้ว mount เข้าไปเป็นไฟล์

พอ start container ปุ๊บก็จะบูทค่อนข้างนานหน่อยเพราะจะต้องออก SSL ให้เสร็จซะก่อน (และกว่าจะรอ DNS update มันนาน) หลังจากนั้นพอได้ SSL เรียบร้อยแล้วเราก็ค่อยย้าย DNS เข้ามาที่เครื่องใหม่ ก็เป็นอันเสร็จ ย้ายได้แบบ zero downtime


แผนการไปได้อย่างสวยครับ ผ่านไปหนึ่งวันเว็บก็ล่ม เพราะขา inter ที่เครื่องเกิด packet loss ไป 75% บน Google Cloud เลย proxy กลับมาไม่ได้

Boku wa Iyada!!!

ว่าจะใส่รูปริสะแต่ท่าริสะก็ไม่สื่อ

ย้ายข้อมูลเข้า PostgreSQL

อย่างสุดท้ายที่อยากลองคือจะใช้ PostgreSQL ดู เพราะมันมีลูกเล่นเยอะมาก เช่น

  • JSON Column สามารถเก็บข้อมูลเหมือน NoSQL ได้ และ query ได้ด้วย
  • DDL Rollback สามารถจับ ALTER TABLE ใส่ใน transaction แล้ว rollback ได้ ซึ่งถ้าเป็น MySQL เวลาอัพเกรดแล้วพังกลางทางก็จะต้องไปถอยด้วยมือ

แต่ JSON Column นี่คงไม่ได้ใช้หรอก เพราะตอน dev เราใช้ SQLite 5555

เวลาจะย้าย database ก็ต้องเอาชัวร์ก่อนว่าโปรแกรมจะรันใน Postgres ได้ ซึ่งเว็บก็มี test ครอบคลุม flow ของ use หมดแล้ว ก็แค่แก้ cloudbuild.yaml ให้รัน test ใน Postgres ด้วย เท่ากับว่าตอนนี้เว็บจะต้อง test ผ่านทั้งบน SQLite, MariaDB และ Postgres ซึ่งพอใช้ Django ORM อยู่แล้วก็ test ผ่านเกือบหมดในชุดเดียว ติดแค่ Custom SQL จุดนึงที่เขียนไว้เอง

และส่วนสำคัญก็คือการย้ายข้อมูล วิธีที่ใช้ก็คือจะให้ Django ORM create table ให้ก่อน เสร็จแล้วค่อยย้ายเฉพาะ data เข้าไปโดยใช้โปรแกรม pgloader

วิธีใช้ pgloader ก็จะต้องมี script ก่อน ซึ่งก็เขียนไม่ยากหน้าตาประมาณนี้

LOAD DATABASE
    FROM mysql://username:password@localhost/tmstreamlabs
    INTO postgresql://postgres:password@localhost/tmstreamlabs

    WITH include no drop, truncate, create no tables, create no indexes, no foreign keys, reset sequences, disable triggers

    ALTER SCHEMA 'tmstreamlabs' RENAME TO 'public'
;

ก็คือให้อ่านข้อมูลจาก MySQL เข้ามา Postgres โดยห้าม drop, create table/index/foreign key และปิด relation check ชั่วคราว (จะต้องรันด้วย postgres superuser) และให้ truncate table ก่อนทำงานทุกครั้ง

เวลาจะรันก็ต้องเปิด tunnel เข้า database server ทั้ง 2 ตัวด้วย เพราะอยู่ใน Docker ทั้งคู่ เข้าจากด้านนอกไม่ได้

  • ของเดิมใช้ Docker ก็ทำ ssh forwarding ปกติ: ssh madoka.whs.in.th -L 127.0.0.1:3306:container-ip:3306
  • ส่วนของใหม่บน Kubernetes ก็ให้ kubectl forward ได้เลย kubectl port-forward postgres-pod-name 5432

พอเปิด tunnel ให้แล้วแล้วก็รัน pgloader ด้วยคำสั่ง sudo docker run --rm -it --net host -v `pwd`:/iac/ dimitri/pgloader pgloader -v /iac/pgloader-script ก็เป็นอันเรียบร้อย

2017-07-22T05:11:27.962000Z LOG report summary reset
       table name      total time       read      write
-----------------  --------------  ---------  ---------
Total import time       4m30.965s    45.748s 3m47.254s

สำหรับวิธีนี้ความเร็วก็จะขึ้นอยู่กับ network ที่บ้านด้วยเพราะทุกอย่างจะวิ่งผ่านเครื่องเราหมด ซึ่งของเว็บก็ใช้เวลาแค่ 5 นาที

สรุปแผนการย้าย

สรุปขั้นตอนทั้งหมดที่ทำก็คือ

  1. ประกาศกำหนดการ downtime ในเพจ
  2. ลด DNS TTL
  3. ทดสอบโปรแกรมบน Postgres
  4. สร้าง Container Engine cluster
  5. ติดตั้งโปรแกรมทั้งหมด และ Caddy ตัวใหม่ลงใน Container Engine
  6. ทดสอบการย้ายข้อมูลเข้า Postgres (มาถึงขั้นตอนนี้น่าจะไม่เจออุปสรรค์ในการย้ายแล้ว)
  7. ย้าย DNS เข้า server ใหม่ โดย proxy ไปที่ server เก่า
  8. Down website ทั้งหมดแล้วย้ายข้อมูลเข้า Postgres
  9. เพิ่ม DNS TTL กลับมา และ monitor ระบบใหม่
  10. config server เก่าให้ proxy ไปที่ server ใหม่

ซึ่งในตอนที่บอกว่าจะย้าย server ยังไม่รู้เลยว่าจะต้อง down นานขนาดไหน แต่พอเริ่มทำถึงข้อ 6 แล้วก็เริ่มคิดว่าใน 30 นาทีน่าจะจบได้ และในวันจริงปิดเว็บไปแค่ 15 นาทีเท่านั้น