ออก Wildcard SSL ด้วย Let’s Encrypt สำหรับ Docker

เมื่อช่วงต้นปีที่ผ่านมา Let’s Encrypt ก็ได้เปิดตัว Wildcard SSL แล้วก็เลยคิดว่าควรจะรีบย้ายไปสักที

เหตุผลหลักๆ คืออย่างที่พอทราบกันว่าใน certificate ที่ออกด้วย Let’s Encrypt นั้นจะต้อง list subdomain ทั้งหมดใน subject alternate name

ขอบคุณ srakrn.me

ซึ่งถ้าเรามี internal subdomain ก็ไม่เหมาะเท่าไรที่จะ list ในนั้น วิธีที่ผมกันคนขี้สงสัยแบบนี้ก็คือการเอา domain ภายในไปออกอีกใบนึง จะได้ไม่อยู่ในรายชื่อ

แต่ที่หลายคนไม่รู้คือการออกใบรับรอง SSL โดยผู้ให้บริการที่น่าเชื่อถือนั้นจะมีการส่งข้อมูลไปยังฐานข้อมูล Certificate Transparency ด้วย ตัวอย่างเช่น subdomain ของ whs.in.th ไม่ว่าจะเอาไปซ่อนไว้ใบไหนก็จะเห็นทั้งหมด ฉะนั้นก็เลยคิดว่าน่าจะต้องเริ่มเลิกใช้แล้ว

Prerequisite

ข้อกำหนดการออก wildcard certificate ของ Let’s Encrypt คือเราจะต้องยึนยันตนผ่าน DNS ซึ่งผมเห็น guide ภาษาไทยจำนวนหนึ่งแนะนำให้ตั้งค่า DNS เอง ซึ่งอันตราย เพราะมันจะ auto renew ให้ไม่ได้แล้วเว็บจะเข้าไม่ได้หลังหมดอายุ

ฉะนั้นแล้วเราจะต้องใช้ DNS Hosting ที่สามารถ automate ได้ด้วย ซึ่งเจ้าที่ฟรีก็จะมี DigitalOcean กับ CloudFlare

สำหรับเว็บผมนั้นใช้ DNS Hosting ดังนี้

  • whs.in.th: Google Cloud DNS
  • cupco.de: CloudFlare

ที่เลือกใช้ 2 อันเพราะอยากกระจายความเสี่ยง (และ 2 เจ้านี้รองรับ DNSSEC) ส่วนที่ไม่ใช้ SSL ฟรีของ CloudFlare เพราะไม่อยากให้ CloudFlare เห็นข้อมูลของเราวิ่งผ่านจึงใช้เป็น DNS อย่างเดียวโดยไม่เปิด proxy และบาง subdomain ที่เปิด proxy นั้นก็ตั้งให้ CloudFlare verify การเชื่อมต่อระหว่าง CloudFlare กับ server ด้วยเพื่อความปลอดภัย

ขอ API Key

สำหรับ CloudFlare สามารถขอ API Key ได้ที่หน้า Profile ด้านล่างสุด เลือก Global API Key

ตอนนี้ดูเหมือนว่า CloudFlare จะยังไม่มี API key per domain หรือจำกัด role (ถึงแม้จะเป็น enterprise ก็ตาม — เค้าแนะนำให้ทำ service account มาแล้วจำกัด permission)

จากนั้นให้เอา key ไว้ในไฟล์ ini ดังนี้

dns_cloudflare_email = อีเมลที่ใช้ login
dns_cloudflare_api_key = api key ที่ได้

สำหรับ Google Cloud นั้นให้เข้าไปออก Service account

โดยตั้ง role = DNS Administrator และเลือก Furnish new private key แบบ JSON แล้วเก็บไฟล์ JSON ไว้ใน folder เดียวกับ CloudFlare API key

SSL First Time

จากนั้นเราจะออก cert ใบแรกกันครับ

