網頁

2023/8/11

使用冪等API達成安全的重試操作 Making retries safe with idempotent APIs

本篇為「Making retries safe with idempotent APIs」的翻譯。


在亞馬遜,我們經常在服務中看到複雜的操作被分解成呼叫多個小型服務的控制流程,每一個小服務負責整體流程的一部分。以啟動Amazon Elastic Compute Cloud (EC2)執行個體為例,「檯面下」這涉及呼叫多個服務來決定位置、建立EBS卷宗(volume)、建立彈性網路介面和搭建虛擬機(VM)。但這些服務的呼叫可能不定時會失敗。我們必須讓這流程成功才能帶來良好的顧客體驗。為了達成這點,我們研究控制流程來將所有的服務引導到一個良好狀態。

我們在許多案例中發現,最簡單的解決方案就是最佳方案。在剛描述的情境中,只要重試呼叫直到成功就是最好的辦法。有趣的是,如Marc Brooker在Timeouts, retries, and backoff with jitter文章中說明,很地外地發現有許多暫時性或隨機錯誤可以透過重試呼叫來克服。如所見一樣簡單,這個模式是如此有效,所以我們把預設重試行為整合到我們的AWS SDK實作。這些實作會自動重試因為網路錯誤、伺服器端錯誤或服務速率限制而失敗的請求。能夠簡化重試請求減少許多客戶端要處理的邊緣案例。這樣可減少許多呼叫服務時需要的樣板程式碼。在這情境中需要的樣板程式碼會包裹對遠端服務的呼叫來處理各種可能出現的錯誤狀況。

然而,處理暫時性錯誤的服務呼叫重試是基於一個簡化假設,即操作重試不會產生任何副作用。換個說法,我們只想確定呼叫的結果只會發生一次,即使我們在重試迴圈中需要呼叫很多次。回到之前的例子,在啟動EC2執行個體的過程中,我們不希望看到重試建立EBS卷宗的失敗呼叫而結果產生兩個EBS卷宗。這篇文章中,我們討論AWS如何利用冪等API操作來處理不需要的重試副作用,藉此帶來一個更健壯的服務,同時利用重試的好處來簡化客戶端的程式碼。


重試和潛在的副作用

讓我們考慮一個假設情境來深入這個議題,顧客使用Amazon EC2 RunInstances API操作。在我們的情境中,我們的顧客想要執行單個工作,這工作要求「最多一個」在任何時間運行的EC2執行個體。為了達成此目的,我們的顧客搭建流程來要求Amazon EC2啟動這個新的工作。然而,因為某種原因,或許是因為網路逾時,搭建流程沒有收到回應。



允許呼叫者重試這類操作,我們需要讓操作為冪等(idempotent)。冪等操作是指請求可以被重送或重試而沒有額外的副作用的操作,在分散式系統中非常有用的特性。

我們可以藉由提供一個合約來簡化客戶端程式碼,該合約允許客戶端假設任何非驗證錯誤可以透過重試請求直到成功來克服。然而,這方法將一些額外的複雜性帶入到服務實作中。在有許多客戶端呼叫及許多請求在傳送的分散式系統中,挑戰是如何分辨出哪些請求是之前請求的重複?

有許多方法可以找出請求是否為重複。例如,可能根據請求中的參數來產生合成令牌(token)。你可以產生參數的雜湊並假設任何來自同個呼叫者的相同參數的請求則為重複。表面上,這似乎同時簡化了顧客體驗和服務實作。任何看起來和之前完全一樣的請求可視為重複。但是我們發現這方法不適用所有情況。例如,假設在非常接近的時間內,一起收到來自同個呼叫者的兩個建立Amazon DynamoDB資料表的相同請求是同個請求的重複可能是合理的假設。然而,如果這些請求是用來啟動Amazon EC2執行個體,那麼我們的假設可能就不成立了。呼叫者可能真的想要建立兩個一樣的EC2執行個體。

