継続的インテグレーション環境の構築(2)

【連載】

AWSで実践! 基盤構築・デプロイ自動化

【第41回】継続的インテグレーション環境の構築(2)

[2020/09/23 09:00]川畑 光平 ブックマーク ブックマーク

本連載では、AWSリソース基盤構築の自動化を実践しています。現在は、第2回から解説してきた継続的インテグレーション環境をCloudFormationを使って自動構築する方法について解説しています。

構成図

前回は、SonarQubeServerのベース環境となるAWSリソース(VPC、セキュリティグループ、ALB、ECSクラスタ)の実装とRDS環境を作成しました。続く今回は、CloudFormationのカスタムリソースを使って、RDS構築後のデータベースにユーザーを追加するLambdaファンクションを実装します。

実装にあたって使用するランタイムライブラリは、以下の通りです。

動作対象 バージョン
Java 1.8
Spring Boot 2.3.2.RELEASE
Spring Cloud Function 3.0.8.RELEASE
Spring Cloud AWS 2.2.2.RELEASE
AmazonSDK Lambda Java event 2.0.2
AmazonSDK Lambda Java core 1.1.0

なお、実際のソースコードはGitHub上にコミットしています。以降のソースコードでは本質的でない記述を一部省略しているので、実行コードを作成する場合は、必要に応じて適宜GitHub上のソースコードも参照してください。

CloudFormationカスタムリソースとLambdaファンクションの実装

CloundFormationのカスタムリソースとは、ユーザーが任意のリソースタイプ名を定義し、CloudFormationスタックとして利用できる機能です。一方、AWS Lambda-backedカスタムリソースは、AWS Lambdaファンクションと関連付けることができるカスタムリソースで、CloudFormationスタックが作成、更新、削除される度にLambdaファンクションを呼び出すことができます。

今回は、連載「AWSで作るクラウドネイティブアプリケーションの基本」の第1回でも解説したSpring Cloud Functionや、第12回でも解説したSpring Cloud AWSを使用して、RDSに初期処理を行う(ユーザーの作成を行うDDLを実行する)Lambdaファンクションを実装します。

以下の通り、spring-cloud-functionやspring-cloud-starter-aws、DDLを実行するのでJDBC Templateが使えるspring-boot-starter-data-jdbcを含めておいてください。また、RDSのエンドポイントやパスワードはCloudFormationのスタック情報やSystems Manager Parameter Storeから取得するので、AWS SDKの「aws-java-sdk-ssm」「aws-java-sdk-core」、Lambdaファンクション実装のための「aws-lambda-java-core」「aws-lambda-java-events」も必要になります。

CloudFormationのスタック情報からデータを取得する実装の詳細は本連載の第34回も適宜参考にしてください。今回は、設定クラスなど既に解説したものは省略して、差分があるポイントのみ説明していきます。

<dependencies>
  <dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-data-jdbc</artifactId>
  </dependency>
  <dependency>
     <groupId>org.springframework.cloud</groupId>
     <artifactId>spring-cloud-function-context</artifactId>
  </dependency>
  <dependency>
     <groupId>org.springframework.cloud</groupId>
     <artifactId>spring-cloud-function-adapter-aws</artifactId>
  </dependency>
  <dependency>
     <groupId>org.springframework.cloud</groupId>
     <artifactId>spring-cloud-starter-aws</artifactId>
  </dependency>
  <dependency>
     <groupId>org.springframework.cloud</groupId>
     <artifactId>spring-cloud-starter-aws-jdbc</artifactId>
  </dependency>
  <dependency>
     <groupId>com.amazonaws</groupId>
     <artifactId>aws-lambda-java-events</artifactId>
     <version>2.0.2</version>
  </dependency>
  <dependency>
     <groupId>com.amazonaws</groupId>
     <artifactId>aws-lambda-java-core</artifactId>
     <version>1.1.0</version>
  </dependency>
  <dependency>
     <groupId>com.amazonaws</groupId>
     <artifactId>aws-java-sdk-ssm</artifactId>
     <version>1.11.756</version>
  </dependency>
  <dependency>
     <groupId>com.amazonaws</groupId>
     <artifactId>aws-java-sdk-core</artifactId>
     <version>1.11.756</version>
  </dependency>