$ sudo docker volume create certs
# อย่าลืมเปลี่ยน path ไป folder เก็บ account และชื่อไฟล์ account
$ sudo docker run --rm -v "/path/to/account/folder:/dns:ro" \
  -v "certs:/etc/letsencrypt:z" certbot/dns-google renew \
  --dns-google-credentials /dns/service_account.json

Saving debug log to /var/log/letsencrypt/letsencrypt.log

How would you like to authenticate with the ACME CA?
-------------------------------------------------------------------------------
1: Obtain certificates using a DNS TXT record (if you are using Google Cloud DNS
for DNS). (dns-google)
2: Spin up a temporary webserver (standalone)
3: Place files in webroot directory (webroot)
-------------------------------------------------------------------------------
Select the appropriate number [1-3] then [enter] (press 'c' to cancel): ตอบ 1
Plugins selected: Authenticator dns-google, Installer None
Enter email address (used for urgent renewal and security notices) (Enter 'c' to
cancel): กรอกอีเมล

-------------------------------------------------------------------------------
Please read the Terms of Service at
https://letsencrypt.org/documents/LE-SA-v1.2-November-15-2017.pdf. You must
agree in order to register with the ACME server at
https://acme-v02.api.letsencrypt.org/directory
-------------------------------------------------------------------------------
(A)gree/(C)ancel: a

-------------------------------------------------------------------------------
Would you be willing to share your email address with the Electronic Frontier
Foundation, a founding partner of the Let's Encrypt project and the non-profit
organization that develops Certbot? We'd like to send you email about our work
encrypting the web, EFF news, campaigns, and ways to support digital freedom.
-------------------------------------------------------------------------------
(Y)es/(N)o: n
Please enter in your domain name(s) (comma and/or space separated)  (Enter 'c'
to cancel): กรอกโดเมนลงไป เช่น whs.in.th,*.whs.in.th
Obtaining a new certificate
Performing the following challenges:
dns-01 challenge for whs.in.th
dns-01 challenge for whs.in.th

...

IMPORTANT NOTES:
 - Congratulations! Your certificate and chain have been saved at:
   /etc/letsencrypt/live/whs.in.th/fullchain.pem
   Your key file has been saved at:
   /etc/letsencrypt/live/whs.in.th/privkey.pem
   Your cert will expire on 2018-10-02. To obtain a new or tweaked
   version of this certificate in the future, simply run certbot
   again. To non-interactively renew *all* of your certificates, run
   "certbot renew"
 - If you like Certbot, please consider supporting our work by:

   Donating to ISRG / Let's Encrypt:   https://letsencrypt.org/donate
   Donating to EFF:                    https://eff.org/donate-le

เป็นอันใช้ได้ สำหรับ CloudFlare ก็ทำคล้ายๆ กัน คือ

sudo docker run --rm -v "/path/to/account/folder:/dns:ro" \
  -v "certs:/etc/letsencrypt:z" certbot/dns-cloudflare renew \
  --dns-cloudflare-credentials /dns/cloudflare.ini

ติดตั้งลงใน nginx

เสร็จแล้วเราก็สามารถ mount volume เข้าไปใน nginx ได้เลย

sudo docker run -d --restart=always --name=nginx \
    -v nginx:/etc/nginx \
    -v certs:/etc/certs:ro,z \
    -p 80:80 -p 443:443 \
    --hostname madoka.whs.in.th nginx

สำหรับการตั้งค่า certificate นั้นแนะนำให้ตั้งตาม Mozilla SSL Generator โดยระบุดังนี้

ssl_certificate /etc/certs/live/whs.in.th/fullchain.pem;
ssl_certificate_key /etc/certs/live/whs.in.th/privkey.pem;
ssl_trusted_certificate /etc/certs/live/whs.in.th/chain.pem;

เป็นอันเรียบร้อย

Renew

สำหรับการ renew นั้นเราจะทำ shell script ไว้

#!/bin/bash

CERT_VOLUME=certs
DNS_VOLUME=/path/to/dns

docker run --rm -v "$DNS_VOLUME:/dns:ro" -v "$CERT_VOLUME:/etc/letsencrypt:z" certbot/dns-google renew \
    -n --agree-tos --dns-google-credentials /dns/service_account.json
