Computer >> บทช่วยสอนคอมพิวเตอร์ >  >> การเขียนโปรแกรม >> Redis

สถาปัตยกรรม Serverless: AWS Lambda, Upstash Redis และ Go สำหรับแอปที่ขยายได้และคุ้มค่า

บทนำ

แพลตฟอร์มคำนวณแบบ Serverless นั้นยอดเยี่ยม แต่หากไม่มีฐานข้อมูลแบบ Serverless จะมีข้อจำกัดมากเกินไป

ขณะที่ผมกำลังสร้างแพลตฟอร์มสำหรับหลักสูตรที่จะเปิด قريب "The Elements of CI/CD" ผมต้องการฐานข้อมูลแบบ Serverless เนื่องจากตัดสินใจใช้ AWS Lambda เป็นเซิร์ฟเวอร์สำหรับงานบางอย่าง ข้อกำหนดที่ผมมีคือ:

  1. ราคาแบบจ่ายตามการใช้งาน (Pay as you go) ไม่ต้องการจ่ายรายชั่วโมงหรือรายโหนด แต่จ่ายตามการใช้งาน (คำขอ, พื้นที่จัดเก็บ ฯลฯ) ควรเริ่มต้นได้ในราคาถูกมาก และค่าใช้จ่ายเพิ่มขึ้นตามสัดส่วนเมื่อการใช้งานเพิ่มขึ้น
  2. เลย์เทนซีต่ำ (Low latency) ไม่มีใครชอบการตอบสนองช้า ดังนั้นการสืบค้นฐานข้อมูลควรเร็วจากภายในภูมิภาค AWS (เช่น eu-west-1)
  3. ประสบการณ์นักพัฒนาดีเยี่ยม (Great DevX) ต้องการอินเทอร์เฟซที่สะดวกสำหรับฐานข้อมูลโดยไม่ต้องเรียน DSL เฉพาะทางใหม่ หรือเสียเวลาหลายชั่วโมงกับการตั้งค่าเว็บไซต์

Upstash Redis ตอบสนองข้อกำหนดทั้งหมดข้างต้น และทำได้ดีมาก

  • จ่ายตามการใช้งาน?
    • ✅ เริ่มต้นได้ในราคาถูกมาก และราคาก็คุ้มค่าเมื่อขยายสเกล!
  • เลย์เทนซีต่ำ?
    • ✅ <1ms เมื่อสืบค้นจากภายใน AWS Lambda!
  • DevX ดี?
    • ✅ เป็น Redis มาตรฐาน แค่นั้นแหละ

ในบทความนี้เราจะดูวิธีใช้ Upstash Redis จากภายใน AWS Lambda ให้แน่ใจว่าเพียงพอต่อความต้องการของเรา ขณะเดียวกันก็รักษาโค้ดให้ดูแลรักษาได้ง่าย เพื่อทดสอบในเครื่องหรือปรับใช้บนแพลตฟอร์มอื่นได้

เราจะสร้างอะไร?

เพื่อความเรียบง่าย เราจะสร้างเพียง 3 จุดสิ้นสุด API:

  1. จุดสิ้นสุด GET|POST /login รับ userId เป็นพารามิเตอร์คิวรีใน GET หรือค่าฟอร์มใน POST จุดสิ้นสุดนี้จะสร้าง Session ID เก็บไว้ใน Redis และตั้งคุกกี้สำหรับการเข้าชมครั้งต่อไป GET ทำให้ง่ายต่อการทดสอบ 🙃
  2. จุดสิ้นสุด GET /lessons/completed ต้องการผู้ใช้ที่ล็อกอินแล้ว (มีคุกกี้พร้อม Session ID) และส่งคืน JSON พร้อมบทเรียนที่ผู้ใช้เรียนจบและเวลา
  3. จุดสิ้นสุด POST /lessons/{lessonSlug}/mark-complete ต้องการผู้ใช้ที่ล็อกอินแล้ว และทำเครื่องหมายว่าบทเรียนที่ระบุโดย lessonSlug เรียนจบแล้วพร้อมเวลาปัจจุบัน

หมายเหตุ: โค้ดด้านล่างขาดรายละเอียดบางอย่าง จึงไม่พร้อมสำหรับการใช้งานจริงโดยคัดลอกวางได้เลย เช่น ควรตรวจสอบว่า lessonSlug ที่ให้มามีอยู่จริงก่อนอัปเดต จุดสิ้นสุดล็อกอินควรยอมรับรหัสผ่านและตรวจสอบด้วย salt/hash ก่อนสร้าง Session ID ฯลฯ

1. การตั้งค่า

  • โค้ดทั้งหมดด้านล่างมีอยู่ในที่เก็บ aws-playground ของผม หากต้องการดูว่าทุกอย่างเข้ากันอย่างไร

ตามที่จะเห็น เราสร้าง 2 จุดเข้าสู่โปรแกรม (entrypoints) คือ 2 คำสั่งรันได้ หนึ่งสำหรับเซิร์ฟเวอร์ท้องถิ่นทั่วไป อีกหนึ่งสำหรับ AWS Lambda วิธีนี้ช่วยให้เราทดสอบตรรกะทั้งหมดในเครื่อง และเขียน unit/integration test ได้ตามปกติ

ความแตกต่างระหว่างทั้งสองมีเพียงในส่วน 1.2 และ 1.3 ด้านล่าง

1.1 พื้นที่ทำงาน (Workspace)

ก่อนดำเนินการเขียนโค้ด ให้ตั้งค่าไดเรกทอรีทำงานสำหรับ Go

  1. ดาวน์โหลด Go
  2. สร้างบัญชีและฐานข้อมูล Upstash Redis ในภูมิภาคที่ต้องการ ควรเป็นภูมิภาค AWS เดียวกับที่จะปรับใช้ Lambda ผมจะใช้ eu-west-1 (ยุโรป, ไอร์แลนด์) ในบทความนี้

สถาปัตยกรรม Serverless: AWS Lambda, Upstash Redis และ Go สำหรับแอปที่ขยายได้และคุ้มค่า

หลังทำขั้นตอนข้างต้นเสร็จ เราสามารถสร้างพื้นที่ทำงานได้ ต่อไปสมมติว่าโค้ดอยู่ที่ ~/dev/aws-lambda-upstash-redis

mkdir -p ~/dev/aws-lambda-upstash-redis
cd ~/dev/aws-lambda-upstash-redis

จากนั้นสร้างแพ็กเกจ Go:

go mod init com.upstash/example/aws-lambda-upstash-redis

1.2 จุดเข้าสู่โปรแกรมเซิร์ฟเวอร์ท้องถิ่น

  • วางโค้ดต่อไปนี้ใน ~/dev/aws-lambda-upstash-redis/cmd/server/main.go
package main
import (
    "log"
    "net/http"
    "os"
    "com.upstash/example/aws-lambda-upstash-redis/core"
)
func main() {
    mux := core.NewMux()
    port := os.Getenv("PORT")
    if len(port) == 0 {
        port = "5000"
    }
    if err := http.ListenAndServe(":"+port, mux); err != nil {
        log.Fatal(err)
    }
}

