TipMe: เบื้องหลังระบบเข้ารหัสระบบรับชำระผ่าน True Wallet

เรื่องนึงที่อยากอธิบายในบล็อคมาสักพักคือด้านหลังระบบ True Wallet ในเว็บ TipMe.in.th (TMStreamlabs เดิม) ซึ่งคิดว่าน่าจะทำให้ผู้ใช้งานมั่นใจในระบบมากขึ้น

คำเตือนคือบล็อคนี้จะเขียน technical แบบไม่เกรงใจ ฉะนั้นถ้าจะอ่านก็ทนๆ หน่อยนะครับ

Threat model

ก่อนออกแบบระบบตัวนี้ต้องมองถึงภัยอันตรายกันก่อน

เรื่องแรกที่เราจะต้องคำนึงถึง คือเรามี HTTPS อยู่แล้ว มั่นใจได้ว่าจาก browser ผู้ใช้งานจนถึงฝั่ง server นั้นจะไม่มีการดักฟังแน่นอน ฉะนั้นจุดที่จะต้องออกแบบให้ปลอดภัยคือ

  1. Server ถูกแฮคแล้วผู้โจมตีสามารถนำรหัสผ่านที่เก็บไว้ หรือ private key ออกได้
  2. Network ระหว่าง server ถูกดักฟัง ทำให้อาจจะถูก man in the middle ปลอมเว็บ True Wallet

สำหรับสิ่งที่เรายังไม่มองว่าเป็นจุดที่จะต้องป้องกันจากในเว็บเราคือ

  1. ถูกเจาะจากฝั่งผู้ใช้งาน
  2. การเข้ารหัส HTTPS ถูกเจาะ เพราะถ้าเกิดขึ้นจริงเว็บใหญ่ๆ น่าจะโดนก่อนเรา
  3. ผู้ให้บริการ server แอบอ่านข้อมูลของ server เรา ซึ่งเราทำได้แค่เลือกผู้ให้บริการที่ดูน่าเชื่อถือ
  4. Hacker วาง backdoor ไว้ในระบบ ซึ่งตรวจจับป้องกันได้ยาก
  5. Firewall ถูกเจาะทะลุเข้ามา

Client side protection

สำหรับเรื่องการถูกเจาะจากฝั่งผู้ใช้งาน แน่นอนว่าป้องกันหมด 100% ไม่ได้ แต่เว็บจะมีการป้องกันชั้นนึงอยู่แล้วคือใช้ CSP เพื่อระบุว่าโหลดข้อมูลจาก URL ใดได้บ้าง ซึ่งเราพยายามลดให้ได้มากที่สุด ปัจจุบันกฎที่เราใช้จะอนุญาตให้โหลดจากโดเมนต่อไปนี้เท่านั้น (และบน HTTPS เท่านั้น)

ไฟล์ JavaScript/CSS

  • tipme.in.th ของเราเอง
  • static.tipme.in.th ของผู้ให้บริการ CDN เรา (Byteark)
  • cdn.jsdelivr.net ให้บริการ CDN เช่นกัน ซึ่งตอนหลังเราลดความเสี่ยงโดยเลิกโหลด JavaScript จาก JSDelivr แล้ว ให้โหลดจาก CDN ของเราเท่านั้น สำหรับ CSS เรามองว่าความเสี่ยงต่ำจึงยอมให้โหลดจากเว็บอื่นได้ เผื่อจะติด browser cache ด้วย จะได้โหลดเร็วขึ้น
  • sentry.io สำหรับเก็บ error reporting
  • Google Analytics
  • อนุญาตให้ใช้ inline CSS ได้ แต่ห้ามใช้ inline JavaScript ทุกประเภท รวมทั้งคำสั่ง eval หรือเทียบเท่า

