使用 Amazon Step Functions 和 Amazon Athena 實現簡易大資料編排

亞馬遜雲開發者發表於2023-04-22

很多公司都在亞馬遜雲上圍繞 Amazon S3 實現了自己的資料湖。資料湖的建設涉及到資料攝入、清洗、轉換,以及呈現等多個步驟,還需要對這些步驟進行編排,這對很多人手不足或者初識資料湖的團隊形成了挑戰。

在本篇文章中,我將介紹一個使用 Amazon Step Functions 和 Amazon Athena 的簡易大資料編排方案。如果你的團隊現在已經有相當部分沉睡資料,想要利用,但是又沒有專人或者專門的力量的公司,那麼可以參考這個方案,在數天時間內搭建起一套可用的基礎版大資料流水線,開始對資料進行一些探索和挖掘。

方案整體都採用無伺服器服務,使用者無需擔心基建費用,完全只為用量付費,實現低成本快速啟動。

亞馬遜雲科技開發者社群為開發者們提供全球的開發技術資源。這裡有技術文件、開發案例、技術專欄、培訓影片、活動與競賽等。幫助中國開發者對接世界最前沿技術,觀點,和專案,並將中國優秀開發者或技術推薦給全球雲社群。如果你還沒有關注/收藏,看到這裡請一定不要匆匆劃過,點這裡讓它成為你的技術寶庫!

服務介紹

開始之前,我們簡單介紹下方案的兩個核心服務。

Amazon Athena 是一個無伺服器版的 SQL 大資料查詢服務,底層基於 PrestoDB 引擎。使用者可以提交 SQL 語句,而這個引擎則根據語句來分散式掃描資料湖中的檔案,最後彙總成結果。除了查詢之外,Athena 也可以用作簡單的 ETL 工具。它按照掃描的檔案的大小來收費。

Amazon Step Functions 是一個無伺服器編排服務。它可以幫助我們設計一個包含多個步驟的流程(有向無環圖,Directed Acyclic Graph,簡稱 DAG),讓每個步驟的輸出變成下一個步驟的輸入,並且支援步驟併發、條件判斷以及不同的重試機制等。它和亞馬遜雲科技的其他服務有著很好的整合,並且也是完全按照步驟執行的次數來收費。

業務介紹

簡單介紹一下業務。

假設我們是一家傳統的白電公司。雖然我們追隨潮流,在我們的很多新電器上搭載了 IoT 功能,並且也收到了很多的 IoT 資料,但這些資料其實並沒太好地利用起來。現在,我們希望能做一個資料湖,用最低的成本,快速從這些資料裡面挖掘一些價值。

目前最困擾我們的問題是電器品質和維修問題,以冰箱為例,如果商用冰箱出故障,可能會導致食品變質導致食品衛生問題,而如果儲存的是藥品,則更可能導致嚴重的問題;而家用冰箱如果出故障,也會嚴重影響客戶體驗和對品牌的信任。所以,我們希望能對裝置回報的資料進行挖掘,看看冰箱在故障之前,通常出現什麼指標異常,不同地區的同款冰箱在指標上是否有區別,以及不同的使用方式是否對冰箱的壽命和維修產生影響。

在這些問題之上,我們可能會形成一套預測性維護的機制,在冰箱出故障之前就做好預判,提前維護保養,避免問題的發生。

整體架構

架構的整體資料流向圖上已經展示得很清楚,我們本次重點關注這些服務使用的細節,以及串接這些服務時的一些要點。

資料攝入

本次的資料來源格式是 GZip 壓縮好的 JSON Lines 檔案,每天可能是單個或者數個檔案。檔案已經存放在某個內網 HTTP 節點,我們需要定期去拉取,並且上傳到 S3 桶。

資料格式示範如下。

{"model": "model-1234", "city": "test-city-1", "reading_1": "15.6"}
{"model": "model-4323", "city": "test-city-2", "reading_1": "4.5"}
{"model": "model-3135", "city": "test-city-1", "reading_1": "7.4"}
{"model": "model-4237", "city": "test-city-3", "reading_1": "8.1"}
{"model": "model-9928", "city": "test-city-1", "reading_1": "6.3"}

把檔案上傳到 S3 桶之後,我們可以直接在 Athena 的查詢編輯器中使用如下 SQL 語句建立外部表。

