Logo

新人日誌

首頁關於我部落格

新人日誌

Logo

網站會不定期發佈技術筆記、職場心得相關的內容,歡迎關注本站!

網站
首頁關於我部落格
部落格
分類系列文

© 新人日誌. All rights reserved. 2020-present.

Function as a Service(FaaS):你只交出一個函式,剩下的交給平台

最後更新:2026年8月29日基礎概念

你寫完一個後端服務,租一台雲端機器把它跑起來,一切正常。

然後帳單來了。

白天尖峰有人在用,半夜三點一個人都沒有,而這兩段時間的價錢一模一樣。

你會很自然地想:有沒有辦法只在有人用的時候才付錢?

這個念頭聽起來合理,但往下想一步就卡住了。

如果不留一台一直開著的機器,我的程式碼平常要待在哪裡、誰在請求進來的時候執行它?

Function as a Service(函式即服務,一般簡稱 FaaS)就是雲端廠商對這個問題給出的答案。

它的做法是把「一直開著」這件事整個拿掉。

以前你交出去的是一支完整的服務:啟動之後它自己守在那裡收請求、自己決定怎麼回應,直到有人把它關掉。

現在你交出去的只有一個函式:它不會自己啟動,也不負責收請求,只在被呼叫的時候把事情做完、把結果回傳出去。

守著等請求那件事換平台來做,所以這個函式平常不佔任何資源,跑完就收掉。

計費也跟著換一套算法,從「機器開了幾小時」變成「函式被執行了幾次、每次多久」,沒人用的時段就是零。

代價是這個模型帶來一整組新的限制:函式能跑多久、資料能不能留在記憶體裡、為什麼第一次呼叫特別慢,這些都得重新學一次。

而要看懂這些限制是從哪裡冒出來的,得先弄清楚一件事:你現在那台機器,到底是什麼東西讓它非得一直開著不可。

從一台機器到一個函式

那台機器為什麼非得一直開著

一個提供縮圖服務的 Node.js 後端,大概會長這樣:

const express = require('express')
const app = express()

app.get('/thumbnail', (req, res) => {
  res.send(makeThumbnail(req.query.url))
})

app.listen(3000)

這段程式碼做了三件事。

express() 建立一個 app 物件,由它負責看懂進來的 HTTP 請求。

app.get('/thumbnail', ...) 登記一條規則:有人用 GET 打 /thumbnail 的時候,執行這個函式。

req.query.url 是網址問號後面那串參數,express 已經幫你解析好了;做完縮圖之後 res.send(...) 把結果寫回去,變成一個 HTTP 回應。

最後 app.listen(3000) 打開 3000 這個 port,開始收請求。

值得留意的是,接住請求、解析參數、送出回應這三件事全都發生在這支程式裡面,是 express 在做,而 express 跟著你的程式碼一起跑。

而這段程式碼真正的重點不在中間處理請求的那幾行,而在最後一行。

app.listen(3000) 執行完之後,這個程式不會結束。

它會停在那裡佔著 3000 這個 port,等下一個請求進來,來一個處理一個,直到有人把它關掉為止。

一個像這樣不會自己結束、持續等待工作的程式,叫做常駐程式(long-running process)。

而常駐程式要活著,就得有一台機器一直開著。

一台一直開著的機器,是要有人照顧的。

作業系統出了安全性更新誰去裝、流量變大的時候誰去多開幾台、尖峰過了之後誰決定把多的收回來——這些全部變成你的工作。

而且它每一秒都在計費,不管那一秒有沒有人來,這就是開頭那張帳單的來源。

這些事情沒有一件跟產生縮圖有關係。

FaaS 的程式碼長什麼樣

把開頭講的那套模型換成程式碼,同一個縮圖功能會變成這樣:

exports.handler = async (event) => {
  const url = event.queryStringParameters.url
  return {
    statusCode: 200,
    body: makeThumbnail(url)
  }
}

