Google Cloud Platform でデータ ETL タスクを構築するためのベスト プラクティス

データ処理では、多くの場合、ETL タスクを構築し、データをロードし、変換して、データ ストレージに書き込む必要があります。 Google のクラウド プラットフォームでは、ETL タスクを構築するためのさまざまなソリューションが提供されています。私もこれらのソリューションを調査し、ソリューション間の利点と欠点を比較して、自分のビジネス シナリオに最適なソリューションを見つけました。

ビジネス シナリオでは、Kafka からデータを定期的に取得する必要があるとします。データ クリーニング、データの関連付け、およびデータ エンリッチメントの操作を行った後、データは Bigquery データ ウェアハウスに書き込まれ、将来の統計分析レポートの生成が容易になります。

Google Cloud Platform は、このタスクを達成するためのいくつかのソリューションを提供します。

1. Datafusion。UI インターフェースで ETL パイプラインを設計し、パイプラインを Spark アプリケーションに変換し、Dataproc 上で実行できるようにデプロイします。

2. Spark アプリケーション コードを作成し、Dataproc 上で実行するか、K8S クラスタ上の Spark オペレータを介して実行をスケジュールします。

3. Dataflow ランナーを介して VM 上でタスクを実行する Apache Beam コードを作成します。

オプション 1 の利点は、基本的にコードを記述する必要がなく、パイプラインの設計をグラフィカル インターフェイス上で完了できることです。欠点は、追加の要件がある場合に実装が不便になる可能性があること、そして最も重要なことは、コストが高すぎることです。 Datafusion を 24 時間稼働させるには、インスタンスに個別にデプロイする必要があります。このインスタンスのエンタープライズ バージョンの料金は 1 時間あたり約数ドルです。さらに、パイプラインは Dataproc インスタンスの実行時にスケジュールを設定するため、追加のコストが発生します。

オプション 2 の利点は、Spark コードを通じてさまざまなニーズに柔軟に対応できることです。 Dataproc は Hadoop クラスタに基づいており、Zookeeper、ドライバ、エグゼキュータ VM が必要であるため、欠点も比較的高価です。 K8S クラスターを使用する場合、Spark オペレーターも別のポッドで 24 時間実行する必要があり、追加のドライバーおよびエグゼキューター ポッドの実行をスケジュールする必要があります。

Beam のコードは、Spark、Flink、Dataflow、その他のエンジンで実行できる一般的なストリーム バッチ処理フレームワークを提供しており、Dataflow は Google が提供する優れたエンジンであるため、オプション 3 は、包括的な検討の結果、最適なソリューションです。タスク中に、Dataflow は VM をスケジュールします。オンデマンドで実行し、ランタイム料金のみを請求します。

したがって、私のビジネス シナリオでは、オプション 3 を使用するのが最も適切です。以下に実装プロセス全体を紹介します。

Beamバッチ処理タスクの実装

Dataflow の公式テンプレートでは Kafka データを消費して Bigquery に書き込む例がありますが、これはストリーム処理で実装されており、私のビジネス シナリオではそこまでリアルタイムにデータを処理する必要はなく、データを処理するだけで済みます。はい、そのためバッチ処理の方が適切であり、コストも大幅に節約できます。

Beam の Kafka I/O コネクタは、デフォルトで無制限のデータ、つまりストリーミング データを処理します。バッチ モードで処理するには、データを制限付きデータに変換できるように、withStartReadTime メソッドと withStopReadTime メソッドを呼び出して、読み取る Kafka トピックの開始オフセットと終了オフセットを取得する必要があります。これら 2 つのメソッドを呼び出すときに注意する必要があるのは、Kafka にこのタイムスタンプ以上のタイムスタンプを持つメッセージがない場合、エラーが報告されるため、特定のタイムスタンプを決定する必要があることです。

次のコードは、Kafka メッセージのすべてのパーティション内のメッセージのタイムスタンプが、指定したタイムスタンプより大きいかどうかをチェックします。タイムスタンプが存在しない場合は、これらのパーティション内の最新のタイムスタンプの中から最も古いものを見つける必要があります。たとえば、トピックには 3 つのパーティションがあり、指定するタイムスタンプは 1697289783000 ですが、3 つのパーティション内のすべてのメッセージはこのタイムスタンプより小さいため、各パーティション内のメッセージの最新のタイムスタンプを見つけて、これら 3 つのパーティションの最新のタイムスタンプのうち最も古いものが、指定されたタイムスタンプとして使用されます。

public class CheckKafkaMsgTimestamp {
    private static final Logger LOG = LoggerFactory.getLogger(CheckKafkaMsgTimestamp.class);
   