連載「AWSで作るクラウドネイティブアプリケーションの基本」の第1回と同様、HandlerクラスとFunctionクラスを実装します。これまでのhandleRequest()メソッドは継承クラスのものをオーバーライドするのみでしたが、今回はCloudFormationからの複数のイベントをハンドリングするので以下の通り実装します。

package org.debugroom.mynavi.sample.sonarqube.initdb.app.handler;

import com.amazonaws.services.lambda.runtime.Context;
import lombok.extern.slf4j.Slf4j;
import org.debugroom.mynavi.sample.sonarqube.initdb.app.CloudFormationResponseSender;
import org.debugroom.mynavi.sample.sonarqube.initdb.app.model.Status;
import org.springframework.cloud.function.adapter.aws.SpringBootRequestHandler;
import reactor.core.publisher.Flux;

import java.util.Map;
import java.util.Objects;

@Slf4j
public class LambdaTriggerHandler extends
     SpringBootRequestHandler<Map<String, Object>, String> {                                       // (A)

    @Override
    public Object handleRequest(Map<String, Object> event, Context context) {                      // (B)
        for(String key : event.keySet()){
            log.info("[Key]" + key + " [Value]" + event.get(key).toString());                      // (C)
        }
        Object requestType = event.get("RequestType");                                             // (D)
        if(requestType != null && Objects.equals(requestType.toString(), "Delete")){               // (E)
            CloudFormationResponseSender.send(event, context, Status.SUCCESS,
              event.get("ResourceProperties"), event.get("PhysicalResourceId").toString(), false); // (F)
            return Flux.just("Complete!");                                                         // (G)
        }
        Object result = super.handleRequest(event, context);                                       // (H)
        if(requestType != null && result instanceof Flux){
            CloudFormationResponseSender.send(event, context, Status.SUCCESS,
              event.get("ResourceProperties"), null, false);                                       // (I)
        }
        return result;
     }

 }

各実装の詳細は以下の通りです。

項番 説明
A CloudFormationからのイベントを受け取るため、SpringBootRequestHandlerをInput型パラメータとしてMap<String Object>型を、Output型パラメータとしてStringを指定します
B handleRequest()メソッドをオーバーライドします。CloudFormationからは、スタックの作成、更新、削除といったイベントでLambdaファンクションがコールされるため、イベントに応じて異なる処理を行うように実装します
C イベントのパラメータをログ出力して表示させます。このログはCloudWatchに出力されますが、トラブルシューティング時に必要なデータになります。
D イベントの種別を取得します。スタックの作成、更新、削除がそれぞれ"Create"、"Update"、"Delete"に対応します
E スタックを削除する場合の処理を実装します
F イベントのパラメータに含まれているResponseURLに、"SUCCESS"シグナルを送信します。実装の詳細は後述します。この処理がなければ、スタックの削除処理が完了しません
G 処理完了後は適当な文字列を出力します。この文字列はCloudWatch上に出力されるので、完了通知などに利用することができます
H Functionクラスに実装した処理を呼び出します。実装の詳細は後述します。
I Hの実行後、イベントのパラメータに含まれているResponseURLに、"SUCCESS"シグナルを送信します(実装の詳細は後述します)。この処理がなければ、スタックの作成/更新処理が完了しません

また、CloudFormationにシグナルを送信するためのCloudFormationResponseSenderの実装は以下の通りです。

package org.debugroom.mynavi.sample.sonarqube.initdb.app;

importcom.amazonaws.services.lambda.runtime.Context;
importcom.fasterxml.jackson.databind.ObjectMapper;
importcom.fasterxml.jackson.databind.PropertyNamingStrategy;
importlombok.extern.slf4j.Slf4j;
importorg.debugroom.mynavi.sample.sonarqube.initdb.app.model.CfnResponse;
importorg.debugroom.mynavi.sample.sonarqube.initdb.app.model.Status;
importorg.springframework.boot.web.client.RestTemplateBuilder;
importorg.springframework.http.HttpEntity;
importorg.springframework.http.HttpHeaders;
importorg.springframework.http.HttpMethod;
importorg.springframework.http.MediaType;
importorg.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
importorg.springframework.util.LinkedMultiValueMap;
importorg.springframework.util.MultiValueMap;
importorg.springframework.web.client.RestOperations;
importorg.springframework.web.util.UriComponentsBuilder;