docker run --rm -v "$DNS_VOLUME:/dns:ro" -v "$CERT_VOLUME:/etc/letsencrypt:z" certbot/dns-cloudflare renew \
    -n --agree-tos --dns-cloudflare-credentials /dns/cloudflare.ini
docker kill -s HUP nginx

จะเห็นว่าตอนท้ายนั้นเราจะยิง HUP ไปหา nginx container ด้วยเพื่อให้ reload certificate

แล้วก็ใส่ไว้ใน crontab ของ root ให้รันทุกวันเป็นอันเสร็จ

$ sudo crontab -e
0 0 * * *   /path/to/update-certs.sh > /dev/null 2>&1

(อาจจะแก้ให้มัน log ไว้เพื่อตรวจสอบก็ได้)

สรุป

หนึ่งปีครึ่งผ่านไป รู้สึกว่า Let’s Encrypt มีการพัฒนาขึ้นมาก ทั้งบริการ และ certbot ก็ตาม รอบนี้รู้สึกว่าขั้นตอนซับซ้อนน้อยกว่าเดิมมากและ user friendly

สำหรับตอนหน้าหลังจากเป็นสายฟรีมานาน ว่าจะออก SSL EV ใบละหมื่นบ้างว่าจะมีขั้นตอนอย่างไร

Code splitting แบบใหม่ใน Webpack 4

สำหรับคนใช้ Webpack ผมเชื่อว่าหลายๆ คนจะใช้ code splitting อยู่ และ code splitting ที่ง่ายๆ โง่ๆ ตรงไปตรงมาที่สุดที่น่าจะใช้กันก็คือ vendor code split หรือการแยกโค้ดใน node_modules ไปเป็นอีก bundle เนื่องจากว่าโค้ดในนั้นมักจะอัพเดตไม่บ่อยเท่าโค้ดเรา ก็จะช่วยให้แคชได้นานขึ้น

แต่พอถึงยุค Webpack 4 แล้ว เราสามารถทำ Vendor code split ได้มากขึ้น และเผลอๆ จะ split ได้ยิบย่อยลงไปได้อีกด้วยระบบ Chunk graph

ปัญหาของ code split

ก่อนจะไปถึง vendor code split นั้นจะขอเล่าถึง Code split แบบใหม่ใน Webpack 4 ก่อน

ใน Webpack 3 นั้นหากเราทำ async code splitting นั้นเราจะได้ Bundle ประมาณนี้

จากรูปจะเห็นปัญหาว่า หาก entrypoint ไม่ได้ใช้ lodash แต่ทั้ง Overlay และ Settings chunk ใช้ lodash จะทำให้ Webpack เอา lodash ไปใส่ทั้ง 2 bundle user ก็ต้องโหลด lodash หลายรอบ

วิธีแก้ไขในอดีตก็มักจะใช้ vendor code split เพื่อแยก node_modules ออกไป (หรือ CommonsChunkPlugin ที่แยกตามจำนวนครั้งที่ใช้)

หรือถ้าโกงหน่อยก็จะใส่ require('lodash') ไว้ใน entrypoint เพื่อบังคับให้ entrypoint มีการเรียก lodash

แต่ปัญหาก็ยังไม่จบแค่นั้น เพราะพอเราแยก node_modules ออกไปก็จะได้ chunk ใหม่ที่มีทุกมอดูลรวมกันอยู่ ก็กลายเป็นว่าพอเข้าหน้า Overlay ก็ต้องโหลด jQuery มาด้วยถึงจะไม่ได้ใช้ (ในรูปไม่ได้วาดไว้ แต่สมมุติว่ามีหน้าที่ใช้ jQuery หลายหน้า)

ในเว็บ TipMe ก็มีปัญหาแบบนี้เช่นเดียวกันเพราะเว็บเราใช้ jQuery แต่ก็จะมีบาง widget ที่ใช้ React เขียน ก็คงไม่เหมาะที่จะต้องโหลด React ทุกหน้า วิธีที่เราทำอยู่คือทำ DLL bundle 3 ชุด

  • jQuery
  • React
  • Runtime (common ระหว่าง 2 bundle ด้านบน เช่น Polyfill)