อื่นๆ

  • img.tmstreamlabs.cupco.de เว็บนี้ของเราเช่นเดียวกัน ซึ่งโดเมนนี้จะ proxy ภาพจากเว็บอื่นๆ มาในกรณีที่ผู้ใช้งานแทรกรูปภาพภายนอก โดยเราเลือกใช้ Camo ที่พัฒนาโดย GitHub (ฟีเจอร์นี้คนใช้งานน้อย อาจจะเอาออก)
  • tmsdata.s3.byteark.com โดเมนนี้เก็บภาพที่ผู้ใช้งานเรา upload ขึ้นไป อยู่บน Byteark
  • อนุญาตให้ AJAX ภายนอกไปที่ Google Analytics และ Sentry เท่านั้น (ยังมองว่าจุดนี้เป็นความเสี่ยง แต่ว่าไม่คุ้มค่าที่ลงทุนป้องกันในส่วนนี้)

อยากแนะนำให้ลองแกะดู CSP Header ของเว็บจริงเทียบดูด้วยครับ เนื่องจากมันจะยาวๆ ลงไว้ในบล็อคแล้วจะรกเอา

CSP จะป้องกันการโจมตีได้หลายลักษณะ เช่น

  • หากโดน cross site scripting (XSS) ผู้โจมตีก็จะต้องหาทางเรียก JavaScript ให้ได้อีกทีหนึ่งถึงจะขโมยข้อมูลได้
  • ถ้าเกิดทำได้จริง การส่งข้อมูลออกก็ทำได้จาก เพราะไม่อนุญาตให้ AJAX ข้ามไปในโดเมนอื่นๆ นอกจากที่เรากำหนด
  • หรือจะใช้วิธีเรียกภาพก็ไม่สามารถทำได้ เพราะเรากำหนด URL ของภาพที่อนุญาตด้วย (แนะนำให้อ่าน GitHub’s post-CSP journey)
  • ถ้าจะหลอกให้ Camo proxy ออกไปก็ทำได้ยากอีก เพราะ URL ที่ Camo ใช้มีการเซ็นกำกับจาก server ไม่สามารถสร้าง URL สุ่มสี่สุ่มห้าได้ (คือต้องหลอก server เราด้วย)

ถามว่ามันป้องกันหมด 100% ไหม ผมก็ไม่กล้ารับรองว่าเว็บจะเหนียวต่อทุกการโจมตี แต่ก็คิดว่าด้วยปราการหลายๆ ชั้นที่เรามีน่าจะทำให้ผู้โจมตีต้องเสียเวลาพอสมควรก่อนที่จะทำอะไรได้

ที่น่าสนใจคือ Sentry และ CSP ทำให้เราพบว่ามี user ของเราจำนวนหนึ่งถูกแอบแก้หน้าเว็บเพจเรา เนื่องจากเราเจอ error หลายครั้งที่ไม่ได้เกิดจากซอร์สของเว็บเราเอง ตัวอย่างเช่นเคสนี้ ที่ผู้ใช้งานติด Adware แต่ตัว Adware ไม่สามารถโหลดโฆษณาได้เพราะ CSP เราดักเอาไว้

ถามว่ามันกันหมดหรือเปล่า ก็ต้องบอกว่าไม่ครับ อย่างที่เห็นภาพคือ JavaScript ของ Adware นั้นรันแล้ว แค่ส่งข้อมูลไม่ได้เท่านั้นเอง

True Wallet support

ลากยาวไปถึงด้านนอกของเว็บเสียนาน มาพูดถึงเรื่องของ True Wallet ตามหัวข้อดีกว่าครับ

True Wallet เป็นฟีเจอร์นึงที่มี user request มานานมากๆ และเราอยากจะทำนานแล้ว

ปัญหาคือ True Wallet ไม่มี public API ให้ใช้ เราหาอยู่นานมากว่ามีใครให้บริการบ้าง ที่เห็นก็จะมีเจ้าหนึ่งอยู่ ซึ่งจากที่ลองกดๆ ดูแล้วรู้สึกว่าไม่น่าเชื่อถือสักเท่าไร (เผลอๆ บริการของเค้าจะอายุน้อยกว่าเว็บเราอีก) และเห็นว่าเค้าให้กรอกรหัสผ่านของผู้โอนเลยซึ่งดูน่ากลัวมาก เป็นผมผมก็ไม่กล้าโอน ก็เลยตัดสินใจว่าจะทำระบบเองทั้งหมดที่เรากล้าใช้เอง