importjava.io.UnsupportedEncodingException;
importjava.net.URI;
importjava.net.URLDecoder;
importjava.net.URLEncoder;
importjava.nio.charset.StandardCharsets;
importjava.util.Arrays;
importjava.util.Collections;
importjava.util.Map;

@Slf4j
public class CloudFormationResponseSender {

    public static <T> void send(Map<String, Object> event, Context context,
                          Status status, T responseData, String physicalResourceId,
                          boolean noEcho){
        Object responseURL = event.get("ResponseURL");                              // (A)

        ObjectMapper mapper = new ObjectMapper()
          .setPropertyNamingStrategy(PropertyNamingStrategy.UPPER_CAMEL_CASE);      // (B)
        MappingJackson2HttpMessageConverter converter =
          new MappingJackson2HttpMessageConverter();
        converter.setObjectMapper(mapper);
        RestOperations restOperations = new RestTemplateBuilder()
          .messageConverters(Arrays.asList(converter))
          .build();                                                                 // (C)

        if(responseURL != null){
            log.info("ResponseURL : " + responseURL.toString());
            CfnResponse<T> cfnResponse = CfnResponse.<T>builder()
              .Status(status)
              .Reason("See the details in CloudWatch Log Stream: " + context.getLogStreamName())
              .PhysicalResourceId(physicalResourceId == null ?
                      context.getLogStreamName(): physicalResourceId)
              .StackId(event.get("StackId").toString())
              .RequestId(event.get("RequestId").toString())
              .LogicalResourceId(event.get("LogicalResourceId").toString())
              .NoEcho(noEcho)
              .Data(responseData)
              .build();                                                              // (D)

            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            HttpEntity<CfnResponse> requestEntity = new HttpEntity<>(cfnResponse, headers);
            restOperations.exchange(getUri(responseURL.toString()),
              HttpMethod.PUT, requestEntity, Void.class);                            // (E)
       }else {
           throw new IllegalStateException("No ResponseURL to send.");
       }
    }

    protected static URI getUri(String responseURL){                                 // (F)

        String url = responseURL.split("\\?")[0];
        MultiValueMap params = new LinkedMultiValueMap<>();

        try {
            String[] urlParams = responseURL.split("\\?")[1].split("&");
            for(String param : urlParams){
                String key = param.split("=")[0];
                String value = URLDecoder.decode(param.split("=")[1], StandardCharsets.UTF_8.toString());
                params.put(key, Collections.singletonList(URLEncoder.encode(value, "UTF-8")));
            }
        }catch (UnsupportedEncodingException e){
            e.printStackTrace();
        }

        return UriComponentsBuilder.fromHttpUrl(url)
          .queryParams(params)
          .build(true)
          .toUri();
    }
}

実装のポイントは以下の通りです。

項番 説明
A インプットパラメータから、ResponseURLを抽出します
B CloudFormationに送信するJSON文字列パラメータの変数は英大文字で始まるため、JacksonのマッパーライブラリのPropertyNamingStrategyをUPPER_CAMEL_CASEに指定します
C Bを設定したコンバータをセットして、RestOperationsを生成します
D 送信するJSON文字列パラメータを生成します。パラメータの詳細はAWSドキュメント「cfn-response モジュール」 を参考にしてください
E ResponseURLに対し、DのパラメータやHTTPヘッダを付与して、PUTメソッドでリクエストを送信します
F 既にURLエンコードされたResponseURLが2重エンコードにならないよう、パラメータを分解して取得し直してからURIを再作成します

なお、使用する通信ライブラリによっては、既にURLエンコードされた文字列を2重にエンコードして無効なリクエストにしてしまう場合があるため、ライブラリの仕様に注意して実装する必要があります。

