LoginSignup
0

More than 3 years have passed since last update.

API Gateway から WebSocket で EC2 インスタンス上の java サーブレットにリクエストを送る (AWS)

Posted at

はじめに

クライアントから WebSocket で送られてきたメッセージを API Gateway 経由で java サーブレット (jetty) に送る手順を残します。
java サーブレットでは、AWS SDK を使って受け取ったメッセージをそのままクライアントに返します。こちらも簡単に手順を残しておきます。

サーバ構成は下図のような感じです。

インスタンスに API Gateway からとインターネットゲートウェイからの両方でアクセスできるようになってますが、後者は開発端末からの ssh 接続用です。
とりあえず動かす、が目的だったので、踏み台サーバは立てずローカルから直接接続できる構成にしています。

記事中にちょくちょく「▶︎ メモ」が出てきますが、クリックすると開閉します。手順の補足となるキャプチャなどがしまってあります。

環境

  • OS:macOS Mojava
  • java バージョン:11.0.2

EC2 インスタンスの作成

まずはデフォルトサブネット内にインスタンスを作成します。
この時にセキュリティグループの設定を間違えると NLB やローカルから接続できないので要注意です。
上図のような構成にするには、次のように設定します。

  • ネットワーク:デフォルト VPC
  • サブネット:任意のデフォルトサブネット
  • セキュリティグループ:下表のルールで新しいセキュリティグループを作成
タイプ プロトコル ポート範囲 ソース
SSH TCP 22 マイ IP
カスタム TCP TCP 8080 デフォルト VPC の CIDR

1 つ目のルールで開発 PC からの ssh 接続を許可し、2 つ目のルールでこの後作成する NLB からのアクセスを許可します。


メモ
メモ 1
CIDR は VPCコンソールで確認できます。



メモ 2
自作のサブネット内にインスタンスを作った場合、インターネットと接続するには別途 Elastic IP の設定が必要ですが、デフォルトサブネット内に作成する場合は不要です (VPC も自作の場合はさらにインターネットゲートウェイとルーティングテーブルの設定が必要)。


メモ 3
セキュリティグループはファイアウォールのようなもので、作成は VPC 単位、適用はインスタンス単位です。なので、1 つのセキュリティグループを複数のインスタンスに適用できます。


▲ メモ

NLB の作成

クライアントからのリクエストを EC2 にルーティングするための設定を行います。
まずロードバランサー作成の最初の画面で、次のように入力します。

  • スキーム:内部
  • リスナー
    • ロードバランサーのプロトコル:TCP
    • ロードバランサーのポート:8080
  • VPC:デフォルト VPC
  • アベイラビリティゾーン:EC2 インスタンスが存在するアベイラビリティゾーン


メモ
メモ 1
アベイラビリティゾーンは VPC コンソールで確認できます。アベイラビリティゾーンはインスタンス作成時に直接は指定していませんが、サブネットが存在するアベイラビリティゾーンが間接的に設定されます。



メモ 2
スキームをインターネット向けに設定すると、NLB のを外部のリソースにルーティングさせることになります。
今回は VPC 内の EC2 をターゲットとして選択するので、スキーマは内部を選択します。
尚、インターネット向けの NLB で VPC リンク (後述) として登録しようとすると、警告が表示されます。



メモ 3
アベイラビリティゾーンの設定では、1 つのアベイラビリティゾーン内にあるデータセンターで障害が発生した場合に備えて複数のアベイラビリティゾーンを指定した方が良い…らしいですがそもそも EC2 が 1 つなので今回は 1 つだけ指定します。


▲ メモ

「ルーティングの設定」では、ルーティング先のターゲットの種類などを登録します。

  • ターゲットグループ
    • ターゲットグループ:新しいターゲットグループ
      • 先ほど作成した EC2 インスタンス を登録する
    • ターゲットの種類:インスタンス
    • プロトコル:TCP
    • ポート:8080
  • ヘルスチェック
    • プロトコル:TCP
    • (詳細設定) ポート:トラフィックポート


メモ
メモ 1
以上の設定で、NLB は以下のようなことを行います。


1. 指定したターゲットグループに登録された全てのターゲットの状態をヘルスチェクに指定したプロトコル、ポートでモニタリング
2. リスナーで指定した TCP:8080 のリクエスト受付
3. 1 でチェックして正常だったターゲットにリクエストをルーティング