    public static KafkaResult getTimestamp(String bootstrapServer, String topic, long startTimestamp, long stopTimestamp) {
        long max_timestamp = stopTimestamp;
        long max_records = 5L;
        Properties props = new Properties();
        props.setProperty("bootstrap.servers", bootstrapServer);
        props.setProperty("group.id", "test");
        props.setProperty("enable.auto.commit", "true");
        props.setProperty("auto.commit.interval.ms", "1000");
        props.setProperty("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        props.setProperty("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);

        // Get all the partitions of the topic
        int partition_num = consumer.partitionsFor(topic).size();
        HashMap<TopicPartition, Long> search_map = new HashMap<>();
        ArrayList<TopicPartition> tp = new ArrayList<>();
        for (int i=0;i<partition_num;i++) {
            search_map.put(new TopicPartition(topic, i), stopTimestamp);
            tp.add(new TopicPartition(topic, i));
        }
        // Check if message exist with timestamp greater than search timestamp
        Boolean flag = true;
        ArrayList<TopicPartition> selected_tp = new ArrayList<>();
        //LOG.info("Start to check the timestamp {}", stopTimestamp);
        Map<TopicPartition, OffsetAndTimestamp> results = consumer.offsetsForTimes(search_map);
        for (Map.Entry<TopicPartition, OffsetAndTimestamp> entry : results.entrySet()) {
            OffsetAndTimestamp value = entry.getValue();
            if (value==null) {   //there is at least one partition don't have timestamp greater or equal to the stopTime
                flag = false;
                break;
            }
        }
        // Get the latest timestamp of all partitions if the above check result is false
        // Note the timestamp is the earliest of all the partitions. 
        if (!flag) {
            max_timestamp = 0L;
            consumer.assign(tp);
            Map<TopicPartition, Long> endoffsets = consumer.endOffsets(tp);
            for (Map.Entry<TopicPartition, Long> entry : endoffsets.entrySet()) {
                Long temp_timestamp = 0L;
                int record_count = 0;
                TopicPartition t = entry.getKey();
                long offset = entry.getValue();
                if (offset < 1) {
                    LOG.warn("Can not get max_timestamp as partition has no record!");
                    continue;
                }
                consumer.assign(Arrays.asList(t));
                consumer.seek(t, offset>max_records?offset-5:0);
            
                Iterator<ConsumerRecord<String, String>> records = consumer.poll(Duration.ofSeconds(2)).iterator();
                while (records.hasNext()) {
                    record_count++;
                    ConsumerRecord<String, String> record = records.next();
                    LOG.info("Topic: {}, Record Timestamp: {}, recordcount: {}", t, record.timestamp(), record_count);
                    if (temp_timestamp == 0L || record.timestamp() > temp_timestamp) {
                        temp_timestamp = record.timestamp();
                    }
                }
                //LOG.info("Record count: {}", record_count);
                if (temp_timestamp > 0L && temp_timestamp > startTimestamp) {
                    if (max_timestamp == 0L || max_timestamp > temp_timestamp) {
                        max_timestamp = temp_timestamp;
                    }
                    selected_tp.add(t);
                    LOG.info("Temp_timestamp {}", temp_timestamp);
                    LOG.info("Selected topic partition {}", t);
                    LOG.info("Partition offset {}", consumer.position(t));
                    //consumer.seek(t, -1L);
                }
            }
        } else {
            selected_tp = tp;
        }
        consumer.close();
        LOG.info("Max Timestamp: {}", max_timestamp);
        return new KafkaResult(max_timestamp, selected_tp);
    }
}

上記のコードを呼び出すことで、選択するパーティションと対応するタイムスタンプを取得できます。これら 2 つの情報を使用して、指定された時間範囲内の Kafka データを制限付きデータに変換できます。以下は、Beam がパイプラインを作成し、データを処理して、BigQuery に書き込むためのコードです。

KafkaResult checkResult = CheckKafkaMsgTimestamp.getTimestamp(options.getBootstrapServer(), options.getInputTopic(), start_read_time, stop_read_time);
stop_read_time = checkResult.max_timestamp;
ArrayList<TopicPartition> selected_tp = checkResult.selected_tp;

PCollection<String> input = pipeline
    .apply("Read messages from Kafka",
        KafkaIO.<String, String>read()
            .withBootstrapServers(options.getBootstrapServer())
            .withKeyDeserializer(StringDeserializer.class)
            .withValueDeserializer(StringDeserializer.class)
            .withConsumerConfigUpdates(ImmutableMap.of("group.id", "telematics_statistic.app", "enable.auto.commit", true))
            .withStartReadTime(Instant.ofEpochMilli(start_read_time))
            .withStopReadTime(Instant.ofEpochMilli(stop_read_time))
            .withTopicPartitions(selected_tp)
            .withoutMetadata())
    .apply("Get message contents", Values.<String>create());