這個檔案只 export 了一樣東西,就是 exports.handler 這個箭頭函式,它也是平台唯一會執行的那個函式。

這個函式在上一段其實也出現過,只是位置不一樣。

把兩邊處理請求的那一行抽出來並排:

app.get('/thumbnail', (req, res) => { ... })

exports.handler = async (event) => { ... }

上面那行是 Express 版,函式被當成第二個參數傳給 app.get。

下面那行是 FaaS 版,函式被指定給 exports.handler。

差別在於這個函式交給了誰。

Express 版有 express 可以交,交出去之後由 express 記住它,等對的網址被打進來再呼叫。

FaaS 版沒有 express,得讓檔案外面的平台拿得到它,所以改成放在 exports 上。

而兩段擺在一起,真正值得注意的是消失的東西,不是留下來的東西。

沒有 listen、沒有 port、沒有 app 物件,整個檔案裡沒有任何一行在講「怎麼收到請求」。

沒有 listen,請求是怎麼進來的

順著一次請求跑一遍就清楚了。

有人打 /thumbnail?url=... 這個網址進來,接住它的是平台,不是你的程式碼——上一節那三件事,全部搬到平台那邊去了。

平台把這個 HTTP 請求整理成一個 JavaScript 物件,然後呼叫你的 handler,把那個物件當作參數傳進去——這就是 event。

event.queryStringParameters 裡面是網址問號後面那串參數,平台已經幫你解析好了,所以 url 直接拿得到。

接著你的函式做完該做的事,return 一個物件出去。

這個物件的形狀同樣是平台的約定:statusCode 放 HTTP 狀態碼,body 放回應的內容。

平台收到之後,把它組裝成一個真正的 HTTP 回應送回給發出請求的人。

所以「怎麼收請求」和「怎麼送回應」兩頭都是平台在做,你的程式碼只剩中間那一段。

要注意的是,平台執行的只有一個函式,但你部署上去的還是整個專案。

原始碼、node_modules、設定檔,一起打包送上去,平台收到的是幾十個檔案、上百個函式。

那平台打開這包東西之後,要從哪一個檔案的哪一個函式開始跑?

平台怎麼跟你的函式接上線

入口:平台怎麼知道從哪一個函式開始跑

因為你會另外告訴它。

除了程式碼,部署的時候還要附一份設定,描述這個函式要用哪個版本的 runtime、記憶體給多少、逾時設多久。

這份設定沒有統一的格式,長什麼樣子取決於你用什麼方式部署。

如果你是在雲端主控台上點一點,它就是網頁上的幾個欄位,你在標著 Handler 的那一格填一個值進去。

如果你用部署工具,它就是專案裡的一個檔案——Serverless Framework 是 serverless.yml、AWS SAM 是 template.yaml、Terraform 又是另一種寫法。

檔名和語法各家不同,但要填的東西大同小異,其中一項就是入口:

runtime: nodejs20.x
handler: index.handler

這行有個地方很容易看花:冒號左邊的 handler: 和右邊的 index.handler,兩個 handler 是不同的東西。

左邊那個是設定項的名字,由平台規定,你改不得。

右邊那個才是你填的值,格式是「檔名.函式名」,中間用一個點隔開。

index 指的是專案裡的 index.js 這個檔案。

點後面的 handler 指的是這個檔案 export 出來的那個名字,也就是程式碼裡 exports.handler 的那個 handler。

所以 handler 這個字在教學文件裡到處出現,不是因為它是關鍵字,只是大家都照抄了預設值。

你想叫它別的完全可以:檔案改名成 main.js、程式碼改成 exports.run,設定就填 main.run,只要兩邊對得上,平台一樣找得到。

真正不能省的是 exports 這個動作。

沒有 export 出去,這個函式就只活在檔案內部,平台從外面看不到它,名字填得再對也找不到人。

入口以外:那些平台不認得的程式碼

平台認得的名字,就只有你填進設定的那一個。

