Pentaho + AWSで、GUIなETLをコンテナ化してサーバー管理工数を減らしてみた(3/3)

# はじめに
PentahoはGUIでETLを定義できるETLツールですが、本番実行するには通常 RHELなどサーバーが必要です。
ただ１つサーバーで複数のETLジョブを同時実行すると、１つのETLジョブが原因で他のETLジョブが異常終了してしまうことがあります。

この問題はコンテナを活用することで解決できます。また、AWSのサービスを組み合わせることでサーバーの管理工数を減らすことができます。

記事の全体像は[前々回](https://qiita.com/Tomohiko_Takeuchi/items/ddd198f7ac2e7d368a2d)をご参照ください。

今回は[前回](https://qiita.com/Tomohiko_Takeuchi/items/65b0c52299afa9f7e91b)の続きで、前回で作成したECSにETLジョブ（タスク）を実行させるUIを作成します。AWS Management ConsoleでもETLジョブを実行できますが、簡単にETLジョブを実行できるWebUIを用意します。



# 記事の構成
1. [Pentahoのコンテナイメージを作成しAmazon ECR (以降、ECR)にpushする](https://qiita.com/Tomohiko_Takeuchi/items/ddd198f7ac2e7d368a2d "Pentahoのコンテナイメージを作成しAmazon ECR (以降、ECR)にpushする")
2. [1.のコンテナイメージをAmazon ECS で動かす](https://qiita.com/Tomohiko_Takeuchi/items/65b0c52299afa9f7e91b "1.のコンテナイメージをAmazon ECS で動かす")
3. ETLジョブ実行用のWebUIを作成する (←今回)


# 構成図
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/deb41e76-b768-4e78-a05f-fa77505bdf2a.png)


ブラウザーとのやりとりはAmazon API Gateway(以下 API GW)が行います。
1. ブラウザーからAPI GWのURLへアクセスします
1. API GWはS3から取得したhtmlとjavascriptのファイルをブラウザーへ返します
1. ユーザーはWebUIの実行ボタンをクリックします。そうすると、API GWを経由してAWS Lambda(以下、Lambda)にアクセスします
1. LambdaはAWS SDKを使用してETLジョブを起動します

また、ETL定義の一覧やETLジョブの一覧も同様の流れで取得します。


# 事前準備
* LambdaからECSを呼び出せるロールを作成 (ここでは`PentahoServerlessLambda`とします)
* API GWからS3へアクセスできるロールを作成(ここでは`APIGatewayS3ProxyPolicy`とします)

# 1. Lambda関数の作成
以下のLambda関数を作成します。
| # | 関数名 | 内容 |
|:--|:--|:--|
|1|listetldefs|S3に格納されているETL定義の一覧を返します|
|2|runetljob|指定されたETL定義を実行します|
|3|listetljobs|実行したETLジョブの一覧を返します|

## 1.1 listetldefsの作成
[AWS Management Console]→[Lambda]をクリックします。
左ペインの[関数]をクリックします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/6e6745ee-7cae-4f77-ad87-c153fe1d1bb1.png)

右上の[関数を作成]をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/e660830d-e472-47b9-8a7b-813b3035a666.png)

以下のような画面になるので、関数名(`listetldefs`) と事前に作成したロールを設定し[関数の作成]をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/cdfa5500-b905-47ca-b9f7-e0ceedbce6df.png)

コード編集する画面になるので、以下のコードを入力します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/12d015f5-adc7-4d56-be32-8b4b45e8c6d1.png)
(`<bucketname>` にはETL定義を格納しているS3のバケットを入力してください)
```javascript
import {
    S3Client,
    S3ServiceException,
    paginateListObjectsV2,
  } from "@aws-sdk/client-s3";
  
  
  export const handler = async (event) => {
    const client = new S3Client({});
    const objects = [];
    const paginator = paginateListObjectsV2(
        { client,  pageSize: 1000 },
        { Bucket: '<bucketname>' },
    );
    for await (const page of paginator) {
        objects.push(page.Contents.filter(o => o.Key.endsWith('.ktr')).map((o) => ({
            path: o.Key,
        })));
    }
    console.log("Ktr list", objects.flat());
  
    return objects.flat();
  };
```

コードを入力後、デプロイしてテストします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/eac5fc60-52f8-4956-b4c5-349d5cf973d6.png)