また、Handlerから呼び出されるFunctionクラスの実装は以下の通りです。JdbcTemplateを使ってDDLをRDSに対して実行します。パスワードなどの秘匿データはSystemsManager ParameterStoreから取得する実装にしています。

package org.debugroom.mynavi.sample.sonarqube.initdb.app.function;

import com.amazonaws.services.simplesystemsmanagement.AWSSimpleSystemsManagement;
import com.amazonaws.services.simplesystemsmanagement.model.GetParameterRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import reactor.core.publisher.Flux;

import java.util.Map;
import java.util.function.Function;

@Slf4j
public class InitDBFunction implements Function <Map <String, Object>, Flux<String>> {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Autowired
    AWSSimpleSystemsManagement awsSimpleSystemsManagement;

    @Override
    public Flux<String> apply(Map<String, Object> stringObjectMap) {
        GetParameterRequest request = new GetParameterRequest();
        request.setName("mynavi-sonarqube-rds-sonar-password");
        request.setWithDecryption(true);
        jdbcTemplate.execute("CREATE ROLE sonar WITH LOGIN PASSWORD '"
          + awsSimpleSystemsManagement.getParameter(request).getParameter().getValue()
          + "';");
        return Flux.just("Complete!");
    }
}

第1回のやり方と同じく、実装したソースコードをLambdaファンクションとしてビルドして、前回作成したS3バケットにアップロードするシェルスクリプトを作成して実行します。

#!/usr/bin/env bash

bucket_name=debugroom-mynavi-sonarqube-cfn-lambda-bucket
stack_name="mynavi-sonarqube-s3-lambda"
template_path="rds/1-s3-lambda-deploy-cfn.yml"
s3_objectkey="mynavi-sample-sonarqube-initdb-0.0.1-SNAPSHOT-aws.jar"

if [ "" == "`aws s3 ls | grep $bucket_name`" ]; then
    aws cloudformation deploy --stack-name ${stack_name} --template-file ${template_path} --capabilities CAPABILITY_IAM
fi

cd rds/lambda-sonarqube-dbinit
./mvnw package
aws s3 cp target/${s3_objectkey} s3://${bucket_name}/

今回は、RDSへの初期化処理を行うLambdaファンクションを実装してS3へアップロードしました。次回は、Lambdaデプロイやカスタムリソースを実行するCloudFormationテンプレートを実装します。

著者紹介


川畑 光平(KAWABATA Kohei) - NTTデータ

金融機関システム業務アプリケーション開発・システム基盤担当、ソフトウェア開発自動化関連の研究開発を経て、デジタル技術関連の研究開発・推進に従事。

Red Hat Certified Engineer、Pivotal Certified Spring Professional、AWS Certified Solutions Architect Professional等の資格を持ち、アプリケーション基盤・クラウドなど様々な開発プロジェクト支援にも携わる。AWS Top Engineers & Ambassadors選出。

本連載の内容に対するご意見・ご質問は Facebook まで。

※ 本記事は掲載時点の情報であり、最新のものとは異なる場合がございます。予めご了承ください。

一覧はこちら

連載目次

関連リンク

この記事に興味を持ったら"いいね!"を Click
Facebook で IT Search+ の人気記事をお届けします
注目の特集/連載
[解説動画] Googleアナリティクス分析&活用講座 - Webサイト改善の正しい考え方
[解説動画] 個人の業務効率化術 - 短時間集中はこうして作る
ミッションステートメント
教えてカナコさん! これならわかるAI入門
AWSではじめる機械学習 ~サービスを知り、実装を学ぶ~
対話システムをつくろう! Python超入門
Kubernetes入門
SAFeでつくる「DXに強い組織」~企業の課題を解決する13のアプローチ~
PowerShell Core入門
AWSで作るマイクロサービス
マイナビニュース スペシャルセミナー 講演レポート/当日講演資料 まとめ
セキュリティアワード特設ページ

一覧はこちら

今注目のIT用語の意味を事典でチェック!

一覧はこちら

会員登録(無料)

ページの先頭に戻る