Serverless Functions là paradigm lập trình mới phát triển và deploy cloud services. Trong thế giới serverless, chúng ta trừu tượng hóa provisioning, maintenance, scaling backend services cho cloud provider. Điều này cải thiện đáng kể developer productivity bằng cách cho dev tập trung giải quyết vấn đề cụ thể. Mặc dù có nhiều ưu/nhược điểm xây dựng serverless functions, một điều cần cân nhắc là language support. Gần đây, Google 발표했다 hỗ trợ Ruby 2.7 cho Google Cloud Functions, và bài này tập trung vào build, test, deploy serverless function Ruby trên Google Cloud Functions cùng ưu/nhược điểm serverless functions.
Xây dựng hệ thống OTP Serverless
One-time passwords (OTPs) là mã số ngắn dùng cho authentication, như khi ngân hàng gửi OTP qua tin nhắn xác thực danh tính.
Trong bài này, chúng ta xây dựng OTP function xử lý ba trách nhiệm cốt lõi:
POST /otp: Tạo và gửi OTP message đến phone_number cung cấp.
# Request
{ "phone_number": "+2347012345678" }
# Response
{ "status": true, "message": "OTP sent successfully", "data": { "phone_number": "+2347012345678", "otp": 6872, "expires_at": "2021-02-09 07:15:25 +0100" } }
PUT /otp/verify: Xác thực OTP đối với OTP user cung cấp.
# Request
{ "phone_number": "+2347012345678", "otp": 7116 }
# Response
{ "status": true, "message": "OTP verified", "data": {} }
PUT /otp/resend: Thử tạo và gửi lại OTP đến phone_number cung cấp.
# Request
{ "phone_number": "+2347012345678" }
# Response
{ "status": true, "message": "OTP sent successfully", "data": { "phone_number": "+2347012345678", "otp": 8533, "expires_at": "2021-02-09 08:59:16 +0100" } }
Để đơn giản, cloud function của chúng ta sẽ backed bởi Cloud MemoryStore (Redis hoặc Memcache trên GCP) thay vì full SQL/NoSQL Database. Điều này cũng cho chúng ta học về shared state trong môi trường stateless.
Viết Google Cloud Function với Ruby
Để viết functions trong GCF, chúng ta dựa vào Functions Framework do Google Cloud team cung cấp cho building GCF functions (chi tiết sau).
Đầu tiên, tạo App directory và vào directory.
mkdir otp-cloud-function && cd otp-cloud-function
Tiếp theo, tạo Gemfile và cài đặt.
Giống hầu hết Ruby app tiêu chuẩn, chúng ta dùng bundler quản lý dependencies của function.
source "https://rubygems.org"
# Core
gem "functions_framework", "~> 0.7"
# Twilio for Sms
gem 'twilio-ruby', '~> 5.43.0'
# Database
gem 'redis'
# Connection Pooling
gem 'connection_pool'
# Time management
gem 'activesupport'
# API Serialization
gem 'active_model_serializers', '~> 0.10.0'
group :development, :test do
gem 'pry'
gem 'rspec'
gem 'rspec_junit_formatter'
gem 'faker', '~> 2.11.0'
end
bundle install
Tạo Function
Khác nhau các hosting environment cho phép chỉ định file khác nhau chứa functions. Google Cloud Functions, tuy nhiên, yêu cầu phải là app.rb ở root project directory. Bây giờ chúng ta sẵn sàng viết function.
Mở app.rb và tạo function:
# Cloud Functions Entrypoint
require 'functions_framework'
require 'connection_pool'
require 'active_model_serializers'
require './lib/store'
require './lib/send_sms_notification'
require './lib/response'
require './lib/serializers/models/base_model'
require './lib/serializers/models/otp_response'
require './lib/serializers/application_serializer'
require './lib/serializers/base_model_serializer'
require './lib/serializers/otp_response_serializer'
FunctionsFramework.on_startup do |function|
# Setup Shared Redis Client
require 'redis'
set_global :redis_client, ConnectionPool.new(size: 5, timeout: 5) { Redis.new }
end
# Define HTTP Function
FunctionsFramework.http "otp" do |request|
store = Store.new(global(:redis_client))
data = JSON.parse(request.body.read)
if request.post? && request.path == '/otp'
phone_number = data['phone_number']
record = store.get(phone_number)
unless record.nil? || record.expired?
data = Models::OtpResponse.new(phone_number: phone_number, otp: record['otp'], expires_at: record['expires_at'])
json = Response.generate_json(status: true, message: 'OTP previously sent', data: data)
return json
end
otp = rand(1111..9999)
record = store.set(phone_number, otp)
SendSmsNotification.new(phone_number, otp).call
data = Models::OtpResponse.new(phone_number: phone_number, otp: record['otp'], expires_at: record['expires_at'])
Response.generate_json(status: true, message: 'OTP sent successfully', data: data)
elsif request.put? && request.path == '/otp/verify'
phone_number = data['phone_number']
record = store.get(phone_number)
if record.nil?
return Response.generate_json(status: false, message: "OTP not sent to number")
elsif record.expired?
return Response.generate_json(status: false, message: 'OTP code expired')
end
is_verified = data['otp'] == record['otp']
if is_verified
return Response.generate_json(status: true, message: 'OTP verified')
else
return Response.generate_json(status: false, message: 'OTP does not match')
end
elsif request.put? && request.path == '/otp/resend'
phone_number = data['phone_number']
store.del(phone_number)
otp = rand(1111..9999)
record = store.set(phone_number, otp)
SendSmsNotification.new(phone_number, otp).call
data = Models::OtpResponse.new(phone_number: phone_number, otp: record['otp'], expires_at: record['expires_at'])
json = Response.generate_json(status: true, message: 'OTP sent successfully', data: data)
else
Response.generate_json(status: false, message: 'Request method and path did not match')
end
end
Đây là nhiều code, chúng ta sẽ phân tích:
Functions_Framework.on_startuplà block code chạy per Ruby instance trước khi functions bắt đầu xử lý request. Lý tưởng để chạy initialization trước khi functions được gọi. Ở đây, tôi dùng để tạo và share connection pool đến Redis server:set_global :redis_client, ConnectionPool.new(size: 5, timeout: 5) { Redis.new }Điều này cho phép share pool Redis connection objects qua nhiều concurrent function invocations. Nhiều startup có thể định nghĩa. Chúng chạy theo thứ tự định nghĩa. Cũng quan trọng:
Functions Frameworkkhông cung cấp hook đặc biệt chạy sau function completion.Functions_Framework.http 'otp' do |request|xử lý request và response processing của functions. Function này hỗ trợ ba route pattern khác nhau. Có thể định nghĩa các loại function khác (ví dụ:Functions_Framework.cloud_event 'otp' do |event|) xử lý event từ các Google services khác. Cũng có thể định nghĩa multiple functions cùng file nhưng deploy độc lập.Trong
store = Store.new(global(:redis_client)), methodglobaldùng để retrieve object lưu trong global shared state. Như dùng ở trên, chúng ta retrieve Redis client từ Connection pool định nghĩa trong global setup trong blockstartup.ResponsevàModels::OtpResponsexử lý response serialization vớiactive_model_serializersđể cho JSON response format đúng.
Test Function Local
Thư viện Functions Framework cho phép dễ dàng test functions local trước khi deploy lên cloud. Để test local, chạy
bundle exec functions-framework-ruby --target=otp --port=3000
--target dùng chọn function deploy.
Mặc dù manual testing tốt, automated tests và self-testing software là thánh grail trong testing. Functions Framework cung cấp helper methods cho cả Minitest và RSpec giúp test functions cho cả http và cloudevents handlers. Đây là ví dụ test:
require './spec/spec_helper.rb'
require 'functions_framework/testing'
describe 'OTP Functions' do
include FunctionsFramework::Testing
describe 'Send OTP', redis: true do
let(:phone_number) { "+2347012345678" }
let(:body) { { phone_number: phone_number }.to_json }
let(:headers) { ["Content-Type: application/json"] }
it 'should send OTP successfully' do
load_temporary "app.rb" do
request = make_post_request "/otp", body, headers
response = call_http "otp", request
expect(response.status).to eq 200
expect(response.content_type).to eq("application/json")
parsed_response = JSON.parse(response.body.join)
expect(parsed_response['status']).to eq true
expect(parsed_response['message']).to eq 'OTP sent successfully'
end
end
end
end
Deploy Function
Đầu tiên, cần deploy Redis server dùng Google Cloud Memorystore, mà function phụ thuộc. Tôi không đi sâu vào cách deploy Redis server lên GCP, vì ngoài phạm vi bài viết.
Có nhiều cách deploy function lên môi trường Google Cloud Functions: deploy từ máy, deploy từ GCP console, deploy từ code repositories. Modern software engineering khuyến khích CI/CD cho hầu hết development, và bài này tập trung deploy Cloud Function từ Github với Github Actions dùng deploy-cloud-functions.
Hãy setup deployment file (.github/workflows/deploy.yml).
name: Deployment
on:
push:
branches:
- main
jobs:
deploy:
name: Function Deployment
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- id: deploy
uses: google-github-actions/deploy-cloud-functions@main
with:
name: otp-cloud-function
runtime: ruby26
credentials: ${{ secrets.gcp_credentials }}
env_vars: "TWILIO_ACCOUNT_SID=${{ secrets.TWILIO_ACCOUNT_SID }},TWILIO_AUTH_TOKEN=${{ secrets.TWILIO_AUTH_TOKEN }},TWILIO_PHONE_NUMBER=${{ secrets.TWILIO_PHONE_NUMBER }},REDIS_URL=${{ secrets.REDIS_URL }}"
Environment Variables
Trong code trên, dòng cuối cho phép chỉ định environment variables có sẵn cho function trong môi trường Google Cloud. Lưu ý vì lý do bảo mật, chúng ta không expose variables trong code-base; thay vào đó, dùng Github actions secrets giữ thông tin private. Để check tokens đã deploy đúng, check cloud function trong Google Console, như dưới:

Authentication
Tạo Service Account với roles Cloud Functions Admin và Service Account User.
Service account dùng cho machine-to-machine IAM. Vì vậy, khi một system, dù chạy trên Google Cloud hay không, nói chuyện với system khác trên Google Cloud, service account cần thiết để identify ai đang request access đến Google resource. Roles Cloud Functions Admin và Service Account User cho phép xác định user có authorized access resource không. Trong trường hợp này, Github Action runner giao tiếp với Google Cloud authenticate as service account với permissions cần thiết deploy function.


Tạo Service Account Key, download JSON, thêm vào GitHub Secrets.


Voila! 🎉 Cloud Function đã deploy thành công.
Cloud Functions Limits vs. AWS Limits
Dưới là so sánh chi tiết hai provider Serverless function lớn nhất:

Functions Framework Contract vs. Serverless Framework
Bài này tập trung build cloud functions cho Google Cloud Functions. Phần này so sánh build với Functions Framework vs. Serverless Framework.
Serverless Frameworkdựa trênserverless.yml, Functions Framework dựa trênFunctions Framework Contract, dùng deploy serverless functions trên Google Cloud Infrastructure.- Với
Serverless Framework, chỉ có vài ví dụ, không rõ cách build và deploy serverless functions với Ruby lên các Google serverless environments (Cloud Functions, Cloud Run, Knative). VớiFunctions Framework Contract, build với Ruby trên các Google products khác nhau rất straightforward.- Theo điểm trước,
Functions Framework Contractlàm rất dễ chuyển backing language đằng sau function mà không nhất thiết thay đổi nhiều deployment process.
- Theo điểm trước,
- Tính đến lúc viết,
Functions Frameworkchỉ hỗ trợ interoperability trên Google Cloud Serverless Environments và Knative environments.Serverless Frameworktuy nhiên hỗ trợ multiple platforms trên multiple providers.
Tham khảo, full code available tại đây.