Long Running Actions with Helidon

原文はこちら。
The original article was written by Daniel Kec (Java Developer at Oracle).
https://medium.com/helidon/long-running-actions-with-helidon-573b296017d1
https://danielkec.github.io/blog/helidon/lra/saga/2021/10/12/helidon-lra.html

マイクロサービス環境で一貫性を実現するための、ロックフリーで疎結合なアプローチの待望の仕様であるMicroProfile Long Running Actions (以下、LRA) が、ついにバージョン1.0としてリリースされました。Helidon MPではLRA 1.0のサポートを、最新のリリース2.4.0から提供します。

MicroProfile LRA
https://download.eclipse.org/microprofile/microprofile-lra-1.0/microprofile-lra-spec-1.0.html
Helidon 2.4.0 released!
https://medium.com/helidon/helidon-2-4-0-released-18370c0ebc5e
https://logico-jp.io/2021/11/16/helidon-2-4-0-released/

LRAは緩く分離された分散トランザクションの代替であり、完全にJAX-RSに統合されています。トランザクショナルなBeanメソッドではなく、トランザクショナルなJAX-RSメソッドです。

LRAはSAGAパターンを踏襲しており、非同期の補正(補償)を使って、最終的なデータの整合性(結果整合性)を維持できます。

Long-running transaction
https://en.wikipedia.org/wiki/Long-running_transaction

コストの高い分離をステージングする必要はありません。この方法は、データの整合性を監視するための追加の負担を取り除き、マイクロサービスの世界で高く評価されている機能である高スケーラビリティを提供します。

LRA Transaction

複数の参加者がすべてのLRAトランザクションに参加できます。参加者 (participants) は、LRA固有のアノテーションで注釈が付けられたメソッドを持つJAX-RSリソースです。これらのアノテーションを使って、@LRAと他の参加者を結合するので、トランザクションの補償時 (@Compensate) やトランザクションの完了時 (@Complete) に一緒に呼び出すことができます。

@Path("/example")
@ApplicationScoped
public class LRAExampleResource {

    @PUT
    @LRA(value = LRA.Type.REQUIRES_NEW, timeLimit = 500, timeUnit = ChronoUnit.MILLIS)
    @Path("start-example")
    public Response startExample(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId, String data) {
        // Executed in the scope of new LRA transaction
        return Response.ok().build();
    }

    @PUT
    @Complete
    @Path("complete-example")
    public Response completeExample(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId) {
        // Called by LRA coordinator when startExample method sucessfully finishes
        return LRAResponse.completed();
    }

    @PUT
    @Compensate
    @Path("compensate-example")
    public Response compensateExample(@HeaderParam(LRA_HTTP_CONTEXT_HEADER) URI lraId) {
        // Called by LRA coordinator when startExample method throws exception or don't finish before time limit
        return LRAResponse.compensated();
    }
}

LRAトランザクションに参加するすべての参加者は、 @Compensate@Complete@AfterLRA などでアノテーションされたリソースにつながるURLである、補償リンク (compensation link) を提供する必要があります。LRAコーディネーターは、LRAトランザクションの状態が変化したときに、どのリソースを呼び出すかを追跡します。JAX-RSリソース・メソッドに @LRA(REQUIRES_NEW) が付いている場合、インターセプトされたすべての呼び出しは、リソース・メソッドが呼び出される前に、コーディネーター内で新しいLRAトランザクションを開始し、新しい参加者としてそれに参加します。作成されたLRAトランザクションのIDは、リソースメソッドでLong-Running-Actionヘッダを通じてアクセスできます。リソース・メソッドの呼び出しが正常に終了すると、コーディネーターに対し、LRAトランザクションがクローズされたと報告されます。参加者が @Complete メソッドを持っている場合、最終的にはコーディネーターが適切なLRA IDヘッダーと、LRAトランザクション内に参加している他の参加者全員の @Complete メソッドを使って再度呼び出します。

Participants