拿上面那段程式碼來說,平台從頭到尾只認得 handler,它不知道 makeThumbnail 存在,也不會去呼叫它。

makeThumbnail 就只是 handler 內部用到的一個普通函式,跟你平常寫的任何一個函式沒有兩樣。

所以 handler 後面你想放幾個檔案、幾個函式、幾個第三方套件,平台都不管,它只負責敲入口那一扇門。

這也是為什麼 FaaS 說的「一個函式」指的一直都是入口這一個,不是整個專案只准有一個函式。

平台現在知道該敲哪一扇門了,但它不會自己決定什麼時候去敲。

觸發器:敲門的時機由誰決定

也是你決定的。

你要幫這個函式接上一個觸發器(trigger),也就是一份描述「什麼事情發生時要呼叫它」的設定。

常見的觸發器有這幾種:

  • HTTP 請求打到某個網址
  • 訊息佇列(message queue)裡出現新訊息
  • 排程時間到了,也就是雲端版的 cron
  • 物件儲存(object storage)裡有檔案被上傳
  • 資料庫的某一列被改動

前面那個縮圖函式接的是 HTTP 請求:有人打 /thumbnail?url=... 進來,平台才呼叫它。

換成「物件儲存裡有檔案被上傳」來看看。

物件儲存是雲端專門用來放檔案的服務,AWS 的 S3、Google Cloud 的 GCS 都算,使用者上傳的原圖通常就存在那裡。

這類服務可以在有新檔案進來的時候發出一個事件,而你可以把函式接到這個事件上。

接上去之後,整條流程變成:使用者上傳原圖到物件儲存、物件儲存發出事件、平台呼叫你的函式、函式讀原圖產生縮圖再寫回去。

跟 HTTP 版的差別在時機:那一版是有人來要才做,這一版是圖一進來就先做好,等真的有人要看的時候,縮圖早就在那裡了。

程式碼會變成這樣:

exports.handler = async (event) => {
  for (const record of event.Records) {
    const bucket = record.s3.bucket.name
    const key = record.s3.object.key
    await createThumbnail(bucket, key)
  }
}

函式的外殼一模一樣,還是 exports.handler = async (event) => { ... },做的事也還是產生縮圖。

同一個 event,裡面裝的東西完全不同

兩個版本真正的差別,在 event 裡。

HTTP 版拿到的 event,裝的是一次 HTTP 請求被拆解之後的樣子:

{
  "httpMethod": "GET",
  "path": "/thumbnail",
  "queryStringParameters": { "url": "https://example.com/cat.jpg" },
  "headers": { "user-agent": "..." }
}

所以前面那一版才寫得出 event.queryStringParameters.url。

檔案上傳版拿到的 event,裝的則是哪個儲存空間裡的哪個檔案被動了:

{
  "Records": [
    {
      "s3": {
        "bucket": { "name": "user-photos" },
        "object": { "key": "cat.jpg" }
      }
    }
  ]
}

bucket.name 是儲存空間的名字,object.key 是檔案在裡面的路徑,兩個湊起來才指得到一個檔案——這就是上面那段程式碼裡兩行 const 在取的東西。

兩份 event 沒有一個欄位是共通的,httpMethod、path、headers 全部消失,整份程式碼裡再也找不到 HTTP 的痕跡。

這裡有個容易踩的地方:event.Records 是複數。

平台不保證一件事對應一次呼叫。

使用者一口氣選了三張圖上傳,平台有可能把它們合併起來,只呼叫你的函式一次,Records 裡放三筆記錄。

所以這段程式碼才要用迴圈。

如果寫成 event.Records[0] 直接取第一筆,平常單張上傳的時候完全正常,一旦有人多選幾張,後面那幾張就會安靜地沒有縮圖,而且不會有任何錯誤訊息告訴你。

要不要合併是平台為了效率自己決定的,你控制不了,所以「一次呼叫可能要處理好幾筆」得當成預設情況來寫。