1.3 จุดเข้าสู่โปรแกรม AWS Lambda

  • วางโค้ดต่อไปนี้ใน ~/dev/aws-lambda-upstash-redis/cmd/lambda/main.go
package main
import (
    "com.upstash/example/aws-lambda-upstash-redis/core"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/awslabs/aws-lambda-go-api-proxy/httpadapter"
)
func main() {
    mux := core.NewMux()
    lambda.Start(httpadapter.NewV2(mux).ProxyWithContext)
}

1.4 ตรรกะหลัก (Core Logic)

ตรรกะหลักจะอยู่ในแพ็กเกจ core เพื่อให้ทั้งสองจุดเข้าสู่โปรแกรมใช้ร่วมกัน

  • วางโค้ดต่อไปนี้ใน ~/dev/aws-lambda-upstash-redis/core/lib.go
package core
import (
    "github.com/go-chi/chi/v5"
)
func NewMux() *chi.Mux {
    r := chi.NewRouter()
    return r
}

1.5 การสร้าง/คอมไพล์ (Building / Compiling)

ผมมักเขียน makefile เล็กๆ เพื่อไม่ต้องพิมพ์คำสั่งยาวทุกครั้งที่ต้องการคอมไพล์ คัดลอกต่อไปนี้ไปที่ ~/dev/aws-lambda-upstash-redis/makefile:

default: build
clean: rm -rf build/
build: build-lambda build-server
build-lambda: clean
    GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o build/handler cmd/lambda/main.go
    cd build/ && zip handler.zip ./handler
build-server: clean
    CGO_ENABLED=0 go build -o build/server cmd/server/main.go

ไม่ต้องกังวลรายละเอียดตอนนี้ แค่รู้ว่าทำให้เราสามารถ:

  • make build-server: สร้างไบนารีสำหรับรันเซิร์ฟเวอร์ในเครื่อง (ไฟล์ ./build/server)
  • make build-lambda: สร้างไบนารีสำหรับรันบน AWS Lambda (ไฟล์ ./build/handler และ ./build/handler.zip)
  • make หรือ make build ทำทั้งสองอย่าง

ตัวเลือก CGO_ENABLED=0 ทำให้ไบนารีเป็น static compile (ไม่พึ่งไลบรารีภายนอก) ตัวเลือก GOOS=linux GOARCH=amd64 จำเป็นสำหรับ cross-compile ให้ตรงกับสภาพแวดล้อม Linux ของ AWS Lambda หากพัฒนาบน Mac หรือ Windows

ต่อไป รัน go mod tidy เพื่อดึง dependency ทั้งหมด จำเป็นต้องรันทุกครั้งที่เพิ่มหรือลบ dependency

สุดท้าย รัน make ครั้งหนึ่ง เพื่อสร้างทุกอย่างและตรวจสอบว่าพื้นที่ทำงานพร้อม ก่อนจะเข้าสู่รายละเอียดโค้ด

2. การนำ API ไปใช้งาน

ในส่วนนี้เราจะทำงานในไฟล์ ~/dev/aws-lambda-upstash-redis/core/lib.go เสมอ

โค้ดต่อไปนี้กำหนดจุดสิ้นสุด API ที่กล่าวไว้ โดยใช้ไลบรารี go-chi ที่ยอดเยี่ยม

import (
    //...
    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
)
func NewMux() *chi.Mux {
    r := chi.NewRouter()
    r.Use(middleware.RequestID)
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Get("/login", login)
    r.Post("/login", login)
    r.Group(func(r chi.Router) {
        r.Use(UsersWithSessionOnly)
        r.Get("/lessons/completed", listLessonsCompleted)
        r.Post("/lessons/{lessonSlug}/mark-complete", markLessonComplete)
    })
    return r
}

ในสนิปเปตข้างต้น r.Group(...) สร้างเลเยอร์ร่วมเพื่อใช้ middleware ปกติสำหรับเส้นทางที่กำหนดภายใน ที่นี่เราเพิ่ม middleware ของเราเอง UsersWithSessionOnly ซึ่งจะรับประกันว่าคำขอมีคุกกี้พร้อม Session ID ที่ใช้งานอยู่

2.1 Middleware UsersWithSessionOnly

ใน middleware นี้เราต้องการ:

  1. ดึงคุกกี้ที่มี Session ID หากไม่มีให้ล้มเหลว
  2. สืบค้น Redis เพื่อดึงรายละเอียดผู้ใช้จาก Session ID หาก Session ID ไม่ใช้งานอยู่ให้ล้มเหลว
  3. เก็บ User ID ไว้ใน context.Context ของคำขอ เพื่อให้ middleware หรือ handler ต่อไปเข้าถึงได้

ก่อนอื่น ต้องมีโค้ด boilerplate สำหรับ import และนิยามที่ใช้ทุกที่

import (
    //...
    "log"
    "os"
    "strings"
    "github.com/go-redis/redis/v8"
)
type contextKey struct {
    name string
}
const (
    COOKIE_AUTH_NAME = "xxx_session_id"
)
var (
    CTX_USER_ID = &contextKey{"LoggedInUserId"}
    redisDb = NewClient()
)
func NewClient() *redis.Client {
    redisUrl := strings.TrimSpace(os.Getenv("UPSTASH_REDIS_URL"))
    if redisUrl == "" {
        log.Fatalln("Required env UPSTASH_REDIS_URL not set!")
    }
    opt, _ := redis.ParseURL(redisUrl)
    redisDb := redis.NewClient(opt)
    return redisDb
}

และนี่คือตรรกะหลักของ middleware การยืนยันตัวตน

// Middleware UsersWithSessionOnly จำกัดการเข้าถึงเฉพาะผู้ใช้ที่ล็อกอินแล้ว
// หากตรวจสอบผ่าน context จะมี user id (CTX_USER_ID)
func UsersWithSessionOnly(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        c, err := r.Cookie(COOKIE_AUTH_NAME)
        if err != nil {
            render.Status(r, http.StatusForbidden)
            render.JSON(w, r, struct{}{})
            return
        }
        ctx := r.Context()
        userId, err := redisDb.Get(ctx, "session:"+c.Value).Result()
        if err == redis.Nil {
            // หา session ไม่เจอ = ห้ามเข้าถึง API!
            render.Status(r, http.StatusForbidden)
            render.JSON(w, r, struct{}{})
            return
        } else if err != nil {
            // เกิดข้อผิดพลาดขณะสืบค้น Redis!
            render.Status(r, http.StatusInternalServerError)
            render.JSON(w, r, struct{ Message string }{Message: "We could not validate the provided session ID"})
            return
        }
        // ตั้งค่าไว้ให้ middleware และ handler ต่อไปใช้
        next.ServeHTTP(w, r.WithContext(context.WithValue(ctx, CTX_USER_ID, userId)))
    })
}

2.2 markLessonComplete(...)