メモ 2
NLB を作成しただけではターゲットグループのヘルスチェックは unhealthy となっていますが、インスタンス上でサーブレットを起動すると healthy になります。


▲ メモ

WebSocket API の作成とデプロイ


テンプレート選択式を使った WebSocket API を作成し、デプロイします。
テンプレート選択式を使うと、クライアントからの情報をサーバで扱いやすい形式に変換してからサーバーに渡すことができます。

API の作成

最初に、空の API を作成します。作成段階で \$connect、\$disconnect、\$default ルートキーが定義されていますが、全て空 (ルートがない) の状態です。
ルート選択式を「$request.body.action」として WebSocketAPI を作成します。


メモ
WebSocket 作成時、以下のような画面が表示されない場合は REST に一度チェックし、再度 WebSocket にチェックすると良いです。

▲ メモ

ルート選択式により、クライアントからの json リクエストボディの特定のプロパティの値を使って処理が振分けることができます。
「$request.body.action」であれば、action プロパティの値と一致するルートキーのルートへリクエストが送られます。その後さらにルートに紐づけられた Lambda 関数や VPC リンクなどへとリクエストが送られます。

\$connect と \$disconnect は、ルート選択式で振分けられるのではなく、接続時、切断時のルートキーです。
\$default は action プロパティ値と一致するルートキーが無い場合やリクエストボディが json でなかった場合に呼び出されます。
今回は新しくルートキーは作成しないので、クライアントからメッセージが送られた場合は \$default に振分けられます。

VPC リンクの作成

API Gateway と NLB を繋ぐ準備として、VPC リンクを作成します。

作成は、API Gateway コンソールで API を選択し、メニューの「VPC リンク」から行います。
名前を入力し、NLB を選択して「作成」をクリックします。
ステータスが「利用可能」となれば完了です (5 分程度かかる)。


メモ
VPC リンクは、API Gateway から VPC 内のリソース (今回であれば jettyサーバ) にアクセスする際の、API Gateway の接続先となるもので、実体はリソースにリクエストを振分けるよう設定されたロードバランサーです。
インターネットを経由せずに VPC の内外を繋ぐため、このような経路を作る必要があります。
(ということなのだと思っています。)


▲ メモ

ルートの設定

NLB への接続と任意の形式へのデータ変換を行うための設定をします。
今回クライアントからメッセージを送ると、\$default に振分けられるので、\$default ルートを設定します。

\$default ルートの統合リクエストに以下を入力して保存します。

  • 統合タイプ:VPC リンク
  • プロキシ統合の使用:チェックを外す
  • VPC リンク:先ほど作成した VPC リンク
  • HTTP メソッド:POST
  • エンドポイント URL:http://(NLB の DNS 名):8080/(API パス)
  • デフォルトタイムアウトの使用:チェックを入れる

統合タイプでクライアントからのリクエストを処理する先を指定しています。java サーブレットを直接設定することはできないので、java サーブレットがある EC2 インタンスへ振分け設定した NLB を登録した VPC リンクを設定します。これが API Gateway と NLB を繋ぐ設定にあたります。


メモ
メモ 1
エンドポイント URL の DNS 名は EC2 コンソールから確認できます。



メモ 2
\$connect のルートの設定はオプションなので無くても問題ありません。
\$disconnect はベストエフォート型 (最前は尽くすけど保証はしない) です。こちらもルートの設定が無くても問題ありません。


メモ 3
プロキシ統合については正確には理解できていないのですが、リクエスト内の情報をバックエンドと連携するため、かつては自分で作成していたマッピングの定義を、API Gateway がいい感じにやってくれる、というもののようです。
今回はマッピングを自分で設定するのでチェックを外しています。


メモ 4
統合タイプが Lambda であれば 1 ルートキーに対し 1 Lambda 関数を割当てて処理を振分けられますが、java サーブレットの場合はエンドポイント URL 末尾の API パスを使ってサーブレット内で処理を振分けます (他にも方法はあると思いますが)。


▲ メモ

リクエストテンプレート選択式の入力ができるようになるので、以下を入力して保存します。

  • テンプレート選択式:\\$default
  • テンプレートキー:$default
  • テンプレートの生成:以下のテンプレート選択式
{
  "connectionId": "$context.connectionId",
  "domainName": "$context.domainName",
  "stage": "$context.stage",
  "body": $input.json('$.body.body')
}