リソースメソッドが例外を吐いて終了する場合、コーディネーターにLRAがキャンセルされたと報告され、コーディネーターはそのトランザクションの下で登録されたすべての参加者に@Compensate メソッドを呼び出します。

Participant cancel

トランザクションがタイムアウト前にクローズされなかった場合、コーディネーターはトランザクションをキャンセルし、タイムアウトしたトランザクションのすべての参加者の補償エンドポイントを呼び出します。

Participant timeout

LRA Coordinator

HelidonのLong Running Actionsの実装では、クラスター全体でLRAを編成するためにLRAコーディネーターが必要です。これは追加のサービスなので、クラスタでLRA機能を有効にする必要があります。LRAコーディネーターは、どの参加者がどのLRAトランザクションに参加したかを追跡し、LRAトランザクションが完了またはキャンセルされると、参加者のLRA補償リソースを呼び出します。

Helidonでは以下をサポートしています。

  • Narayana LRA Coordinator
  • 実験的な Helidon LRA Coordinator

Narayana LRA Coordinator

Narayanaは、Arjuna Coreを中心に構築されており、分散型トランザクションの分野で長く信頼されている、有名なトランザクションマネージャです。Narayana LRA CoordinatorはLong Running Actionsのサポートをもたらし、市場で最初のLRA Coordinatorです。

wget https://search.maven.org/remotecontent?filepath=org/jboss/narayana/rts/lra-coordinator-quarkus/5.11.1.Final/lra-coordinator-quarkus-5.11.1.Final-runner.jar \
-O narayana-coordinator.jar \
&& java -Dquarkus.http.port=8070 -jar narayana-coordinator.jar

Experimental Helidon LRA Coordinator

Helidonには、開発やテストの目的で簡単に設定できる独自の実験用コーディネーターがあります。本番環境での使用はお勧めできませんが、LRAリソースのテストに適した軽量なソリューションです。

docker build -t helidon/lra-coordinator https://github.com/oracle/helidon.git#:lra/coordinator/server
docker run -dp 8070:8070 --name lra-coordinator --network="host" helidon/lra-coordinator

具体的なユースケースを見てみましょう。

Online Cinema Booking System

仮想的な映画館では、オンライン予約システムを必要としています。オンライン予約システムを2つのスケーラブルなサービス(座席の予約と支払い)に分割します。これらのサービスは完全に分離されており、統合はREST APIの呼び出しを使うのみです。

この予約サービスは、最初に座席を予約します。予約サービスはLRAトランザクションを新規で開始し、最初のトランザクション参加者としてトランザクションに参加します。LRAコーディネーターとのすべてのコミュニケーションは裏側で行われ、LRA IDを使ってアクセスできます。このIDは、リクエストヘッダーLong-Running-Actionとして、JAX-RSメソッドの新しいトランザクションに割り当てられています。なお、Lra#endfalseに設定されているため、JAX-RSメソッドが終了した後もLRAはアクティブなままであることに注意してください。

LRA#end
https://download.eclipse.org/microprofile/microprofile-lra-1.0/apidocs/org/eclipse/microprofile/lra/annotation/ws/rs/LRA.html#end–

    @PUT
    @Path("/create/{id}")
    // Create new LRA transaction which won't end after this JAX-RS method end
    // Time limit for new LRA is 30 sec
    @LRA(value = LRA.Type.REQUIRES_NEW, end = false, timeLimit = 30)
    @Produces(MediaType.APPLICATION_JSON)
    public Response createBooking(@HeaderParam(LRA.LRA_HTTP_CONTEXT_HEADER) URI lraId,
                                  @PathParam("id") long id,
                                  Booking booking) {

        // LRA ID assigned by coordinator is provided as artificial request header
        booking.setLraId(lraId.toASCIIString());

        if (repository.createBooking(booking, id)) {
            LOG.info("Creating booking for " + id);
            return Response.ok().build();
        } else {
            LOG.info("Seat " + id + " already booked!");
            return Response
                    .status(Response.Status.CONFLICT)
                    .entity(JSON.createObjectBuilder()
                            .add("error", "Seat " + id + " is already reserved!")
                            .add("seat", id)
                            .build())
                    .build();
        }
    }