การดำเนินการตรงไปตรงมา: เก็บใน Redis ว่าบทเรียนที่ระบุโดยพารามิเตอร์เส้นทาง lessonSlug เรียนจบแล้วที่เวลาปัจจุบันของคำขอ

ใน Redis เราจะเก็บแผนที่ (map) สำหรับแต่ละผู้ใช้ โดยแต่ละคู่ key-value ในแผนที่จะเป็นบทเรียนเป็น key และวันที่เรียนจบเป็น value ดังนั้นจึงใช้คำสั่ง Redis HSET วิธีนี้ง่ายกว่าการเก็บ key แยกต่อบทเรียน เพราะดึงบทเรียนทั้งหมดของผู้ใช้ได้ครั้งเดียวทีหลัง

func markLessonComplete(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    lessonSlug := chi.URLParam(r, "lessonSlug")
    userId := r.Context().Value(CTX_USER_ID).(string)
    timeNow := time.Now().Format(time.RFC3339)
    err := redisDb.HSet(ctx, "lessons:"+userId, lessonSlug, timeNow).Err()
    if err != nil {
        render.Status(r, http.StatusInternalServerError)
        render.JSON(w, r, struct{ Message string }{Message: "We could not save your progression..."})
        return
    }
    render.JSON(w, r, struct {
        LessonSlug    string
        LastCompleted string
    }{ lessonSlug, timeNow, })
}

2.3 listLessonsCompleted(...)

คล้ายส่วนก่อนหน้า เพียงแต่ส่งคืนแผนที่บทเรียนที่เรียนจบทั้งหมดในรูป JSON ใช้คำสั่ง HGETALL

func listLessonsCompleted(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    userId := r.Context().Value(CTX_USER_ID).(string)
    lessons, err := redisDb.HGetAll(ctx, "lessons:"+userId).Result()
    if err == redis.Nil {
        lessons = map[string]string{}
    } else if err != nil {
        render.Status(r, http.StatusInternalServerError)
        render.JSON(w, r, struct{ Message string }{Message: "We could not load your lessons..."})
        return
    }
    render.JSON(w, r, struct {
        Lessons map[string]string
    }{ lessons, })
}

2.4 login(...)

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

Session ID สร้างโดยไลบรารี ksuid ซึ่งมีข้อได้เปรียบเหนือ UUID ทั่วไป และถือว่าทำงานได้ 1 ชั่วโมง ใช้ฟังก์ชัน time-to-live ของคำสั่ง Redis SET เพื่อลบออกจากฐานข้อมูลอัตโนมัติหลัง 1 ชั่วโมง

import (
    // ...
    "github.com/segmentio/ksuid"
)
func login(w http.ResponseWriter, r *http.Request) {
    // ตรวจสอบข้อมูลประจำตัวและอัปเดต Redis session แล้วส่งคืน Set-Cookie
    // คำเตือน: ควรตรวจสอบข้อมูลประจำตัวจริงใน production!
    // ...
    // ตอนนี้สมมติว่าถูกต้องเสมอ และสร้าง session token อัตโนมัติ
    // โดยบันทึกลง Redis และตั้งเป็นคุกกี้
    userId := strings.TrimSpace(r.FormValue("userId"))
    if userId == "" {
        render.Status(r, http.StatusBadRequest)
        render.JSON(w, r, struct{ Message string }{Message: "Missing required userId"})
        return
    }
    sessionId := ksuid.New()
    redisDb.Set(r.Context(), "session:"+sessionId.String(), userId, time.Hour*1)
    http.SetCookie(w, &http.Cookie{
        Name:     COOKIE_AUTH_NAME,
        Value:    sessionId.String(),
        Path:     "/",
        MaxAge:   int((time.Hour * 1).Seconds()),
        // ควรเป็น true เมื่อปรับใช้ production (https) แต่ในเครื่องต้องเป็น false (http)
        Secure:   false,
    })
    http.Redirect(w, r, "/lessons/completed", http.StatusTemporaryRedirect)
}

3. สาธิต - ในเครื่อง (Locally)

เฮ้ย โค้ดเยอะมาก 😅

ลองสาธิตดูเร็วๆ ว่าทุกอย่างทำงานได้ตามคาด

  • ก่อนอื่น ตั้งตัวแปรสภาพแวดล้อม UPSTASH_REDIS_URL เป็น URL ของฐานข้อมูลที่สร้างในส่วน 1 หาได้ที่แท็บ details ของหน้าฐานข้อมูล (ดูส่วน 1.1)
export UPSTASH_REDIS_URL="<your-url-here>"
  • จากนั้นสร้างและรันเซิร์ฟเวอร์ท้องถิ่น:
make build-server && ./build/server

การทดสอบผ่านเบราว์เซอร์

ทดสอบในเบราว์เซอร์โดยเข้า https://localhost:5000/lessons/completed

สถาปัตยกรรม Serverless: AWS Lambda, Upstash Redis และ Go สำหรับแอปที่ขยายได้และคุ้มค่า

ได้ 403 - Forbidden มาล็อกอินกันที่ https://localhost:5000/login?userId=lambros

สถาปัตยกรรม Serverless: AWS Lambda, Upstash Redis และ Go สำหรับแอปที่ขยายได้และคุ้มค่า

ล็อกอินสำเร็จ และถูก redirect ไป /lessons/completed อัตโนมัติ แต่ยังว่างอยู่ ลองทำเครื่องหมายบทเรียนว่าเรียนจบ ในแท็บ console ของ devtools รัน:

await ( await fetch("https://localhost:5000/lessons/123/mark-complete", {
    method: "POST",
    credentials: "same-origin",
}) ).json();
// ควรได้ประมาณ:
// {LessonSlug: '123', LastCompleted: '2022-10-12T02:01:14+03:00'}

เข้า https://localhost:5000/lessons/completed อีกครั้ง จะเห็นบทเรียนถูกทำเครื่องหมายแล้ว:

{
    "Lessons": {
        "123": "2022-10-12T02:01:14+03:00"
    }
}

เสร็จสิ้น ทุกอย่างทำงานได้ดี!

ดูในฐานข้อมูล Redis เองผ่าน Data Browser ที่เพิ่งเปิดตัวก็ยืนยันว่าข้อมูลที่คาดไว้อยู่ครบถ้วน

สถาปัตยกรรม Serverless: AWS Lambda, Upstash Redis และ Go สำหรับแอปที่ขยายได้และคุ้มค่า

4. AWS Lambda

เพื่อทดสอบและปรับใช้บน AWS Lambda เราจะใช้ sam cli

  • ติดตั้ง SAM cli และตรวจสอบว่า user/role มีสิทธิ์เหมาะสม
  • SAM cli ต้องการเทมเพลต Cloudformation คัดลอกต่อไปนี้ไปที่ aws-iac/sam-template.yml:
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Defines all the AWS resources we need for our Upstash Redis API.
Resources:
  GoUpstashRedis:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: ../build/handler.zip
      Handler: handler
      Runtime: go1.x
      MemorySize: 512
      FunctionUrlConfig:
        AuthType: NONE
        Cors:
          AllowCredentials: false
          AllowMethods: ["*"]
          AllowOrigins: ["*"]