テンプレートキーを保存しようとすると忠告が表示されますが、「はい」で大丈夫です。
保存しても画面上で特に変化はありませんがちゃんと保存されています。


メモ

"body" に設定した \$input.json('$.body.body') には、クライアントからのリクエスト中の body プロパティの値が入ります。例えばクライアントから以下のようなメッセージが送られてきた場合、"hello" が入ります。
{
  "action": "actionName"
  "body": "hello"
}

▲ メモ

API のデプロイ

「アクション」で「API のデプロイ」を選択し、任意のステージを選ぶか作るかしてデプロイします。

デプロイすると Websocke URL が生成されます。URL の最後にはステージ名が入り、クライアントから API Gateway へのアクセスにはこの URL を使います。

以上で Websocket API の作成とデプロイは完了です。


メモ
(ひとやすみ)

▲ メモ

IAM ユーザの作成

クライアントに対してメッセージを送り返す際、java サーブレットでは、AWS SDK for Java API を使って API Gateway にリクエストを送信しています。
このリクエストを実行させるには、API Gateway へのアクセス権限を持ったユーザの認証情報が必要です。

以下の設定で新しいユーザーを作成します。

  • アクセスの種類:「プログラムによるアクセス」にチェック
  • ポリシー:既存のポリシー > AmazonAPiGatewayInvokeFullAccess をアタッチ

認証情報の CSV ファイルをダウンロードして保存します。
認証情報は必ずダウンロードし、ファイルは安全な場所に保管してください

取得した認証情報は、java のシステムプロパティに設定します (後述)。


メモ


▲ メモ

java サーブレットの実装

サーブレットには jetty、ビルドツールは maven を利用しています。
クライアントからのリクエストを処理して返す部分は下のような感じです (エラー処理や null チェックは全て省略しています)。
クライアントからのリクエストを Map に変換した後は、request.get("xxx") でリクエスト中の値を取得できます。
xxx には、API Gateway のテンプレートで設定したキー名が入ります。
IAM ユーザの認証情報を設定せずに実行しようとすると、クライアントへの送信で認証エラーが発生します。

WebSocket.java

import java.util.Map;
import java.nio.ByteBuffer;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.nio.charset.CharsetEncoder;

import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.codehaus.jackson.map.ObjectMapper;

import com.google.common.io.ByteStreams;

import com.amazonaws.client.builder.AwsClientBuilder.EndpointConfiguration;
import com.amazonaws.regions.Regions;
import com.amazonaws.services.apigatewaymanagementapi.AmazonApiGatewayManagementApi;
import com.amazonaws.services.apigatewaymanagementapi.AmazonApiGatewayManagementApiClientBuilder;
import com.amazonaws.services.apigatewaymanagementapi.model.PostToConnectionRequest;

public class WebSocket {

    /**
     * メイン
     */
    void sendMessage(ServletRequest request_){

        // クライアントからのリクエストを Map に変換
        Map<String, Object> request = parseRequest(request_);

        // API Gateway へリクエストを送るオブジェクトとリクエストデータの作成
        AmazonApiGatewayManagementApi client = createClient(request);
        PostToConnectionRequest postRequest = cratePostRequest(request);

        // クライアントへ送信
        client.postToConnection(postRequest);

    }

    /** 
     * クライアントからのリクエストを Map に変換 
     */
    void parseRequest(ServletRequest request_){
        HttpServletRequest request = (HttpServletRequest) request_;
        byte[] requestBodyAsBytes = ByteStreams.toByteArray(request.getInputStream());
        ObjectMapper objectMapper = new ObjectMapper();
        Map<String, Object> request = objectMapper.readValue(requestBodyAsBytes, Map.class);
    }

    /** 
     * API Gateway にリクエストを送るオブジェクト作成
     */
    AmazonApiGatewayManagementApi createClient(Map<String, Object> request) {

        String domainName = (String) request.get("domainName");
        String stage = (String) request.get("stage");

        EndpointConfiguration config = new EndpointConfiguration(
            "https://" + domainName + "/" + stage, Regions.AP_NORTHEAST_1.getName());
        return AmazonApiGatewayManagementApiClientBuilder.standard()
            .withEndpointConfiguration(config).build();
    }