CREATE EXTERNAL TABLE example (
    model STRING,
    city STRING,
    reading_1 STRING
)
ROW FORMAT SERDE 'org.openx.data.jsonserde.JsonSerDe'
LOCATION 's3://{bucket-name}/'

建立成功後,我們就可以立即進行查詢。

SELECT * 
FROM example;

這裡需要注意的是 Athena 支援的是單個檔案壓縮,而不是我們常見的 TAR 包壓縮。也就是說,每個檔案都是透過 gzip filename.json 命令壓縮成 filename.json.gz 而不是透過 tar cfz 命令打包並壓縮成 .tar.gz,否則 Athena 將無法識別。

當然,通常我們的 IoT 資料都包含大量的欄位,這裡很可能我們不會用寫 SQL 的方式來建表,而是用 Amazon Glue 的爬蟲服務進行爬取,自動建表和識別欄位型別。爬蟲的使用不是本文的重點,如有需要,讀者可參考其他關於 Glue 爬蟲的文章。

無格式文字檔案處理

在 IoT 場景中,有時候我們會遇到特定的原始資料格式。它並不是 JSON 格式,也不是其他認可的形式,而是取決於使用的裝置,類似下面這樣的格式。

DEV {model=23482, sn='238148234571', reading_1=23.5}
DEV {model=36740, sn='9942716322', reading_1=}

此時,我們可以借用 Athena 的正規表示式匹配編解碼器(RegEx SerDe),來把資料讀取成字串,再進行處理。注意:資料仍然需要按行分割。

CREATE EXTERNAL TABLE example_regex (
    model STRING,
    sn STRING,
    reading_1 STRING
)
ROW FORMAT SERDE 'org.apache.hadoop.hive.serde2.RegexSerDe'
WITH SERDEPROPERTIES (
"input.regex" = "^DEV\\s*\\{model=(.*?),\\s*sn='(.*?)',\\s*reading_1=(.*?)\\}"
) LOCATION 's3://{bucket-name}/{prefix}';

使用這種方式,所有的欄位必須有固定順序,正規表示式捕捉到的字元,會被按順序錄入到欄位中,方便後續處理。

資料預處理

原始資料傳輸到 S3 桶後,我們需要對它做一些預處理,方便後續正式使用。

欄位格式轉換

首先,我們需要對欄位格式轉換。因為原始的 IoT 資料所有欄位幾乎都是字串格式,不便於操作,所以我們需要把這些欄位格式轉換成正確的格式。我們先建立新的目標表。

CREATE EXTERNAL TABLE example_preprocessed (
    model STRING,
    city STRING,
    reading_1 DOUBLE
)
STORED AS PARQUET
LOCATION 's3://{bucket-name}/{prefix}'
TBLPROPERTIES ("parquet.compression"="SNAPPY");

注意,此時我們不僅轉換欄位格式,儲存格式也換成了更便於統計操作的 Parquet,並使用 Snappy 進行了壓縮。

對於 Athena 來說,欄位轉換非常簡單,只需使用 SQL 的 CAST、DATE_PARSE 等型別轉換函式。比如,我們可以使用如下方式語句把原始資料轉換成正確的格式並插入到新的表。

INSERT INTO example_preprocessed 
SELECT model, city, CAST(reading_1 AS DOUBLE) as reading_1 FROM example;

動態欄位對映

在 IoT 場景中,我們還可能會遇到動態欄位對映問題。

比如,每個裝置都會回傳 data_01、data_02、date_03 這樣的欄位,但是不同裝置、不同型號甚至不同版本的裝置,所傳回來的欄位代表的意思可能不同。在 A 裝置上 data_01 可能是溫度,而在 B 裝置上,data_01 則可能是門開關的角度。

這就需要我們有一個表來儲存欄位的對映關係,並且能動態地對這些資料進行對映。核心思路如下。

  • 儲存一份表、全欄位、全對映目標欄位的對映關係
  • 遍歷這個對映關係,並且使用 INSERT INTO 語句,按順序列出所有源欄位和目標欄位
  • 新增反向條件,對未在對映關係列表中的裝置進行預設對映

這個思路主要是藉助了 INSERT INTO 可以同時列出欄位和值並按順序來插入的功能。下面是一段示意程式碼。

import time
import boto3