เสร็จแล้วเวลา build เราจะมี Gulp filter ที่อ่านไฟล์แล้วดูว่าไฟล์นี้มีคำสั่ง // use React หรือเปล่า ถ้ามีก็จะ link กับ jQuery + React DLL แต่ถ้าไม่มีก็จะ link กับ jQuery อย่างเดียว

วิธีนี้ก็ยังไม่ใช่วิธีที่ดีเท่าไร เพราะ

  • DLL ก็ยังมีมอดูลที่ไม่จำเป็นอยู่
  • ต้องนั่ง list module ที่ใช้ DLL เอง ซึ่งไม่รู้ว่าเราเลือกมาแล้วดีที่สุดหรือเปล่า
  • DLL ทำ Tree shaking กับโค้ดเราไม่ได้ เพราะเวลา build DLL มันไม่อ่านโค้ดเรา
  • Build tooling ซับซ้อน
  • เวลา build ก็ซับซ้อน ต้องรัน Gulp 3 รอบ คือ build runtime, build DLL แล้วถึงจะ build app
  • เวลาใช้ก็ซับซ้อน เพราะที่หน้าต้อง include DLL ให้ถูกว่าหน้านี้ใช้อะไร

All new code splitting

Webpack 4 นั้นจะเปลี่ยนวิธี split ใหม่โดยค่าเริ่มต้นนั้นจะตัด vendor chunk ได้ฉลาดมากขึ้น

นั่นคือ Webpack สามารถสร้าง chunk ที่เป็น common ระหว่างมอดูลได้แล้ว โดยเป็นค่า default เราไม่ต้องทำอะไรเพียงแค่ใช้ Webpack 4 เท่านั้น

แต่จะเห็นว่าชื่อไฟล์นั้นจะเป็น vendor~page-a~page-b ซึ่งพอเราเพิ่มหน้าเข้าไปไฟล์ก็จะเปลี่ยนชื่อ ถ้าต้องการ cache ให้ได้นานๆ นั้นแนะนำให้ตั้งค่า Webpack ดังนี้

{
    optimization: {
        splitChunks: {
            name: false,
        }
    }
}

ซึ่งจะทำให้ chunk ที่ได้นั้นใช้ชื่อเป็น chunk ID แทนที่จะระบุชื่อมอดูลภายใน

Entrypoint splitting

ถึงตรงนี้แล้ว splitting แบบใหม่ก็ยังไม่ดีเท่าของเก่า เพราะมันยังไม่แยก node_modules ออกจาก Entrypoint (หรือถ้ามีหลาย entrypoint มันก็ยังไม่แยก common ออกมา) เพราะค่าเริ่มต้นของ Webpack จะปิดเอาไว้

เหตุผลที่ปิดก็เดาได้ไม่ยากครับ ปกติถ้าเรา split มือ เราจะรู้ชื่อ chunk แล้วเราก็จะเขียน script tag ไปเรียกมา แต่ถ้า Webpack ทำให้ user ก็อาจจะไม่เข้าใจว่าต้องเรียก chunk ที่แยกออกมา ก็เลยปิดไว้เป็นหลัก

ทีนี้เราจะเปิดได้อย่างไร? วิธีการเปิดคือตั้งค่าดังนี้ใน Webpack

{
    optimization: {
        splitChunks: {
            chunks: 'all',
            maxInitialRequests: 5, // default to 2, adjust as needed
        },
        runtimeChunk: 'single', // optional, recommended
    }
}

ซึ่งค่าต่างๆ จะมีผลดังนี้

  • maxInitialRequests จะกำหนดว่าให้ split ได้สูงสุดกี่ chunk
  • runtimeChunk จะแยกส่วน manifest ไปเป็นอีก chunk ซึ่ง manifest จะเปลี่ยนทุกครั้งที่มีการแก้โค้ดไม่ว่าไฟล์ไหนอยู่แล้ว การแยกไปเลยก็จะทำให้ cache ได้ดีขึ้น
  • อื่นๆ สามารถดูได้ตาม docs