Outputs:
  GoUpstashRedisApi:
    Description: "Endpoint URL"
    Value: !GetAtt GoUpstashRedisUrl.FunctionUrl
  GoUpstashRedis:
    Description: "Lambda Function ARN"
    Value: !GetAtt GoUpstashRedis.Arn
  GoUpstashRedisIamRole:
    Description: "Implicit IAM Role created for GoUpstashRedis"
    Value: !GetAtt GoUpstashRedisRole.Arn
  • สร้าง handler bundle สำหรับ AWS Lambda:
make build-lambda
  • เพิ่มต่อไปนี้ใน makefile เพื่อปรับใช้ง่ายหลังเปลี่ยนโค้ด:
sam-deploy: build-lambda
    sam deploy -t aws-iac/sam-template.yml --stack-name "UpstashRedisGoArticleStackDemo" --region eu-west-1 --resolve-s3 --no-confirm-changeset --no-fail-on-empty-changeset --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM CAPABILITY_AUTO_EXPAND
  • ปรับใช้ในภูมิภาคที่ระบุ (ดูคำสั่งก่อนหน้า)
make sam-deploy
  • ควรได้ผลลัพธ์ประมาณนี้:
CloudFormation outputs from deployed stack
-----------------------------------------------------------------------------------------------------------------------------------------------------------
Outputs
-----------------------------------------------------------------------------------------------------------------------------------------------------------
Key                 GoUpstashRedis
Description         Lambda Function ARN
Value               arn:aws:lambda:eu-west-1:<redacted>:function:UpstashRedisGoArticleStackDem-GoUpstashRedis-baB8dQPkTfg0
Key                 GoUpstashRedisIamRole
Description         Implicit IAM Role created for GoUpstashRedis
Value               arn:aws:iam::<redacted>:role/UpstashRedisGoArticleStac-GoUpstashRedisRole-16UWC7HR6KII8
Key                 GoUpstashRedisApi
Description         Endpoint URL
Value               https://6pmmwqmg5vec3bcsldabckaf5i0nlgje.lambda-url.eu-west-1.on.aws/
-----------------------------------------------------------------------------------------------------------------------------------------------------------
Successfully created/updated stack - UpstashRedisGoArticleStackDemo in eu-west-1
  • URL ของ AWS Lambda ที่ปรับใช้แล้วแสดงใน output เช่น https://6pmmwqmg5vec3bcsldabckaf5i0nlgje.lambda-url.eu-west-1.on.aws/ ลองทำซ้ำขั้นตอนสาธิตในเบราว์เซอร์โดยแทน localhost ด้วยโดเมนจริง
    • หรือหา URL ได้จาก output ของ CloudFormation stack UpstashRedisGoArticleStackDemo ในคอนโซล CloudFormation
    • หมายเหตุ: ต้องตั้งตัวแปรสภาพแวดล้อม UPSTASH_REDIS_URL ในการตั้งค่า AWS Lambda ด้วย มิฉะนั้นจะ crash ไปที่คอนโซล AWS Lambda → คลิก Lambda ที่เพิ่งปรับใช้ → แท็บ Configuration → เมนูซ้าย Environment variables ใส่ key UPSTASH_REDIS_URL และ value เป็น Upstash Redis URL คลิก Save Lambda ก็พร้อมใช้งาน

4.1 การทดสอบ SAM ในเครื่อง

สามารถทดสอบ Lambda ในเครื่องได้โดยให้ไฟล์ sample-event.json ที่มี path/cookie/query parameters ถูกต้อง ตัวอย่าง JSON อยู่ที่ aws-lambda-upstash-redis-article/sample-event.json

  • เมื่อมีไฟล์ event JSON ที่ถูกต้องแล้ว รันคำสั่งต่อไปนี้เพื่อเรียกใช้ตรรกะเซิร์ฟเวอร์เหมือนบน AWS Lambda:
sam local invoke -t aws-iac/sam-template.yml -e sample-event.json

4.2 ความปลอดภัยของ Upstash Redis URL

ในบทความนี้ เพื่อความเรียบง่าย เราส่ง Upstash Redis URL (รวมรหัสผ่าน) ผ่านตัวแปรสภาพแวดล้อม ไม่ต้องการ hardcode ลงใน SAM Cloudformation template ที่มี version control ร่วมกับโค้ด จึงต้องตั้งค่าผ่านคอนโซล AWS Lambda ด้วยมือ

มีวิธีดีกว่านี้: ใช้ AWS Systems Manager Parameter Store และทรัพยากร Cloudformation AWS::SSM::Parameter เก็บ URL (ตั้งค่าครั้งเดียวและคงไว้ระหว่างการปรับใช้) และเปลี่ยนโค้ด Lambda ให้ดึงค่าพารามิเตอร์ตอนรันไทม์ หรือ inject เป็น env variable ใน sam-template.yml แบบอัตโนมัติ (แต่ยังคงเป็น plain text ในคอนโซล)

การเปลี่ยนโค้ดให้ดึงจาก SSM Parameter Store ทำได้ง่ายเพราะเราแยก entrypoints ไว้แล้ว จึงสามารถดึงพารามิเตอร์เฉพาะตอนรันใน AWS Lambda (~/dev/aws-lambda-upstash-redis/cmd/lambda/main.go) และส่งต่อให้ฟังก์ชัน NewClient() ที่สร้าง Redis client

5. เร็วแค่ไหน?

นอกจากการเรียกครั้งแรก (cold start) ที่ใช้ประมาณ 100-120 ms ทุกการเรียกต่อมานั้นเร็วมากและอยู่ภายใต้ 4 ms เสมอ

ด้านล่างคือตัวอย่าง hot invocation สำหรับจุดสิ้นสุด /login?userId=lambros ที่นำไปใช้ข้างต้น:

สถาปัตยกรรม Serverless: AWS Lambda, Upstash Redis และ Go สำหรับแอปที่ขยายได้และคุ้มค่า

เห็นได้ว่า total duration ของคำขอคือ 2.06 ms ใช่แล้ว สองมิลลิวินาที สำหรับการสร้าง Session ID เขียนลง Upstash Redis แบบ remote และส่งคืนการ redirect

ดูส่วน Log output อย่างละเอียด จะเห็นว่าคำขอใช้เวลา 789.435μs จากมุมมองของโค้ดเรา หมายความว่าตรรกะของเราเสร็จภายใน 1 ms (ประมาณ 0.790 ms) น่าทึ่งมากสำหรับการใช้ฐานข้อมูล remote 🤯

สรุป

ผมหैरประหลาดใจมากกับประสิทธิภาพของ Upstash Redis โดยเฉพาะเพราะยากที่จะหาประสิทธิภาพแบบนี้สำหรับฐานข้อมูล Serverless ที่เหมาะกับแพลตฟอร์มอย่าง AWS Lambda

Redis API สะดวกสบาย Upstash Redis มีโมเดลราคาที่ยอดเยี่ยม DevX เยี่ยม และเร็ว ผมชอบการผสมผสานนี้มาก!