在亞馬遜,我們偏好的做法是將呼叫者提供的唯一請求識別符加入到API合約中。來自同個呼叫者的同個請求識別符可以視為重複請求並做相應的處理。我們想藉由讓顧客透過API語義清楚表示意圖來減少顧客的可能非預期結果。達成冪等操作的呼叫者提供的唯一請求識別符滿足此需求。因為唯一識別符會出現在日誌記錄中,這也帶來意圖可被立即稽核的好處,就像AWS CloudTrail那樣。此外,通過將建立的資源標記為唯一的客戶端請求識別符,顧客能夠識別由任何特定請求建立的資源。在Amazon EC2 DescribeInstances回應中可看到這個例子,其顯示用來建立EC2執行個體的唯一識別符。(Amazon EC2 API唯一的客戶端請求識別符被稱為ClientToken)。

下圖顯示一個請求/回應流程範例,其在冪等重試的情境中使用唯一客戶端請求識別符:



範例中,顧客請求建立有唯一客戶端請求識別符的資源。服務收到請求後會先檢查之前是否看過這個識別符。若沒看過,則服務會開始處理請求。服務會為這個請求建立以顧客識別符和唯一客戶端請求識別符為主鍵的冪等「會話(session)」。若後續請求是來自同個顧客的同個唯一請求識別符,那麼服務就知道已經看過這個請求並可以做出適當反應。一個考量重點是,結合記錄冪等令牌和所有與處理請求有關的可變操作上必須滿足原子性、一致性、隔離性和持久性(ACID)特性。這確保我們可避免在記錄冪等令牌時建立資源失敗,或相反,建立了資源但記錄冪等令牌失敗。

前圖顯示請求已被看過的情況下,語義等效的回應準備。可以認為這符合操作冪等的法條不是必要的。考慮使用唯一請求識別符123來呼叫假設的CreateResource操作的情況。如果第一個請求被接收並處理,但回應從未返回呼叫者,那麼呼叫者將以識別符123重試請求。然而,資源現在可能已經在第一次的請求中建立。對這個請求的可能回應是回傳ResourceAlreadyExists代碼。這樣符合冪等的基本原則,因為重試呼叫沒有副作用。然而,從呼叫者的觀點來看這導致了不確定性,因為資源建立的結果是因為這個請求還是先前的請求並不清楚。同時讓引入重試為預設行為多了一點挑戰。這是因為雖然請求沒有副作用,之後重試結果的回傳代碼很可能改變呼叫者的執行流程。現在呼叫者需要處理已經存在的資源,甚至在呼叫之前資源不存在的情況(從呼叫者的觀點來看)。在此情境中,雖然以服務端的觀點來看沒有副作用,但從客戶端的觀點來看回傳的ResourceAlreadyExists是有副作用的。


語義等效與支援重試策略

另一種選擇是在某間隔內,對每一次相同唯一請求識別符的情況都回傳一個語義等效回應。這意味著來自同個呼叫者與同個唯一請求識別符的重試請求的任何後續回應會與第一次成功請求的回應意思相同。這種方法有一些非常有用的特性,特別是當我們希望藉由安全且簡單地重試在伺服器端出現故障的操作來改善客戶體驗時,就像我們在AWS SDK中透過重試策略所做的那樣。

使用亞馬遜EC2 RunInstances API操作和AWS CLI時,可以看到冪等與語義等效回應和自動重試邏輯。注意AWS CLI(像AWS SDK)支援預設重試策略即為我們在這邊用的。此範例中,我們使用下面的AWS CLI命令來啟動EC2執行個體。

$ aws ec2 run-instances --image-id ami-04fcd96153cb57194 --instance-type t2.micro

在許多AWS API操作中,唯一的客戶端請求識別符是由ClientToken欄位建模的。注意我們的請求中不提供唯一的客戶端請求識別符。回應中的ClientToken屬性是由遠端服務回傳。這是因為如果沒有提供識別符的話,AWS CLI會為請求產生唯一的ID,這允許上游服務發生暫時故障時在「背後」進行重試。重試的請求/回應範例可以透過等待幾分鐘,然後使用AWS CLI發起另一個請求,並使用前一次請求返回的同一個ClientToken來模擬。要注意的是回傳的回應與第一次回應非常類似但並不相同:

$ aws ec2 run-instances --image-id ami-04fcd96153cb57194 --instance-type t2.micro --client-token eb3c3141-a229-4ca0-b005-eb922e2cabdc