## 1.2 runetljobの作成
作成の流れは`listetldefs`と同様です。
コードについては以下を入力してください
(`cluster: "ecs"` `taskDefinition: "pdi-test"` `name: "pdi"`は前回作成したクラスター名、タスク定義、コンテナ名を入力してください。)
```javascript
import { ECSClient, RunTaskCommand } from "@aws-sdk/client-ecs";

/**
 * etldefに記載されたETL定義を実行する。
 * event.etldef: etl definition file path
 * 
 * @param {etldef: string} event 
 */
export const handler = async (event) => {

  const etldef = event.etldef;
  console.log("etldefpath", etldef);
  const params = {
      cluster: "ecs",
      taskDefinition: "pdi-test",
      count: 1,
      overrides: { // TaskOverride
        containerOverrides: [ // ContainerOverrides
          { // ContainerOverride
            name: "pdi",
            command: [
              "/opt/pdi/data-integration/pan.sh",
              "-file",
              'pvfs://s3/' + etldef
            ],
          },
        ],
      },
      tags: [ // タグにETL定義のパスを設定する
        { 
          key: "etldef",
          value: etldef,
        },
      ],
  };
  
  try {
      const client = new ECSClient({ region: "ap-northeast-1" });
      const data = await client.send(new RunTaskCommand(params));
      console.log("Task started successfully", data);
      const response = {
        statusCode: 200,
        body: JSON.stringify(data),
      };
      return response;
  } catch (err) {
    const response = {
      statusCode: 500,
      body: JSON.stringify(err ),
    };
    return response;
  }

};
```
## 1.3 listetljobsの作成
作成の流れは`listetldefs`と同様です。
コードについては以下を入力してください。(`clusternmae = "ecs"`には前回作成したクラスター名を入力してください)

```javascript
import { ECSClient, ListTasksCommand,DescribeTasksCommand } from "@aws-sdk/client-ecs";

/**
 * ステータスを引数に、ステータスのETLジョブ一覧を取得する 
 * @returns [{id: string, etldef: string, start: Date, stop: Date}]
 * */ 
async function getTasks(status)  {
  const client = new ECSClient({ region: "ap-northeast-1" });

  const clusternmae = "ecs" // クラスター名

  const params = {
    cluster: clusternmae,
    desiredStatus: status,
  };
  const data = await client.send(new ListTasksCommand(params));
  // console.log("Task listed successfully", data);
  if (data.taskArns.length === 0){
    return [];
  } else {
    const myArns = {
      cluster: clusternmae,
      tasks: [ 
      ],
      include: [ // TAGにETL定義のパスが入っているのでTAG情報も取得する
        "TAGS",
      ],
    };
    myArns.tasks = data.taskArns
    const descData = await client.send(new DescribeTasksCommand(myArns));
    // console.log(JSON.stringify(descData,null,'\t'));

    const tasks = descData.tasks.map(task => ({
        id: task.taskArn,
        etldef: task.tags[0] ? task.tags[0].value : undefined ,
        start: task.startedAt,
        stop: task.stoppedAt,
      }
    ));
    return tasks;
  }
};

/**
 * クラスターで動いているETLジョブの一覧を返す
 * @returns [{id: string, etldef: string, start: Date, stop: Date}]
 */
export const handler = async (event) => {
  
  const statuses = ["PENDING", "RUNNING", "STOPPED"];

  const tasks = await Promise.all(statuses.map(status => getTasks(status)));
  // const tasks = await getTasks("STOPPED");

  console.log("Task listed successfully", tasks.flat());
  return tasks.flat();
};
```

# 2. HTML/JavaScriptファイルの格納
HTMLファイルとJavaScriptファイルをS3に格納します。
格納場所はそれぞれ以下としています。
* HTML: \<bucketname>/sample/serverless/mngetljobs.html
* JavaScript: \<bucketname>/sample/serverless/mngetljobs_script.js

## 2.1 HTMLの内容
HTMLの内容は以下になります。jQueryとBootstrapを使用しています。

```HTML
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ETL実行画面</title>
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/css/bootstrap.min.css" rel="stylesheet">
    <style>
        tr.selected {
            background-color: #19a3e4;
        }
    </style>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.5.2/js/bootstrap.min.js"></script>
    
</head>
<body>
    <div class="container">
        <h1 class="mt-5">ETL定義リスト</h1>
        <table id="etldefsTable" class="table">
            <thead>
                <tr>
                    <th>パス</th>
                </tr>
            </thead>
            <tbody id="etldefs-table-body">
                <!-- Data will be inserted here -->
            </tbody>
        </table>
        <button id="sendButton" class="btn btn-primary">選択したETL定義を実行</button>
    </div>


    <div class="container mt-5">
        <h1 class="mb-4">ETL実行リスト</h1>
        <table class="table table-striped">
            <thead>
                <tr>
                    <th>ETLジョブ</th>
                    <th>Start</th>
                    <th>Stop</th>
                </tr>
            </thead>
            <tbody id="etljobs-table-body">
                
                <!-- Data will be inserted here -->
            </tbody>
        </table>
    </div>
    <script src="mngetljobs_script.js" ></script>
</body>
</html>
```