เรื่องแรกที่จะทำให้ต่างเลยคือ จากเดิมของเค้าที่ให้คนโอนกรอกรหัสผ่าน ผมว่าน่ากลัว เลยจะเปลี่ยนใหม่ให้ Streamer เป็นคนกรอกรหัสของตัวเองดีกว่า แล้วคนโอน (ซึ่งมีจำนวนมากกว่า) ก็โอนได้เลยโดยไม่ต้องกรอกรหัสผ่าน

Sealed boxes

ด้านหลังของระบบนี้เราใช้ libsodium sealed box ที่เราเขียนขึ้นมาเองใน JavaScript

ถ้าใครเคยศึกษา crypto มาบ้าง เวลาได้ยินคำนี้ปุ๊บต้องตั้งธงทันทีครับ เขียน crypto เองจะปลอดภัยได้ยังไง?

คำตอบคือ Sealed box มี design ที่น่าสนใจมากครับ มันเป็นแบบนี้

Public key libsodium Box
— 32 bytes — — Variable length —

นั่นหมายความว่าข้างในจริงๆ มันคือ Box ที่แปะ public key ไว้ข้างหน้าเท่านั้นเอง เราไม่ต้อง design crypto เองเลย

สำหรับ Box นั้นก็จะใช้ TweetNaCl.js สร้าง ซึ่งโค้ดข้างในนั้นพอร์ทมาจาก TweetNaCl ภาษา C ที่ผู้สร้างอัลกอริธึมอย่าง djb และคณะได้เขียนไว้ แถมตัวที่แปลงเป็น JavaScript แล้วยังได้รับ security audit จาก Cure53 แล้วด้วย ก็เรียกได้ว่าเป็นไลบรารีที่มีดีกรีความปลอดภัยสูงมากเลยทีเดียว

(อธิบายสั้นๆ สำหรับ TweetNaCl เป็นโครงการเขียน library เข้ารหัสที่ทำงานได้เทียบเท่า libnacl โดยมีความยาวเพียง 140 ตัวอักษร * 100 tweets = 14000 ตัวอักษรเท่านั้น ความเร็วอาจจะเทียบกันไม่ได้ แต่ทำให้สามารถศึกษาโค้ดทั้งหมดได้ง่ายขึ้น ส่วน libsodium นั้นเป็นโครงการที่พัฒนาต่อจาก libnacl ที่ไม่ได้พัฒนาต่อแล้ว)

วิธีที่เราใช้อยู่จะเป็นแบบนี้ครับ

  1. Clientside จะ download public key ของฝั่ง server จาก https://tipme.in.th/truewallet/pk
  2. เมื่อ streamer กรอกรหัสผ่านเข้ามาในหน้าเว็บแล้วเราจะสร้าง sealed box จาก public key ของ server ซึ่งภายใน library จะมีกระบวนการดังนี้
    1. Client จะสร้าง JSON ที่ระบุ username + password
    2. Client จะสุ่ม key pair (public + private) ขึ้นมาใหม่
    3. Client จะสร้าง key เข้ารหัสจาก public key ของ server และ private key ที่สุ่มได้ โดยใช้อัลกอริธึม X25519
    4. Client จะสร้าง nonce ซึ่งจำเป็นต้องใช้ในขั้นตอนถัดไป โดยหาค่าแฮชของ public key ที่สุ่มได้ ต่อกับ public key ของ server โดยใช้อัลกอริธึม BLAKE2b (ในขั้นตอนนี้ nonce ควรจะเป็นค่าที่ไม่ใช้ซ้ำ ซึ่งในเคสของเรา public key ของเรานั้นสุ่มทุกครั้งอยู่แล้ว เราเลยเอามาใช้เพื่อจะได้ไม่ต้องส่ง nonce ไปกับข้อความด้วย)
    5. Client จะใช้ key เข้ารหัส และ nonce เข้ารหัส JSON ที่ได้ในขั้นตอนแรก โดยใช้อัลกอริธึม XSalsa20
    6. Client จะคำนวณ hash เพื่อป้องกันไม่ให้แก้ไขข้อมูล โดยใช้อัลกอริธึม Poly1305 และใช้ key จากข้อ 3
    7. Client จะแปะ public key ลงไปด้านหน้าของผลลัพท์ ตามด้วย hash เป็นอันเสร็จ
  3. หลังจากได้ Sealed box มาแล้ว client จะส่งไปทั้งก้อนให้ server
  4. Server validate ข้อมูลที่ได้รับ โดยพยายามเปิด sealed box ออกมา, validate JSON schema แล้วลองใช้รหัสผ่าน login เข้าที่เว็บ True Wallet (โดย validate SSL ของ True Wallet ด้วยว่าถูกต้อง)
  5. เมื่อถูกต้องแล้ว Server จะแยกส่วนของ Public key ที่สุ่มได้กับ Box ออกจากกัน บันทึก box ลงในฐานข้อมูล ส่วน public key นำไปสร้าง URL ส่งให้กับผู้ใช้ (ในขั้นตอนนี้ผู้ใช้งานก็ต้องเชื่อล่ะครับว่าเราไม่ได้เก็บ public key ไป)