    /**
     * API Gateway に送るリクエストの作成
     */
    private PostToConnectionRequest cratePostRequest(Map<String, Object> request){

        String connectionId = (String) request.get("connectionId");

        // メッセージをバイトデータに変換
        CharsetEncoder encoder = Charset.forName("UTF-8").newEncoder();
        String message = String.valueOf(request.get("body"));
        ByteBuffer buff = encoder.encode(CharBuffer.wrap(message));

        PostToConnectionRequest postRequest = new PostToConnectionRequest();
        // 送信先のコネクションを設定
        postRequest.setConnectionId(connectionId);
        // 送信するデータを設定
        postRequest.setData(buff);

        return postRequest;
    }
}

動作確認

準備

maven でビルドした後、EC2 インスタンスに war ファイルをコピー・展開します。


war ファイルを EC2 インスタンス上にコピー

scp -i 秘密鍵のパス warファイルのパス ユーザ名@インスタンスのパブリックIPアドレス:インスタンス上のディレクトリ



scp -i ~/.ssh/ssh_key.pem ~/Project/target/websocket.war ec2-user@xx.xxx.xxx.xxx:/home/ec2-user
  • 秘密鍵:EC2 インスタンス作成の最後にダウンロードもしくは設定した秘密鍵
  • ユーザ名:ec2-user (インスタンスイメージが Amazon Linux 2 の場合)
  • インスタンス上のディレクトリ:war ファイルのコピー先ディレクトリを指定

インスタンスのパブリック IP の確認



EC2 インスタンスに ssh でアクセス
秘密鍵、ユーザ名、パブリック IP アドレスについては先ほどと同じです。

ssh -i 秘密鍵のパス ユーザ名@インスタンスのパブリックIPアドレス


EC2 インスタンスに JDK、JRE (version 11) のインストール
Amazon Linux 2 に対応した OpenJDKディストリビューションが公開されているのでそちらをインストールします。

sudo yum update
sudo yum install java-11-amazon-corretto


コピーした war ファイルの展開
jar コマンドはカレントディレクトリにファイルを展開するので適当なディレクトリに移動してから実行。

jar xvf warファイルのパス

以上が完了したら、IAM ユーザの作成で取得した認証キーをシステムプロパティとして設定し、サーブレットを起動してください。

-Daws.accessKeyId=XXXXXXXXXXXXXXXXXXXX
-Daws.secretKey=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

動作確認

動作確認には chrome の拡張機能 Simple WebSocket Client を使います。
または、wscat をインストールして使ってください。

Simple WebSocket Client で、API Gateway でデプロイした際にステージに設定された URL にアクセスします。
Status が OPEND となれば接続成功です。ここで接続できなければ、ネットワーク・セキュリティ周りの設定を見直してください。

接続できたら、以下の内容を Request に入力して Send をクリックします。

{"action":"default", "body":"hello"}

以下のように body の内容がそのまま表示されれば成功です。

さて、今回は API Gateway でリクエスト内容をマッピングしてから EC2 リソースに送信する設定を行いました。そちらも確認します。
CloudWatch で API Gateway のログを見られるように設定するとマッピング後のリクエストが確認できます。

おまけ

今回は body の中に "hello" のみがマッピングされる設定になっていますが、テンプレートを少し変えれば他の内容をマッピングすることもできます。試しに、クライアントからの内容をそのままマッピングするようにしてみます。テンプレート選択式を以下のように変更・保存します。

{
  "connectionId" : "$context.connectionId",
  "domainName" : "$context.domainName",
  "stage" : "$context.stage",
  "body" : $input.body
}

デプロイして再接続後にメッセージを送ってみると、メッセージ全体が body の値として入ります。

クライアントもメッセージ全体が返ってくるようになります。

お世話になった記事

ありがとうございました。

EC2 インスタンスの作成

NLB の作成

WebSocket API の作成とデプロイ

IAM ユーザの作成

java サーブレットの実装

動作確認

おわりに

(未来の自分のために) 手順やらメモやら軒並み書いたらだいぶ長くなってしまいました。
今回地味に大変だったのは適切なドキュメントや記事を見つけること (そして理解すること) でした。
また 0 から同じページを探せと言われればなかなかしんどい気がします。
そんな理由から、お世話になった記事には似たようなページが並んでいます。あしからず。

おわり!

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0