AWS SDK和AWS CLI利用這行為來簡化顧客使用這些工具的體驗。我們的SDK和CLI知道哪些操作支援冪等合約,且如果呼叫者未提供唯一的客戶端請求識別符產生識別符並加入到請求中。產生的識別符會重複在重試中使用,因此確保我們的請求符合「最多一次」的承諾。因為回應(甚至重試的回應)是語義等效,客戶端的呼叫程式碼可以完全不用知道任何SDK程式碼中的重試並簡化收到回應時的處理。


遲到的請求和唯一的客戶端請求識別符壽命

當請求抵達遲到時會發生一個有趣的分散式系統冪等邊緣案例。考慮服務中有兩個行為者和客戶端建立資源重試操作且之後的請求遲到。在這中間行為者刪除了資源:



對這問題每個服務有不同的最佳處理方式。即使在先前描述的情境中,我們EC2 RunInstances採取的方式是遵從最初的冪等合約。在這情況我們堅守最少驚訝原則(principle of least astonishment)。這意味著一個語義等效回應是最小驚訝的方式,即使資源已經被刪除了,這為使用AWS SDK和AWS SDK這類工具帶來一致體驗。

根據之前的EC2執行個體啟動範例,我們透過終止之前啟動的EC2執行個體來建模此邊緣案例:

$ aws ec2 terminate-instances --instance-ids i-xxxxxxxxxxxxxxxxx

如果我們以同樣參數和唯一的請求識別符來模擬「遲到」的RunInstances請求重試,我們將得到一個語義等效的回應。然而,如同以下範例顯示,執行個體現在被中止了。

$ aws ec2 run-instances --image-id ami-04fcd96153cb57194 --instance-type t2.micro --client-token eb3c3141-a229-4ca0-b005-eb922e2cabdc

為了支援此行為,我們需要在我們的服務中保留對初始冪等請求的認知。然而無限期地保留這種認知是不實際的。無限期保留可能對顧客體驗有不良影響,如果未來的請求識別符與更之前的請求識別符發生衝突。需要的保留依服務和服務資源而異。對於EC2執行個體來說,我們發現限制時間範圍在資源的生命週期,在之後加上一個時間間隔是有效的做法,這樣可合理假設任何遲到請求不是送達就是不再有效。


顧客在之後的請求改變請求參數而請求ID相同時的回應。

我們設計API讓顧客可以明確表達他們的意圖。考慮一種情況。我們收到一個曾經見過的唯一客戶端請求令牌,但參數組合不同於之前的請求。我們最安全的做法是假設顧客想要不同的結果,而且這可能不是同個請求。針對這種狀況,我們回傳一個驗證錯誤來指出冪等請求之間的參數不匹配。為了支援這種深度驗證,我們也儲存用於初始請求及客戶端請求識別符的參數。


結論

亞馬遜CTO Werner Vogels提醒我們,在亞馬遜學到的一教訓是預期未預期的狀況。他提醒我們故障是必然的,因此值得去建構視故障為當然的系統。重要的是為這些故障編寫程式碼,但沒有區別的是,這改善所提供解決方案的完整性。然而,離開投資在區分程式碼需要花費時間。

在之前談到的Timeouts, retries, and backoff with jitter一文中,Marc Brooker展示了如何利用重試來減緩暫時性錯誤,及如何將SDK及工具裡改善使用性的重試進行自動化。基於該篇文章,在本文中我們探討AWS如何利用冪等來為顧客帶來自動重試政策的同時仍然遵守部分API合約操作中固有的「最多一次」承諾。

我們發現我們所概述的方法運作良好,我們也聽說顧客欣賞此方法帶來的複雜性降低。然而,建立符合本文所描述的合約的服務有固有的成本和複雜性,且複雜性不適用於所有解決方案。有時,更好的方法是,花額外一些時間去滿足更多這裡描述的嚴格合約要求;在其他情況下,通過較不複雜的合約來快速改變是比較好的做法,因為對我們的顧客是最有利的。在更嚴格「最多一次」合約的情況下,我們發現本文的方法適合滿足顧客的需求。


關於作者

Malcolm Featonby是亞馬遜網路服務(Amazon Web Services)的主任工程師。他在2011年加入亞馬遜且多年來與AWS EC2的各個團隊一起工作。最近Malcolm轉調到AWS容器組織與實現AWS ECS、ECR和Farget的團隊一起工作。Malcolm熱衷於高成長環境中的服務擴展和系統解耦。他擁有資訊科技碩士學位。

沒有留言:

張貼留言