ฉะนั้นแล้วในฝั่งของ server เองจะมีข้อมูลแค่ตัว Box ที่เก็บ username + password แต่ไม่สามารถเปิดมาอ่านเองได้แน่นอน เพราะไม่มี public key ของอีกฝั่ง ซึ่งเป็นไปตาม Threat model ของเราว่า ต่อให้ server ถูก hack ขโมยข้อมูลไปทั้งหมด รวมถึง private key ของเราด้วยก็ไม่สามารถเปิดอ่านข้อมูลได้

ในส่วนนี้นั้นเรามีความคิดว่าเราอาจจะ simplify ให้ง่ายขึ้นโดยส่งรหัสผ่านไปเป็น plain text ให้ server เลย (เพราะเรามี HTTPS อยู่แล้ว) แล้วไปสร้าง sealed box ที่ server ซึ่งก็ตอบโจทย์ได้เหมือนกัน แต่การทำ encrypt ให้ผู้ใช้ดูก็อาจจะทำให้ผู้ใช้ “รู้สึก” ได้ว่าเว็บมีการเข้ารหัสจริงๆ

สำหรับ overlay นั้นการทำงานก็จะเป็นดังนี้ครับ

  1. URL ของ Overlay คือ https://tipme.in.th/truewallet/poll?username=....#publickey ซึ่งเราเลือกเอา public key ไว้ใน # เพื่อป้องกันไม่ให้ติดใน log ของ Web server
  2. ด้านในหน้านั้นจะมี JavaScript ซึ่งจะ poll ไปที่เว็บอยู่เรื่อยๆ โดยส่ง username และ public key ไปทาง POST
  3. Server ตรวจสอบว่าผู้ใช้งานไม่ติด rate limit
  4. Server เอา public key ที่ได้ไปถอดรหัสแล้ว login เข้า True Wallet ได้ และเช็คข้อมูลการโอนเงินในบัญชี
  5. ในเวอร์ชั่นหลังๆ เพื่อลดโหลดของเรา Server จะจำ Cookie ของ True Wallet ไว้ระยะหนึ่งด้วย ซึ่งจะใช้ก็ต่อเมื่อเราตรวจสอบว่าข้อมูลที่ส่งเข้ามานั้นสามารถถอดรหัส box ได้จริง ขั้นตอนนี้จะทำให้เราลด request ไปได้อีก 1 ขั้น

โอนอัตโนมัติ