AWS Lambda + Upstash Redis + Go = 🚀❤️

บทนำ

แพลตฟอร์มคำนวณแบบ Serverless นั้นยอดเยี่ยม แต่หากไม่มีฐานข้อมูลแบบ Serverless จะมีข้อจำกัดมากเกินไป

ขณะที่ผมกำลังสร้างแพลตฟอร์มสำหรับหลักสูตรที่จะเปิด قريب "The Elements of CI/CD" ผมต้องการฐานข้อมูลแบบ Serverless เนื่องจากตัดสินใจใช้ AWS Lambda เป็นเซิร์ฟเวอร์สำหรับงานบางอย่าง ข้อกำหนดที่ผมมีคือ:

  1. ราคาแบบจ่ายตามการใช้งาน (Pay as you go) ไม่ต้องการจ่ายรายชั่วโมงหรือรายโหนด แต่จ่ายตามการใช้งาน (คำขอ, พื้นที่จัดเก็บ ฯลฯ) ควรเริ่มต้นได้ในราคาถูกมาก และค่าใช้จ่ายเพิ่มขึ้นตามสัดส่วนเมื่อการใช้งานเพิ่มขึ้น
  2. เลย์เทนซีต่ำ (Low latency) ไม่มีใครชอบการตอบสนองช้า ดังนั้นการสืบค้นฐานข้อมูลควรเร็วจากภายในภูมิภาค AWS (เช่น eu-west-1)
  3. ประสบการณ์นักพัฒนาดีเยี่ยม (Great DevX) ต้องการอินเทอร์เฟซที่สะดวกสำหรับฐานข้อมูลโดยไม่ต้องเรียน DSL เฉพาะทางใหม่ หรือเสียเวลาหลายชั่วโมงกับการตั้งค่าเว็บไซต์

Upstash Redis ตอบสนองข้อกำหนดทั้งหมดข้างต้น และทำได้ดีมาก

  • จ่ายตามการใช้งาน?
    • ✅ เริ่มต้นได้ในราคาถูกมาก และราคาก็คุ้มค่าเมื่อขยายสเกล!
  • เลย์เทนซีต่ำ?
    • ✅ <1ms เมื่อสืบค้นจากภายใน AWS Lambda!
  • DevX ดี?
    • ✅ เป็น Redis มาตรฐาน แค่นั้นแหละ

ในบทความนี้เราจะดูวิธีใช้ Upstash Redis จากภายใน AWS Lambda ให้แน่ใจว่าเพียงพอต่อความต้องการของเรา ขณะเดียวกันก็รักษาโค้ดให้ดูแลรักษาได้ง่าย เพื่อทดสอบในเครื่องหรือปรับใช้บนแพลตฟอร์มอื่นได้

เราจะสร้างอะไร?

เพื่อความเรียบง่าย เราจะสร้างเพียง 3 จุดสิ้นสุด API:

  1. จุดสิ้นสุด GET|POST /login รับ userId เป็นพารามิเตอร์คิวรีใน GET หรือค่าฟอร์มใน POST จุดสิ้นสุดนี้จะสร้าง Session ID เก็บไว้ใน Redis และตั้งคุกกี้สำหรับการเข้าชมครั้งต่อไป GET ทำให้ง่ายต่อการทดสอบ 🙃
  2. จุดสิ้นสุด GET /lessons/completed ต้องการผู้ใช้ที่ล็อกอินแล้ว (มีคุกกี้พร้อม Session ID) และส่งคืน JSON พร้อมบทเรียนที่ผู้ใช้เรียนจบและเวลา
  3. จุดสิ้นสุด POST /lessons/{lessonSlug}/mark-complete ต้องการผู้ใช้ที่ล็อกอินแล้ว และทำเครื่องหมายว่าบทเรียนที่ระบุโดย lessonSlug เรียนจบแล้วพร้อมเวลาปัจจุบัน

หมายเหตุ: โค้ดด้านล่างขาดรายละเอียดบางอย่าง จึงไม่พร้อมสำหรับการใช้งานจริงโดยคัดลอกวางได้เลย เช่น ควรตรวจสอบว่า lessonSlug ที่ให้มามีอยู่จริงก่อนอัปเดต จุดสิ้นสุดล็อกอินควรยอมรับรหัสผ่านและตรวจสอบด้วย salt/hash ก่อนสร้าง Session ID ฯลฯ

1. การตั้งค่า

  • โค้ดทั้งหมดด้านล่างมีอยู่ในที่เก็บ aws-playground ของผม หากต้องการดูว่าทุกอย่างเข้ากันอย่างไร

ตามที่จะเห็น เราสร้าง 2 จุดเข้าสู่โปรแกรม (entrypoints) คือ 2 คำสั่งรันได้ หนึ่งสำหรับเซิร์ฟเวอร์ท้องถิ่นทั่วไป อีกหนึ่งสำหรับ AWS Lambda วิธีนี้ช่วยให้เราทดสอบตรรกะทั้งหมดในเครื่อง และเขียน unit/integration test ได้ตามปกติ

ความแตกต่างระหว่างทั้งสองมีเพียงในส่วน 1.2 และ 1.3 ด้านล่าง

1.1 พื้นที่ทำงาน (Workspace)

ก่อนดำเนินการเขียนโค้ด ให้ตั้งค่าไดเรกทอรีทำงานสำหรับ Go

  1. ดาวน์โหลด Go
  2. สร้างบัญชีและฐานข้อมูล Upstash Redis ในภูมิภาคที่ต้องการ ควรเป็นภูมิภาค AWS เดียวกับที่จะปรับใช้ Lambda ผมจะใช้ eu-west-1 (ยุโรป, ไอร์แลนด์) ในบทความนี้

สถาปัตยกรรม Serverless: AWS Lambda, Upstash Redis และ Go สำหรับแอปที่ขยายได้และคุ้มค่า

หลังทำขั้นตอนข้างต้นเสร็จ เราสามารถสร้างพื้นที่ทำงานได้ ต่อไปสมมติว่าโค้ดอยู่ที่ ~/dev/aws-lambda-upstash-redis

mkdir -p ~/dev/aws-lambda-upstash-redis
cd ~/dev/aws-lambda-upstash-redis

จากนั้นสร้างแพ็กเกจ Go:

go mod init com.upstash/example/aws-lambda-upstash-redis

1.2 จุดเข้าสู่โปรแกรมเซิร์ฟเวอร์ท้องถิ่น

  • วางโค้ดต่อไปนี้ใน ~/dev/aws-lambda-upstash-redis/cmd/server/main.go
package main
import (
    "log"
    "net/http"
    "os"
    "com.upstash/example/aws-lambda-upstash-redis/core"
)
func main() {
    mux := core.NewMux()
    port := os.Getenv("PORT")
    if len(port) == 0 {
        port = "5000"
    }
    if err := http.ListenAndServe(":"+port, mux); err != nil {
        log.Fatal(err)
    }
}