PCollectionTuple msgTuple = input
    .apply("Filter message", ParDo.of(new DoFn<String, TelematicsStatisticsMsg>() {
        @ProcessElement
        public void processElement(@Element String element, MultiOutputReceiver out) {
            TelematicsStatisticsMsg msg = GSON.fromJson(element, TelematicsStatisticsMsg.class);
            if (msg.timestamp==0 || msg.vin==null) {
                out.get(otherMsgTag).output(element);
            } else {
                if (msg.timestamp<start_process_time || msg.timestamp>=stop_process_time) {
                    out.get(otherMsgTag).output(element);
                } else {
                    out.get(statisticsMsgTag).output(msg);
                }
            }
        }
    })
    .withOutputTags(statisticsMsgTag, TupleTagList.of(otherMsgTag))); 

// Get the filter out msg
PCollection<TelematicsStatisticsMsg> statisticsMsg = msgTuple.get(statisticsMsgTag);
// Save the raw records to Bigquery
statisticsMsg
    .apply("Convert raw records to BigQuery TableRow", MapElements.into(TypeDescriptor.of(TableRow.class))
        .via(TelematicsStatisticsMsg -> new TableRow()
            .set("timestamp", Instant.ofEpochMilli(TelematicsStatisticsMsg.timestamp).toString())
            .set("vin", TelematicsStatisticsMsg.vin)
            .set("service", TelematicsStatisticsMsg.service)
            .set("type", TelematicsStatisticsMsg.messageType)))
    .apply("Save raw records to BigQuery", BigQueryIO.writeTableRows()
        .to(options.getStatisticsOutputTable())
        .withSchema(new TableSchema().setFields(Arrays.asList(
            new TableFieldSchema().setName("timestamp").setType("TIMESTAMP"),
            new TableFieldSchema().setName("vin").setType("STRING"),
            new TableFieldSchema().setName("service").setType("STRING"),
            new TableFieldSchema().setName("type").setType("STRING"))))
        .withCreateDisposition(CreateDisposition.CREATE_IF_NEEDED)
        .withWriteDisposition(WriteDisposition.WRITE_APPEND));

PipelineResult result = pipeline.run();
try {
    result.getState();
    result.waitUntilFinish();
} catch (UnsupportedOperationException e) {
    // do nothing
} catch (Exception e) {
    e.printStackTrace();
}

各処理タスクが完了した後、現在の stopReadTime を記録し、次回タスクを実行するときにこのタイムスタンプを startReadTime として使用する必要があることに注意してください。これにより、場合によってはデータ欠落の問題を回避できます。このタイムスタンプを GCS バケットに記録できます。ここではコードのこの部分をスキップしてください。

データフロータスクを送信する

次に、Google の Cloud Build 関数を呼び出してコードを Flex テンプレートにパッケージ化します。

まず Java プロジェクトで mvn clean package を実行して、jar ファイルをパッケージ化します。

次に、コマンド ラインで次の環境変数を設定します。

export TEMPLATE_PATH="gs://[your project ID]/dataflow/templates/telematics-pipeline.json" 
export TEMPLATE_IMAGE="gcr.io/[your project ID]/telematics-pipeline:latest" 
export REGION="us-west1"

次に、gcloud build コマンドを実行してイメージをビルドします。

gcloud dataflow flex-template build $TEMPLATE_PATH --image-gcr-path "$TEMPLATE_IMAGE" --sdk-language "JAVA" --flex-template-base-image "gcr.io/dataflow-templates-base/java17-template-launcher-base:20230308_RC00" --jar "target/telematics-pipeline-1.0-SNAPSHOT.jar" --env FLEX_TEMPLATE_JAVA_MAIN_CLASS="com.example.TelematicsBatch"

最後に、コマンドを呼び出してタスクを実行することができます。

gcloud dataflow flex-template run "analytics-pipeline-`date +%Y%m%d-%H%M%S`" --template-file-gcs-location "$TEMPLATE_PATH" --region "us-west1" --parameters ^~^bootstrapServer="kafka-1:9094,kafka-2:9094"~statisticsOutputTable="youprojectid:dataset.tablename"~serviceAccount="[email protected]"~region="us-west1"~usePublicIps=false~runner=DataflowRunner~subnetwork="XXXX"~tempLocation=gs://bucketname/temp/~startTime=1693530000000~stopTime=1697216400000~processStartTime=1693530000000~processStopTime=1697216400000

タスクを自動的かつ定期的に実行する必要がある場合は、データフローにパイプラインをインポートし、以前に指定した Template_path を使用してインポートすることもできます。あとはタスクの定期的なサイクルと開始時刻を設定するだけなのでとても便利です。

おすすめ

転載: blog.csdn.net/gzroy/article/details/133827729