จากนั้นเราจะต้องแก้หน้าเว็บให้ใส่ script tag ให้ครบ แต่… ในเมื่อ Webpack generate ชื่อไฟล์มาให้ แล้วเราจะใส่ src ยังไง?

คำตอบคือในไฟล์ Webpack stats จะมีบอกอยู่ว่า Entrypoint นี้ต้องใช้ไฟล์อะไรบ้าง หน้าตาประมาณนี้

{
  "entrypoints": {
    "bootstrap": {
      "chunks": [
        28,
        10,
        11,
        49
      ],
      "assets": [
        "js/runtime~bootstrap.js",
        "js/runtime~bootstrap.js.map",
        "js/c_10.43ba1082.js",
        "js/10.js.map",
        "js/c_11.45f9671c.js",
        "js/11.js.map",
        "js/c_bootstrap.5f882904.js",
        "js/bootstrap.js.map"
      ]
    },
  }
}

(ถ้าทำ CSS Split ด้วย ก็จะได้ชื่อไฟล์ CSS มาใน assets ด้วยเช่นกัน)

สิ่งที่เราต้องทำคือ อ่านไฟล์ stats แล้วเอามาสร้าง HTML

ก่อนอื่นก็แก้ Webpack ให้สร้าง stats มาเสียก่อน โดยใช้ webpack-stats-plugin

{
    plugins: [
        new stats.StatsWriterPlugin({
            filename: 'stats.json',
            fields: ['entrypoints'],
        }),
    ],
}

จากนั้นเวลา build เราจะได้ไฟล์ stats.json ออกมา จากนั้นเราก็จะแก้ให้แอพเราอ่านไฟล์นี้ขึ้นมา

สำหรับ Django นั้นวิธีที่ผมใช้อยู่ก็คือสร้าง Template tag ขึ้นมา

โดยนำไฟล์ webpack.py ไว้ใน folder templatetags และไฟล์ webpack_entrypoint.html ไว้ใน templates จากนั้นเวลาใช้ก็

{% load webpack %}
{% entrypoint "bootstrap" %}

ก็จะได้ script tag ที่ครบถ้วนทันที

<script src="https://static.tipme.in.th/js/runtime~bootstrap.24e2bfeb3c73.js" crossorigin="anonymous"></script>
<script src="https://static.tipme.in.th/js/c_10.43ba1082.2b93f172c643.js" crossorigin="anonymous"></script>
<script src="https://static.tipme.in.th/js/c_11.45f9671c.d9617c7f9295.js" crossorigin="anonymous"></script>
<script src="https://static.tipme.in.th/js/c_bootstrap.5f882904.9c15205b28f3.js" crossorigin="anonymous"></script>

สรุป

Code split แบบใหม่นี้ลดความซับซ้อนไปในหลายๆ อย่างเลยครับ ในขณะที่เพิ่ม performance ให้ดีขึ้น และยิ่งถ้าเปิดให้มัน split entrypoint ได้ก็ยิ่งเพิ่มประสิทธิภาพอีก

Commit ที่ TipMe ปรับมาใช้ Webpack 4 นั้น สถิติคือ 144 additions, 324 deletions ลบ tooling ออกไปได้เยอะมาก

  • ไม่ต้อง mark แล้วว่า entrypoint นี้ใช้ React เพราะ Webpack จะรู้เองแล้วเอา React chunk ใส่มาใน list ให้อัตโนมัติ
  • ไม่ต้องมี webpack config หลายชุดแล้ว build ทีเดียวออกเลย
  • Build target เหลืออันเดียว
  • ใช้ mode: production แทนการเซตเอง

และสุดท้าย Webpack build time จาก 62550ms เหลือ 35162ms หรือลดลง 43%

(แต่ Gulp เราลดมาแค่ 20 วินาที เพราะมีไฟล์ที่ต้องอัพโหลดขึ้น Sentry มากขึ้น)