另一個差別是這一版沒有 return。

HTTP 版的 return 是有去處的,平台會把它組成 HTTP 回應送給發出請求的人。

這一版沒有人在等結果,你回傳什麼平台都不會拿去用。

它在意的只剩一件事:這次執行有沒有拋出錯誤。

同步與非同步:有沒有人在線上等

這兩個例子分別代表了兩種呼叫方式。

HTTP 那種是同步的,有人在線上等回應;檔案上傳這種是非同步的,平台把事件丟給你就走了,而且你這次執行失敗的話,平台通常會自己重試。

重試意味著同一個事件可能被處理兩次,所以非同步觸發的函式最好寫成重複執行也不會出錯的樣子——這個性質叫冪等(idempotent)。

以縮圖來說,同一張圖再產生一次,覆蓋掉舊的就好,沒有副作用。

不管同步還是非同步,函式一回傳,這一次執行就結束了。

但「結束」到底結束了什麼?

平台為了跑這一次呼叫,總得先在某個地方把你的程式碼準備好。

函式回傳之後,那些準備好的東西是直接丟掉,還是留著等下一次?

函式被呼叫之後

執行環境的生命週期:冷啟動與暖啟動

你的函式不會憑空執行,平台得先生出一個能跑它的地方:一份 CPU 和記憶體、一份你部署上去的程式碼、一個啟動好的 Node.js。

這三樣加起來有個名字,叫執行環境(execution environment)。

把一個執行環境建起來要四步:配置運算資源、下載程式碼、啟動 runtime(也就是 Node.js、Python 這類語言本身的執行程式)、執行最外層程式碼,最後才輪到呼叫 handler。

這四步加起來要花時間,從幾十毫秒到好幾秒都有可能,這段額外的等待就叫冷啟動(cold start)。

而執行環境不會在函式回傳的當下就被回收,平台會先留著它一陣子。

留著的期間如果又有事件進來,平台就把同一個執行環境拿來重用,直接呼叫 handler,前面那四步全部跳過——這種情況叫暖啟動(warm start)。

還有一件事跟生命週期綁在一起:逾時(timeout)。

會有這條線,是因為函式自己不會喊停。

外部 API 一直不回應、迴圈條件寫錯、等一個永遠不會完成的動作——這些情況下函式會一直卡在那裡,而它卡著的時候,那個執行環境也一直被佔住。

對平台來說,這是一份收不回來的資源。

對你來說更直接:FaaS 按執行時間計費,執行時間沒有上限,就等於帳單沒有上限。

還有一個原因是非同步觸發要靠「這次執行失敗了」來決定重不重試,而沒有期限的話,平台根本無從判斷你是失敗了還是還在做。

所以每個函式都有執行時間上限,平台預設值通常是幾秒到幾分鐘,而且有一個天花板,主流平台大多是 15 分鐘。

時間一到,平台直接把這次執行砍掉,不管你做到哪裡。

至於這個值該設多少,其實是在問你願意為「卡住」付多少錢:設成天花板最不容易誤砍,但一個卡死的函式就會燒掉 15 分鐘的執行費;設得太緊,正常但比較慢的執行會被誤殺。

那些準備工作,該寫在哪裡

冷啟動擋不掉,但它要做多少事,有一部分是你決定的。

關鍵在四步裡的第四步:執行最外層程式碼。

所謂最外層,指的是檔案裡那些不在 handler 裡面的程式碼——require、常數宣告、任何寫在檔案頂層的敘述。

平台載入你的檔案時,會把這些從上到下跑一遍,跑完才輪到呼叫 handler。

而這件事只在建立執行環境的時候做一次。

同一個環境之後被重用幾次,handler 就被呼叫幾次,但最外層的程式碼不會再跑第二次。

所以那些「每次呼叫都會用到、但每次重做很浪費」的準備工作,應該放在那裡。

❌ 有問題的寫法:

exports.handler = async (event) => {
  const db = await connectDatabase()
  const rows = await db.query('SELECT * FROM users WHERE id = $1', [event.userId])
  return rows[0]
}

每一次呼叫都重新建立一次資料庫連線。

明明暖啟動可以跳過的準備工作,被搬進 handler 裡面,於是每次都得重做一遍。

建立連線通常要花掉幾十到幾百毫秒,而 FaaS 是按執行時間計費的,這筆錢每一次呼叫都要付。

✅ 改善後的寫法:

const dbPromise = connectDatabase()

exports.handler = async (event) => {
  const db = await dbPromise
  const rows = await db.query('SELECT * FROM users WHERE id = $1', [event.userId])
  return rows[0]
}

建立連線的動作移到最外層,只有冷啟動才會執行到,之後所有暖啟動的呼叫都直接拿現成的連線來用。

這裡存起來的是 promise(代表「之後會拿到連線」的那個物件)而不是連線本身,因為最外層通常不能用 await(能不能用取決於語言和 runtime),所以先把 promise 放著,進到 handler 再 await 它。

第一次呼叫會真的等它完成,之後 await 一個已經完成的 promise 幾乎不花時間。

把連線放在最外層,做的其實是同一件事:把東西存進執行環境裡,讓下一次呼叫還拿得到。

既然連線可以這樣留著,那別的東西是不是也可以?

無狀態:什麼東西可以留在執行環境裡

比方說用一個全域變數,記錄這個函式被呼叫過幾次,看起來就很合理:

❌ 有問題的寫法:

let requestCount = 0

exports.handler = async (event) => {
  requestCount += 1
  return { statusCode: 200, body: `count: ${requestCount}` }
}

連續打十次,你拿到的數字不會是 1 到 10。

比較可能是 1、1、2、1、2、3 這種亂七八糟的序列,因為平台為了應付同時進來的請求會開好幾個執行環境,每個環境都有自己的一份 requestCount。

而且環境閒置一陣子就會被回收,回收之後數字歸零。

✅ 改善後的寫法:

const redis = createRedisClient()

exports.handler = async (event) => {
  const count = await redis.incr('request_count')
  return { statusCode: 200, body: `count: ${count}` }
}

數字改由一個所有執行環境共用的地方保管。

函式本身不再記得任何事情,它只是去跟外面那個地方要一個結果——這就是「無狀態」的意思。

注意 createRedisClient() 還是放在最外層,跟上一節的資料庫連線是同一個做法。

所以最外層不是不能放東西,而是要分清楚放什麼:

  • 可以放:重建代價高、跟這次事件無關、丟掉也不影響正確性的東西,例如資料庫連線、HTTP client、讀進來的設定、載入好的模型
  • 不能放:任何「丟掉就會出錯」的東西,例如計數器、購物車內容、使用者的登入狀態、上一次算到一半的進度

本機磁碟也適用同一條線。

多數平台會給你一個 /tmp 可以寫,但那只是這個執行環境的暫存空間,換一個環境就看不到了,不要拿它當儲存空間用。

函式可以有記憶,但你不能依賴那份記憶。

擴充與並行:一個執行環境一次只服務一個事件

前一節說「平台會開好幾個執行環境」,這句話背後是 FaaS 最違反直覺的一條規則。

傳統的常駐程式可以同時處理幾百個請求——在等資料庫回應的那段空檔,它可以先去服務別的請求。

FaaS 的預設模型不是這樣。

一個執行環境在同一時間只處理一個事件,handler 回傳之前,這個環境不會接第二個事件。

所以同時進來 100 個請求,平台就開 100 個執行環境。

這就是自動擴充(auto scaling),你不需要設定任何規則,它是預設行為。

從這條規則可以直接推出幾件事:

  • 流量突然衝高的時候,冷啟動會集中發生,因為要開的都是全新的執行環境
  • 你的函式跑得快不快,直接決定了平台需要開幾個環境
  • 「一個程式同時服務很多人以節省資源」這個直覺在這裡不成立