ตอนหลังเราพบว่า user ไม่ชอบวิธีที่เราใช้ เพราะ delay ค่อนข้างเยอะ โอนก็ไม่สะดวกโดยเฉพาะในมือถือ เราเลยตัดสินใจพัฒนาระบบโอนเงินอัตโนมัติ โดยกระบวนการจะเป็นดังนี้ครับ

  1. ผู้บริจาค กรอก username + password ของ True Wallet มาบนหน้าเว็บ
  2. Clientside ส่งข้อมูลให้ server (ผ่าน HTTPS)
  3. Server เช็ค rate limit และตรวจสอบว่าการโอนถูกต้อง
  4. Server ใช้ username + password เข้าสู่เว็บ True Wallet และโอนเงิน ขั้นตอนนี้จะได้ OTP reference code ออกมาให้ผู้ใช้ และ session ให้ระบบ
  5. Clientside จะจดจำ session ไว้
  6. ผู้บริจาคกรอก OTP จาก SMS
  7. Server เช็ค rate limit และนำ OTP ไปกรอกคืนที่ True Wallet
  8. เมื่อ True Wallet โอนสำเร็จแล้วจะต้องรอเปลี่ยนสถานะอีกทีหนึ่ง ซึ่งระบบจะ spawn task ใน task queue เพื่อเช็คสถานะเป็นระยะๆ เมื่อเสร็จแล้วจึงจะส่ง donate alert

ไอเดียของการออกแบบระบบนี้คืออยากให้ server เป็น Stateless ให้มากที่สุด ซึ่งจากกระบวนการด้านบนเราทำได้ทุกขั้นตอนยกเว้นขั้นตอนที่ 8 จุดสำคัญที่เป็น state ที่เราจะต้องเก็บก็คือ session ใน True Wallet

วิธีที่เราเลือกใช้ก็คือ dump cookie ออกมาแล้วใช้ Secret Box ของ libsodium เข้ารหัสด้วย key ของ server (คนละ key กับ sealed box) แล้วส่งให้ client จำไว้

ซึ่งวิธีนี้มีความปลอดภัยเพราะ

  • เราไม่ได้เก็บข้อมูล session ไว้บน server ฉะนั้นถึงจะได้ key เข้ารหัสไปก็ต้องไปหาวิธีเอา session จาก user
  • Client หลอก server ไม่ได้เพราะ secret box ก็มีแฮชยึนยันความถูกต้องอยู่ ถ้ามีการแก้ไข server จะรู้และ reject ทันที

นอกจากปลอดภัยแล้วยังสามารถทำให้เรา scale ระบบออกไปได้ง่ายๆ อีกด้วย

ระบบโอนอัตโนมัตินี้เราไม่บังคับครับ ผู้ใช้งานสามารถโอนด้วยระบบเดิมก็ได้ แต่ตอนหลังเราก็จะเปิดตัวเลือกให้ Streamer ปิดระบบโอนด้วยมือไปได้ด้วย เพื่อให้ setup ง่ายขึ้น ไม่ต้องใส่ overlay

ปัจจุบันมีผู้ใช้งานระบบโอนอัตโนมัติประมาณ 95% ซึ่งผมเองก็แปลกใจเหมือนกันว่าคนกล้าใส่รหัสผ่านให้เราเยอะขนาดนี้เลยทีเดียว

สรุป

การออกแบบความปลอดภัยนั้นเราจะยึดหลักที่ว่าทั้ง 2 ฝั่งจะไม่เชื่อกันเลย

เราไม่เชื่อว่า client จะไม่หลอกเรา

เราไม่เชื่อว่า server จะไม่หลอกเรา

ซึ่งจากระบบของ TipMe ที่เล่าไปนั้น เราพยายามออกแบบตามหลักการนี้ให้ได้มากที่สุด ถึงแม้ว่าสุดท้ายแล้วเราจะต้องยอมเชื่อว่า server ไม่ได้เก็บข้อมูลในบางจุดบ้าง แต่คิดว่าจุดนี้น่าจะเป็น compromise ที่ดีที่สุดระหว่าง security และ usability ภายใต้ข้อจำกัดของ browser แล้วครับ และการออกแบบนี้ก็เป็นไปตาม threat model ที่เราวางไว้แล้ว

1 เดือนกับ Google Cloud Container Builder

Google Cloud แอบ launch Container Builder มาสักพักแล้วครับ (ยังไม่ขึ้นในหน้า product list) ก็เลยว่าจะลองเล่นสักหน่อย

Background