1.3 จุดเข้าสู่โปรแกรม AWS Lambda

  • วางโค้ดต่อไปนี้ใน ~/dev/aws-lambda-upstash-redis/cmd/lambda/main.go
package main
import (
    "com.upstash/example/aws-lambda-upstash-redis/core"
    "github.com/aws/aws-lambda-go/lambda"
    "github.com/awslabs/aws-lambda-go-api-proxy/httpadapter"
)
func main() {
    mux := core.NewMux()
    lambda.Start(httpadapter.NewV2(mux).ProxyWithContext)
}

1.4 ตรรกะหลัก (Core Logic)

ตรรกะหลักจะอยู่ในแพ็กเกจ core เพื่อให้ทั้งสองจุดเข้าสู่โปรแกรมใช้ร่วมกัน

  • วางโค้ดต่อไปนี้ใน ~/dev/aws-lambda-upstash-redis/core/lib.go
package core
import (
    "github.com/go-chi/chi/v5"
)
func NewMux() *chi.Mux {
    r := chi.NewRouter()
    return r
}

1.5 การสร้าง/คอมไพล์ (Building / Compiling)

ผมมักเขียน makefile เล็กๆ เพื่อไม่ต้องพิมพ์คำสั่งยาวทุกครั้งที่ต้องการคอมไพล์ คัดลอกต่อไปนี้ไปที่ ~/dev/aws-lambda-upstash-redis/makefile:

default: build
clean: rm -rf build/
build: build-lambda build-server
build-lambda: clean
    GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -o build/handler cmd/lambda/main.go
    cd build/ && zip handler.zip ./handler
build-server: clean
    CGO_ENABLED=0 go build -o build/server cmd/server/main.go

ไม่ต้องกังวลรายละเอียดตอนนี้ แค่รู้ว่าทำให้เราสามารถ:

  • make build-server: สร้างไบนารีสำหรับรันเซิร์ฟเวอร์ในเครื่อง (ไฟล์ ./build/server)
  • make build-lambda: สร้างไบนารีสำหรับรันบน AWS Lambda (ไฟล์ ./build/handler และ ./build/handler.zip)
  • make หรือ make build ทำทั้งสองอย่าง

ตัวเลือก CGO_ENABLED=0 ทำให้ไบนารีเป็น static compile (ไม่พึ่งไลบรารีภายนอก) ตัวเลือก GOOS=linux GOARCH=amd64 จำเป็นสำหรับ cross-compile ให้ตรงกับสภาพแวดล้อม Linux ของ AWS Lambda หากพัฒนาบน Mac หรือ Windows

ต่อไป รัน go mod tidy เพื่อดึง dependency ทั้งหมด จำเป็นต้องรันทุกครั้งที่เพิ่มหรือลบ dependency

สุดท้าย รัน make ครั้งหนึ่ง เพื่อสร้างทุกอย่างและตรวจสอบว่าพื้นที่ทำงานพร้อม ก่อนจะเข้าสู่รายละเอียดโค้ด

2. การนำ API ไปใช้งาน

ในส่วนนี้เราจะทำงานในไฟล์ ~/dev/aws-lambda-upstash-redis/core/lib.go เสมอ

โค้ดต่อไปนี้กำหนดจุดสิ้นสุด API ที่กล่าวไว้ โดยใช้ไลบรารี go-chi ที่ยอดเยี่ยม

import (
    //...
    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
)
func NewMux() *chi.Mux {
    r := chi.NewRouter()
    r.Use(middleware.RequestID)
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Get("/login", login)
    r.Post("/login", login)
    r.Group(func(r chi.Router) {
        r.Use(UsersWithSessionOnly)
        r.Get("/lessons/completed", listLessonsCompleted)
        r.Post("/lessons/{lessonSlug}/mark-complete", markLessonComplete)
    })
    return r
}

ในสนิปเปตข้างต้น r.Group(...) สร้างเลเยอร์ร่วมเพื่อใช้ middleware ปกติสำหรับเส้นทางที่กำหนดภายใน ที่นี่เราเพิ่ม middleware ของเราเอง UsersWithSessionOnly ซึ่งจะรับประกันว่าคำขอมีคุกกี้พร้อม Session ID ที่ใช้งานอยู่

2.1 Middleware UsersWithSessionOnly

ใน middleware นี้เราต้องการ:

  1. ดึงคุกกี้ที่มี Session ID หากไม่มีให้ล้มเหลว
  2. สืบค้น Redis เพื่อดึงรายละเอียดผู้ใช้จาก Session ID หาก Session ID ไม่ใช้งานอยู่ให้ล้มเหลว
  3. เก็บ User ID ไว้ใน context.Context ของคำขอ เพื่อให้ middleware หรือ handler ต่อไปเข้าถึงได้

ก่อนอื่น ต้องมีโค้ด boilerplate สำหรับ import และนิยามที่ใช้ทุกที่

import (
    //...
    "log"
    "os"
    "strings"
    "github.com/go-redis/redis/v8"
)
type contextKey struct {
    name string
}
const (
    COOKIE_AUTH_NAME = "xxx_session_id"
)
var (
    CTX_USER_ID = &contextKey{"LoggedInUserId"}
    redisDb = NewClient()
)
func NewClient() *redis.Client {
    redisUrl := strings.TrimSpace(os.Getenv("UPSTASH_REDIS_URL"))
    if redisUrl == "" {
        log.Fatalln("Required env UPSTASH_REDIS_URL not set!")
    }
    opt, _ := redis.ParseURL(redisUrl)
    redisDb := redis.NewClient(opt)
    return redisDb
}

และนี่คือตรรกะหลักของ middleware การยืนยันตัวตน

// Middleware UsersWithSessionOnly จำกัดการเข้าถึงเฉพาะผู้ใช้ที่ล็อกอินแล้ว
// หากตรวจสอบผ่าน context จะมี user id (CTX_USER_ID)
func UsersWithSessionOnly(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        c, err := r.Cookie(COOKIE_AUTH_NAME)
        if err != nil {
            render.Status(r, http.StatusForbidden)
            render.JSON(w, r, struct{}{})
            return
        }
        ctx := r.Context()
        userId, err := redisDb.Get(ctx, "session:"+c.Value).Result()
        if err == redis.Nil {
            // หา session ไม่เจอ = ห้ามเข้าถึง API!
            render.Status(r, http.StatusForbidden)
            render.JSON(w, r, struct{}{})
            return
        } else if err != nil {
            // เกิดข้อผิดพลาดขณะสืบค้น Redis!
            render.Status(r, http.StatusInternalServerError)
            render.JSON(w, r, struct{ Message string }{Message: "We could not validate the provided session ID"})
            return
        }
        // ตั้งค่าไว้ให้ middleware และ handler ต่อไปใช้
        next.ServeHTTP(w, r.WithContext(context.WithValue(ctx, CTX_USER_ID, userId)))
    })
}

2.2 markLessonComplete(...)

การดำเนินการตรงไปตรงมา: เก็บใน Redis ว่าบทเรียนที่ระบุโดยพารามิเตอร์เส้นทาง lessonSlug เรียนจบแล้วที่เวลาปัจจุบันของคำขอ