這裡要小心,別把兩種「同時」搞混了。

一種是跨事件的同時:100 個請求一起進來怎麼辦。這件事平台用「開 100 個執行環境」解決,不歸你管,你也管不到。

另一種是單次執行內部的同時:你這一次呼叫裡要打三支 API,要不要一起發出去。這件事還是歸你管,而且照樣值得做:

const [user, orders, prefs] = await Promise.all([
  fetchUser(id),
  fetchOrders(id),
  fetchPrefs(id)
])

三支 API 各要 200 毫秒,一支一支等是 600 毫秒,一起發出去是 200 毫秒。

而執行時間在 FaaS 裡直接等於錢,所以這種寫法比在傳統後端更划算。

換句話說,「一個環境一次一個事件」限制的是你不能再指望一支程式同時服務很多人,不是叫你把非同步從程式碼裡拿掉。

這條規則也製造出 FaaS 專案最常見的一個災難。

資料庫連線:自動擴充帶來的第一個大坑

前面教你把連線放在最外層,一個執行環境一條連線。

平常十個環境就是十條連線,很健康。

但流量突增開到 500 個環境,就是 500 條連線同時打向資料庫,而一般資料庫的預設連線上限只有一兩百條。

超過上限之後,資料庫會直接拒絕新的連線,你的函式開始拋錯——而且偏偏是在流量最大的時候。

第一個會冒出來的念頭是:那乾脆改回把連線寫在 handler 裡,用完就關。

這沒有用。

同一時刻還是有 500 個環境在跑,還是 500 條連線同時存在,只是每條的壽命變短。你沒有解決任何事,卻讓每一次呼叫都多付一次建立連線的時間。

真正的做法是在函式和資料庫中間放一個連線池代理(connection pooler),例如 AWS 的 RDS Proxy,或自己架一台 PgBouncer。

代理自己跟資料庫維持一小把長連線,比如 50 條,而你的 500 個函式連的是代理,不是資料庫。

函式送查詢過來,代理就從那 50 條裡挑一條沒在用的送出去,拿到結果再轉回去。

之所以夠用,是因為一次查詢通常只佔用連線幾毫秒,其餘時間那條連線都是閒著的。50 條輪流轉,足以應付 500 個同時在跑的函式。

計費:沒有流量的時候是零

計費公式大致上是「呼叫次數」加上「執行時間 × 配置的記憶體大小」。

關鍵在於沒有被呼叫的時候完全不計費。

一個月只被觸發三次的函式,帳單就是那三次的錢;而一台開著的機器,不管有沒有流量,每小時的費用都一樣。

所以 FaaS 划算的地方是流量稀疏、或者起伏很大的工作負載。

反過來說,持續高流量的服務用 FaaS 常常比包一台機器貴,因為你付的是每一次執行的零售價。

這個公式還有一個直接的推論:不要在 handler 裡面乾等。

sleep 三秒等外部系統回應,那三秒是要付錢的,而且佔著一個執行環境不放。

要等就把事情丟進訊息佇列,讓另一個事件在該發生的時候觸發另一次執行。

還有一個反直覺的地方:把記憶體調高,帳單有機會變便宜。

要看懂這件事,得先知道你能設定的其實只有記憶體。

FaaS 平台不讓你單獨選 CPU,而是按你設定的記憶體等比例配運算力給你——設 1024 MB 拿到的 CPU,大約是設 128 MB 的八倍。

而計費是「執行時間 × 記憶體」,這兩個數字會一起動。

假設一個函式在 128 MB 之下跑 8 秒,成本就是 128 乘以 8。

把它調到 1024 MB,記憶體那一項貴了八倍;但如果這個函式是被算力卡住的,執行時間可能從 8 秒掉到 1 秒出頭,兩項相乘之後總價幾乎不變,甚至更低。

關鍵在於你的函式是被什麼卡住的。