Create new seat booking

座席の予約に成功すると、支払いサービスが同じLRAトランザクションの配下で呼び出されます。レスポンスには、クライアントからアクセスできるように、ヘッダLong-Running-Actionが含まれています。

    reserveButton.click(function () {
        selectionView.hide();
        createBooking(selectedSeat.html())
            .then(res => {
                if (res.ok) {
                    // Notice how we can access LRA ID even on the client side
                    let lraId = res.headers.get("Long-Running-Action");
                    paymentView.attr("data-lraId", lraId);
                    paymentView.show();
                } else {
                    res.json().then(json => {
                        showError(json.error);
                    });
                }
            });
    });

別のバックエンドリソースを、Long-Running-Actionヘッダーを再度設定することで、同じLRAトランザクションを使って呼び出すことも可能です。

    function makePayment(cardNumber, amount, lraId) {
        return fetch('/booking/payment', {
            method: 'PUT',
            headers: {
                'Content-Type': 'application/json',
                'Long-Running-Action': lraId
            },
            body: JSON.stringify({"cardNumber": cardNumber, "amount": amount})
        })
    }

Payment form

バックエンドは異なるサービスをJAX-RSクライアントを使って呼び出しますが、Long-Running-Actionヘッダーを設定してLRAトランザクションを伝播する必要はありません。すべてのJAX-RSクライアントの場合と同じく、LRA実装が自動的にやってくれます。

    @PUT
    @Path("/payment")
    // Needs to be called within LRA transaction context
    // Doesn't end LRA transaction
    @LRA(value = LRA.Type.MANDATORY, end = false)
    @Produces(MediaType.APPLICATION_JSON)
    public Response makePayment(@HeaderParam(LRA.LRA_HTTP_CONTEXT_HEADER) URI lraId,
                                JsonObject jsonObject) {
        LOG.info("Payment " + jsonObject.toString());
        // Notice that we don't need to propagate LRA header
        // When using JAX-RS client, LRA header is propagated automatically
        ClientBuilder.newClient()
                .target("http://payment-service:7002")
                .path("/payment/confirm")
                .request()
                .rx()
                .put(Entity.entity(jsonObject, MediaType.APPLICATION_JSON))
                .whenComplete((res, t) -> {
                    if (res != null) {
                        LOG.info(res.getStatus() + " " + res.getStatusInfo().getReasonPhrase());
                        res.close();
                    }
                });
        return Response.accepted().build();
    }

支払いサービスが別の参加者としてこのトランザクションに参加します。0000-0000-0000 以外の任意のカード番号でLRAトランザクションをキャンセルします。リソースメソッドが終了すると、LRA#endtrueに設定されるため、LRAトランザクションが完了します。

    @PUT
    @Path("/confirm")
    // This resource method ends/commits LRA transaction as successfully completed
    @LRA(value = LRA.Type.MANDATORY, end = true)
    @Produces(MediaType.APPLICATION_JSON)
    @Consumes(MediaType.APPLICATION_JSON)
    public Response makePayment(@HeaderParam(LRA.LRA_HTTP_CONTEXT_HEADER) URI lraId,
                                Payment payment) {
        if (!payment.cardNumber.equals("0000-0000-0000")) {
            LOG.warning("Payment " + payment.cardNumber);
            throw new IllegalStateException("Card " + payment.cardNumber + " is not valid! "+lraId);
        }
        LOG.info("Payment " + payment.cardNumber+ " " +lraId);
        return Response.ok(JSON.createObjectBuilder().add("result", "success").build()).build();
    }