# 只列印 SQL

dry_run = False

# 源表和目標表

source_table = 'source_table'
target_table = 'target_table'
db = 'dbname'

# 對映關係表,從資料庫中取出後改成如下格式

mapping = {
  # 型號名字為 Key
  'BCD': {
    'field': 'filed',  # 所有欄位都必須列出來,即便是完全對應
    'model': 'model',
    'data01': 'temperature',  # 舉例對映 data01 > temperature,data01 > door_status
    'data02': 'door_status'
  },
  'ABC': {
    'field': 'field',  # 所有欄位都必須列出來,即便是完全對應
    'model': 'model',
    'data01': 'door_status',  # 舉例對映 data01 > door_status,data01 > temperature
    'data02': 'temperature'
  },
  # 未被匹配的型號使用預設對映
  'Other': {
    'field': 'field',
    'model': 'model',
    'data01': 'other', # 對映的目標欄位必須存在於目標表,如果有目標表欄位沒有覆蓋,就會變成 NULL
    'data02': 'other2' # 對映的目標欄位不能重複
  }
}

# 封裝 Athena 請求和 SQL 到函式

client = boto3.client('athena')

def insert_with_mapping(model, mapping):
  source_columns = [f'"{k}"' for k in mapping.keys()]
  target_columns = [f'"{v}"' for v in mapping.values()]

  query = f'INSERT INTO {target_table} ({",".join(target_columns)}) SELECT {",".join(source_columns)} FROM {source_table} WHERE '

  if type(model) == list:
    models = [f"'{m}'" for m in model]
    query += f'model NOT IN ({",".join(models)})'
  else:
    query += f"model = '{model}'"

  print(query)

  if (dry_run):
    return

  query_start = client.start_query_execution(
      QueryString = query,
      QueryExecutionContext = {
          'Database': db
      }, 
      ResultConfiguration = { 'OutputLocation': 's3://my-athena-result-bucket'}
  )

  max_execution = 100 # 設定最長執行時間
  state = 'RUNNING'

  while (max_execution > 0 and state in ['RUNNING', 'QUEUED', 'SUCCEEDED','FAILED']):
    max_execution = max_execution - 1
    response = client.get_query_execution(QueryExecutionId = query_start['QueryExecutionId'])

    if 'QueryExecution' in response and \
            'Status' in response['QueryExecution'] and \
            'State' in response['QueryExecution']['Status']:
      state = response['QueryExecution']['Status']['State']
      if state == 'FAILED':
          print(response)
          raise Exception(f'> {model} INSERTION FAILED.')
          break
      elif state == 'SUCCEEDED':
          results = client.get_query_results(QueryExecutionId=query_start['QueryExecutionId'])
          print(f'> {model} INSERTION SUCCEEDED.')
          break

    print('WAITING...')
    time.sleep(1)

# 遍歷每個模型,分別插入

mapping_without_other = { k: v for k, v in mapping.items() if k != 'Other' }
mapping_other = mapping['Other']

for model, column_mapping in mapping_without_other.items():
  insert_with_mapping(model, column_mapping)

insert_with_mapping(list(mapping_without_other.keys()), mapping_other)

分段匯入

因為INSERT INTO ... SELECT 語句會有 100 個分割槽的限制,如果我們按小時分割槽,一次匯入了超過 100 個小時的資料,或者按照模型分割槽,一次匯入超過 100 個模型,就會匯入失敗。

這時候,我們需要做分段匯入。分段匯入的方式很直白,就是用 WHERE 語句把資料分拆。比如每次插入 99 小時資料,或者每次插入 99 個模型。

清除已處理資料

最後,我們還需要刪除已經預處理的資料,方便下一天匯入新的資料繼續處理。由於 S3 本身沒有提供萬用字元刪除的功能,所以我們只能使用一個指令碼列出所有的資料檔案,然後統一刪除。

資料統計

業務核心的資料統計反而是整個流程中比較簡單的部分,因為所有業務邏輯都使用 SQL 語句來表示。本次文章的重點不是業務梳理,所以對具體的 SQL 查詢語句不再做展示,讀者可根據自己需要來撰寫和呼叫。

流水線編排

在所有流程都明確下來,並且手動執行完畢後,我們就可以開始設計自動化流水線了。