ใน Redis เราจะเก็บแผนที่ (map) สำหรับแต่ละผู้ใช้ โดยแต่ละคู่ key-value ในแผนที่จะเป็นบทเรียนเป็น key และวันที่เรียนจบเป็น value ดังนั้นจึงใช้คำสั่ง Redis HSET วิธีนี้ง่ายกว่าการเก็บ key แยกต่อบทเรียน เพราะดึงบทเรียนทั้งหมดของผู้ใช้ได้ครั้งเดียวทีหลัง

func markLessonComplete(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    lessonSlug := chi.URLParam(r, "lessonSlug")
    userId := r.Context().Value(CTX_USER_ID).(string)
    timeNow := time.Now().Format(time.RFC3339)
    err := redisDb.HSet(ctx, "lessons:"+userId, lessonSlug, timeNow).Err()
    if err != nil {
        render.Status(r, http.StatusInternalServerError)
        render.JSON(w, r, struct{ Message string }{Message: "We could not save your progression..."})
        return
    }
    render.JSON(w, r, struct {
        LessonSlug    string
        LastCompleted string
    }{ lessonSlug, timeNow, })
}

2.3 listLessonsCompleted(...)

คล้ายส่วนก่อนหน้า เพียงแต่ส่งคืนแผนที่บทเรียนที่เรียนจบทั้งหมดในรูป JSON ใช้คำสั่ง HGETALL

func listLessonsCompleted(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    userId := r.Context().Value(CTX_USER_ID).(string)
    lessons, err := redisDb.HGetAll(ctx, "lessons:"+userId).Result()
    if err == redis.Nil {
        lessons = map[string]string{}
    } else if err != nil {
        render.Status(r, http.StatusInternalServerError)
        render.JSON(w, r, struct{ Message string }{Message: "We could not load your lessons..."})
        return
    }
    render.JSON(w, r, struct {
        Lessons map[string]string
    }{ lessons, })
}

2.4 login(...)

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

Session ID สร้างโดยไลบรารี ksuid ซึ่งมีข้อได้เปรียบเหนือ UUID ทั่วไป และถือว่าทำงานได้ 1 ชั่วโมง ใช้ฟังก์ชัน time-to-live ของคำสั่ง Redis SET เพื่อลบออกจากฐานข้อมูลอัตโนมัติหลัง 1 ชั่วโมง

import (
    // ...
    "github.com/segmentio/ksuid"
)
func login(w http.ResponseWriter, r *http.Request) {
    // ตรวจสอบข้อมูลประจำตัวและอัปเดต Redis session แล้วส่งคืน Set-Cookie
    // คำเตือน: ควรตรวจสอบข้อมูลประจำตัวจริงใน production!
    // ...
    // ตอนนี้สมมติว่าถูกต้องเสมอ และสร้าง session token อัตโนมัติ
    // โดยบันทึกลง Redis และตั้งเป็นคุกกี้
    userId := strings.TrimSpace(r.FormValue("userId"))
    if userId == "" {
        render.Status(r, http.StatusBadRequest)
        render.JSON(w, r, struct{ Message string }{Message: "Missing required userId"})
        return
    }
    sessionId := ksuid.New()
    redisDb.Set(r.Context(), "session:"+sessionId.String(), userId, time.Hour*1)
    http.SetCookie(w, &http.Cookie{
        Name:     COOKIE_AUTH_NAME,
        Value:    sessionId.String(),
        Path:     "/",
        MaxAge:   int((time.Hour * 1).Seconds()),
        // ควรเป็น true เมื่อปรับใช้ production (https) แต่ในเครื่องต้องเป็น false (http)
        Secure:   false,
    })
    http.Redirect(w, r, "/lessons/completed", http.StatusTemporaryRedirect)
}

3. สาธิต - ในเครื่อง (Locally)

เฮ้ย โค้ดเยอะมาก 😅

ลองสาธิตดูเร็วๆ ว่าทุกอย่างทำงานได้ตามคาด

  • ก่อนอื่น ตั้งตัวแปรสภาพแวดล้อม UPSTASH_REDIS_URL เป็น URL ของฐานข้อมูลที่สร้างในส่วน 1 หาได้ที่แท็บ details ของหน้าฐานข้อมูล (ดูส่วน 1.1)
export UPSTASH_REDIS_URL="<your-url-here>"
  • จากนั้นสร้างและรันเซิร์ฟเวอร์ท้องถิ่น:
make build-server && ./build/server

การทดสอบผ่านเบราว์เซอร์

ทดสอบในเบราว์เซอร์โดยเข้า https://localhost:5000/lessons/completed

สถาปัตยกรรม Serverless: AWS Lambda, Upstash Redis และ Go สำหรับแอปที่ขยายได้และคุ้มค่า

ได้ 403 - Forbidden มาล็อกอินกันที่ https://localhost:5000/login?userId=lambros

สถาปัตยกรรม Serverless: AWS Lambda, Upstash Redis และ Go สำหรับแอปที่ขยายได้และคุ้มค่า

ล็อกอินสำเร็จ และถูก redirect ไป /lessons/completed อัตโนมัติ แต่ยังว่างอยู่ ลองทำเครื่องหมายบทเรียนว่าเรียนจบ ในแท็บ console ของ devtools รัน:

await ( await fetch("https://localhost:5000/lessons/123/mark-complete", {
    method: "POST",
    credentials: "same-origin",
}) ).json();
// ควรได้ประมาณ:
// {LessonSlug: '123', LastCompleted: '2022-10-12T02:01:14+03:00'}

เข้า https://localhost:5000/lessons/completed อีกครั้ง จะเห็นบทเรียนถูกทำเครื่องหมายแล้ว:

{
    "Lessons": {
        "123": "2022-10-12T02:01:14+03:00"
    }
}

เสร็จสิ้น ทุกอย่างทำงานได้ดี!

ดูในฐานข้อมูล Redis เองผ่าน Data Browser ที่เพิ่งเปิดตัวก็ยืนยันว่าข้อมูลที่คาดไว้อยู่ครบถ้วน

สถาปัตยกรรม Serverless: AWS Lambda, Upstash Redis และ Go สำหรับแอปที่ขยายได้และคุ้มค่า

4. AWS Lambda

เพื่อทดสอบและปรับใช้บน AWS Lambda เราจะใช้ sam cli

  • ติดตั้ง SAM cli และตรวจสอบว่า user/role มีสิทธิ์เหมาะสม
  • SAM cli ต้องการเทมเพลต Cloudformation คัดลอกต่อไปนี้ไปที่ aws-iac/sam-template.yml:
AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: Defines all the AWS resources we need for our Upstash Redis API.
Resources:
  GoUpstashRedis:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: ../build/handler.zip
      Handler: handler
      Runtime: go1.x
      MemorySize: 512
      FunctionUrlConfig:
        AuthType: NONE
        Cors:
          AllowCredentials: false
          AllowMethods: ["*"]
          AllowOrigins: ["*"]