如果它卡在等外部 API 回應,那再多的 CPU 也不會讓回應早一點到,執行時間不變,調高記憶體就是純粹多付錢。

所以這件事沒有通則,值得拿真實的工作量實際量一次:同一個函式分別配 128、512、1024 MB 跑跑看,記下執行時間,乘一乘就知道哪一檔划算。

到這裡,FaaS 的執行模型該有的部分都出現過了:怎麼被呼叫、能跑多久、能留下什麼、怎麼收錢。

剩下的問題是它適不適合你手上那件工作,而要判斷這個,得先知道你是在跟哪些東西做比較。

這東西適不適合你

你其實有好幾個選項:Serverless、FaaS、BaaS、PaaS

FaaS 不是「把東西丟上雲端」的唯一做法,它只是幾個選項裡的一個。

麻煩的是這幾個名詞常常被混著用,尤其是 Serverless——它有時候指一整類服務,有時候被直接拿來當 FaaS 的別名。

先把它們分開。

Serverless(無伺服器)不是一種產品,而是一整類雲端服務共有的特性:你不需要配置或維護機器、按實際用量計費、沒有流量的時候費用趨近於零。

這個名字有點誤導——機器當然還在,只是不再是你的關注對象。

FaaS 是 Serverless 底下的一種,它負責的是「執行你寫的程式碼」。

BaaS(Backend as a Service,後端即服務)是另一種,它負責的是「你不想自己寫的後端功能」——身分驗證、資料庫、檔案儲存,直接用別人做好的,例如 Firebase 或 Supabase。

PaaS(Platform as a Service,平台即服務)不在 Serverless 底下,但它是另一個很常見的選項。

它的做法是你把整個應用程式交出去,平台負責準備機器、裝好 runtime、把程式跑起來,你不用碰作業系統,Heroku、Google App Engine、Render 都屬於這一類。

這是最容易跟 FaaS 搞混的一個,因為兩邊聽起來都是「你只管寫程式碼,其他交給平台」。

差別在你還要自己負責多少:

你要自己負責的範圍沒有流量的時候怎麼擴充
虛擬機器作業系統以上的所有東西機器開著,照樣計費你自己加機器
PaaS整個應用程式常駐程式跑著,照樣計費你設規則,平台加 instance
FaaS一個函式什麼都沒在跑,不計費平台按事件數開執行環境
你要自己負責的範圍作業系統以上的所有東西
沒有流量的時候機器開著,照樣計費
怎麼擴充你自己加機器
你要自己負責的範圍整個應用程式
沒有流量的時候常駐程式跑著,照樣計費
怎麼擴充你設規則,平台加 instance
你要自己負責的範圍一個函式
沒有流量的時候什麼都沒在跑,不計費
怎麼擴充平台按事件數開執行環境

PaaS 上面跑的仍然是一個常駐程式,它有 listen、有 port,你通常還是要決定開幾個 instance。

FaaS 沒有事件的時候,你的程式碼根本不在執行狀態。

中間還有一種混血:Google Cloud Run、AWS App Runner 這類容器服務,你交出去的是一個容器映像檔,但沒有流量時可以縮到零。

它們讓你保留自己決定作業系統與相依套件的自由,同時拿到 Serverless 的計費模型,而且通常允許一個 instance 同時處理多個請求。

什麼時候該用 FaaS

適合的情況大多有一個共同點:工作是一件一件來的,而且不需要一直待命。

  • 事件處理:檔案上傳後產生縮圖、訂單成立後寄信、資料寫入後同步到搜尋引擎
  • 排程工作:每天凌晨跑報表、每小時清一次過期資料,取代掛在某台機器上的 cron
  • 接收 webhook:第三方服務的通知打進來,你收下、驗證、丟進訊息佇列
  • 流量稀疏或極度突發的 API:內部工具、活動頁面、一年只忙三天的服務
  • 膠水程式碼:把兩個系統接起來的那五十行,不值得為它養一台機器