不管是 Step Functions,還是 Apache Airflow,流水線工具基本都基於「有向無環圖」(Directed Acyclic Graph,簡稱 DAG)的理念。有向,指的是流水線中的步驟都明確指向下一個步驟,直至結束;無環,指的是步驟只往一個方向走,不能折返,形成迴圈。

之所以要避免迴圈,是因為排程器需要知道步驟的先後順序(依賴關係)。如果出現了 A → B → A 這樣的迴圈,那麼排程器就會發現 A 需要等 B 執行完,但 B 又需要等 A 執行完,就沒辦法決定先執行哪一個了。反之,如果所有步驟都朝一個方向推進,又沒有迴圈,就能明確先後順序,並且也可以知道哪些步驟可能是可以並行執行,提升效率。

在 Step Functions 中,流水線被稱作「狀態機」(State Machine)。每個狀態機分為多個步驟,而每個步驟則是一個亞馬遜雲 API 的呼叫。上一個步驟的輸出,會作為下一個步驟的輸入,直到出錯或者執行結束。當然,步驟也可以呼叫其他狀態機,從而把多個狀態機串聯成一個大的工作流。

資料攝入

我們原來是在 Amazon EC2 例項上直接執行命令來下載資料。現在,我們要把這個命令放到狀態機裡,有兩個選擇。

  • 使用 Amazon Lambda 的無伺服器函式直接執行這個命令
  • 使用一臺 EC2 機器來執行這個命令

這裡主要需要考慮的是下載的檔案大小。寧夏和北京區域的 Lambda 本地臨時儲存只有 512MB,海外最高可配置至 10GB,所以,如果下載的檔案超過這個上限,就可能需要考慮 EFS 等外部儲存方案,或者改用 EC2 來執行。

如果用 EC2 例項來執行命令,就沒有執行時長和儲存空間的問題。不過,我們還需要一個方便的方式可以呼叫例項上的命令,並且把執行結返回到步驟中。

要遠端執行命令,我們可以使用 Amazon System Manager(下簡稱 SSM)。如果你使用的是 Amazon Linux,則其客戶端已經隨系統安裝,我們只需要為這個例項新增如下策略即可使用。

  • arn:aws-cn:iam::aws:policy/AmazonSSMManagedInstanceCore,這個託管策略允許 SSM 操控該例項,包括執行命令、從瀏覽器中登入例項等。

因為下載和上傳的時間不確定,所以我們這裡需要有一個「等待」的過程。這裡,我們需要呼叫 Step Functions 的 API,告訴它任務執行的結果。這個需要我們的 EC2 例項具備如下許可權。

  • states:SendTaskSuccess,傳送任務成功訊號
  • states:SendTaskFailure,傳送任務失敗訊號
  • states:SendTaskHeartbeat,傳送任務心跳訊號,確認任務還在執行

這裡有一個問題,就是 EC2 上的執行者需要知道現在執行的是哪個任務,這樣才能在傳送訊號的時候附帶上任務 ID。Step Functions 提供了一個方式傳入後設資料,就是在引數鍵值後面新增 .$,然後在引數中使用 $$ 來引用。

從上圖可以看出,我們把原來的 TaskToken 改成了 TaskToken.$,然後就可以直接使用 $$.Task.Token 來取出後設資料中包含的「任務令牌」(Task Token)。任務執行完成時,我們只需要使用 SendTaskSuccess 並帶上這個令牌,Step Functions 就會認為這個任務已經執行完成。

任意一個字串引數,都可以用這個方式來替換成後設資料中的值。藉此,我們可以在任意步驟中獲得任務名字、狀態機原始引數等後設資料。

但這裡還有一個問題,那就是 SSM 的 sendCommand API 引數只支援陣列,不支援字串。這就意味著我們沒辦法用 .$ 字尾的方式把後設資料直接傳入,只能透過一個 Lambda 函式做一下轉發。此時,Lambda 函式需要有呼叫 ssm:sendCommand 的許可權。

這裡我寫了一個示範的 Lambda 函式。

import json
import boto3