Outputs:
  GoUpstashRedisApi:
    Description: "Endpoint URL"
    Value: !GetAtt GoUpstashRedisUrl.FunctionUrl
  GoUpstashRedis:
    Description: "Lambda Function ARN"
    Value: !GetAtt GoUpstashRedis.Arn
  GoUpstashRedisIamRole:
    Description: "Implicit IAM Role created for GoUpstashRedis"
    Value: !GetAtt GoUpstashRedisRole.Arn
  • สร้าง handler bundle สำหรับ AWS Lambda:
make build-lambda
  • เพิ่มต่อไปนี้ใน makefile เพื่อปรับใช้ง่ายหลังเปลี่ยนโค้ด:
sam-deploy: build-lambda
    sam deploy -t aws-iac/sam-template.yml --stack-name "UpstashRedisGoArticleStackDemo" --region eu-west-1 --resolve-s3 --no-confirm-changeset --no-fail-on-empty-changeset --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM CAPABILITY_AUTO_EXPAND
  • ปรับใช้ในภูมิภาคที่ระบุ (ดูคำสั่งก่อนหน้า)
make sam-deploy
  • ควรได้ผลลัพธ์ประมาณนี้:
CloudFormation outputs from deployed stack
-----------------------------------------------------------------------------------------------------------------------------------------------------------
Outputs
-----------------------------------------------------------------------------------------------------------------------------------------------------------
Key                 GoUpstashRedis
Description         Lambda Function ARN
Value               arn:aws:lambda:eu-west-1:<redacted>:function:UpstashRedisGoArticleStackDem-GoUpstashRedis-baB8dQPkTfg0
Key                 GoUpstashRedisIamRole
Description         Implicit IAM Role created for GoUpstashRedis
Value               arn:aws:iam::<redacted>:role/UpstashRedisGoArticleStac-GoUpstashRedisRole-16UWC7HR6KII8
Key                 GoUpstashRedisApi
Description         Endpoint URL
Value               https://6pmmwqmg5vec3bcsldabckaf5i0nlgje.lambda-url.eu-west-1.on.aws/
-----------------------------------------------------------------------------------------------------------------------------------------------------------
Successfully created/updated stack - UpstashRedisGoArticleStackDemo in eu-west-1
  • URL ของ AWS Lambda ที่ปรับใช้แล้วแสดงใน output เช่น https://6pmmwqmg5vec3bcsldabckaf5i0nlgje.lambda-url.eu-west-1.on.aws/ ลองทำซ้ำขั้นตอนสาธิตในเบราว์เซอร์โดยแทน localhost ด้วยโดเมนจริง
    • หรือหา URL ได้จาก output ของ CloudFormation stack UpstashRedisGoArticleStackDemo ในคอนโซล CloudFormation
    • หมายเหตุ: ต้องตั้งตัวแปรสภาพแวดล้อม UPSTASH_REDIS_URL ในการตั้งค่า AWS Lambda ด้วย มิฉะนั้นจะ crash ไปที่คอนโซล AWS Lambda → คลิก Lambda ที่เพิ่งปรับใช้ → แท็บ Configuration → เมนูซ้าย Environment variables ใส่ key UPSTASH_REDIS_URL และ value เป็น Upstash Redis URL คลิก Save Lambda ก็พร้อมใช้งาน

4.1 การทดสอบ SAM ในเครื่อง

สามารถทดสอบ Lambda ในเครื่องได้โดยให้ไฟล์ sample-event.json ที่มี path/cookie/query parameters ถูกต้อง ตัวอย่าง JSON อยู่ที่ aws-lambda-upstash-redis-article/sample-event.json

  • เมื่อมีไฟล์ event JSON ที่ถูกต้องแล้ว รันคำสั่งต่อไปนี้เพื่อเรียกใช้ตรรกะเซิร์ฟเวอร์เหมือนบน AWS Lambda:
sam local invoke -t aws-iac/sam-template.yml -e sample-event.json

4.2 ความปลอดภัยของ Upstash Redis URL

ในบทความนี้ เพื่อความเรียบง่าย เราส่ง Upstash Redis URL (รวมรหัสผ่าน) ผ่านตัวแปรสภาพแวดล้อม ไม่ต้องการ hardcode ลงใน SAM Cloudformation template ที่มี version control ร่วมกับโค้ด จึงต้องตั้งค่าผ่านคอนโซล AWS Lambda ด้วยมือ

มีวิธีดีกว่านี้: ใช้ AWS Systems Manager Parameter Store และทรัพยากร Cloudformation AWS::SSM::Parameter เก็บ URL (ตั้งค่าครั้งเดียวและคงไว้ระหว่างการปรับใช้) และเปลี่ยนโค้ด Lambda ให้ดึงค่าพารามิเตอร์ตอนรันไทม์ หรือ inject เป็น env variable ใน sam-template.yml แบบอัตโนมัติ (แต่ยังคงเป็น plain text ในคอนโซล)

การเปลี่ยนโค้ดให้ดึงจาก SSM Parameter Store ทำได้ง่ายเพราะเราแยก entrypoints ไว้แล้ว จึงสามารถดึงพารามิเตอร์เฉพาะตอนรันใน AWS Lambda (~/dev/aws-lambda-upstash-redis/cmd/lambda/main.go) และส่งต่อให้ฟังก์ชัน NewClient() ที่สร้าง Redis client

5. เร็วแค่ไหน?

นอกจากการเรียกครั้งแรก (cold start) ที่ใช้ประมาณ 100-120 ms ทุกการเรียกต่อมานั้นเร็วมากและอยู่ภายใต้ 4 ms เสมอ

ด้านล่างคือตัวอย่าง hot invocation สำหรับจุดสิ้นสุด /login?userId=lambros ที่นำไปใช้ข้างต้น:

สถาปัตยกรรม Serverless: AWS Lambda, Upstash Redis และ Go สำหรับแอปที่ขยายได้และคุ้มค่า

เห็นได้ว่า total duration ของคำขอคือ 2.06 ms ใช่แล้ว สองมิลลิวินาที สำหรับการสร้าง Session ID เขียนลง Upstash Redis แบบ remote และส่งคืนการ redirect

ดูส่วน Log output อย่างละเอียด จะเห็นว่าคำขอใช้เวลา 789.435μs จากมุมมองของโค้ดเรา หมายความว่าตรรกะของเราเสร็จภายใน 1 ms (ประมาณ 0.790 ms) น่าทึ่งมากสำหรับการใช้ฐานข้อมูล remote 🤯

สรุป

ผมหैरประหลาดใจมากกับประสิทธิภาพของ Upstash Redis โดยเฉพาะเพราะยากที่จะหาประสิทธิภาพแบบนี้สำหรับฐานข้อมูล Serverless ที่เหมาะกับแพลตฟอร์มอย่าง AWS Lambda

Redis API สะดวกสบาย Upstash Redis มีโมเดลราคาที่ยอดเยี่ยม DevX เยี่ยม และเร็ว ผมชอบการผสมผสานนี้มาก!

AWS Lambda + Upstash Redis + Go = 🚀❤️