## 2.2 JavaScriptの内容
JavaScriptの内容は以下になります。

```JavaScript
const listEtldefsUrl = 'api/listetldefs';

$.get(listEtldefsUrl, function(data) {
    let tableBody = $('#etldefs-table-body');
    data.forEach(function(item) {
        let row = `<tr>
            <td>${item.path}</td>
        </tr>`;
        tableBody.append(row);
    });
});

const listEtlJobsUrl = 'api/listetljobs';

function showEtlJobs(data)  {
    let tableBody = $('#etljobs-table-body');
    tableBody.empty();
    data.forEach(function(item) {
        let row = `<tr>
            <td>${item.etldef}</td>
            <td>${item.start}</td>
            <td>${item.stop}</td>
        </tr>`;
        tableBody.append(row);
    });
};

$.get(listEtlJobsUrl, showEtlJobs);

let selectedRow = null;

$(document).on('click','#etldefs-table-body tr', function() {
    if (selectedRow) {
        $(selectedRow).removeClass('selected');
    }
    selectedRow = this;
    $(selectedRow).addClass('selected');
});

$(document).ready(function() {
    $('#sendButton').click(function() {
        if (selectedRow) {
            const rowData = $(selectedRow).children('td').map(function() {
                return $(this).text();
            }).get();

            const jsonData = {
                etldef: rowData[0]
            };

            const url = 'api/runetljob';

            $.ajax({
                url: url,
                type: 'POST',
                contentType: 'application/json',
                data: JSON.stringify(jsonData),
                dataType: 'json',
                success: function(response) {
                    console.log('Success:', response);
                    $.get(listEtlJobsUrl, showEtlJobs);
                },
                error: function(error) {
                    console.error('Error:', error);
                }
            });
        } else {
            alert('行を選択してください。');
        }
    });
});
```

# 3. API の作成
[AWS Management Console]→[API Gateway]をクリックします。左ペインで[API]をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/af4aacef-2d4f-4258-8f3b-73d3c299b2d6.png)

右上の[APIの作成]をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/32034684-e435-4d61-a066-a1df5e023c19.png)

VPC内で参照できれば良いので[REST API プライぺート]を構築します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/13c3d98d-443e-4d1a-9c83-96f89c9f1ff7.png)

API名やVPCエンドポイントを指定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/bc27aafa-bdf1-40e3-8378-7b77b5586e3b.png)

プライベートREST APIではVPCエンドポイントを使用します。次の情報を参照し作成してください。

https://qiita.com/nagisa_53/items/d643ca8db43ccf9cebfb

https://docs.aws.amazon.com/ja_jp/apigateway/latest/developerguide/private-api-tutorial.html

## 3.1 HTML/JavaScriptのリソースの作成
ブラウザーで使用するHTMLとJavaScriptのリソースを作成します。
### 3.1.1 HTMLのリソース作成
ルート(`/`)を指定した状態で[リソースを作成]をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/04f4005c-8e65-426b-ac8a-75dd7f86fed8.png)

リソース名に`mngetljobs.html`を入力してリソースを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/562c5368-f69b-42da-94b2-0c706765faca.png)

[メソッドを作成]をクリックします
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/b32338d4-bd88-471a-9627-44805ccf9d09.png)


S3からHTMLファイルを取得する設定を行います。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/a700a73e-a6c1-4893-bb91-1f9862f0f9c2.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/843f0f0d-6073-40f3-a935-d84b533e4b7f.png)

| # | 項目 | 設定内容 |
|:--|:--|:--|
|1|メソッドタイプ|GET|
|2|統合タイプ|AWSのサービス|
|3|AWSリージョン|`ap-northeast-1`(バケットを配置しているリージョン)   |
|4|HTTPメソッド|GET|
|5|アクションタイプ|パスオーバーライドを使用|
|6|パスオーバーライド|HTMLを格納しているパスを入力(今回は`<bucketname>/sample/serverless/mngetljobs.html`)|
|7|実行ロール|API GWからS3へアクセスできるロール(今回は`APIGatewayS3ProxyPolicy`、入力補完できないのでIAMからコピー＆ペーストすると楽）|
|8|コンテンツタイプ|`text/html` ([モデルを追加]をクリックして設定します)|

### 3.1.2 JavaScriptのリソース作成
HTMLと同様です。
パスオバーライドが`<bucketname>/sample/serverless/mngetljobs_script.js`、コンテンツタイプが`application/javascript`になります。

HTML/JavaScriptのリソースを作成後、以下のようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/37547bf8-504c-4a89-97f6-c4df40a65457.png)