支払いのオペレーションが失敗したり、タイムアウトが発生したりした場合、LRAトランザクションはキャンセルされ、トランザクション参加時に提供された補償リンク使ってすべての参加者に通知されます。LRAコーディネーターは @Compensate で注釈が付けられたメソッドをLRA idをパラメータとして付けて呼び出します。これだけで、予約サービスで座席の予約を解除して、別のお客様の利用に供することができます。

    @Compensate
    public Response paymentFailed(URI lraId) {
        LOG.info("Payment failed! " + lraId);
        repository.clearBooking(lraId)
                .ifPresent(booking -> {
                    LOG.info("Booking for seat " + booking.getSeat().getId() + "cleared!");
                    Optional.ofNullable(sseBroadcaster)
                            .ifPresent(b -> b.broadcast(new OutboundEvent.Builder()
                                    .data(booking.getSeat())
                                    .mediaType(MediaType.APPLICATION_JSON_TYPE)
                                    .build())
                            );
                });
        return Response.ok(ParticipantStatus.Completed.name()).build();
    }

Payment form

LRAを使ったCinema Bookingサンプルプロジェクトは以下のリポジトリからアクセスできます。

GitHubHelidon MicroProfile LRA example
https://github.com/danielkec/helidon-lra-example

このサンプルプロジェクトでは、Oracle Kubernetes EngineもしくはローカルのMinikubeにデプロイできるよう、簡単なKubernetes (k8s) サービスの設定を用意しています。

Minikube
https://minikube.sigs.k8s.io/docs/

Deploy to minikube

前提条件

  • minikubeをインストール、起動済みであること
  • minikubeのDockerデーモンがある環境 eval $(minikube docker-env)

Pushing directly to the in-cluster Docker daemon (docker-env)
https://minikube.sigs.k8s.io/docs/handbook/pushing/#1-pushing-directly-to-the-in-cluster-docker-daemon-docker-env

Build images

minikubeのDockerデーモンを直接操作するので、やるべきことはDockerイメージのビルドだけです。

bash build.sh;

最初のビルドの場合、すべてのアーティファクトをダウンロードするため数分かかる点にご注意ください。以後のビルドでは、依存関係のあるレイヤーをキャッシュ済みなのでビルドはずっと高速です。

Deploy to minikube

bash deploy-minikube.sh

このスクリプトは名前空間全体を再作成し、 cinema-reservation の以前の状態すべてを消去します。デプロイはNodePortで公開され、ポート付きのURLを出力の最後に出します。

namespace "cinema-reservation" deleted
namespace/cinema-reservation created
Context "minikube" modified.
service/booking-db created
service/lra-coordinator created
service/payment-service created
service/seat-booking-service created
deployment.apps/booking-db created
deployment.apps/lra-coordinator created
deployment.apps/payment-service created
deployment.apps/seat-booking-service created
service/cinema-reservation exposed
Application cinema-reservation will be available at http://192.0.2.254:31584

Deploy to OCI OKE cluster

前提条件

  • OKE K8s cluster
  • Git、docker、Oracle Container Engine for Kubernetes (OKE) クラスタにアクセスできるよう構成済みのkubectlを持つを備えたOCI Cloud Shell

Pushing images to your OCI Container registry

まず最初に、Dockerイメージをプッシュして、OKE K8sがプルできるようにするための場が必要です。コンテナレジストリはOCIテナントの一部なので、ログインが必要です。

docker login <REGION_KEY>.ocir.io

レジストリのユーザー名は

<TENANCY_NAMESPACE>/joe@example.com

です。ここで joe@example.com がOCIユーザーだとします。パスワードは joe@example.com の認可トークンです。リージョンキーとテナント名前空間を取得するには、以下のコマンドをOCI Cloud Shellで実行します。

# Get tenancy namespace and container registry
echo "" && \
echo "Container registry: ${OCI_CONFIG_PROFILE}.ocir.io" && \
echo "Tenancy namespace: $(oci os ns get --query "data" --raw-output)" && \
echo "" && \
echo "docker login ${OCI_CONFIG_PROFILE}.ocir.io" && \
echo "Username: $(oci os ns get --query "data" --raw-output)/joe@example.com" && \
echo "Password: --- Auth token for user joe@example.com" && \
echo ""

