フレクトのクラウドblog re:newal

http://blog.flect.co.jp/cloud/からさらに引っ越しています

Microsoft AzureでSpring Boot製バッチを動かすために(1)

こんにちは。クラウドインテグレーション事業部の上原です。

弊社では「クラウドインテグレーション」の名前の通り、様々なクラウドサービスを活用したシステムを提供しています。

www.flect.co.jp

私がいま参加しているプロジェクトでは、インフラとして
ググっても情報が出てこないことでおなじみ Microsoft Azureを採用しています。
このプロジェクトで新しくバッチアプリケーションを作成することになり、そのためのサービスや構成について色々と調査する機会がありましたので、今回はこちらを共有させて頂こうと思います。

対象アプリケーション

環境

  • macOS Catalina
  • Docker 19.03.12
  • azure-cli 2.10.1

Azure CLI のインストール | Microsoft Docs

要件

あまり具体的には言及できないので、仮に「社用車の動態管理システム」と置き換えて説明します。

今回作成するバッチは、15分間隔で以下の処理を行います。

  • 支社(Branch)の一覧をDBから取得する
  • 支社毎に、管理されている社用車(Vehicle)の走行データを外部システムから収集する
  • 社用車がどのように移動したかを分析し、社用車に紐づく取引先への訪問履歴レコード(VisitHistory)を生成する
  • 訪問履歴レコードをDBに登録する

このバッチはSpring Boot製の既存アプリケーションの拡張という位置づけであり、既存の資産を流用する必要があります。 そのため、このバッチもSpring Bootで実装し、できるだけコードがAzureに依存しないような構成を目指しました。

構成1:とにかく動かしてみる

まずはとにかく動かしてみましょう。
15分おきの実行制御から分析処理までをアプリケーション内で完結させ、それをサーバ上で常時稼働させます。

f:id:flect-uehara:20200830164354p:plain

Azureでサーバインスタンスを立ち上げるには、App Service というサービスを使用します。
まずは稼働するサーバの性能を定義する App Service Plan を作成し、その中にVMにあたるApp Serviceを作成します。 この辺り、AWSGCPとはちょっと勝手が異なりますね。

Azureには、Dockerイメージをプライベートに格納できる Azure Container Registry(ACR) というサービスがあります。
そしてACRには、イメージをpushすると自動的にApp Serviceまでデプロイしてくれる機能があります。ありがたく使わせていただきましょう。

docs.microsoft.com

アプリケーションの実装

アプリケーションの方ですが、Spring Batchを使って実装してみます。

まずは Spring Initializr(https://start.spring.io/)で雛形を生成。プロジェクトを立ち上げます。
バージョンは既存のアプリケーションに合わせるため、ちょっと古めです。

f:id:flect-uehara:20200831005233p:plain

 

Spring BatchにはChunk方式とTasklet方式があるのですが、今回は既存の資産を使い回すこともあり、記述の自由度が高いTasklet方式を採用します。
バッチの起動スケジュールやJobの定義はConfigurationに記述し、実装はTaskletに書いていきます。

spring.io

f:id:flect-uehara:20200831103907p:plain

▼Application.java

@SpringBootApplication
@EnableScheduling // スケジューリングを有効化
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

▼MyConfiguration.java

@Configuration
@EnableBatchProcessing
public class MyConfiguration {

    private final JobLauncher jobLauncher;

    private final JobBuilderFactory jobBuilderFactory;

    private final StepBuilderFactory stepBuilderFactory;

    private final MyTasklet myTasklet;

    @Autowired
    public MyConfiguration(
            JobLauncher jobLauncher,
            JobBuilderFactory jobBuilderFactory,
            StepBuilderFactory stepBuilderFactory,
            MyTasklet myTasklet) {
        this.jobLauncher = jobLauncher;
        this.jobBuilderFactory = jobBuilderFactory;
        this.stepBuilderFactory = stepBuilderFactory;
        this.myTasklet = myTasklet;
    }

    @Scheduled(cron = "0 */1 * * * *") // ここで実行間隔を設定
    public void schedule() throws
            JobParametersInvalidException,
            JobExecutionAlreadyRunningException,
            JobRestartException,
            JobInstanceAlreadyCompleteException {
        jobLauncher.run(job(), new JobParametersBuilder().toJobParameters());
    }

    @Bean
    public Job job() {
        return this.jobBuilderFactory.get("job")
                .incrementer(new RunIdIncrementer())
                .start(step())
                .build();
    }

    @Bean
    public Step step() {
        return this.stepBuilderFactory.get("step")
                .allowStartIfComplete(true)
                .tasklet(myTasklet)
                .build();
    }
}
@Component
public class MyTasklet implements Tasklet {

    @Override
    public RepeatStatus execute(@Nullable StepContribution contribution, @Nullable ChunkContext chunkContext) {

        List<Branch> branches = new ArrayList<>();

        // (支社リストをDBから取得する処理)

        List<VisitHistory> visitHistories = branches.parallelStream()
                .map(this::analyze)
                .flatMap(Collection::stream)
                .collect(Collectors.toList());

        // (訪問履歴を登録する処理)

        return RepeatStatus.FINISHED;
    }

    private List<VisitHistory> analyze(Branch branch) {
        List<VisitHistory> results = new ArrayList<>();

        // (解析処理)

        return results;
    }
}

 

デフォルトの挙動だと起動直後にバッチ処理が実行されてしまうため、設定ファイル(yaml)から自動実行をオフにします。
DBへの接続情報も忘れずに書いておきましょう。build.gradle のdependenciesにjdbcドライバを追加しておく必要もあります。

▼application.yml

spring:
  datasource:
    url: # (jdbc接続文字列)
    driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
  batch:
    job:
      enabled: false

▼build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-batch'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    runtimeOnly 'com.microsoft.sqlserver:mssql-jdbc:8.4.0.jre8'
        // 以下略
}

 