## 3.2 Lambda関数のリソース作成
API GWからLambda関数を呼び出すリソースを作成します。
ルート(`/`)を指定した状態で[リソースを作成]をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/9b1d1bd5-3bbe-4c4a-892a-74c7cde71047.png)

リソース名に`api`を入力します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/930bb50f-f956-408f-8605-89f480fffcb7.png)

### 3.2.1 listetldefsのAPI作成
`/api`を選択した状態で[リソースを作成]をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/eb41c98f-5645-4ee6-882f-569abc180762.png)

リソース名に`listetldefs`を入力してリソースを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/992a4bc3-0f43-4907-9d9b-06512470fb06.png)

`listetldefs`を選択した状態でメソッドを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/d6f1c829-7634-4648-8420-9826dbe92517.png)

以下のように設定してメソッドを作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/f5088912-3af6-4079-9e46-017af44ae416.png)
| # | 項目 | 設定内容 |
|:--|:--|:--|
|1|メソッドタイプ|GET|
|2|統合タイプ|Lambda関数|
|3|Lambda関数|プルダウンからlistetldefsを選択します  |

### 3.2.2 runetljobのAPI作成
listetldefsと同様に作成しますが、メソッドタイプは`POST`になります。

### 3.2.3 listetljobsのAPI作成
listetldefsと同様に作成します。

APIの作成後は以下のようになります。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/ff1d6992-cab8-4d48-b740-fea56fa08194.png)

## 3.5 リソースポリシーの作成
VPC内からのみ呼び出せるようにリソースポリシーを作成します。
左ペインのリソースポリシーをクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/ce8f237c-dea6-4f24-8af3-2a34ed8c4b60.png)

右側のポリシーをクリックします。
ポリシーは以下のように入力します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/9c9de6fb-3149-4c7b-b309-a076452288d4.png)
`aws:SourceVpc`にはVPC-IDを入力します。
```
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Deny",
            "Principal": "*",
            "Action": "execute-api:Invoke",
            "Resource": "execute-api:/*",
            "Condition": {
                "StringNotEquals": {
                    "aws:SourceVpc": "vpc-xxxxxx"
                }
            }
        },
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "execute-api:Invoke",
            "Resource": "execute-api:/*"
        }
    ]
}
```
[変更を保存]をクリックすると以下のようになります。`Resource`は自動で変換されるようです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/b8d12a66-0e6c-42d7-9c00-20833556acfb.png)


## 3.4 APIのデプロイ
APIを呼び出せるようにデプロイします。
[APIをデプロイ]をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/78ef85b3-674f-464b-bbe5-ed2f9e263563.png)

ステージに`*新しいステージ*`を選択し、ステージ名には`test`を入力します。そして、[デプロイ]をクリックします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/15f6dadc-39b8-49c0-a97f-5283dd3de553.png)

ステージの`test`を展開し`mngetljobs.html`のGETのURLがブラウザーからアクセスするURLです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/f4fbffa6-176e-4781-8d95-ac31ee18908e.png)


# 4. 実行
VPC内のEC2のブラウザーからアクセスすると以下のように表示され、[選択したETL定義を実行]ボタンを押すとETLジョブを実行します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4022493/b985ec2a-c97e-4b4a-b76d-9134e1cb449e.png)

# その他
製品の改良により予告なく記載されている仕様が変更になることがあります。

### 他社商品名、商標等の引用に関する表示
* Amazon Web Services、AWS、Powered by AWS ロゴ、Amazon EC2、Amazon Elastic Container Service、Amazon ECS、Amazon S3、Amazon Virtual Private Cloud、Amazon VPC、AWS Lambda、AWS Fargate、は、Amazon.com, Inc. またはその関連会社の商標です。
* Pentahoは、Hitachi Vantara LLCの商標または登録商標です。
* HITACHIは，株式会社 日立製作所の商標または登録商標です。
* その他記載の会社名，製品名などは，それぞれの会社の商標もしくは登録商標です。