以下のような出力が出ます。

Container registry: eu-frankfurt-1.ocir.io
Tenancy namespace: fr8yxyel2vcv

docker login eu-frankfurt-1.ocir.io
Username: fr8yxyel2vcv/joe@example.com
Password: --- Auth token for user joe@example.com

コンテナレジストリ、名前空間、認可トークンは後の作業のために保存しておきましょう。

ローカルのDockerでOCI Container Registryにログインしている場合、パラメータとしてコンテナレジストリとテナント名前空間を使い、 build-oci.sh を実行できます。

以下は実行例です。

bash build-oci.sh eu-frankfurt-1.ocir.io fr8yxyel2vcv

出力結果は以下のようになります。

docker build -t eu-frankfurt-1.ocir.io/fr8yxyel2vcv/cinema-reservation/payment-service:1.0 .
...
docker push eu-frankfurt-1.ocir.io/fr8yxyel2vcv/cinema-reservation/seat-booking-service:1.0
...
docker build -t eu-frankfurt-1.ocir.io/fr8yxyel2vcv/cinema-reservation/seat-booking-service:1.0 .
...
docker push eu-frankfurt-1.ocir.io/fr8yxyel2vcv/cinema-reservation/payment-service:1.0
...

スクリプトは実行前にdocker buildコマンドを表示します。最初のビルドではすべてのアーティファクトをダウンロードするために数分かかる点にご注意ください。以後のビルドでは依存関係のレイヤーをキャッシュ済みなのでずっと高速にビルドします。

プッシュしたイメージを公開するためには、OCIコンソースを開いて両リポジトリをPublicに設定します。

Developer Tools>Containers & ArtifactsContainer Registry

https://cloud.oracle.com/registry/containers/repos

Deploy to OKE

OCI Cloud Shellでクローンされた helidon-lra-example リポジトリを、K8s descriptorと一緒に利用できます。以前の手順でプッシュしたイメージに変更を加えます。

OCI Cloud shellでの操作例です。

git clone https://github.com/danielkec/helidon-lra-example.git
cd helidon-lra-example
bash deploy-oci.sh

kubectl get services

出力結果は以下のようになります。

NAME                         TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)          AGE
booking-db                   ClusterIP       192.0.2.254    <none>        3306/TCP         34s
lra-coordinator              NodePort        192.0.2.253    <none>        8070:32434/TCP   33s
oci-load-balancing-service   LoadBalancer    192.0.2.252    <pending>     80:31192/TCP     33s
payment-service              NodePort        192.0.2.251    <none>        8080:30842/TCP   32s
seat-booking-service         NodePort        192.0.2.250    <none>        8080:32327/TCP   32s

デプロイの直後、OCI がプロビジョニングを行っているため、外部ロードバランサーの EXTERNAL-IP <pending> になっていることがわかります。少し後にkubectl get servicesを実行すると、Helidon Cinema exampleが80番ポートで公開されている外部IPアドレスが表示されていることがわかります。

Conclusion

分散システムの整合性を補償ロジックで維持するというアイデアは新しいものではありませんが、特別なツールなしに実現するのは非常に複雑です。MicroProfile Long Running Actionsはまさにそのようなツールであり、複雑さを隠蔽してくれるので、ビジネスロジックに集中できるようにしてくれます。

現在、他のLRAコーディネーターとの互換性や、メッセージングにおけるLRAコンテキストのサポートなど、さらなるエキサイティングな機能にすでに取り組んでいます。

是非ご期待ください。

Resources

コメントを残す

以下に詳細を記入するか、アイコンをクリックしてログインしてください。

WordPress.com ロゴ

WordPress.com アカウントを使ってコメントしています。 ログアウト /  変更 )

Google フォト

Google アカウントを使ってコメントしています。 ログアウト /  変更 )

Twitter 画像

Twitter アカウントを使ってコメントしています。 ログアウト /  変更 )

Facebook の写真

Facebook アカウントを使ってコメントしています。 ログアウト /  変更 )

%s と連携中