เดิมที TMStreamlabs อยู่บน GitLab ครับ และใช้ Docker ในการ deploy มาตั้งแต่ต้นอยู่แล้วเพราะบน Infra ใหม่ ทุกอย่างต้องเป็น Docker หมด

ของเดิมเลยผมจะใช้ GitLab CI มา build image และเก็บไว้บน GitLab Registry ซึ่งก็โอเคดียกเว้นว่า GitLab Registry pull ช้ามาก

ย้ายบ้าน

หลังๆ GitLab เริ่มไม่นิ่ง ผมก็เลยว่าได้เวลาย้ายบ้านแล้ว พอดีนึกออกว่า GitHub (Edu) ให้ private repo ไม่อั้นมาเป็นปีแล้ว ก็เลยย้ายไป GitHub พร้อมๆ กับย้าย builder มาใช้ Container Builder ซึ่งเค้าว่ามันเร็ว

สำหรับ Container Builder มีสมบัติดังนี้ครับ

  • (เค้าว่า) มันเร็ว เพราะใช้ network ของ Google
  • Builder รันบนเครื่อง n1-standard-1 ซึ่งได้ Dedicated CPU Core เลย ในขณะที่ GitLab ใช้ DigitalOcean ที่จะ share core ระหว่างผู้ใช้
  • ผมเดาว่า builder อยู่ในอเมริกา ซึ่งผมก็ส่ง feature request ไปแล้วว่าอยากให้เลือกที่อื่นได้
  • Parallel build step ได้ แต่ parallel build ทั้งหมดไม่ได้ยกเว้นข้าม project
  • ราคาไม่แพงเท่าไร และฟรีวันละ 120 นาทีซึ่งผมยังไม่เคยใช้หมด
  • โค้ดเราจะโดน mirror จาก GitHub มาใส่ Google Cloud Source Repositories

ซึ่ง build ของ TMStreamlabs ก็ไม่มีอะไรอยู่แล้วครับ ใช้ Dockerfile ได้เลย แล้วก็จะมี test step อีกทีนึง (เรารัน test หลัง build เพราะต้องการ test ด้วย container ผลลัพท์จริงๆ ซึ่งจะจับบั๊กบางประเภทได้ด้วย เช่นลืมลง shared library) วิธีการก็ไม่ยากครับ ใน cloudbuild.yaml ก็เขียนไปว่า

steps:
- name: gcr.io/cloud-builders/docker
  args: ['build', '-t', 'asia.gcr.io/$PROJECT_ID/tmstreamlabs', '.']
- name: gcr.io/cloud-builders/docker
  entrypoint: /workspace/scripts/test.sh
  env:
    - IMAGE_NAME=asia.gcr.io/$PROJECT_ID/tmstreamlabs
images:
  - asia.gcr.io/$PROJECT_ID/tmstreamlabs

Shared workspace

ความเจ๋ง (?) ของ Container Builder คือ folder ที่ build จะแชร์กันข้าม build step ครับ จะต่างกับ GitLab ตรงที่ GitLab จะต้องบอกให้มันก๊อปออกมาเอง (เพราะอาจจะรัน build step ที่เครื่องอื่น) ฉะนั้นแล้วมันทำให้เราเล่นอะไรได้สะดวกมากๆ

ตอนหลังผมเลยจัดชุดใหญ่เลยครับ ด้วยการเพิ่ม Gulp + Webpack เข้ามาใน project Django ซึ่งเดิมทีผมตั้งคำถามหลายรอบว่าจะรัน build step แบบนี้ตรงไหนดี ก็พบว่า pattern แบบนี้แหละเวิร์คที่สุดแล้ว

steps:
- name: yarnpkg/node-yarn
  args: ['./scripts/minify.sh']
- name: gcr.io/cloud-builders/docker
  args: ['build', '-t', 'asia.gcr.io/$PROJECT_ID/tmstreamlabs', '.']
- name: gcr.io/cloud-builders/docker
  entrypoint: /workspace/scripts/test.sh
  env:
    - IMAGE_NAME=asia.gcr.io/$PROJECT_ID/tmstreamlabs