def lambda_handler(event, context):
    print(event)
    
    client = boto3.client('ssm')

    instance_id = 'i-xxxxxxxx' # 示意程式碼,使用硬編碼
    response = client.send_command(
        InstanceIds=[instance_id],
        DocumentName='AWS-RunShellScript',
        Parameters={
            'commands': [
                f'aws stepfunctions send-task-success --region cn-northwest-1 --task-token {event["TaskToken"]} --task-output {{}}'
            ] 
        }
    )
    return {
        'statusCode': 200,
        'body': json.dumps(response, default=str)
    }

這個函式會呼叫 ssm:sendCommand,在指定例項上執行命令。這裡作為演示,只會傳送成功訊號。如需增加命令,直接在 commands 引數下,傳送訊號之前,增加所需的命令即可。如果要在在生產環境下使用,可能我們還會加入錯誤處理之類的,或者把所需要的命令直接寫成一個完善的指令碼。

資料處理

資料處理可能會用到「併發」(Parallel)和「判斷」(Choice)兩種流步驟。流步驟指的是不直接呼叫 API,而是做一些流程上的操作。比如「併發」讓我們可以並行多個步驟,而「判斷」則可以讓我們根據上個步驟的不同輸出來選擇執行不同的步驟。

在資料處理階段,我們可能會同時執行多個轉換,比如可能按日期、城市來把不同的資料提取到不同的表內。在資料計算階段,我們也可能會同時執行相互之間沒有依賴關係的統計運算。這也是利用了 S3 儲存高併發、高吞吐的優勢。

此外,我們還可以使用條件判斷。比如,在收到超過 10 萬條記錄時,才啟動統計操作。再比如,當發現某個城市的故障率飆升時,發出告警等等。

定時觸發

還有一個常見的需求是定時觸發。如前面業務簡介所言,我們可能會需要每天定時觸發某個狀態機,或者按週期觸發,比如每 6 小時執行一次。此時,我們可以藉助 Amazon EventBridge 的定時功能。

開啟 Amazon EventBridge 服務,並找到「規則 > 建立規則」,「規則型別」選擇「計劃」。

接下來,我們就可以輸入 cron 表示式,或者輸入週期了。

cron 表示式需要填寫所有下面的欄位,比如在「分鐘」框輸入 1 就代表每個小時的第 1 分鐘,而在「一週中的某天」框輸入 2 則代表每週二。注意其中「一個月中的某天」和「一週中的某天」是有衝突的,所以二者只能輸入一個,然後把另一個用 ? 代替。如果希望每分鐘、每小時等都執行,那麼就使用 * 代替。

輸入成功時,會在下方列出下次執行的時期。注意:目前此處的 cron 表示式僅使用 UTC 時間,所以在使用時需要把時區也算進去。

接下來,我們可以把我們的狀態機設定成「目標」。

儲存之後,我們就可以在規則詳情頁面看到接下來 10 次觸發時間。

總結

這篇文章中,我們以一個 IoT 場景為例,展示瞭如何結合 Step Functions 和 Athena 來實現簡易的大資料呼叫。正確使用這些服務,可以讓我們在數天之內就形成一個資料湖,讓我們可以開始對資料湖中的資料進行探索。

很多傳統公司在開拓新業務時往往會產生大量資料,但這些資料的使用需要大量專業開發和運維,這對很多剛成立的大資料團隊造成了很大的壓力。使用這些託管服務,使用者無需再關心底層伺服器,而可以把大量時間用在業務梳理和資料的價值挖掘上,大大降低了大資料的入門門檻。

當然,這篇文章主要還是拋磚引玉,有很多點因為篇幅問題未能涉及。比如:

  • 任務出錯時的恢復、告警和重試機制
  • 任務的監控和統計
  • 更實時的資料攝入
  • 資料的增量更新
  • 更高效的分割槽和資料查詢方式
  • 資料的安全性和許可權控制

這些都是在使用更加深入後必然會遇到的問題。後續我們會有更多文章為大資料初學者介紹如何使用託管和無伺服器服務來實現這些機制。

希望這篇文章對讀者有所幫助,快速搭建其自己的資料湖。

本篇作者

張玳
Amazon 解決方案架構師。十餘年企業軟體研發、設計和諮詢經驗,專注企業業務與 Amazon 服務的有機結合。譯有《軟體之道》《精益創業實戰》《精益設計》《網際網路思維的企業》,著有《體驗設計白書》等書籍。

文章來源:https://dev.amazoncloud.cn/column/article/630a141b76658473a32...

相關文章