では、まずはローカルで動かしてみます。

$ ./gradlew bootRun

f:id:flect-uehara:20200830171848p:plain

ちゃんと定義どおりのスケジュールで動いてくれているようです。

Azureへのデプロイ

続いて、作成したアプリケーションをAzure環境にデプロイします。
なお、リソース(ACR・App Service)はあらかじめAzureポータル上で作成済であるものとします。

プロジェクト直下にDockerfileを作成し、コンテナの起動と同時にアプリケーションが実行されるように設定します。

https://docs.microsoft.com/ja-jp/azure/container-registry/container-registry-get-started-docker-cli

▼Dockerfile

FROM adoptopenjdk/openjdk8:alpine
EXPOSE 8080
ARG JAR_FILE=build/libs/*.jar
COPY ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

 

App Service側の設定も必要です。
App Serviceはデフォルトでポート80をリッスンする仕様になっているため、これをアプリに合わせて8080に変更します。 継続的デプロイ*1やログの有効化も忘れずに行いましょう。 *2

▼setupAppService.sh

resourceGroupName=my-resource-group # リソースグループ名
appName=my-batch-app # App Serviceのリソース名
dockerName=my_batch # イメージ名
registryName=mybatchregistry # ACRのリソース名

# 環境変数の設定
az webapp config appsettings set --resource-group ${resourceGroupName} \
                                 --name ${appName} \
                                 --settings WEBSITES_PORT=8080 &&

# デプロイ設定
az webapp config container set --name ${appName} \
                               --resource-group ${resourceGroupName} \
                               --docker-custom-image-name ${registryName}.azurecr.io/${dockerName}:latest \
                               --docker-registry-server-url https://${registryName}.azurecr.io &&

# 継続的デプロイの有効化
az webapp deployment container config --name ${appName} \
                                      --resource-group ${resourceGroupName} \
                                      --enable-cd true &&

# ログの有効化
az webapp log config --resource-group ${resourceGroupName} \
                     --name ${appName} \
                     --docker-container-logging filesystem

 

それでは、Dockerイメージを作成し、ACRにデプロイしてみましょう。

▼deploy.sh

dockerName=my_batch # イメージ名
registryName=mybatchregistry # ACRのリソース名

# ビルド
./gradlew clean build -x test &&
docker build . -t ${dockerName} &&

# デプロイ
az acr login --name ${registryName} &&
docker tag ${dockerName} ${registryName}.azurecr.io/${dockerName}:latest  &&
docker push ${registryName}.azurecr.io/${dockerName}:latest &&
docker rmi ${registryName}.azurecr.io/${dockerName}:latest

▼ Azure Portalの「コンテナーの設定」

f:id:flect-uehara:20200831101112p:plain

無事に動き始めました。

デメリット

以上でひとまず動くようになりましたが、この構成にはいろいろと問題があります。

App Serviceには自動/手動でインスタンス数を変える(スケールアウト)機能があるのですが、
今の実装は1アプリケーション内で処理が完結する前提になっているため、何かしらの工夫をしない限り、台数を増やすことができません。

docs.microsoft.com

幸い、分析処理は支社毎に独立して実行できるため、キューを利用してメッセージ数(支社数)に応じてスケールするパターンが適用できそうです。

また、サーバは常時稼働となるため、待機中も料金がかかり続けます。
Azureにはサーバレスの仕組みがあるので、うまく利用して実行中だけ課金されるようにしたいですね。

…こうしてどんどん深みにハマっていくわけですが、詳しくは次回の記事でご説明しようと思います。
それでは!

*1:継続的デプロイを有効化すると、イメージのpushを検知して自動で新しいイメージに切り替えてくれるのですが…CLIで有効化した場合、Webhookの生成がうまくいかずに再起動しないと切り替わらない状態になることがあるようです。App Serviceの「コンテナーの設定」メニューで「保存」ボタンを押したら直りました。

*2:私はここの設定周りで半日ほどハマりました。単純に反映が遅いだけのケースも多々ありますが、起動時にエラーが起きている可能性もあるので、うまく動かないときはApp Serviceの「高度なツール」メニューからログをダウンロードしてみると良いです。