不適合的情況通常卡在生命週期或延遲上:

  • 超過逾時上限的長時間任務:影片轉檔、大批次資料處理,做到 15 分鐘會被砍掉
  • 需要維持長連線的服務:WebSocket 這種要一直掛著的連線,跟「執行完就結束」天生衝突
  • 對延遲極度敏感的路徑:冷啟動的那幾百毫秒到幾秒,在使用者面前是看得出來的
  • 持續高流量的核心服務:算下來多半比自己包機器貴
  • 重度依賴本機狀態的程式:像是需要載入好幾 GB 資料到記憶體才能開始工作的服務

就算適合,還有三筆帳要算

場景對了不代表就該用,下面三件事最好在動手之前先想過。

第一是廠商鎖定(vendor lock-in)。

handler 的簽名、event 的格式、觸發器的設定方式,各家都不一樣,換平台不只是搬程式碼那麼簡單。

第二是本機開發變麻煩。

你的函式現在是由平台呼叫的,本機沒有那個平台,就得靠模擬工具,而模擬跟實際跑起來永遠有差距。

第三是問題變難追。

一個請求可能穿過五個函式和兩個訊息佇列,出事的時候你要有分散式追蹤(distributed tracing)才知道斷在哪一段,否則就是在十份日誌之間人工比對時間戳記。

回到那張帳單

整篇文章其實只講了一件事:你交出去的不再是一支自己跑起來的程式,而是一個函式,平台在事件發生的時候呼叫它,跑完就收掉。

剩下的全部都是從這句話推出來的。

平台要執行你的函式,總得知道從哪裡開始,於是入口寫在部署設定裡。

函式不會自己啟動,得靠觸發器把它接到某一件事上;而非同步觸發失敗時平台會自己重試,寫成重複執行也不出錯的樣子才安全。

執行環境是臨時建起來的,所以有冷啟動;建好之後平台會留著重用,昂貴的初始化擺在最外層才划算;但它不保證留著,計數器、進度這種丟掉就出錯的東西就不能放進去。

一個環境同一時間只服務一個事件,擴充於是等於開更多環境——流量一衝高,資料庫連線數也跟著衝高。

至於好處,全在「只有真的在執行才計費」這條規則上:半夜沒人用的那幾個小時,帳單真的是零,那正是開頭那個念頭想要的東西。

代價也一樣具體:函式不能跑超過十五分鐘、不能掛著長連線、第一次被呼叫會慢上幾百毫秒到幾秒。

這三件事就是最快的三把尺。

你手上那件工作如果一把尺都沒碰到,FaaS 大概是對的選擇。

如果碰到了其中一把,就值得回頭看看 PaaS 或容器服務——它們沒那麼便宜,但也沒那麼多規矩。

目前還沒有留言,成為第一個留言的人吧!

發表留言

留言將在審核後顯示。

基礎概念

目錄

  • 從一台機器到一個函式
  • 那台機器為什麼非得一直開著
  • FaaS 的程式碼長什麼樣
  • 沒有 listen,請求是怎麼進來的
  • 平台怎麼跟你的函式接上線
  • 入口:平台怎麼知道從哪一個函式開始跑
  • 入口以外:那些平台不認得的程式碼
  • 觸發器:敲門的時機由誰決定
  • 同一個 event,裡面裝的東西完全不同
  • 同步與非同步:有沒有人在線上等
  • 函式被呼叫之後
  • 執行環境的生命週期:冷啟動與暖啟動
  • 那些準備工作,該寫在哪裡
  • 無狀態:什麼東西可以留在執行環境裡
  • 擴充與並行:一個執行環境一次只服務一個事件
  • 資料庫連線:自動擴充帶來的第一個大坑
  • 計費:沒有流量的時候是零
  • 這東西適不適合你
  • 你其實有好幾個選項:Serverless、FaaS、BaaS、PaaS
  • 什麼時候該用 FaaS
  • 就算適合,還有三筆帳要算
  • 回到那張帳單