images:
  - asia.gcr.io/$PROJECT_ID/tmstreamlabs

สังเกตว่าเราจะเพิ่มอีก step ไปก่อนหน้าครับ โดยจะรัน minify.sh ซึ่งข้างในก็จะรัน yarn install + gulp แล้วก็ลบ node_modules ทิ้งไป

สำหรับในฝั่ง Django จะเซตใน settings.py ดังนี้ครับ

built_static_root = os.path.join(BASE_DIR, 'static')
if os.path.isdir(built_static_root):
    STATICFILES_DIRS = [built_static_root]

ซึ่ง script Gulp ของผมนั้นจะไป scan หา /static/js/ แล้ววิ่งผ่าน uglifyjs เข้าไปเก็บที่ static/js/* ด้านนอก ทำให้ตอน dev ก็จะเรียกไฟล์ไม่ minify เวลา compile แล้วก็จะเรียกไฟล์ minify ได้เลย และวิ่งผ่าน collectstatic ได้ด้วยทำให้สามารถใช้ Manifest static file storage ได้ (สำหรับไฟล์ที่ใช้ webpack จะเก็บแยกนอก static ครับ ซึ่งเวลา dev ต้องเปิด gulp watch ค้างไว้ให้มัน build ตลอด)

Parallel Step

ตอนแรกๆ Parallel step มีบั๊กครับ คือ schema จะ validate ไม่ผ่านเลย แต่ตอนนี้เหมือนจะแก้แล้ว ทำให้เราสามารถรัน step หลายๆ อันพร้อมกันได้ เช่นแบบนี้

steps:
- name: yarnpkg/node-yarn
  args: ['./scripts/minify.sh']
- name: gcr.io/cloud-builders/gsutil
  args: ['cp', '-r', 'gs://tmsbuildassets/', '/workspace/gs/']
  waitFor: ['-']
- name: gcr.io/cloud-builders/docker
  args: ['build', '-t', 'asia.gcr.io/$PROJECT_ID/tmstreamlabs', '.']

# prepull images in use
- name: gcr.io/cloud-builders/docker
  args: ['pull', 'mariadb:latest']
  waitFor: ['-']
- name: gcr.io/cloud-builders/docker
  args: ['pull', 'redis:alpine']
  waitFor: ['-']
- name: gcr.io/cloud-builders/docker
  args: ['pull', 'willwill/wait-for-it']
  waitFor: ['-']

- name: gcr.io/cloud-builders/docker
  entrypoint: /workspace/scripts/test.sh
  env:
    - IMAGE_NAME=asia.gcr.io/$PROJECT_ID/tmstreamlabs
images:
  - asia.gcr.io/$PROJECT_ID/tmstreamlabs

นี่คือทั้งไฟล์ที่ใช้อยู่แล้วครับ ซึ่งจะเห็นว่ามันจะ pull images ทั้งหมดมาพร้อมกันเลย และพอเราบอกว่ามันไม่ depends on อะไรเลยจะทำให้มัน pull พร้อมๆ กับรัน yarn เลย

อีกอันนึงที่จะเห็นนะครับ คือผมใช้ gsutil ใน build script ด้วย ซึ่งเจ้าเครื่อง container builder นี่จะมีสิทธิ์พิเศษใน IAM (จะเขียนว่า Google APIs service account อีเมล projectid@cloudbuild.gserviceaccount.com) ทำให้เราสามารถ grant สิทธิ์ให้เครื่อง builder โดยเฉพาะได้ ไม่ต้องเปิด public

ข้อเสีย

สำหรับตอนนี้ข้อเสียที่เจอคือ error reporting มันไม่ดีเลยครับ เพราะ build เสร็จมันจะเงียบไปเลย ในขณะที่ GitLab CI จะเมลสถานะกลับมา ตรงนี้อาจจะต้องเอา image อีกอันมา trigger service แจ้งเตือนอีกที ซึ่งก็ไม่รู้ว่ามันจะทำให้ระบบไม่หยุดทำงานตอนเจอ error ไปซะก่อนได้หรือเปล่า