20210809のAWSに関する記事は30件です。

TerraformでTFlintが効かなくなったときの対処

これは何 TerraformでTFlintが効かなくなったときのメモです。 VScodeのProblem欄に文法エラーが出なくなりました。 原因 Terraform Language ServerのUpdateをしていなかったのが原因でした。 そういえば、VScodeの右下になんか更新のお知らせきてたな〜とは思ってましたが、まさかこれとは… なんでこれなのかというと、はっきりと納得はできていないのですが、GitHubの開発概念を見にいったらなんとなく理解できました。 Language Serverを見にいってるんですかね。 【引用】 TFLint is just a thin wrapper of Terraform. Configuration loading and expression evaluation etc. depend on Terraform's internal API, and it only provides an interface to do them as a linter. とりあえず解決はできたものの、 そもそもLinterってなんだ、という理解が自分にはもっと必要そうです。 どなたか詳しい方がいらっしゃったら、ご教示願いたいです。 【参考】 https://github.com/terraform-linters/tflint/blob/master/docs/developer-guide/architecture.md https://github.com/terraform-linters/tflint/blob/master/docs/developer-guide/core-concept.md
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[EC2] pingとICMPについて

EC2のネットワークの設定をしている際、疎通確認としてpingをしてもレスポンスが返ってこなかったため原因を調べました。 原因 セキュリティグループのインバウンドルールでICMPの通信を許可していなかったことが原因でした。 pingとは 元々pingを「ネットワークの疎通を確認するもの」として曖昧に理解していました。 pingの仕組みを調べたところ、 指定したIPアドレス宛てにICMPエコー要求メッセージを送信しています。これは「送ったデータをそっくりそのまま送り返してください」という内容です。ICMPエコー要求メッセージを受け取ると、ICMPエコー応答メッセージとして、データをそのまま送り返します。ICMPエコー応答メッセージが返ってくれば、Pingは成功です。 とあります。1 ここで ICMPという単語が出てきました。 詳細に理解するにはこの部分を知る必要がありそうです。 ICMPとは まずICMPとは、Internet Control Message Protocolの略で、TCP/IPにおけるインターネット層で利用され、接続しているサーバ間の通信状態を確認するプロトコルです。2 HTTPやSMTPなどがTCP/IPの一番上の層にあたるアプリケーション層であるのに対し、インターネット層というのはそれよりも下の層で、複数のネットワーク間のデータ転送を担っています。 TCP/IPに関してはこちらの説明がとてもわかりやすかったです。 https://www.itmanage.co.jp/column/tcp-ip-protocol/ 平たくいうと、ICMPはそのネットワーク間の通信状態を確認するためのプロトコルとなります。 その上でpingというのは、ICMPのプロトコルを利用して通信状態を確認できるソフトウェア、という位置付けになります。 今回の事例では、pingが通らなかったのはICMPの通信を許可していなかったためであり、設定してpingが通ることが確認できました。 結論 pingはICMPを利用してインターネット層における通信状態を確認できるものであり、EC2サーバへpingを通すにはICMPを使った通信を許可する必要があります。 pingの仕組み https://www.n-study.com/tcp-ip/ping/ ↩ ICMPとは https://www.fenet.jp/dotnet/column/%E8%A8%80%E8%AA%9E%E3%83%BB%E7%92%B0%E5%A2%83/icmp/8149/#ICMP ↩
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

CloudFormationのベストプラクティス、あるのか?

常々、CloudFormationテンプレートは本番環境で運用保守するの嫌だなあと思っています。 1.「CloudFormation、コレジャナイ」のきっかけ まず「CloudFormation、コレジャナイ」と思ったきっかけになった出来事です↓ CloudFormationで管理されたシステムの変更でエラー連発した話 2021/7/22(木)「JAWS-UG CLI専門支部 #188R CloudFormation入門」のLTで発表した資料になります。 JAWS-UG朝会で波多野さんが「CloudFormationに限らずIaC全般、イメージほど運用管理は楽にならない」とおっしゃっており、その後のお話も私も常々感じていたことだったので首がもげるほど共感すると共に、「CloudFormation苦手って言っていいんだ!」という謎の希望を感じLTに至りました。 ディスるわけではないのですが、CloudFormationは本当に使いどころを考えないと  楽しさ<初期環境構築の楽さ<<<<<<<<<運用負荷 となり、システム変更や運用保守時に大変な負荷になることがあります。 AWS関連LTで「好きなサービスはCloudFormationです!」と多くの方が発表されるのをお聞きするのですが、「CloudFormationが好きです!」とおっしゃる方の多くは初期構築者・導入者ではないでしょうか。 2.CloudFormationはクラウドの柔軟性を殺す IaCは「誰でも全く同じインフラがスタックの展開だけで構築できる」、再現度が高いことがメリットです。 裏を返すと「変化に弱い」、クラウドの利点である「変化に柔軟に対応できる」「アジリティ(Agility)」の一部を犠牲にする面があります。 3.汎用性を求めるためにパラメータを多用 よくあるのが「他のシステム構築にも利用できるように汎用性を高めよう!パラメータを使おう!」というケースです。 パラメータを多用すると再現性が落ち、CFnのメリットが減ってしまいます。 さらに、数あるスタックで同じパラメータを使用していると、パラメータ変更要件があったとき、パラメータが紐づいている部分をすべてのテンプレートでコメントアウトするとか削除するとかして、紐づきを解除する必要があります。 (パラメータが別のスタックに紐づいていると「~in use.~なんちゃら」というエラーでパラメータ変更ができない) 一部変更するだけでも、紐づいてるパラメータが多いほど変更に関係ないリソースにまで影響が及び、スタック単位の大事故に繋がる可能性があります。 4.「置換」の恐ろしさ 事故でありがちなのが、スタックを展開する際に変更セットで「置換」の項目が「True」になっていて、そのまま展開してしまうパターンです。(Replacementが走る) これを本番環境のでやってしまい、RDSの中身を吹っ飛ばし空っぽのRDSが作成されたという話を聞きました。ぞっとします。 「変更セットを確認すればいいよ!」「ロールバック設定にアラートを仕込んでおけば大丈夫!」という声が聞こえてきました。ごもっともでもちろん絶対確認する箇所なのですが、その時のreplacementは阻止できてもその部分の変更はスタック更新で実施することを諦めてマネコンかCLIから変更、あとからCFnテンプレートがドリフトしない様に直す、という過程が発生するのですよね。 「既存システムのパラメータの一部を変えるためにCFnテンプレートのパラメータ紐づいてる部分を時間をかけて全部コメントアウトして展開してパラメータ変更して再度展開したらreplacementが走って本番環境のEC2とRDSの中身吹っ飛んだ」ということは十分にあり得ます。 5.CloudFormationテンプレート、本当に引き継げていますか? 上記のような事故はおそらくCFn大好きな構築チームの人がずっと張り付いていれば起こらないと思います。ですがそんなわけにもいかないと思いますので、おそらく同じチームの人や運用保守チームの人に引き継ぐ過程が発生します。 「引き継ぎ」は、ただシステムの構成や概要を説明するだけではなく、CFnの場合「どこの何が何に紐づいてるか」を明確にしないとシステム変更で上記のような問題が発生します。システムのごく一部変更するだけでも、紐づいてるパラメータが多いほど、変更に関係ないリソースまで影響が及び、さらにCFnに慣れていない運用保守メンバーが対応を実施することで、スタック単位の大事故発生確率が高くなります。 お客様からしてみたら 「システム変更はこの部分しか依頼してないのになぜほかのリソースまで影響が出たんですか?」 「この部分の変更だけなのに検証と調査とテンプレート修正に時間かかりすぎでは?ここだけ直すのにどうしてそんなにかかるんですか?」 と思いますよね。依頼主と対応側の乖離も発生し、説明が大変です。 6.CloudFormationのユースケース 個人的に考えるCloudFormationのユースケースを書いてみます。 ★CFnが適しているケース 1.必ず実施すべきセキュリティ設定をテンプレートにしておく  例えば「CloudTrail証跡有効化する」「ルートアカウントのMFAを有効化しアクセスキーを削除する」等、アカウント開設時に実施すべき項目をテンプレにして必ず実施する決まりとする、など 2.ハンズオン  時間が限られており、一人ひとりが個人のAWSアカウントで全く同じ環境を準備する必要がある、などのケースではとても有効 3.本番環境の変更作業の前に検証環境を一発展開して検証  ただし実際は「検証用のSWのライセンスがない」「大量インスタンスを展開すると多額の課金が発生」などの理由でそのままCFnテンプレートが使用できることは少ない もし上記以外で有効な使いどころがあれば教えてください。 7.CloudFormationのメリットとともにデメリットも理解しておきたい 読み解くこと、記述ができることがゴールではなく、お客様のビジネスを助けるために最適なシステムを設計・構築・運用することが最終目標であるはずです。CloudFormationのメリットもデメリットもおさえたうえで、使いどころを考えていかねばならないサービスだなぁと思います。 色々書き連ねましたが、伝えたいことはすべて波多野さんの資料にまとまっていますのでぜひお読みください。特に「CloudFormation、言うてそんなに大変か?勉強すればいいじゃん、今はIaCとか当たり前だし必須だよ」と思われたあなたにぜひ一度、お読みいただきたいです。 CloudFormationの理想と現実 〜 冷静にCloudFormationを考える/20210722-jawsug-cli-cloudformation そのCFnテンプレート引き継げますか? 言うては何ですけどテンプレート化だけなら時間をかければ誰でもできる 伝えたいことをすべて言語化してくれている資料で、繰り返し伝えていきたい内容です。 8.参考リンク CFn、さまざまな便利機能もありますので学びつつ、使いどころをおさえて付き合っていきたい所存でございます。 ・(AWS公式docs)AWS CloudFormation ベストプラクティス ・(DevelopersIO)【アップデート】ついに来た!CloudFormationで手動で作成したリソースをStackにインポート可能になりました ・(DevelopersIO)知らなかった事を後悔した。既存のリソースからCloudFormationのテンプレート生成 Former2 の紹介 #reinvent 2019 ★2021/8/10(火)JAWS-UG CLI専門支部 #204L CFn EC2::VPCGatewayAttachmenより、Former2は3rdParty製というかオープンソースで、オーストラリアのイアンさんが個人で作ったものだそうです。(AWS Heroの方) ・(AWS公式docs)CloudFormation ヘルパースクリプトリファレンス ・(AWS公式docs)チュートリアル: 別の AWS CloudFormation スタックのリソース出力を参照する ・変更セットを使用したスタックの更新 ・AWS CloudFormation Guard (プレビュー) の紹介 – インフラストラクチャコンプライアンスのための新しいオープンソース CLI ・(AWS公式docs)AWS CloudFormation デザイナー とは ・(DevelopersIO)YAMLに対応したAWS CloudFormation デザイナーを試してみた 9.ゆる募 「CloudFormationテンプレート引き継いだけど、今までより運用が楽になってすごくうれしい!」という話があればぜひ教えてください。 追記 「terraform vs cloudformation」や「ansible vs terraform」も永遠の課題と思うのでそちらも少し調べてみようと思っています。 (OpsWorkでおなじみの)chefが廃れたという歴史があるので、IaCやAnsibleをがっつりやるよりCLIを勉強した方が学習コストは高いというお話を聞いており…
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

チュートリアル「静的ウェブサイトをホスティングする」をやってみる

はじめに このチュートリアルを実際にやってみた記録です。 できるようになること GitHubに登録した静的なウェブサイトをAmplifyで公開します。GitHub上のコンテンツが更新されると自動的に再デプロイされる仕組みになります。 準備するもの AWSのアカウント GitHubのアカウント 手順 実際のチュートリアルの中ではReactのコンテンツを作っていますが、React環境がなかったためindex.htmlファイルが一つの簡易コンテンツ版としました。gitコマンドの操作わからなかったのでGitHubのウェブサイトから操作しています。 GitHubにRepositoryを作成してhtmlファイルを置く GitHubにログインし、右側の「+」マークを押して、「New repository」を選択する。 「Repository name」を適当に入力したら「Create repository」ボタンを押す。 Repositoryができたら、直下に下記のようなhtmlファイルを登録します。 index.html <html> <head><title>My Static Web</title></head> <body> <h1>Hello World!</h1> Nice to meet you. </body> </html> ブランチ名は「main」となっています。 これでコンテンツの作成が終わりました。 Amplifyに接続する Amplifyのコンソールを開いて右上の「New app」ボタンから「Host web app」を選択します。 「From your existing code」のメニューで「GitHub」を選んで「Continue」ボタンを押します。 ここで初めての場合GitHubの認証が行われます。GitHubのUIDとPASSWORDが必要になります。 認証が完了したら先ほどのリポジトリとブランチを選んで「次へ」ボタンをクリックします。 「ビルド設定の構成」の画面が出てくるので、「Allow AWS Amplify.....」にチェックを入れて「次へ」をクリックします。 確認画面で「保存してデプロイ」をクリックします。 デプロイが少しずつ進んでいきます。 デプロイが完了したら、左側のプレビュー画面かURLをクリックするとデプロイされた画面が表示されます。 GitHubのファイルを更新する GitHubでファイルを更新するたびにAmplifyが自動で再デプロイを行います。ちなみにビルド中にURLをクリックすると、変更前のコンテンツが表示されます。 おわりに これだけの手順で、GitHubにcommitしたら自動でデプロイ公開されるので、便利さに驚きました。 参考文献
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AWS Step Functions 調査メモ

はじめに AWS Step Functions の調査メモ。深堀りはせず概要を把握することが目標です。 Step Functions で Lambda 実行 Step FunctionsでCloudWatch logsにログを書き込むLambdaを実行します。 以下はログを書き込むLambda(Python3.8)です。 Lambda実行時に受け取ったJSONデータをログに記録します。 また、return で受け取ったJSONデータを返しています。 test001 import json def lambda_handler(event, context): print("test desu {0}".format(event) ) return (event) 必要なIAMロールは、CluodWatch Logs の以下3つ CreateLogGroup CreateLogStream PutLogEvents 作成したStepFunctionsのjsonコードです。 MyStateMachine001 { "Comment": "This is your state machine", "StartAt": "Lambda Test001", "States": { "Lambda Test001": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke", "OutputPath": "$.Payload", "Parameters": { "FunctionName": "arn:aws:lambda:ap-northeast-1:000000000000:function:test001:$LATEST", "Payload.$": "$" }, "Retry": [ { "ErrorEquals": [ "Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException" ], "IntervalSeconds": 2, "MaxAttempts": 6, "BackoffRate": 2 } ], "End": true, "Comment": "あいうえお" } } } Step FunctionsのWorkflow Studioです。 実行時のペイロードです 実行ステータスが成功と表示されればStep Functionsとしては成功しています Lambda test001 の実行ログにStep Functions実行時のJSONデータが記録されています(期待した挙動) ペイロードとは ペイロードとは、StepFunctions 実行時に渡すJSON形式のデータのこと。 どうやら引数みたいなものらしい。 ペイロードには3つの設定があります。 設定 概要 Use state input as payload StepFuncitons 実行時に設定 Enter payload StepFuncitonsコード内に設定 No payload ペイロード不要 フローとは フローとは、Step Functions に用意された条件分岐などのプログラム処理のことです。 2021年8月時点では7種類のフローがあります。 フロー 概要 Choice 条件分岐 If文みたいなもの。 Parallel 並行処理 Map ループ処理 For文に似てる。同時実行数を設定できる。 Pass 情報を渡すだけ。入力値をもとに出力値を作成できる。デバッグする際に便利 Wait 指定した時間(秒)処理と止める。指定した日時まで処理を止めることもできる。 Success,Fail Step Functions の実行結果を設定できる。 フロー使ってみる 7種類のフローを使ってみます 【Choice】使ってみる Choiceは条件分岐の処理です。実行結果によって、実行する処理を変えることができます。 作成したStep Functionsのjsonデータです。 { "Comment": "This is your state machine", "StartAt": "Lambda Test001", "States": { "Lambda Test001": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke", "OutputPath": "$.Payload", "Parameters": { "FunctionName": "arn:aws:lambda:ap-northeast-1:000000000000:function:test001:$LATEST", "Payload.$": "$" }, "Retry": [ { "ErrorEquals": [ "Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException" ], "IntervalSeconds": 2, "MaxAttempts": 6, "BackoffRate": 2 } ], "Comment": "あいうえお", "Next": "Choice" }, "Choice": { "Type": "Choice", "Choices": [ { "Variable": "$.HOGE", "StringEquals": "HOGE 200", "Next": "Lambda HOGE 200" } ], "Default": "Lambda Not HOGE 200" }, "Lambda HOGE 200": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke", "OutputPath": "$.Payload", "Parameters": { "FunctionName": "arn:aws:lambda:ap-northeast-1:000000000000:function:test001:$LATEST", "Payload": { "FUGA": "FUGA 100" } }, "Retry": [ { "ErrorEquals": [ "Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException" ], "IntervalSeconds": 2, "MaxAttempts": 6, "BackoffRate": 2 } ], "End": true }, "Lambda Not HOGE 200": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke", "OutputPath": "$.Payload", "Parameters": { "FunctionName": "arn:aws:lambda:ap-northeast-1:000000000000:function:test001:$LATEST", "Payload": { "FUGA": "FUGA 200" } }, "Retry": [ { "ErrorEquals": [ "Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException" ], "IntervalSeconds": 2, "MaxAttempts": 6, "BackoffRate": 2 } ], "End": true } } } 実行時のペイロード① <実行結果①> 実行時のペイロード② <実行結果②> 【Parallel】使ってみる 平行処理ができる様子 【Map】【Wait】使ってみる lambda test001 を指定した回数実行する処理を作成します。 項目配列へのパスに、JSONデータの中の配列情報をもつ箇所を指定します。 最大同時実行を 1 に設定し、1回のループで1つ処理を行うようにしました。 lambda test001に S3バケットにファイル出力する仕組みを追加 (Lambda)test001 import json import boto3 from datetime import datetime s3 = boto3.resource('s3') def lambda_handler(event, context): bucket = 'tmp-hokdvhvwzw' # S3バケット名 key = 'test_' + datetime.now().strftime('%Y-%m-%d-%H-%M-%S') + '.txt' file_contents = str(event) # ファイルの内容 obj = s3.Object(bucket,key) # S3バケット名とパスを指定 obj.put( Body=file_contents ) # S3バケットにファイル出力 print("key = {0}, event = {1}".format(key, event) ) return (event) 必要なIAMロール CluodWatch Logs CreateLogGroup CreateLogStream PutLogEvents S3バケット AmazonS3FullAccess ↑ ホントはここまで強い権限は必要ない 実行時に設定するJSONデータ { "detail": { "shipped": [ { "prod": "E01", "dest-code": 1001, "note": "AAA" }, { "prod": "E02", "dest-code": 1002, "note": "BBB" }, { "prod": "E03", "dest-code": 1003, "note": "CCC" }, { "prod": "E04", "dest-code": 1004, "note": "DDD" }, { "prod": "E05", "dest-code": 1005, "note": "EEE" } ] } } S3バケットに出力されたファイル PS C:\Users\user01> cat .\test_2021-08-08-23-18-06.txt {'prod': 'E01', 'dest-code': 1001, 'note': 'AAA'} PS C:\Users\user01> cat .\test_2021-08-08-23-18-36.txt {'prod': 'E02', 'dest-code': 1002, 'note': 'BBB'} PS C:\Users\user01> cat .\test_2021-08-08-23-19-06.txt {'prod': 'E03', 'dest-code': 1003, 'note': 'CCC'} PS C:\Users\user01> cat .\test_2021-08-08-23-19-36.txt {'prod': 'E04', 'dest-code': 1004, 'note': 'DDD'} PS C:\Users\user01> cat .\test_2021-08-08-23-20-07.txt {'prod': 'E05', 'dest-code': 1005, 'note': 'EEE'} PS C:\Users\user01> 【Pass】使ってみる Passの次に実行するlambda test001 に渡すJSONデータを加工します。 lambdaはこれ ↓ を使用しました。 test001 import json def lambda_handler(event, context): print("test desu {0}".format(event) ) return (event) Passの設定で、HOGE,FUGA の値をもつJSONデータを作成します。FUGAの値は、ペイロードのJSONデータから取得します。 実行時に設定するJSONデータ { "FUGA": "FUGA 123" } Step Functions 実行後のLambda test001 のログ 2021-08-09T09:50:24.212+09:00 test desu {'HOGE': 'HOGE-1234567890', 'FUGA': 'FUGA 123'} 【Success】【Fail】使ってみる ペイロードの中のHOGEの値が 100 の場合にSuccessとなり、それ以外はFailになる処理を作りました。 <実行結果> 作ってみた(1):S3バケット上のJSONデータをもとにループ処理する S3バケットに格納したJSONデータを取得し、そのJSONデータの情報をもとにループ処理を実行します。 Lambda testLoadJSONfroms3 testLoadJSONfroms3 import boto3 import json def lambda_handler(event, context): print(event) s3 = boto3.resource("s3") bucket = s3.Bucket( event["bucketName"] ) jsonFile = bucket.Object(event["prefix"] + "/" + event["json"]) jsonData = jsonFile.get() jsonInfo = jsonData['Body'].read() return jsonInfo test001 import json def lambda_handler(event, context): print("test desu {0}".format(event) ) return (event) ※IAMロールの見直し※ 作成したStep Functionsに2種類(testLoadJSONfroms3,test001)のLambdaを実行する権限があるかIAMロールを要確認 StepFunctions { "Comment": "This is your state machine", "StartAt": "Lambda LoadJSONfromS3", "States": { "Lambda LoadJSONfromS3": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke", "Parameters": { "Payload.$": "$", "FunctionName": "arn:aws:lambda:ap-northeast-1:000000000000:function:testLoadJSONfromS3:$LATEST" }, "Retry": [ { "ErrorEquals": [ "Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException" ], "IntervalSeconds": 2, "MaxAttempts": 6, "BackoffRate": 2 } ], "Next": "Pass (Debug)" }, "Pass (Debug)": { "Type": "Pass", "Next": "Pass (Filter)" }, "Pass (Filter)": { "Type": "Pass", "InputPath": "$.Payload", "Next": "Map" }, "Map": { "Type": "Map", "End": true, "Iterator": { "StartAt": "Lambda TEST001", "States": { "Lambda TEST001": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke", "OutputPath": "$.Payload", "Parameters": { "Payload.$": "$", "FunctionName": "arn:aws:lambda:ap-northeast-1:000000000000:function:test001:$LATEST" }, "Retry": [ { "ErrorEquals": [ "Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException" ], "IntervalSeconds": 2, "MaxAttempts": 6, "BackoffRate": 2 } ], "Next": "Wait" }, "Wait": { "Type": "Wait", "Seconds": 5, "End": true } } }, "MaxConcurrency": 1 } } } ペイロードには、jsonデータがあるS3バケットとプレフィックス(ディレクトリ)を指定します。 ペイロード { "bucketName": {{S3バケット名}} "prefix": {{プレフィックス}} "json": {{jsonファイル名}} } S3バケットにあるJSONデータ S3バケットにあるJSONデータ [ {"StackName": "test001-vpc","Code": "vpc.yml", "PJPrefix": "Project1","VPCCIDR": "10.11.0.0/16"}, {"StackName": "test001-subnet1","Code": "subnet-public.yml", "PJPrefix": "Project1","NetworkName": "Net001","PublicSubnetCIDR": "10.11.1.0/24", "AZName": "ap-northeast-1a"}, {"StackName": "test001-sg","Code": "sg.yml", "PJPrefix": "Project1","ServiceName": "ServiceA","SGNo": "001"} ] 作ってみた(2):RunCommandを実行し結果を取得する EC2に対しRunCommandでOSコマンドを実行し、その結果取得するStep Functionsを作ります。 Step Functions のjsonコードはこちら。 StepFunctions { "Comment": "This is your state machine", "StartAt": "Lambda RunCommand", "States": { "Lambda RunCommand": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke", "OutputPath": "$.Payload", "Parameters": { "FunctionName": "arn:aws:lambda:ap-northeast-1:000000000000:function:testRunCommand:$LATEST", "Payload.$": "$" }, "Retry": [ { "ErrorEquals": [ "Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException" ], "IntervalSeconds": 2, "MaxAttempts": 6, "BackoffRate": 2 } ], "Next": "Wait" }, "Wait": { "Type": "Wait", "Seconds": 1, "Next": "Lambda RunCommand2" }, "Lambda RunCommand2": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke", "OutputPath": "$.Payload", "Parameters": { "Payload.$": "$", "FunctionName": "arn:aws:lambda:ap-northeast-1:000000000000:function:testRunCommand2:$LATEST" }, "Retry": [ { "ErrorEquals": [ "Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException" ], "IntervalSeconds": 2, "MaxAttempts": 6, "BackoffRate": 2 } ], "Next": "Choice" }, "Choice": { "Type": "Choice", "Choices": [ { "Variable": "$.com_status", "StringEquals": "Success", "Next": "Lambda RunCommand3" }, { "Variable": "$.com_status", "StringEquals": "InProgress", "Next": "Wait" } ], "Default": "Fail" }, "Lambda RunCommand3": { "Type": "Task", "Resource": "arn:aws:states:::lambda:invoke", "OutputPath": "$.Payload", "Parameters": { "Payload.$": "$", "FunctionName": "arn:aws:lambda:ap-northeast-1:000000000000:function:testRunCommand3:$LATEST" }, "Retry": [ { "ErrorEquals": [ "Lambda.ServiceException", "Lambda.AWSLambdaException", "Lambda.SdkClientException" ], "IntervalSeconds": 2, "MaxAttempts": 6, "BackoffRate": 2 } ], "End": true }, "Fail": { "Type": "Fail" } } } Lambda RunCommand はRunCommandを実行します RunCommand import json import boto3 ssm = boto3.client('ssm') def lambda_handler(event, context): command = event["command"] instance_id = event["instance_id"] r = ssm.send_command( InstanceIds = [instance_id], DocumentName = "AWS-RunShellScript", Parameters = { "commands": [ command ] } ) command_id = r['Command']['CommandId'] print("command_id = {0}".format(command_id) ) return { 'command_id': command_id } Lambda RunCommand2 RunCommandの実行結果を取得します。 invocations[0]['Status']は、処理中なら InProgress 実行成功なら Success 実行失敗なら Failedとなります。 RunCommand2 import json import boto3 ssm = boto3.client('ssm') def lambda_handler(event, context): command_id = event["command_id"] res = ssm.list_command_invocations( CommandId = command_id, Details = True ) invocations = res['CommandInvocations'] com_status = invocations[0]['Status'] output = "output={0}".format(invocations[0]['CommandPlugins'][0]['Output']) return { 'command_id': command_id, 'com_status': com_status, 'output': output } Lambda RunCommand3 は特になにも処理していません。 RunCommand3 import json def lambda_handler(event, context): output = event["output"] return { 'statusCode': 200 } StepFunctions実行時のペイロード ペイロードには、実行するコマンドと実行するEC2のインスタンスIDを設定します。 ペイロード(コマンド成功) { "command": "ifconfig", "instance_id": "i-XXXXXXXXXXX" } ペイロード(コマンド失敗) { "command": "BADcommand", "instance_id": "i-XXXXXXXXXXX" } <実行結果> さいごに AWS Step Functionsの使い方がちょっとわかった気がします。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Sequel Ace - RDS/localhostのMySQLに接続する

まえがき 前回の投稿でphpMyAdminに手こずったと記述しました。 ? RDS - 既存のRDSを削除して新たにRDSを作成しWordPress.orgと接続させる localhost上でphpMyAdminに触れ、MySQLの管理・可視化の大切さを感じ、 安心感を持つことができました。CUIに少しは慣れたつもりですが、 phpMyAdminみたいにデータベースのGUIツールがあればなぁと思って辿り着いたのが Sequel Aceでした。アプリケーションかつSSHキーでの認証なので安全性の確立と、 ブラウザにバーンとエラーが出ないのでそちらの安心感もあります笑 本編のSequel Aceは1時間ほどで設定が完了しました。 おまけではMAMPを使用し、表題のTCP/IPでlocalhost上のMySQLと接続します。 さあ、ドーレ港に到着しました!一本杉を目指しましょう? もうレオリオとクラピカがいるから安心ですねo(^o^)o ※※ TCP/IP接続なのですが、127.0.0.1をlocalhostと統一しています。 ※※ 説明 localhost 127.0.0.1 種類 ホスト名 IPアドレス 接続 UNIXソケット TCP/IP 目的 自分自身を指すホスト名(名前) 自分自身を指すIPアドレス(住所) 動作環境 ・MacBook Air (Retina, 13-inch, 2020) ・Big Sur11.4 前提 ・AWSでEC2インスタンス(サーバ)を作成済み ・RDSインスタンス(データベース)がMySQLで作成済み 1. App StoreでSequel Aceをインストール Homebrewでのインストールもあるのでお好きな方法でインストールしてください。 2. Sequel Aceの設定 (1)インストールが完了したらアプリを開きます (2)各項目に入力していきます ①上部タブのSSHをクリックします ②Name:任意のお名前 ・RDS情報 ③MySQL Host:RDS DBインスタンスのエンドポイント(○○○rds.amazonaws.com) ④Username:RDS DBインスタンスのマスターユーザー名 ⑤Password:RDS DBインスタンスのマスターパスワード -> RDS作成時に設定したマスターユーザー名とマスターパスワードです。 ⑥Database:任意のお名前(空白可) ⑦Port:空欄(3306) ⑧Time Zone:デフォルトでOK ・EC2情報 ⑨SSH Hostname: EC2インスタンスのパブリックDNS名 ⑩SSH Username: EC2インスタンスのユーザー名(ec2-user ※※ EC2 Linuxのユーザー名 ※※) ⑪SSH Password: EC2インスタンス作成時に任意の場所に保存した秘密鍵(○○○.pem) ※※ 右にある鍵マーク?をクリックして選択。直接貼り付けは失敗しました。 ※※ ⑫SSH Port:空欄(22) ⑬Add to Favoritesをクリックしてお気に入りに追加しておけば 入力が保存され、次回からログインが楽になります。 (3)以下のように表示されたらYesをクリックします (見切れてしまいました(;;)見辛くて申し訳ございません。ふにゃふにゃ赤線‪︎ 〰︎‬〰︎‬) 接続が成功すればデータベースを参照することが出来ます。 おまけ①:localhostに接続する (´-`).。o( localhostに接続してphpMyAdminで作成したデータベースって見れるんかなぁ) とふと思い、phpMyAdminと決着着けな!と勝手に闘争心燃やして決行することにしました。 かなり苦戦してしまいましたが、Sequel Aceの公式ドキュメントにて、 同じコンピューターで実行されているMAMPまたはXAMPPのMySQLサーバーに接続する方法 でphpMyAdminで作成したデータベース・テーブルを参照することが出来たので こちらも書いておこうと思います!!!!参考になれば嬉しいです? 本題よりも時間がかかってしまいました。。 参考 ? MAMPまたはXAMPPに接続する 苦戦の最中でMAMP接続の公式ドキュメントと運命の出合いを果たしたものの 上手くいかず、またエラーなるもんなぁと思ってテスト接続したら「接続出来ません!」 ではなく「接続が成功しました」と表示され思わず「えっっっっっっっっっっっっっ」って 大きめの声が出ました。もともと声大きいんですがだいぶ家に響きました笑 考えられる理由としてはphp.iniを触ったときにApacheを2回再起動したこと、ぐらいです。。 調べた際のほとんどのエラーはMySQL8.0の認証プラグインで当てはまりませんでした。 MySQL [(none)]> SELECT user, host, plugin FROM mysql.user; +------------------+-----------+-----------------------+ | user | host | plugin | +------------------+-----------+-----------------------+ | wordpress_user | % | mysql_native_password | ~ | | | | mysql.infoschema | localhost | caching_sha2_password | | mysql.sys | localhost | caching_sha2_password | | hoge | localhost | mysql_native_password | +------------------+-----------+-----------------------+ 5 rows in set (0.01 sec) 表示されたエラー MySQL said:っておちゃめ。 MySQL said: Can't connect to MySQL server on '127.0.0.1' (61) Can't connect to MySQL server on '127.0.0.1' (61) SequelAce Unable to connect to host 127.0.0.1, or the request timed out. Be sure that the address is correct and that you have the necessary privileges, or try increasing the connection timeout (currently 10 seconds). MySQL said: Can't connect to MySQL server on '127.0.0.1' (61) Can't connect to MySQL server on '127.0.0.1' (61) Unable to connect to host 127.0.0.1, or the request timed out. Be sure that the address is correct and that you have the necessary privileges, or try increasing the connection timeout (currently 10 seconds). MySQL said: Can't connect to MySQL server on '127.0.0.1' (61) Can't connect to MySQL server on '127.0.0.1' (61) Unable to connect to host 127.0.0.1, or the request timed out. Be sure that the address is correct and that you have the necessary privileges, or try increasing the connection timeout (currently 10 seconds). MySQL said: Can't connect to MySQL server on '127.0.0.1' (61) Can't connect to MySQL server on '127.0.0.1' (61) Unable to connect to host 127.0.0.1, or the request timed out. Be sure that the address is correct and that you have the necessary privileges, or try increasing the connection timeout (currently 10 seconds). MySQL said: Can't connect to MySQL server on '127.0.0.1' (61) Can't connect to MySQL server on '127.0.0.1' (61) 1.TCP/IP接続を介してMAMPに接続 (1)MAMPを起動させます Start -> WebStart✈️で準備OKです。 開いたMAMPページのMySQLを参考に入力していきます。 2.各項目に入力 (1)上部タブのTCP/IPをクリック ①Name:任意のお名前 ②Host:127.0.0.1 ③Username:root ④Password:root(設定していなければ) ⑤Database:任意 ⑥Port:8889(MAMPが使用するデフォルトのMySQLポート) 8889の方は⑦へ✈️ MAMPの設定で3306に変更している方は3306です。 MAMP -> Preferences -> Ports で確認できます。 ⑦Test connection をクリック ⑧接続が成功しましたと表示されれば、 ⑨の接続をクリックします これでphpMyAdminで作成したデータベースやテーブルを参照することが出来ます! もちろんSequel Ace内で作成することも可能です。ものすんごい成長した気分です。。 おまけ②:WordPressのデータベースのテーブル 頭にある❓マークはそのように解釈しているんですが、 全然ちゃうわ!でしたらご教示くださるとめちゃくちゃ嬉しいです。 テーブル名 保存しているデータ ❓wp_as3cf_items プラグインのWP Offload Media+S3にアップロードしたメタデータ(本体であるデータに関する付帯情報が記載されたデータ) wp_commentmeta 各コメントのメタデータ情報 wp_comments WordPressで投稿した記事のコメント・トラックバック(他のブログの記事を自分のブログで引用・参考にしたときに、そのブログに対して通知するしくみ。通知を受けたブログは自分の記事が紹介された記事を関連記事としてその記事内で表示することでお互いに記事をリンクさせることができる)・ピンバックデータ(記事内に記載されたリンク先に対して自動で送信されるデータ)を格納 wp_options サイトURL・ホームURL、サイト名などの基本情報に加え、一部プラグインの設定といったWordPressの各種設定が保存されている wp_postmeta wp_postsのメタデータ。主にカスタムフィールド(記事のタイトルや本文以外に別の情報を追加してサイトに掲載する属性を設定する機能) wp_posts 投稿、固定ページ、メディア、カスタムメニュー、カスタム投稿、リビジョン(執筆・編集した記事の内容をDBに保存して差分を見たり、復元したりする機能)etcの投稿関連の主要データ wp_term_relationships カテゴリー・タグ・カスタムタクソノミー(taxonomy、情報やデータなどを階層構造で整理したもの)と投稿を関連付けるためのデータ wp_term_taxonomy カテゴリー・タグ・カスタムタクソノミーのターム名(タクソノミーの中で追加する個々の項目名)やそのスラッグの情報 wp_termmeta プラグインや個別にカスタマイズする際に使用 wp_terms カテゴリー・タグ・カスタムタクソノミーとターム名や、そのスラッグ(記事やカテゴリなどに付与される名前のひとつ、英語名)の情報 wp_usermeta 各ユーザー独自の、ユーザー・メタデータを格納 wp_users 管理者等のユーザー情報を格納しているテーブル。ユーザー名・パスワード・メールアドレスetcを保存している あとがき すんなりと接続が成功し感動の連続でした。Yesとクリックしたあとスッと表示されたので 「えっ」と言ってしまうぐらいあっという間に設定が完了して中身を見ることが出来ました。 Sequel Aceがあれば一括で管理出来るので非常に便利なアプリケーションだと思います。 何故こんなにもデータベースが気になるのか。。 まるでやたらとカカリコ村の井戸の底が気になるナビィみたいです? ここまでお読みいただきありがとうございました。 参考
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

TerraformでVPCエンドポイントを一気に作る

はじめに Terraformの勉強記録です 本稿ではVPCエンドポイントを一気に作ります 今回は例としてSSMへのアクセスに用いる3つのVPCエンドポイントを作成します 誤りの訂正・より良い方法のご助言、どしどしコメント頂ければ幸いです Terraform 実行環境 AWS Cloud9 Amazon Linux2 t2.micro Cloud9を使う理由 標準でTerraformが備わっているため アウトライン 変数の定義 VPC・サブネット・セキュリティグループの作成 VPCエンドポイントの一括作成  1. 変数の定義 vpc_endpointsで作成したいVPCエンドポイント名を要素にもつリストを定義しています variable.tf locals { name = "terraform" region = "ap-northeast-1" } # VPC variable "vpc_cidr" { type = string default = "10.1.0.0/16" } # Subnet variable "subnets" { type = map(any) default = { private_subnets = { private-1a = { name = "private-1a", cidr = "10.1.10.0/24", az = "ap-northeast-1a" }, private-1c = { name = "private-1c", cidr = "10.1.11.0/24", az = "ap-northeast-1c" }, private-1d = { name = "private-1d", cidr = "10.1.12.0/24", az = "ap-northeast-1d" }, }, } } # VPC Endpoint variable "vpc_endpoints" { type = list(any) default = ["ssm", "ssmmessages", "ec2messages"] }  2. VPC・サブネット・セキュリティグループの作成 VPC vpc.tf resource "aws_vpc" "main" { cidr_block = var.vpc_cidr instance_tenancy = "default" enable_dns_hostnames = true enable_dns_support = true tags = { Name = "${local.name}-vpc" } } サブネット subnet.tf resource "aws_subnet" "private" { for_each = var.subnets.private_subnets vpc_id = aws_vpc.main.id cidr_block = each.value.cidr availability_zone = each.value.az tags = { Name = "${local.name}-${each.value.name}" } } セキュリティグループ security_group.tf resource "aws_security_group" "vpc_endpoint" { name = "vpc_endpoint-sg" description = "vpc_endpoint-sg" vpc_id = aws_vpc.main.id ingress { description = "HTTPS from VPC" from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = [aws_vpc.main.cidr_block] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = "${local.name}-vpc_endpoint-sg" } }  3. VPCエンドポイントの一括作成 var.vpc_endpointsの要素をfor_eachで回す vpc_endpoints.tf resource "aws_vpc_endpoint" "interface" { for_each = toset(var.vpc_endpoints) vpc_id = aws_vpc.main.id service_name = "com.amazonaws.${local.region}.${each.value}" vpc_endpoint_type = "Interface" private_dns_enabled = true # privateサブネットのidをfor式で一括取得 subnet_ids = [for sn in aws_subnet.private : sn.id] security_group_ids = [ aws_security_group.vpc_endpoint.id, ] depends_on = [ aws_vpc.main, aws_subnet.private ] tags = { Name = "${local.name}-${each.value}-endpoint" } } 完成 おわりに 無事にスッキリしたコードでTerraformでVPCエンドポイントを一気に作ることができました ネタがあればまた記事を書こうと思います
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

パイロットライトは『種火』

災害対策(DR)計画の中で、用語からイメージしにくい 『パイロットライト』 について調べました。 1.パイロットライトは『種火』 oracle先生のサイトでイメージしやすい言葉を見つけました。 パイロット・ライトのディザスタ・リカバリ(DR)トポロジの設計 パイロットライトという用語は、ガス駆動ヒーターなどのデバイスで常に点灯し、必要に応じて素早くデバイスを起動できる小さな炎を指します。 いつでも火がおこせるよう消さずに残しておく小さな火、『種火』のイメージでよさそうです。 具体的に言うと、DRリージョンに低スペックのDBを起動してシステムの最も重要なコアデータをレプリケーションしておき、復旧時はこのDBをスケールアップして完全な本番環境をすばやくプロビジョニングするという災害対策方法です。 種火から火を起こすようなイメージですね。 2.4つの災害対策(DR) AWS Well-Architected フレームワークの信頼性の柱にて4つうたわれている災害対策 (DR) のうち、パイロットライトの位置づけは左から2番目にあたります。 (AWS公式docs)災害対策 (DR) を計画する 4つの災害対策(DR)について以下の表にまとめます。 「バックアップ&リストア」を選択すれば、コストは下がりますが復旧までに時間がかかります。 「Active-Active」を選択すれば、復旧までの時間は最短で済みますがコストがかかります。 稼働しているシステムの要件によってコストバランスを考えた災害対策が必要になります。 3.低コストの災害対策を選択する場合 すべての災害対策に当てはまりますが、実際に災害が発生した場合の復旧手順や訓練計画まで実施する必要があります。運用部隊への引き継ぎは手順書の連携だけでなく、実際に手を動かして手順を確認し、いざというときに慌てないよう準備しておくのが最も重要と考えています。 4.参考 (AWS公式Well-Architectedフレームワーク)災害対策 (DR) はどのように計画するのですか?
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Terraformで複数azに跨るサブネットを一気に作る

はじめに Terraformの勉強記録です 本稿ではTerraformで複数azに跨るサブネットを一気に作る手順を記します 複数リソースのループはfor_eachを使用しています 誤りの訂正・より良い方法のご助言、どしどしコメント頂ければ幸いです Terraform 実行環境 AWS Cloud9 Amazon Linux2 t2.micro Cloud9を使う理由 標準でTerraformが備わっているため 作成環境の構成 アウトライン プロバイダー・locals変数 VPC サブネット インターネットゲートウェイ ルートテーブル NATゲートウェイ 1. プロバイダー・locals変数 東京リージョンを指定 variables.tf locals { name = "terraform" region = "ap-northeast-1" } Nameタグに使う文字列など、複数のresourceで共通して使う値をlocals変数に格納 variables.tf provider "aws" { region = local.region } 2. VPC vpc_cidrの値にしたがってVPCを構築 variables.tf variable "vpc_cidr" { type = string default = "10.1.0.0/16" } vpc.tf resource "aws_vpc" "main" { cidr_block = var.vpc_cidr instance_tenancy = "default" enable_dns_hostnames = true enable_dns_support = true tags = { Name = "${local.name}-vpc" } } 3. サブネット 3つのAvailability Zoneそれぞれにパブリック・プライベートサブネットを作成 プライベート・パブリックサブネットそれぞれの設定情報を持つ変数を宣言 for_eachで呼び出すため、map(any)型で情報を格納 variables.tf variable "subnets" { type = map(any) default = { private_subnets = { private-1a = { name = "private-1a", cidr = "10.1.10.0/24", az = "ap-northeast-1a" }, private-1c = { name = "private-1c", cidr = "10.1.11.0/24", az = "ap-northeast-1c" }, private-1d = { name = "private-1d", cidr = "10.1.12.0/24", az = "ap-northeast-1d" }, }, public_subnets = { public-1a = { name = "public-1a" cidr = "10.1.100.0/24" az = "ap-northeast-1a" }, public-1c = { name = "public-1c" cidr = "10.1.101.0/24" az = "ap-northeast-1c" }, public-1d = { name = "public-1d" cidr = "10.1.102.0/24" az = "ap-northeast-1d" } } } } サブネットの作成 for_eachで回す each.value.hoge で欲しい情報にアクセス subnet.tf resource "aws_subnet" "private" { for_each = var.subnets.private_subnets vpc_id = aws_vpc.main.id cidr_block = each.value.cidr availability_zone = each.value.az tags = { Name = "${local.name}-${each.value.name}" } } resource "aws_subnet" "public" { for_each = var.subnets.public_subnets vpc_id = aws_vpc.main.id cidr_block = each.value.cidr availability_zone = each.value.az tags = { Name = "${local.name}-${each.value.name}" } } 4. インターネットゲートウェイ VPCのidと関連付け gateway.tf resource "aws_internet_gateway" "main" { vpc_id = aws_vpc.main.id tags = { Name = "${local.name}-igw" } } 5. ルートテーブル 各サブネット分作成 route_table.tf # create resource "aws_route_table" "private" { for_each = var.subnets.private_subnets vpc_id = aws_vpc.main.id tags = { Name = "${local.name}-${each.value.name}-rt" } } resource "aws_route_table" "public" { for_each = var.subnets.public_subnets vpc_id = aws_vpc.main.id tags = { Name = "${local.name}-${each.value.name}-rt" } } サブネットと関連付ける サブネットもルートテーブルも同一のキーで生成されているため、同じように書ける route_table.tf # association resource "aws_route_table_association" "private" { for_each = var.subnets.private_subnets subnet_id = aws_subnet.private[each.key].id route_table_id = aws_route_table.private[each.key].id } resource "aws_route_table_association" "public" { for_each = var.subnets.public_subnets subnet_id = aws_subnet.public[each.key].id route_table_id = aws_route_table.public[each.key].id } パブリックサブネットはインターネットゲートウェイとも関連付ける route_table.tf # associate with gateway resource "aws_route" "igw" { for_each = var.subnets.public_subnets route_table_id = aws_route_table.public[each.key].id gateway_id = aws_internet_gateway.main.id destination_cidr_block = "0.0.0.0/0" } 6. NATゲートウェイ 一工夫必要 Elastic IPの作成 NATゲートウェイに割り当てるもの パブリックサブネット分、作成 resource "aws_eip" "nat_gateway" { for_each = var.subnets.public_subnets vpc = true tags = { Name = "${local.name}-${each.value.name}-ngw-eip" } } NATゲートウェイの作成 関連付けるサブネット・EIPを指定 (publicサブネットに配置するが、関連付ける対象的にprivateと命名) resource "aws_nat_gateway" "private" { for_each = var.subnets.public_subnets allocation_id = aws_eip.nat_gateway[each.key].id subnet_id = aws_subnet.public[each.key].id tags = { Name = "${local.name}-${each.value.name}-ngw" } depends_on = [aws_internet_gateway.main] # internet_gateway作成後に作成 } Route Tableの関連付け private subnetのルートテーブルにNATゲートウェイへのルートを追加したい route_tableはvar.private_subnetsのkey、nat_gatewayはvar.public_subnetsのkeyで生成されている(ややこしい) zipmapをうまく使って一気に作る resource "aws_route" "ngw" { for_each = zipmap(keys(var.subnets.public_subnets), keys(var.subnets.private_subnets)) route_table_id = aws_route_table.private[each.value].id nat_gateway_id = aws_nat_gateway.private[each.key].id destination_cidr_block = "0.0.0.0/0" } 完成 おわりに 無事、複数azに跨るサブネットを一気に作ることができました ネタがあれば引き続きTerraformの検証記事を書いていく予定です
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

CloudFront Functionsの始め方

はじめに CloudFront Functions はAWSエッジ環境で軽量なJavaScriptを実行できるサービスです。 同じ様なサービスに Lamda@Edge がありますが、CloudFront Functions の方が処理が高速で、利用費が安くなっています。(いくつか制約はありますが。) この記事では CloudFront Functions の特徴から使い方について、ご紹介していきます。 こんな方におすすめ CloudFront Functions を初めて使う方 すでに Lamda@Edge を利用している方 CloudFront Functions の特徴 Lambda@Edge に比べ以下の特徴があります。いくつか制約がありますが、「呼び出し 1,000,000 件あたり 0.10 USD」と Lambda@Edge の利用料金の6分の1になっています。ですので、制約内で対応可能であれば積極的に CloudFront Functions を活用し、それ以外をLambda@Edge で対応すると良いかと思います。 (* Lambda@Edge の利用料金は 「リクエスト 1,000,000 件あたり 0.60USD」) JavaScript (ECMAScript 5.1) 実行場所は 218以上の CloudFront エッジロケーション Viewer Request,Response のみ (Lambda@EdgではOrigin Request,Responseの利用が可能) 実行時間は 1ミリ秒未満 最大メモリは 2MB以内 合計パッケージサイズは 10MB以内 ネットワークアクセス なし ファイルシステムアクセス なし リクエスト本文へのアクセス なし CloudFront Functions の処理イメージ (引用:https://aws.amazon.com/jp/blogs/news/introducing-cloudfront-functions-run-your-code-at-the-edge-with-low-latency-at-any-scale/) CloudFront Functions は、CloudFront によって生成されたイベントに応答してコードを実行します。 CloudFront Functions は、CloudFront がビューアからのリクエスト (ビューアリクエスト) を受信した後、および CloudFront がビューアへのレスポンス (ビューアレスポンス) を転送する前にトリガーできます。 上記の図にもある通り、Lambda@Edge と一緒に利用することも可能です。 Lambda@Edgeとの比較 (引用:https://aws.amazon.com/jp/blogs/news/introducing-cloudfront-functions-run-your-code-at-the-edge-with-low-latency-at-any-scale/) できること キャッシュキー操作と正規化 URLの書き換えとリダイレクト HTTPヘッダーの操作 アクセス承認 公式ドキュメントには上記の処理を実行するのに最適であると紹介されています。 ちなみに制約を守らない JavaScript を実行するとエラーが発生するのでご注意ください。(コンソール上のテスト実行で確認いたしました) 制約違反で試してみたこと ECMAScript6 の記述(let,const etc) setTimeoutで1ミリ秒以上処理を遅延させる ネットワークアクセスを実行する (このようなエラーが出てきます。コードをデプロイする前にはテスト実行しておきましょう) 始め方 1.AWSコンソール > CloudFront > Functions を選択 2.「Create Function」で関数名を入力 3.JavaScriptで処理を実装 今回は参考にメンテナンスページへのリダイレクトを想定した実装をしています。 ECMAScript 5.1 互換のため新しい記述は使えないので注意してください。 sample.js function handler(event) { var request = event.request; var maintenanceUrl = 'https://sample.com/maintenance.html' var response = { statusCode: 302, statusDescription: 'Maintenance', headers: { "localtion": { "value": maintenanceUrl } } }; return response; } 4.テスト実行 「Test function」を実行して、テストが通ることを確認しましょう。 制約に違反した実装になっている場合は、エラーでテストが通らないようになっているので不適切な処理をデプロイすることを防ぐことができます。 5.「Publish」を選択し、コードをデプロイ 6.「Add association」でCloudFront ディストリビューション、Event type および Behavior を選択 割り当て先の CloudFront ディストリビューション、Event type および Behavior を選択して「Add association」を選択 以上の操作でCloudFront Functionsを適用することができます。 いかがでしたでしょうか?少しでもお役に立てましたら幸いです?‍♂️
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AWS Client VPN with TrustLogin

TrustLoginのSAML認証 「トラスト・ログイン (TrustLogin)」はGMOグローバルサイン社が提供するIDaaSです。 2021年7月にSAMLレスポンスに署名する機能が追加されたためAWS Client VPNの認証に使用できるか試してみました。 なお、SAMLアプリテンプレートについては2021年8月4日時点で「順次登録を行なっておりますため、リリース時期は未定となっております。」と担当の方より情報をいただきました。 AWS公式ドキュメント 公式にサポートされているSAMLベースのIdPはOktaとMicrosoft Azure Active Directoryですが、必要な情報はドキュメントに記載されていますのでその他のIdPでも使用できる可能性があります。 TrunstLoginの設定 TrustLoginに管理者としてログインしておきます。 グループの作成 [管理] > [グループ] > [グループ追加]でVPNユーザ用のグループを作成します。作成後にメンバーを追加します。 設定項目 設定内容 グループ名 任意の名前 (VPN-User) グループの説明 任意の内容 SAMLアプリの登録 [管理] > [アプリ] > [SAMLアプリ登録] から以下の内容を設定します。 設定項目 設定内容 アプリケーション名 任意の名前(AWS Client VPN) アイコン 任意のアイコン ログインURL (記入しない) SAMLレスポンスに署名する ON SP認証成功後の移行URL (記入しない) ネームID用値 [メンバー] [email] エンティティID urn:amazon:webservices:clientvpn ネームIDフォーマット emailAddress サービスへのACS URL [HTTP-POST] http://127.0.0.1:35001セルフポータルを利用する場合は以下も追加[HTTP-POST] https://self-service.clientvpn.amazonaws.com/api/auth/sso/saml ログアウトURL (記入しない) SAML属性の設定 「カスタム属性を指定」のボタンで以下を追加します。「memberOf」には最初に作成したグループ名を指定します。グループ名を選択した後に右側の[+]ボタンを押します。 (SP)属性指定名 (SP)属性種類 (SP)属性名 (IdP)属性値 NameID [Email] (記入しない) [メンバー] [メンバー―メールアドレス] FirstName [Persisted] (記入しない) [メンバー] [メンバー―名] LastName [Persisted] (記入しない) [メンバー] [メンバー―姓] memberOf [Persisted] (記入しない) [グループ] [VPN-User] メタ情報のダウンロード IDプロバイダーの情報にある「メタデータをダウンロード」からXMLファイルをダウンロードして保存しておきます。最後に右上の「登録」ボタンで終了します。 アプリにグループ追加 登録されたアプリを選んで、[グループ追加]で先に作ったグループを追加します。 AWS ACMの設定 クライアント VPN エンドポイントで使用するサーバ証明書を発行してACMにアップロードします。なお、クライアント証明書は今回は使用しません。 AWS IAMの設定 コンソールの [IAM] > [ID プロバイダー] > [プロバイダの作成]で追加します。 ID プロバイダーの設定 設定項目 設定内容 プロバイダのタイプ SAML プロバイダ名 任意の名前 (TrustLogon) メタデータドキュメント TrustLogin のメタ情報のダウンロードで保存したXMLファイルをアップロードする AWS VPCの設定 クライアント VPN エンドポイント作成 コンソールの [VPC] > [クライアント VPN エンドポイント] > [クライアント VPN エンドポイント作成] で追加します。 スプリットトンネルはシステム構成、セルフサービスポータルは運用方法に基づき設定します。 設定項目 設定内容 名前タグ 任意の名前 (TrustLogin) 説明 任意の内容 クライアント IPv4 CIDR 任意のアドレス (192.168.100.0/22) サーバー証明書 ARN ACMに設定した証明書を選択 認証オプション ユーザーベースの認証を使用: ON統合認証: ONSAML プロバイダー ARN: IAMで設定したものを選択セルフサービス SAML プロバイダー ARN: (記入しない) クライアント接続の詳細を記録しますか? いいえ クライアント接続ハンドラを有効化しますか? いいえ DNS サーバー 1 IP アドレス 任意のDNS DNS サーバー 2 IP アドレス 任意のDNS トランスポートプロトコル UDP スプリットトンネルを有効にする ON VPC ID 構成済みのVPCを選択 VPN ポート 443 セルフサービスポータルを有効にする OFF AWS Client VPN Endpoint 設定 作成したエンドポイントに以下の設定をします。 関連付け 作成したエンドポイントをVPCに関連づけます。 設定項目 設定内容 VPC 任意のVPC 関連付けるサブネットの選択 任意のサブネット 認証 アクセス対象をCIDR形式で許可します。AWS Client VPNはオンプレミスのネットワークへのアクセスやピア接続先 VPC へのアクセスも可能なので、対象のCIDRを許可します。「0.0.0.0/0」とすると全て許可することになります。 設定項目 設定内容 アクセスを有効にするサブネット 任意のサブネット (0.0.0.0/0) アクセス権を付与する対象 特定のアクセスグループのユーザーへのアクセスを許可する アクセスグループID TrustLoginで作成したグループ名 (VPN-User) 説明 任意の説明 ルートテーブル VPC外のネットワークにアクセスする場合は[ルートの作成]でルーティングを追加します。 設定項目 設定内容 ルート送信先 宛先CIDR ターゲット VPC サブネット ID サブネットIDを選択 説明 任意の説明 クライアント設定のダウンロード [クライアント設定のダウンロード] からクライアントに設定する ovpn ファイルをダウンロードして保存します。VPNの利用者にはこのファイルを配布するか、セルフポータルを有効にして自身でダウンロードしてもらうようにします。 AWS Client VPN for Desktop のダウンロードとインストール クライアントPC用のプログラムをダウンロードしてインストールします。 古いバージョンはSAML認証をサポートしていませんでした。バージョン1.2.0以上でサポートされています。 インストール後に [ファイル] > [プロファイルを管理] > [プロファイルを追加] でダウンロードした ovpn ファイルを指定してプロファイルを追加します。 参考にした情報 【AWS】AWS Client VPN メモ ~OktaでSAML認証+セルフサービスポータル機能使ってみる~ OneLoginとAWS ClientVPNをSAML連携してみた
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

何となくわかった気になる週刊AWS – 2021/7/26週

はじめに お久しぶりです、なじむです。 先週に引き続き、AWS Japan さんがまとめている週刊AWSで確認した内容を自分のメモ用に残していこうと思います。三週間坊主にならないように…そして今回のアップデート内容、全然理解できず自分の知識の無さに絶望しています。。 今回は7/26週のアップデートです。 7/26(月) Amazon S3 Access Points のエイリアスは、S3 バケット名を必要とするあらゆるアプリケーションでアクセスポイントを使いやすくするためのものです S3 Access Points でエイリアスが作成されるようになりました。これにより、今までアクセスポイントに対応していなかったアプリケーションでも、通常のS3のように使用できるようになりました。 S3 Access Points をざっくり言うと、複数ユーザがアクセスする場合にS3 バケットポリシーを大量に設定すると訳が分からなくなるので、ユーザ毎にアクセスポイントという形で小分けに設定しようという機能です。 実際の画面は以下です。右端にエイリアスの欄ができていることが確認できます。末尾の英数字がランダムに発行されます。 詳細な解説や具体例はS3 アクセスポイントのバケットスタイルエイリアスが超絶便利に非常にキレイにまとまっていましたので、こちらを参照するのが良いかと思います。 日本リージョン対応状況 東京:対応 大阪:対応 AWS App2Container が Windows の複合多層アプリケーションのコンテナ化をサポート ※これよく分かりませんでした。。。 .NET、Java アプリケーションをコンテナ化されたアプリケーションに変換するためのコマンドラインツールである App2Container で、以下のような Windows の多層アプリケーションのコンテナ化とデプロイをサポートするようになりました。 Windows の多層アプリケーション 個別にコンテナ化された多層アーキテクチャで動作する IIS アプリケーションや Windows サービスであり、ECS または EKS クラスターにデプロイされ、デプロイされたアプリケーション間の通信用のネットワークリソースを作成するもの 同一ホスト上の協調アプリケーション 同一ホスト上で動作する複数のアプリケーションを単一のコンテナに入れてコンテナ化したもの アップデートの内容から抜粋してみましたが、なるほど分からん状態です。有識者がいたら解説してほしいアプデです。 対応状況も記載がなかったので確証はないのですが、App2Container は CLI のツールなので、リージョンとかではなく CLI が使えれば特に問題ないのだろうと思います。 日本リージョン対応状況 東京:対応 大阪:対応 AWS Lambda は現在、中東 (バーレーン)、アジアパシフィック (大阪)、およびアジアパシフィック (香港) リージョンで最大 10 GB のメモリと 6 つの vCPU コアをサポートするように 大阪リージョンで、サーバレスでプログラムを実行できる Lambda のメモリを 10GB まで拡張できるようになりました(これまでは約 3GB) Lambda 設定したメモリ容量により vCPU 数(と単位時間あたりの料金)も変わりますが、10GB に設定した場合は 6 Core になります。何 GB で何 vCPU かの対比表は見つかりませんでしたが、公式ドキュメントでは、1,769MB で 1vCPU と書いてあるので、10,240GB で 6vCPU ならその通りなのかなと思いました。 実際の画面では確かに 10,240MB 設定できるようになっています。 東京リージョンも当然対応しています。 日本リージョン対応状況 東京:対応 大阪:対応 7/27(火) AWS CloudTrail が Amazon EBS direct API のデータイベントのログ記録のサポートを開始 スナップショットへのデータの直接書き込み、スナップショットのデータの読み取り、2 つのスナップショット間の違いや変更の特定等の操作を行うことができる EBS direct API のログが CloudTrail に記録されるようになりました。 API 操作 備考 StartSnapshot スナップショットの開始処理 CompleteSnapshot スナップショットの完了処理 PutSnapshotBlock スナップショットへのデータの直接書き込み 今回のアプデで追加 ListSnapshotBlocks スナップショットの情報を表示 今回のアプデで追加 ListChangedBlocks 2 つのスナップショット間の違いや変更の特定 今回のアプデで追加 GetSnapshotBlock スナップショットのデータの読み取り 今回のアプデで追加 実際の設定画面は以下です。EBS direct API を CloudTrail に記録するためには、CloudTrail の設定画面にある、イベントセレクターで "高度なイベントセレクター" から有効にする必要があります。 設定後、確かに実行した EBS direct API が有効になっていることが確認できました。 証跡を設定しなくても参照できるイベント履歴では確認できず、証跡を設定した後の CloudwatchLogs からであれば確認できました(一部抜粋) EBS direct API の実行例は毎度のことながらクラスメソッド社のブログを参照ください。 (参考) [re:Invent 2019] EBS direct APIs でEBSスナップショットのデータの中身を比較してみた 日本リージョン対応状況 東京:対応 大阪:対応 Amazon Route 53 が Route 53 Application Recovery Controller を発表 DNS の権威サービスである Route 53 に Amazon Route 53 Application Recovery Controller という新機能が GA となりました。 冗長構成を組む場合、単一のリージョンの複数 AZ にアプリケーションを配置し、ELB で冗長化するのが一般的かと思います。しかし、システムによってはこれよりも高い可用性や RTO が求められる場合があり、複数 AZ 以外にも、複数のリージョンやオンプレへも冗長化を行う構成になっていることがあります。Amazon Route 53 Application Recovery Controller を使用することで、より高い可用性、より小さい RTO が必要なアプリケーションの構成を行うことができます。 実際の環境を構築していないので字面でしか理解していないのですが、Amazon Route 53 アプリケーション復旧コントローラーの紹介に詳細な解説と紙芝居形式のデモがありましたので、是非ご覧ください。 Route 53 自体はグローバルサービスなので、どちらのリージョンも対応しています。 日本リージョン対応状況 東京:対応 大阪:対応 7/28(水) AWS DataSync が、AWS アジアパシフィック (大阪) リージョンで利用可能に オンプレミスストレージシステムと AWS ストレージサービス(S3、EFS、FSx 等)間、および AWS ストレージサービス間で簡単にデータ転送するためのサービスである AWS DataSync が大阪リージョンで使用できるようになりました。 実際の画面は以下です。オンプレ <-> AWS 間を選択した場合に同期できる製品は以下のようです。 日本リージョン対応状況 東京:対応 大阪:対応 Amazon DynamoDB グローバルテーブルが、アジアパシフィック (大阪) リージョンで利用可能に NoSQL のマネージドサービスである DynanoDB のグローバルテーブルが大阪リージョンで使用できるようになりました。DynamoDB グローバルテーブルは複数リージョンの DynamoDB で、低レイテンシー(10 ミリ秒未満)で自動的にデータのレプリケーションを行える特徴を持っており、低レイテンシーのアプリケーション構築、DR サイトの構築に有効です。 レプリケーションの動作に関しては毎度お世話になっているクラスメソッドさんのブログが非常に参考になりました。 (参考) DynamoDBグローバルテーブルを作成して異なるリージョン間での同期を確認してみた 実際の画面は以下です。今回は追加のリージョンは指定していませんが、ここまでできれば追加はできるかと思います。 日本リージョン対応状況 東京:対応 大阪:対応 AWS Amplify が Apple でのサインインのサポートを開始 認証基盤やストレージ等、バックエンドを手軽に構築でき、Web アプリやモバイルアプリの開発に注力できる Amplify というサービスがあります。re:Invent 2020 で AWSアカウントを作成せずにアプリの管理が行える Admin UI がリリースされました。今回のアップデートでは Admin UI から認証方法(Authentication)の API に Sign in with Apple が設定できるようになり、フロントエンドのアプリの認証方法に Sign in with Apple を使用することができるようになっています(多分。ここちょっと自信ないですが多分そう) 実際の設定画面は以下です。Amplify の Admin UI で Sign in with Apple が設定できるようになっています。 入力に必要な Service ID 等は Apple Developer で設定が必要らしいです。当方は Apple Developer Program に登録していない(年間約12,000円程かかるらしい)ので諸々設定ができませんでしたが、必要な項目の設定方法は AWS のブログが参考になるかと思います。 (参考) Enable Sign in with Apple on your app with AWS Amplify 大阪リージョンは Amplify 自体が未対応でした。 日本リージョン対応状況 東京:対応 大阪:未対応 ※Amplify 自体が未対応 7/29(木) Amazon Neptune ML is now generally available with support for edge predictions, automation, and more グラフ専用の機械学習手法であるグラフニューラルネットワーク(GNN)を用いた Amazon Neptune ML が GA となりました。Neptune ML とは何ぞや?についてはクラスメソッドさんのついにグラフ構造を機械学習できるAmazon Neptune MLがリリースされましたが参考になるかと思います。 今回は実際に Neptune ML quick-start CloudFormation stack から CloudFormation をデプロイし、簡単に Neptune ML を構築してみました。 が、どうやって触れば良いのか分からず、触ってみた系のブログ記事も少ないので、どうやって情報収集したものか…というのが現状です。ここは理解できたら今後ブログに起こしたいと思います… 大阪リージョンは Neptune 自体が未対応のため、Neptune ML も未対応です。 日本リージョン対応状況 東京:対応 大阪:未対応 Amazon Neptune now supports the openCypher query language グラフ型データベースのマネージドサービスである Amazon Neptune で、新たに openCypher を用いてクエリできるようになりました(これまで Gremlin、SPARQL に対応) 大阪リージョンは Neptune 自体が未対応のため、本機能も未対応です。 日本リージョン対応状況 東京:対応 大阪:未対応 7/30(金) AWS AppSync が GraphQL API の AWS Lambda によるカスタム認証のサポートを開始 GraphQL API を簡単に作成できるマネージドサービスである AppSync の認証モードに Lambda カスタムオーソライザが使用可能になりました。これまで、AppSync の認証モードには以下 4 つがありましたが、それでは対応できない要件の場合に対応するため Lambda カスタムオーソライザが追加となっています。 API キー Cognito ユーザプール OpenID Connect IAM GraphQL って何ぞや?というところは RESTの課題とGraphQL 〜GraphQLを簡単に使ってみる〜が非常に参考になりますので、是非ご覧ください。 実際の画面は以下です。認証モードに Lambda が追加されています。 AppSync は先日大阪リージョンに対応したので、こちらの機能も使用できるようになっています。 日本リージョン対応状況 東京:対応 大阪:対応 Introducing new Amazon EC2 G4ad instance sizes 2020年12月に発表された AMD Radeon Pro V520 GPU を搭載した G4ad インスタンスで、以下の小さいインスタンスサイズが使用できるようになりました。G4ad.xlarge、G4ad.2xlarge は、視覚効果とアニメーションの作成、および設計とエンジニアリングアプリケーションの実行に使用されるゲームストリーミングと仮想ワークステーションに最適なようです。 G4ad.xlarge G4ad.2xlarge 既存のインスタンスサイズとの比較だと以下のようになります。 これまで、GPU 1枚だと g4ad.4xlarge が最小サイズでしたが、その半分以下の価格で使用できるようになっています(ただし、メモリや CPU、ストレージは 1/4) (参考) Amazon EC2 G4 インスタンス インスタンス オンデマンド単価(*1) GPU vCPU メモリ ストレージ ネットワークパフォーマンス g4ad.xlarge 0.51082 USD 1 4 16 GiB 150 GB NVMe SSD 最大 10 ギガビット g4ad.2xlarge 0.7303 USD 1 8 32 GiB 300 GB NVMe SSD 最大 10 ギガビット g4ad.4xlarge 1.17 USD 1 16 64 GiB 600 GB NVMe SSD 最大 10 ギガビット g4ad.8xlarge 2.34 USD 4 32 128 GiB 1200 GB NVMe SSD 15 ギガビット g4ad.16xlarge 4.68 USD 8 64 256 GiB 2400 GB NVMe SSD 25 ギガビット *1 東京リージョンの Linux での価格 大阪リージョンは未対応でした。G4ad 以外にも、大阪リージョンだと未対応のインスタンスタイプは多かったです。 東京リージョンの場合でも、G 系のインスタンスはデフォルトだと起動可能な vCPU 数が 0 になっているので制限解除を行う必要があります。 日本リージョン対応状況 東京:対応 大阪:未対応 Amazon WorkSpaces Adds Support for USB YubiKey Universal 2nd Factor (U2F) Authentication on PCoIP Windows WorkSpaces VDI(仮想デスクトップ)のマネージドサービスである WorkSpaces への接続に使用する WorkSpaces アプリ(Windows 版)で、Ver.4.0.1 から YubiKey デバイスをサポートするようになりました。 YubiKey は Yubico 社が提供するデバイスで、ワンタイムパスワードの生成や U2F 認証等を行うことができます。今回のアップデートにより、例えば WorkSpaces からでも Google アカウントへのログインに YubiKey を使用することができるようになりました。 YubiKey デバイスを持っていないので紹介だけです。 大阪リージョンは WorkSpaces が未対応のため、本機能も未対応としています。 日本リージョン対応状況 東京:対応 大阪:未対応 Amazon Elastic Block Store がべき等ボリューム作成のサポートを開始 AWSCLI による EBS ボリュームの作成時、トークンを指定することで(--client-token オプションを指定することで)、冪等性のあるボリュームを作成することができるようになりました。 毎度おなじみクラスメソッドさんのブログ EBSボリューム作成時のべき等性を担保できるようになりました が実動作も検証されていて非常に参考になりますので、こちらを参照するのがお勧めです。 バッチ処理とかでトークンを付けると安心なんでしょうか。実際の利用シーンは思いついていませんが、良い機能のようです。 日本リージョン対応状況 東京:対応 大阪:対応 感想 先週有効にした Network Firewallを無効にし忘れていて、$200以上かかっていることに気がつき絶望しました。検証用の環境だし、リソースは都度削除しているしとか思って Budgets の設定をサボると痛い目を見ますね…速攻で設定しました。 そして8/7(土)には公開しようと思っていたのですが、アップデートの内容がよく分からず非常に時間がかかってしまいました(知識の無さに絶望…)ただ、やらないより分からないことが分かることの方が意義があると思うので来週も頑張ります。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AWS認定資格のオンライン受験(自宅)

はじめに 前々からSAA(AWS認定 ソリューションアーキテクトアソシエイト)を受けよう受けようと思いつつ、なんの準備もせず日々過ごしていました。 ピアソンVUEのキャンペーンで2021年5月1日から2021年7月31日の間の受験だと、一回不合格になってももう一度無料で再受験できるというプロモーションが展開されていたのは認識していたがダラダラしており、忘れてたとういうかなんというか…気がつくと7/30日の晩。 で、7/31の早朝6時に起きて急遽AWS認定試験を受験しよう。ダメだったら無料再受験だ!と思い早朝6:30に申し込み、当日7/31の晩21:15からの最終時間の試験を受けることに。 リンク切れになる予感がするのでキャプチャも保存しておきます。また今度やらないかな。 結局1回の受験で合格しましたので再受験はしてないです。 ちなみに当日の昼間は一日中、1200円ぐらいのバーゲンで買ったままのこちらを解いて予習、本番と同じような問題で操作も同じ感じで良かったです。 準備 AWSパートナーのクラスメソッドさんやサーバーワークスさん、トレーニング会社のトレノケートさんなどのブログ記事は読んだことがあるので、流れを把握するために再読。 で、こちらの記事の「後日追記」までちゃんと読んでなかった。写真のようにレコードジャケットや楽器が見えてても良いのかなぁと…楽に考えてた。 試験開始直前の片付け ヘッドフォンやスマートスピーカー、あとレコードジャケットは一応よけていたが… 試験開始前の監督(インド英語?だと思う)とのやり取り ディスプレイの電源切って 電源はちゃんと落としてます いや、ケーブルを抜いてくれ、抜くところもカメラで見せて もう一つのディスプレイもね スピーカー避けて DTM用のモニタースピーカーの件 動かせたら端に除けて 植物 そいつもどかして 植物もかよ… コースター 出窓にWebカメラを置くために磁器のコースターを台にしてた それもどかして ラップトップラック 最初なんのことかわからなかった。ノートPCをクラムシェルモードで縦置きできるやつ それもどけて アナログの小型置き時計 それもどけて キーボード(楽器/DTM機材) これシンセサイザーですが… どけてください あとその横の箱も(オーディオインターフェース等) パスポートチェック 運転免許証(日本)の写真を送っていたのだが、パスポートがあったらそっちの方をアップロードしてくれと… 日本語だけで書かれた運転免許証よりも英語の試験官にはパスポートの方が良いのかもしれません。 片付け結果 開始前 実際は翌日に元に戻した画像ですが。なんとなくその頃あれで話題の小山田くん(Cornelius)のレコードをディスプレイ。 片付け指示完了後 実際は試験完了後に撮影。外部キーボード、外部トラックパッド、外部カメラ、外部スピーカーフォンはOKでした。 その他 画面サイズ 小さい方が良いかも 17inch MacBook Proを使用していましたが、個人的には全画面で小さなフォントだと読みにくかったので解像度を落とすというか、フォントサイズをあげるというかをしておいた方が良いのかなと思った。試験アプリが追従するかわからないですが。 外部デバイス そういえば、試験中は仕舞っておいたBluetoothマウスが動いてしまい、ディスプレイ上にオーバーレイで表示される「接続しました」か「「接続が切れました」の表示がずっと消えない状態で最後まで表示されっぱなしでした。ピアソンVUEの試験アプリのためかも。使わないデバイスは電源OFFにするなりペアリングを解除しておいた方が良いかも。 デスクライト 人感センサー付きのライトを使ってますが、基本マウス選択だけなので動きが少なく消えちゃいます。個室トイレのアレで電気が消えちゃったときに手をふるみたいに不自然な動きを注意されるのも、電気がついたり消えたりするのもアレかなぁと思い、ちょっと焦りました。電灯系の人感センサー機能は切っておいたほうが良いですね。 まとめ 試験官によって対応が厳しいかもしれないので、なるべく物のない部屋で受験する 試験が終わってからお風呂受験というのを知りました 我が家は畳の部屋に何も置いてないので、正座は辛いが2時間ちょいなのでそっちが良いかな 英語の試験官には日本語の運転免許証よりパスポートの方がよい(多分) 画面サイズは大きすぎないほうが見やすいかも 使わない外部デバイスは切っておく(Bluetooth) 人感センサーライトがあるなら機能を切る
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

TerraformでIAMユーザを一気に作る

はじめに 業務でTerraformを使う機会があり、とことん躓いたので備忘録を残します 本稿ではTerraformでIAMユーザを一気に作る手順を記録します 誤りの訂正・より良い方法のご助言、どしどしコメント頂ければ幸いです 参考とさせていただいた記事様 Terraform+GPG で IAM User にログインパスワードを設定 - 外道父の匠 TerraformでIAMグループ・ユーザを作成する - Qiita Terraform 実行環境 AWS Cloud9 Amazon Linux2 t2.micro Cloud9を使う理由 標準でTerraformが備わっているため Cloud9の設定 AWS managed temporary credentialsをオフにする オンだとIAMの一部機能にアクセスできないため EC2インスタンスにAdministratorAccessポリシーを持つロールをアタッチしておく アウトライン GPGで鍵の生成 TerraformでIAMユーザの一括作成 ワンライナーでIAMユーザパスワードの一括復号 おまけ:一括復号ワンライナー完全理解 1.GPGで鍵の生成 鍵の生成 gpg --gen-key 色々聞かれるが、基本デフォルト値(何も入力しないでエンター押すとそうなる) Real Nameは必須 今回はtaishi_oとする パスワードは入力しなくてもいける 今回はなしで 鍵を保存 gpg -o ./taishi_o.public.gpg --export taishi_o gpg -o ./taishi_o.private.gpg --export-secret-key taishi_o 公開鍵をBase64エンコードして保存 cat taishi_o.public.gpg | base64 | tr -d '\n' > taishi_o.public.gpg.base64 taishi_o.public.gpg.base64にエンコード済かつ改行コードが取り除かれた公開鍵が記録される 2. IAMユーザの作成 下記の通りterraformのコードを書く iam_user.tf provider "aws" { region = "ap-northeast-1" } variable "pgp_key" { type = string default = "mQ......Qz"  # taishi_o.public.gpg.base64 の中身 } variable "aws_iam_user" { type = map(any) default = { member1 = { name = "member1", }, member2 = { name = "member2", }, member3 = { name = "member3", }, } } # IAM Userの作成 resource "aws_iam_user" "team_a" { for_each = var.aws_iam_user name = each.value.name path = "/" force_destroy = true } # Login Profileの作成 resource "aws_iam_user_login_profile" "team_a" { for_each = aws_iam_user.team_a user = each.value.name pgp_key = var.pgp_key password_reset_required = true password_length = "20" } output "username" { value = aws_iam_user_login_profile.team_a } ターミナルでterraform apply 出力イメージ username = { "member1" = { "encrypted_password" = "wc......A=" "id" = "member1" "key_fingerprint" = "01...70" "password_length" = 20 "password_reset_required" = true "pgp_key" = "mQ......Qz" "user" = "member1" } "member2" = { "encrypted_password" = "wc......A=" "id" = "member2" "key_fingerprint" = "01...70" "password_length" = 20 "password_reset_required" = true "pgp_key" = "mQ......Qz" "user" = "member2" } "member3" = { "encrypted_password" = "wc......A=" "id" = "member3" "key_fingerprint" = "01...70" "password_length" = 20 "password_reset_required" = true "pgp_key" = "mQ......Qz" "user" = "member3" } } 3. IAMユーザパスワードの一括復号(ワンライナー) 下記をターミナルで実行することで、作成したIAMユーザのパスワードを一括で標準出力できる     - ご自身で作成されたgpgキーを使うところのみご変更ください(下記ではtaishi.oの部分) パスワード一括復号ワンライナー terraform output -json | ruby -rjson -e 'json = JSON.load(ARGF); values = json["username"]["value"]; keys = %w(id encrypted_password); puts [keys, *keys.map{|key| values.map{|value| v = value[1][key].split; key == "encrypted_password" ? v.map{|s| `echo #{s} | base64 -di | gpg -r taishi_o`.chomp} : v}}.transpose].map{|a| a.join(",")}' 出力イメージ gpg: encrypted with 2048-bit RSA key, ID 2E35ECDD, created 2021-08-06 "taishi_o" gpg: encrypted with 2048-bit RSA key, ID 2E35ECDD, created 2021-08-06 "taishi_o" gpg: encrypted with 2048-bit RSA key, ID 2E35ECDD, created 2021-08-06 "taishi_o" id,encrypted_password member1,+B......Bk member2,a#......MS member3,Xz......i{ これらidとencrypted_passwordでサインインすることができる 4. おまけ:一括復号ワンライナー完全理解 TerraformでIAMグループ・ユーザを作成する 様を参考に見様見真似で何も理解できてないまま作ってしまった 分割して理解していく terraform output -json terraformのアウトプットコマンド(json形式) Command: output |(パイプ) command a| command bでcommand aの結果をcommand bに渡せる ruby -rjson -e '...' ruby: Rubyコマンド -rjson: rubyでjsonを扱えるJSONライブラリを読み込む -e:...に記述されたスクリプトを実行する スクリプト内の;は改行を表す json = JSON.load(ARGF); パイプされたterraform output -jsonの出力をjson形式で読み込んで、json変数に入れる ARGFは渡されたものを1つの仮想ファイルにするオブジェクト 備考:渡されたjsonは下記で見れる terraform output -json | ruby -rjson -e 'json = JSON.load(ARGF); puts [JSON.pretty_generate(json)];' putsは標準出力 JSON.pretty_generate(json)はjsonを可読性出力するものっぽい(Pythonで言うならpprint?) terraform output -json | ruby -rjson -e 'json = JSON.load(ARGF); puts [json["username"]["value"]];' values = json["username"]["value"]; データ部を抽出し、values変数に格納 keys = %w(username encrypted_password); usernameとencrypted_passwordという要素をもつ1次元配列keysを宣言 Rubyでは%w(a b c)で["a","b","c"]が作れる puts["char1", "char2"] 出力 char1 char2 要素を改行しながら表示してくれる *keys.map{|key| values.map{|value| v = value[1][key].split; Rubyではlist.mapで要素ごとに処理を追加できる keysの要素(username, encrypted_password)をjsonのキーに指定し、値を変数vに格納している splitは文字列を空白区切りで配列に変換している 備考: *は配列展開 *keysはkeys[0], keys[1]と要素ごとに出力してくれる *があるおかげで最終結果を見やすく改行できている key == "encrypted_password" ? v.map{|s| `echo #{s} | base64 -di | gpg -r taishi_o`.chomp} : v} 三項演算子 条件:keyが"encrypted_password"か否か True: v.map{|s|echo #{s} | base64 -di | gpg -r taishi_o.chomp}の結果を出力 False: v をそのまま出力 ### v.map{|s| `echo #{s} | base64 -di | gpg -r taishi_o`.chomp} encrypted_passwordのリストvの各要素sに対して秘密鍵(?)taishi_oで復号している chompは文字列から改行コードを除いて返すメソッド transpose 配列の転置 transposeの実行でmemberとそのパスワードをセットで表示できる member1 password1 member2 password2 list.map{|a| a.join","} listの要素を,区切りの文字列として連結 これにより下記のようなcsv形式になる member1,password1 member2,password2 おわりに 無事IAMユーザを一気に作り、ログインパスワードを一括で表示できました 余談: countを使ってresourceを一括作成する方法もありますが、こちらは変更に弱くなってしまいます(詳しくは下記リンク様参照) Terraformで配列をloopする時はfor_eachを使った方がいい - cloudfishのブログ 苦しめられますが非常に便利なので、ネタがあれば引き続きTerraformの検証記事を書いていく予定です
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【AWS】Secrets Manager でクレデンシャルを管理する

qiita への投稿を取得して twitter に投稿するプログラムを作ったのですが、 access key や token の管理にAWS Secrets Managerを使用したので、シークレットキーの作成から python での実装までを記載します。 シークレットキーの作成 AWS CLI で Secrets Manager で管理するキーと値のペアを登録してみます。キーと値のペアは json ファイルに記載して、 cli 実行時に指定するようにします。 creds.json { "CONSUMER_KEY": "xxx", "CONSUMER_SECRET": "xxx", "ACCESS_TOKEN": "xxx", "ACCESS_TOKEN_SECRET": "xxx", "QIITA_TOKEN": "xxx" } create-secret コマンドで secret を作成します。 > aws secretsmanager create-secret --name bot-credential --secret-string file://creds.json 出力: { "ARN": "arn:aws:secretsmanager:ap-northeast-1:xxx:secret:bot-credential-xxx", "Name": "bot-credential", "VersionId": "xxx-xxx-xxx-xxx-xxx" } 作成した内容を確認してみます。 > aws secretsmanager get-secret-value --secret-id bot-credential 出力: { "ARN": "arn:aws:secretsmanager:ap-northeast-1:xxx:secret:bot-credential-xxx", "Name": "bot-credential", "VersionId": "xxx-xxx-xxx-xxx-xxx", "SecretString": "{\n \"CONSUMER_KEY\": \"xxx\",\n \"CONSUMER_SECRET\": \"xxx\",\n \"ACCESS_TOKEN\": \"xxx-xxx\",\n \"ACCESS_TOKEN_SECRET\": \"xxx\",\n \"QIITA_TOKEN\": \"xxx\"\n}", "VersionStages": [ "AWSCURRENT" ], "CreatedDate": "2021-08-08T01:28:20.349000+09:00" } シークレットキーが作成されています。 コンソール画面からも確認できます。 secret を作成すると、コンソール画面でサンプルソースコードを確認することができます。 今回はサンプルコードをそのまま利用します。 secrets_manager.py # Use this code snippet in your app. # If you need more information about configurations or implementing the sample code, visit the AWS docs: # https://aws.amazon.com/developers/getting-started/python/ import boto3 import base64 from botocore.exceptions import ClientError def get_secret(): secret_name = "bot-credential" region_name = "ap-northeast-1" # Create a Secrets Manager client session = boto3.session.Session() client = session.client( service_name='secretsmanager', region_name=region_name ) # In this sample we only handle the specific exceptions for the 'GetSecretValue' API. # See https://docs.aws.amazon.com/secretsmanager/latest/apireference/API_GetSecretValue.html # We rethrow the exception by default. try: get_secret_value_response = client.get_secret_value( SecretId=secret_name ) except ClientError as e: if e.response['Error']['Code'] == 'DecryptionFailureException': # Secrets Manager can't decrypt the protected secret text using the provided KMS key. # Deal with the exception here, and/or rethrow at your discretion. raise e elif e.response['Error']['Code'] == 'InternalServiceErrorException': # An error occurred on the server side. # Deal with the exception here, and/or rethrow at your discretion. raise e elif e.response['Error']['Code'] == 'InvalidParameterException': # You provided an invalid value for a parameter. # Deal with the exception here, and/or rethrow at your discretion. raise e elif e.response['Error']['Code'] == 'InvalidRequestException': # You provided a parameter value that is not valid for the current state of the resource. # Deal with the exception here, and/or rethrow at your discretion. raise e elif e.response['Error']['Code'] == 'ResourceNotFoundException': # We can't find the resource that you asked for. # Deal with the exception here, and/or rethrow at your discretion. raise e else: # Decrypts secret using the associated KMS CMK. # Depending on whether the secret is a string or binary, one of these fields will be populated. if 'SecretString' in get_secret_value_response: secret = get_secret_value_response['SecretString'] else: decoded_binary_secret = base64.b64decode(get_secret_value_response['SecretBinary']) # Your code goes here. return secret 実装 qiita から記事を取得して twitter に投稿するプログラムは別記事に書きましたのそちらをご覧ください。 環境変数で取得していた secret key や token を Secrets Manager から取得するように修正します。secrets_manager.py の get_secret_value_response['SecretString'] の値は辞書型では文字列型なので、文字列を辞書型に変換する ast モジュールの literal_eval を使用します。 https://dev.classmethod.jp/articles/secrets_manager_tips_get_api_key/ tweet.py +import ast import tweepy import qiita import os +import secrets_manager -consumer_key = os.environ['CONSUMER_KEY'] -consumer_secret = os.environ['CONSUMER_SECRET'] -access_token = os.environ['ACCESS_TOKEN'] -access_token_secret = os.environ['ACCESS_TOKEN_SECRET'] +secret = ast.literal_eval(secrets_manager.get_secret()) +consumer_key = secret['CONSUMER_KEY'] +consumer_secret = secret['CONSUMER_SECRET'] +access_token = secret['ACCESS_TOKEN'] +access_token_secret = secret['ACCESS_TOKEN_SECRET'] auth = tweepy.OAuthHandler(consumer_key, consumer_secret) auth.set_access_token(access_token, access_token_secret) api = tweepy.API(auth) ...
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Điều hòa, máy giặt, bình nóng lạnh, tủ lạnh chính hãng giá rẻ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AWS Glue python shell で ETL

AWS Glue python shell で ETLジョブ作成する際のハマりどころメモ 2021/1 時点 Glue Python shellとは? GlueのジョブTypeの1つ Sparkを使うまでもない軽いジョブや各種連携に使える 例 DBにSQLを投げる FTPサーバからファイルGET 外部サービスをAPIでキック などなど Python(version2 or 3)で処理を書ける ジョブ実行環境はジョブ毎にサーバレスリソースが割り当てられる(Lambdaのような感じ) 1DPU または 1/16DPU から選べる 1DPUは、4vCPUのコンピューティングと16GBのメモリに相当 Lambdaのように時間制約(15分)はない 気になったポイント [1] VPC内での実行とリソース接続 Glueの「接続」機能で接続先リソースやNWを設定しておき、ジョブの設定で紐付けると、該当のセグメント(SG)を起点としてジョブが実行される [2] 利用できるライブラリ 標準サポートされているライブラリは以下 https://docs.aws.amazon.com/ja_jp/glue/latest/dg/add-job-python.html DB接続にはPyGreSQLを利用できる これ以外のライブラリまたは独自のライブラリを追加することもできる ライブラリを追加するには、python setuptools を使って、WhleelファイルまたはEggファイルを自分で作成して、S3にアップロードし、ジョブでそのパスを指定する 参考:https://dev.classmethod.jp/articles/yoshim-glue-wheel/ 注意点としては、ジョブ実行の際にPyPIに接続してinstallされる動きとなるようで、ジョブ実行場所はNATなどで外部接続が許可されてる必要あり 参考:https://forums.aws.amazon.com/thread.jspa?threadID=308033 [3] 外部FTPサーバからのGET処理はできる? 以下でSFTPサーバからGETしてS3にPutするサンプルが紹介されてる 参考:https://github.com/ShafiqaIqbal/SFTP-S3-Glue-Ingestion-Python/blob/master/Glue_Python_FTP_S3_Ingestion.py SFTP接続のライブラリparamikoを利用する。(標準サポートされてないのでWhlなどで要追加) ただし外部リソースからのGET処理はGlue Studio Connectorという選択肢もある
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【個人開発】Node.js, TypeScriptで現在地から美味しいお店を探すアプリを作ってみた(②)

LINE Messaging APIを使って現在地から美味しいお店を探すアプリを作ってみました。 完成形としては以下の通りです。 アーキテクチャ 今回はサーバーレスアーキテクチャの根幹の機能である、「AWS Lambda」, 「Amazon API Gateway」, 「Amazon DynamoDB」の3つを使用してアプリを作成します。 また、プロビジョニングやデプロイに関してはAWS SAM(Serverless Application Model)を使用します。 対象読者 ・ Node.jsを勉強中の方 ・ TypeScriptを勉強中の方 ・ インフラ初心者の方 ・ ポートフォリオのデプロイ先をどうするか迷っている方 作成の難易度は低めです。 理由は、必要なパッケージも少ないため要件が複雑ではないからです。 また、DynamoDBの操作は、CRUD(作成・読み込み・更新・削除)のうち、C・R・Uの3つを使用するので、初学者の踏み台アプリとして優秀かと思います。 記事 今回は2つの記事に分かれています。 お気に入り店の登録や解除などを今回の記事で行っています。 お店の検索を行うところまでを前の記事で行っています。 前回の記事をやっていないと今回の記事は全く理解できないのでこちらからご確認ください。 Github 完成形のコードは以下となります。 アプリQR こちらから触ってみてください。 アプリの機能 今回は3つの機能を足していきます。 お気に入り登録 クライアント LINE Messaging API(バックエンド) ①メッセージを編集し、「行きつけ」ボタンを追加する ②「行きつけ」をタップする ③DynamoDBを作成する ④ポストバックのデータを元にDynamoDBに登録を行う お気に入り店を探す クライアント LINE Messaging API(バックエンド) ①「行きつけ」をタップする ②user_idを元にDynamoDB から検索を行う ③FlexMessageを作成する ④お店の情報をFlexMessageで送る お気に入り店の解除 クライアント LINE Messaging API(バックエンド) ①「行きつけを解除」をタップする ②user_idとtimestampを元にDynamoDBからデータを削除する ハンズオン! お気に入り登録を行う 機能 これだけじゃイメージがつきにくいと思うので完成図を先に見せます。 ①メッセージを編集し、「行きつけ」ボタンを追加する 「行きつけ」をタップすることで、お店の情報を渡したいのでポストバックアクションを使用します。 こちらを使うことで、dataプロパティの値を受け取ることができます。 普通にメッセージとして送ってもいいのですが、送られる返信がお店の情報になってしまいます。 あまりよろしくないので、採用を見送りました。 ということでやっていきましょう。 今回は前回の記事で作成したCreateFlexMessage.tsに追加していきます。 api/src/Common/TemplateMessage/Gourmet/CreateFlexMessage.ts // Load the package import { FlexMessage, FlexCarousel, FlexBubble } from '@line/bot-sdk'; // Load the module import { sortRatingGourmetArray } from './SortRatingGourmetArray'; // types import { Gourmet, RatingGourmetArray } from './types/CreateFlexMessage.type'; export const createFlexMessage = async ( user_id: string | undefined, googleMapApi: string ): Promise<FlexMessage | undefined> => { return new Promise(async (resolve, reject) => { try { // modules sortRatingGourmetArray const ratingGourmetArray: RatingGourmetArray = await sortRatingGourmetArray( user_id, googleMapApi ); // FlexMessage const FlexMessageContents: FlexBubble[] = await ratingGourmetArray.map((gourmet: Gourmet) => { // Create a URL for a store photo const photoURL = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photoreference=${gourmet.photo_reference}&key=${googleMapApi}`; // Create a URL for the store details const encodeURI = encodeURIComponent(`${gourmet.name} ${gourmet.vicinity}`); const storeDetailsURL = `https://maps.google.co.jp/maps?q=${encodeURI}&z=15&iwloc=A`; // Create a URL for store routing information const storeRoutingURL = `https://www.google.com/maps/dir/?api=1&destination=${gourmet.geometry_location_lat},${gourmet.geometry_location_lng}`; const flexBubble: FlexBubble = { type: 'bubble', hero: { type: 'image', url: photoURL, size: 'full', aspectMode: 'cover', aspectRatio: '20:13', }, body: { type: 'box', layout: 'vertical', contents: [ { type: 'text', text: gourmet.name, size: 'xl', weight: 'bold', }, { type: 'box', layout: 'baseline', contents: [ { type: 'icon', url: 'https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gold_star_28.png', size: 'sm', }, { type: 'text', text: `${gourmet.rating}`, size: 'sm', margin: 'md', color: '#999999', }, ], margin: 'md', }, ], }, footer: { type: 'box', layout: 'vertical', contents: [ { type: 'button', action: { type: 'uri', label: '店舗詳細', uri: storeDetailsURL, }, }, { type: 'button', action: { type: 'uri', label: '店舗案内', uri: storeRoutingURL, }, }, + { + type: 'button', + action: { + type: 'postback', + label: '行きつけ', + data: `lat=${gourmet.geometry_location_lat}&lng=${gourmet.geometry_location_lng}&name=${gourmet.name}&photo=${gourmet.photo_reference}&rating=${gourmet.rating}&vicinity=${gourmet.vicinity}`, + displayText: '行きつけにする', + }, + }, ], spacing: 'sm', }, }; return flexBubble; }); const flexContainer: FlexCarousel = { type: 'carousel', contents: FlexMessageContents, }; const flexMessage: FlexMessage = { type: 'flex', altText: '近隣の美味しいお店10店ご紹介', contents: flexContainer, }; resolve(flexMessage); } catch (err) { reject(err); } }); }; ②「行きつけ」をタップする こちらはクライアント側での操作なのでやることはありません。 ③DynamoDBを作成する 前回の記事では、SAMテンプレートでDynamoDBを作成したのですが、今回は手動で作成します。 DBは以下のような値を持つレコードを作成していきます。 PK SK K K K K K user_id timestamp photo_url name rating store_details_url store_routing_url ユーザー ID タイムスタンプ 店舗の写真 店舗の名前 店舗の評価 店舗詳細 店舗案内 ソートキーを使う場合どのようにSAMを使うのかの記載が見つからなかったので手動とします。(SAMテンプレートでのやり方を知っている方がいましたらお教えいただけますと幸いです。) DynamoDBを作ったことがない人もいると思うので、一応画像で説明します。 名前は何でもいいです。 一応自分は、Gourmets_Favoriteで作成しています。 先に作成しているのでエラーメッセージ出てますが気にしないでください。 ④ポストバックのデータを元にDynamoDBに登録を行う まずは関数を呼び出しているindex.tsから記載していきます。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; + import { putFavorite } from './Common/Database/PutFavorite'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; const LINE_GOURMET_GOOGLE_MAP_API = { Name: 'LINE_GOURMET_GOOGLE_MAP_API', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); + await actionPutFavoriteShop(response, googleMapApi); } catch (err) { console.log(err); } }; // 位置情報もしくはエラーメッセージを送る const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else if (text === '行きつけのお店') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; // 移動手段の「車もしくは徒歩」かを尋ねるメッセージを送る const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; // 上記の選択を経て、おすすめのお店をFlex Messageにして送る const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const isCar = event.message.text; // Perform a conditional branch if (isCar === '車' || isCar === '徒歩') { // Register userId, isCar in DynamoDB await updateIsCar(userId, isCar); const flexMessage = await createFlexMessage(userId, googleMapApi); if (flexMessage === undefined) { return; } await client.replyMessage(replyToken, flexMessage); } else { return; } } catch (err) { console.log(err); } }; + // FlexMessageの「行きつけ」をタップしたらそのお店が登録される + const actionPutFavoriteShop = async (event: WebhookEvent, googleMapApi: + string) => { + try { + // If the message is different from the target, returned + if (event.type !== 'postback') { + return; + } + + // Retrieve the required items from the event + const data = event.postback.data; + const timestamp = event.timestamp; + const userId = event.source.userId; + + // conditional branching + const isFavorite = data.indexOf('timestamp'); + if (isFavorite === -1) { + // Register data, userId in DynamoDB + await putFavorite(data, timestamp, userId, googleMapApi); + } + } catch (err) { + console.log(err); + } + }; では、DynamoDBにデータを追加するコードを書いていきましょう。 データの追加はputを使用します。 また、次にポストバックのデータの使用方法に関してです。 { "type":"postback", "label":"Buy", + "data":"action=buy&itemid=111", "text":"Buy" } データはこのように渡されます。 この値をどのように取得するかお分かりでしょうか? JavaScriptに慣れている方であればすぐにお分かりでしょうね! 指定した区切り文字で分割して文字列の配列にしましょう。 ということで使うのは、splitですね。 ということでやっていきましょう。 api/src/Common/Database/PutFavorite.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const putFavorite = ( data: string, timestamp: number, userId: string | undefined, googleMapApi: string ) => { return new Promise((resolve, reject) => { // data const dataArray = data.split('&'); const lat = dataArray[0].split('=')[1]; const lng = dataArray[1].split('=')[1]; const name = dataArray[2].split('=')[1]; const photo = dataArray[3].split('=')[1]; const rating = dataArray[4].split('=')[1]; const vicinity = dataArray[5].split('=')[1]; // Create a URL for a store photo const photoURL = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photoreference=${photo}&key=${googleMapApi}`; // Create a URL for the store details const encodeURI = encodeURIComponent(`${name} ${vicinity}`); const storeDetailsURL = `https://maps.google.co.jp/maps?q=${encodeURI}&z=15&iwloc=A`; // Create a URL for store routing information const storeRoutingURL = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`; const params = { Item: { user_id: userId, timestamp: timestamp, photo_url: photoURL, name: name, rating: rating, store_details_url: storeDetailsURL, store_routing_url: storeRoutingURL, }, ReturnConsumedCapacity: 'TOTAL', TableName: 'Gourmets_Favorite', }; docClient.put(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; これで完了です。 それでは次に、お気に入りのお店を探しましょう。 お気に入り店を探す 機能 こちらも先にどのような機能かお見せします。 「行きつけ」をタップしたら、お気に入り登録したお店の一覧が表示されます。 ①「行きつけ」をタップする こちらはクライアント側の操作なので特にすることはありません。 ②user_idを元にDynamoDBから検索を行う DynamoDBからお気に入りのお店の情報を取得しましょう。 今回は複数取得する可能性が高いのでqueryを使用します。 ということでやっていきましょう。 api/src/Common/TemplateMessage/Favorite/QueryDatabaseInfo.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const queryDatabaseInfo = async (userId: string | undefined) => { return new Promise((resolve, reject) => { const params = { TableName: 'Gourmets_Favorite', ExpressionAttributeNames: { '#u': 'user_id' }, ExpressionAttributeValues: { ':val': userId }, KeyConditionExpression: '#u = :val', }; docClient.query(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; ③FlexMessageを作成する DynamoDBから取得した値を使用してFlexMessageを作成していきましょう。 api/src/Common/TemplateMessage/Favorite/MakeFlexMessage.ts // Load the package import { FlexMessage, FlexCarousel, FlexBubble } from '@line/bot-sdk'; // Load the module import { queryDatabaseInfo } from './QueryDatabaseInfo'; // types import { Item, QueryItem } from './types/MakeFlexMessage.type'; export const makeFlexMessage = async (userId: string | undefined): Promise<FlexMessage> => { return new Promise(async (resolve, reject) => { try { // modules queryDatabaseInfo const query: any = await queryDatabaseInfo(userId); const queryItem: QueryItem = query.Items; // FlexMessage const FlexMessageContents: FlexBubble[] = await queryItem.map((item: Item) => { const flexBubble: FlexBubble = { type: 'bubble', hero: { type: 'image', url: item.photo_url, size: 'full', aspectMode: 'cover', aspectRatio: '20:13', }, body: { type: 'box', layout: 'vertical', contents: [ { type: 'text', text: item.name, size: 'xl', weight: 'bold', }, { type: 'box', layout: 'baseline', contents: [ { type: 'icon', url: 'https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gold_star_28.png', size: 'sm', }, { type: 'text', text: `${item.rating}`, size: 'sm', margin: 'md', color: '#999999', }, ], margin: 'md', }, ], }, footer: { type: 'box', layout: 'vertical', contents: [ { type: 'button', action: { type: 'uri', label: '店舗詳細', uri: item.store_details_url, }, }, { type: 'button', action: { type: 'uri', label: '店舗案内', uri: item.store_routing_url, }, }, { type: 'button', action: { type: 'postback', label: '行きつけを解除', data: `timestamp=${item.timestamp}`, displayText: '行きつけを解除する', }, }, ], spacing: 'sm', }, }; return flexBubble; }); const flexContainer: FlexCarousel = { type: 'carousel', contents: FlexMessageContents, }; const flexMessage: FlexMessage = { type: 'flex', altText: 'お気に入りのお店', contents: flexContainer, }; resolve(flexMessage); } catch (err) { reject(err); } }); }; 独自の型定義があるのでファイルを作成しましょう。 api/src/Common/TemplateMessage/Favorite/types/MakeFlexMessage.type.ts export type Item = { user_id: string; photo_url: string; rating: string; timestamp: number; name: string; store_routing_url: string; store_details_url: string; }; export type QueryItem = Item[]; ④お店の情報をFlexMessageで送る 最後にFlexMessageで送信しましょう。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage'; + import { makeFlexMessage } from './Common/TemplateMessage/Favorite/MakeFlexMessage'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; import { putFavorite } from './Common/Database/PutFavorite'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; const LINE_GOURMET_GOOGLE_MAP_API = { Name: 'LINE_GOURMET_GOOGLE_MAP_API', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // console.log(JSON.stringify(response)); // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); await actionPutFavoriteShop(response, googleMapApi); + await actionTapFavoriteShop(client, response); } catch (err) { console.log(err); } }; // 位置情報もしくはエラーメッセージを送る const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else if (text === '行きつけのお店') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; // 移動手段の「車もしくは徒歩」かを尋ねるメッセージを送る const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; // 上記の選択を経て、おすすめのお店をFlex Messageにして送る const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const isCar = event.message.text; // Perform a conditional branch if (isCar === '車' || isCar === '徒歩') { // Register userId, isCar in DynamoDB await updateIsCar(userId, isCar); const flexMessage = await createFlexMessage(userId, googleMapApi); if (flexMessage === undefined) { return; } await client.replyMessage(replyToken, flexMessage); } else { return; } } catch (err) { console.log(err); } }; // FlexMessageの「行きつけ」をタップしたらそのお店が登録される const actionPutFavoriteShop = async (event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'postback') { return; } // Retrieve the required items from the event const data = event.postback.data; const timestamp = event.timestamp; const userId = event.source.userId; // conditional branching const isFavorite = data.indexOf('timestamp'); if (isFavorite === -1) { // Register data, userId in DynamoDB await putFavorite(data, timestamp, userId, googleMapApi); } } catch (err) { console.log(err); } }; + // リッチメニューの「行きつけ」をタップしたらメッセージが送られる + const actionTapFavoriteShop = async (client: Client, event: WebhookEvent) => { + // If the message is different from the target, returned + if (event.type !== 'message' || event.message.type !== 'text') { + return; + } + + // Retrieve the required items from the event + const replyToken = event.replyToken; + const userId = event.source.userId; + const text = event.message.text; + + if (text === '行きつけのお店') { + const flexMessage = await makeFlexMessage(userId); + if (flexMessage === undefined) { + return; + } + await client.replyMessage(replyToken, flexMessage); + } else { + return; + } + }; お気に入り店の解除 機能 「行きつけを解除」をタップするとデータが消去されます。 ①「行きつけを解除」をタップする こちらはクライアント側の操作なので特にすることはありません。 ②user_idとtimestampを元にDynamoDBからデータを削除する こちらも同様にポストバックを使用します。 { "type": "postback", "label": "行きつけを解除", "data": `timestamp=${item.timestamp}`, "displayText": "行きつけを解除する", } こちらもsplitを使って値を取得しましょう。 次にDynamoDBの削除は、deleteを使用します。 api/src/Common/Database/DeleteFavorite.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const deleteFavorite = (data: string, userId: string | undefined) => { return new Promise((resolve, reject) => { // data const timestamp: number = Number(data.split('=')[1]); const params = { TableName: 'Gourmets_Favorite', Key: { user_id: userId, timestamp: timestamp, }, }; docClient.delete(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; ではこの関数を読み込みましょう。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage'; import { makeFlexMessage } from './Common/TemplateMessage/Favorite/MakeFlexMessage'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; import { putFavorite } from './Common/Database/PutFavorite'; + import { deleteFavorite } from './Common/Database/DeleteFavorite'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; const LINE_GOURMET_GOOGLE_MAP_API = { Name: 'LINE_GOURMET_GOOGLE_MAP_API', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); await actionPutFavoriteShop(response, googleMapApi); await actionTapFavoriteShop(client, response); + await actionDeleteFavoriteShop(response); } catch (err) { console.log(err); } }; // 位置情報もしくはエラーメッセージを送る const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else if (text === '行きつけのお店') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; // 移動手段の「車もしくは徒歩」かを尋ねるメッセージを送る const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; // 上記の選択を経て、おすすめのお店をFlex Messageにして送る const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const isCar = event.message.text; // Perform a conditional branch if (isCar === '車' || isCar === '徒歩') { // Register userId, isCar in DynamoDB await updateIsCar(userId, isCar); const flexMessage = await createFlexMessage(userId, googleMapApi); if (flexMessage === undefined) { return; } await client.replyMessage(replyToken, flexMessage); } else { return; } } catch (err) { console.log(err); } }; // FlexMessageの「行きつけ」をタップしたらそのお店が登録される const actionPutFavoriteShop = async (event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'postback') { return; } // Retrieve the required items from the event const data = event.postback.data; const timestamp = event.timestamp; const userId = event.source.userId; // conditional branching const isFavorite = data.indexOf('timestamp'); if (isFavorite === -1) { // Register data, userId in DynamoDB await putFavorite(data, timestamp, userId, googleMapApi); } } catch (err) { console.log(err); } }; // リッチメニューの「行きつけ」をタップしたらメッセージが送られる const actionTapFavoriteShop = async (client: Client, event: WebhookEvent) => { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const text = event.message.text; if (text === '行きつけのお店') { const flexMessage = await makeFlexMessage(userId); if (flexMessage === undefined) { return; } await client.replyMessage(replyToken, flexMessage); } else { return; } }; + // FlexMessageの「行きつけを解除」をタップしたらそのお店がDBから削除される + const actionDeleteFavoriteShop = async (event: WebhookEvent) => { + try { + // If the message is different from the target, returned + if (event.type !== 'postback') { + return; + } + + // Retrieve the required items from the event + const data = event.postback.data; + const userId = event.source.userId; + + // conditional branching + const isFavorite = data.indexOf('timestamp'); + if (isFavorite !== -1) { + // Delete Gourmets_Favorite + await deleteFavorite(data, userId); + } + } catch (err) { + console.log(err); + } + }; これで完了です。 すべての機能を盛り込みました。 これでアプリとしては十分使えると思います。 まぁまだ問題点はあります。 FlexMessageは1度で12個しかスクロールできません。 なので、お気に入り店舗が12以上になると表示する方法がありません。 12以上の場合は複数回返信を行うように設定してもいいのですが、 店舗数が増えれば増えるほど見辛くなる問題も孕んでいます。 ただでさえ1つで画面占有の6割以上です。 これを2つ、3つと増やした場合はユーザビリティの悪化に繋がります。 なので残念ながらこれ以上の対応は思いつかないということで、簡易的なお気に入り機能として使っていただけると幸いです。 終わりに LINE Messaging APIを使うことでフロントの開発から解放されます。 LINEという身近なツールを使うことでインストールなどの手間もなく、これがあると便利だなってものを簡単に制作することができます。 ぜひ皆さんもLINE Bot開発をしてみてください。 ここまで読んでいただきありがとうございました。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【個人開発/LINE Messaging API】Node.js, TypeScriptで現在地から美味しいお店を探すアプリを作ってみた(②)

LINE Messaging APIを使って現在地から美味しいお店を探すアプリを作ってみました。 完成形としては以下の通りです。 アーキテクチャ 今回はサーバーレスアーキテクチャの根幹の機能である、「AWS Lambda」, 「Amazon API Gateway」, 「Amazon DynamoDB」の3つを使用してアプリを作成します。 また、プロビジョニングやデプロイに関してはAWS SAM(Serverless Application Model)を使用します。 対象読者 ・ Node.jsを勉強中の方 ・ TypeScriptを勉強中の方 ・ インフラ初心者の方 ・ ポートフォリオのデプロイ先をどうするか迷っている方 作成の難易度は低めです。 理由は、必要なパッケージも少ないため要件が複雑ではないからです。 また、DynamoDBの操作は、CRUD(作成・読み込み・更新・削除)のうち、C・R・Uの3つを使用するので、初学者の踏み台アプリとして優秀かと思います。 記事 今回は2つの記事に分かれています。 お気に入り店の登録や解除などを今回の記事で行っています。 お店の検索を行うところまでを前の記事で行っています。 前回の記事をやっていないと今回の記事は全く理解できないのでこちらからご確認ください。 Github 完成形のコードは以下となります。 アプリQR こちらから触ってみてください。 アプリの機能 今回は3つの機能を足していきます。 お気に入り登録 クライアント LINE Messaging API(バックエンド) ①メッセージを編集し、「行きつけ」ボタンを追加する ②「行きつけ」をタップする ③DynamoDBを作成する ④ポストバックのデータを元にDynamoDBに登録を行う お気に入り店を探す クライアント LINE Messaging API(バックエンド) ①「行きつけ」をタップする ②user_idを元にDynamoDB から検索を行う ③FlexMessageを作成する ④お店の情報をFlexMessageで送る お気に入り店の解除 クライアント LINE Messaging API(バックエンド) ①「行きつけを解除」をタップする ②user_idとtimestampを元にDynamoDBからデータを削除する ハンズオン! お気に入り登録を行う 機能 これだけじゃイメージがつきにくいと思うので完成図を先に見せます。 ①メッセージを編集し、「行きつけ」ボタンを追加する 「行きつけ」をタップすることで、お店の情報を渡したいのでポストバックアクションを使用します。 こちらを使うことで、dataプロパティの値を受け取ることができます。 普通にメッセージとして送ってもいいのですが、送られる返信がお店の情報になってしまいます。 あまりよろしくないので、採用を見送りました。 ということでやっていきましょう。 今回は前回の記事で作成したCreateFlexMessage.tsに追加していきます。 api/src/Common/TemplateMessage/Gourmet/CreateFlexMessage.ts // Load the package import { FlexMessage, FlexCarousel, FlexBubble } from '@line/bot-sdk'; // Load the module import { sortRatingGourmetArray } from './SortRatingGourmetArray'; // types import { Gourmet, RatingGourmetArray } from './types/CreateFlexMessage.type'; export const createFlexMessage = async ( user_id: string | undefined, googleMapApi: string ): Promise<FlexMessage | undefined> => { return new Promise(async (resolve, reject) => { try { // modules sortRatingGourmetArray const ratingGourmetArray: RatingGourmetArray = await sortRatingGourmetArray( user_id, googleMapApi ); // FlexMessage const FlexMessageContents: FlexBubble[] = await ratingGourmetArray.map((gourmet: Gourmet) => { // Create a URL for a store photo const photoURL = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photoreference=${gourmet.photo_reference}&key=${googleMapApi}`; // Create a URL for the store details const encodeURI = encodeURIComponent(`${gourmet.name} ${gourmet.vicinity}`); const storeDetailsURL = `https://maps.google.co.jp/maps?q=${encodeURI}&z=15&iwloc=A`; // Create a URL for store routing information const storeRoutingURL = `https://www.google.com/maps/dir/?api=1&destination=${gourmet.geometry_location_lat},${gourmet.geometry_location_lng}`; const flexBubble: FlexBubble = { type: 'bubble', hero: { type: 'image', url: photoURL, size: 'full', aspectMode: 'cover', aspectRatio: '20:13', }, body: { type: 'box', layout: 'vertical', contents: [ { type: 'text', text: gourmet.name, size: 'xl', weight: 'bold', }, { type: 'box', layout: 'baseline', contents: [ { type: 'icon', url: 'https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gold_star_28.png', size: 'sm', }, { type: 'text', text: `${gourmet.rating}`, size: 'sm', margin: 'md', color: '#999999', }, ], margin: 'md', }, ], }, footer: { type: 'box', layout: 'vertical', contents: [ { type: 'button', action: { type: 'uri', label: '店舗詳細', uri: storeDetailsURL, }, }, { type: 'button', action: { type: 'uri', label: '店舗案内', uri: storeRoutingURL, }, }, + { + type: 'button', + action: { + type: 'postback', + label: '行きつけ', + data: `lat=${gourmet.geometry_location_lat}&lng=${gourmet.geometry_location_lng}&name=${gourmet.name}&photo=${gourmet.photo_reference}&rating=${gourmet.rating}&vicinity=${gourmet.vicinity}`, + displayText: '行きつけにする', + }, + }, ], spacing: 'sm', }, }; return flexBubble; }); const flexContainer: FlexCarousel = { type: 'carousel', contents: FlexMessageContents, }; const flexMessage: FlexMessage = { type: 'flex', altText: '近隣の美味しいお店10店ご紹介', contents: flexContainer, }; resolve(flexMessage); } catch (err) { reject(err); } }); }; ②「行きつけ」をタップする こちらはクライアント側での操作なのでやることはありません。 ③DynamoDBを作成する 前回の記事では、SAMテンプレートでDynamoDBを作成したのですが、今回は手動で作成します。 DBは以下のような値を持つレコードを作成していきます。 PK SK K K K K K user_id timestamp photo_url name rating store_details_url store_routing_url ユーザー ID タイムスタンプ 店舗の写真 店舗の名前 店舗の評価 店舗詳細 店舗案内 ソートキーを使う場合どのようにSAMを使うのかの記載が見つからなかったので手動とします。(SAMテンプレートでのやり方を知っている方がいましたらお教えいただけますと幸いです。) DynamoDBを作ったことがない人もいると思うので、一応画像で説明します。 名前は何でもいいです。 一応自分は、Gourmets_Favoriteで作成しています。 先に作成しているのでエラーメッセージ出てますが気にしないでください。 ④ポストバックのデータを元にDynamoDBに登録を行う まずは関数を呼び出しているindex.tsから記載していきます。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; + import { putFavorite } from './Common/Database/PutFavorite'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; const LINE_GOURMET_GOOGLE_MAP_API = { Name: 'LINE_GOURMET_GOOGLE_MAP_API', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); + await actionPutFavoriteShop(response, googleMapApi); } catch (err) { console.log(err); } }; // 位置情報もしくはエラーメッセージを送る const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else if (text === '行きつけのお店') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; // 移動手段の「車もしくは徒歩」かを尋ねるメッセージを送る const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; // 上記の選択を経て、おすすめのお店をFlex Messageにして送る const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const isCar = event.message.text; // Perform a conditional branch if (isCar === '車' || isCar === '徒歩') { // Register userId, isCar in DynamoDB await updateIsCar(userId, isCar); const flexMessage = await createFlexMessage(userId, googleMapApi); if (flexMessage === undefined) { return; } await client.replyMessage(replyToken, flexMessage); } else { return; } } catch (err) { console.log(err); } }; + // FlexMessageの「行きつけ」をタップしたらそのお店が登録される + const actionPutFavoriteShop = async (event: WebhookEvent, googleMapApi: + string) => { + try { + // If the message is different from the target, returned + if (event.type !== 'postback') { + return; + } + + // Retrieve the required items from the event + const data = event.postback.data; + const timestamp = event.timestamp; + const userId = event.source.userId; + + // conditional branching + const isFavorite = data.indexOf('timestamp'); + if (isFavorite === -1) { + // Register data, userId in DynamoDB + await putFavorite(data, timestamp, userId, googleMapApi); + } + } catch (err) { + console.log(err); + } + }; では、DynamoDBにデータを追加するコードを書いていきましょう。 データの追加はputを使用します。 また、次にポストバックのデータの使用方法に関してです。 { "type":"postback", "label":"Buy", + "data":"action=buy&itemid=111", "text":"Buy" } データはこのように渡されます。 この値をどのように取得するかお分かりでしょうか? JavaScriptに慣れている方であればすぐにお分かりでしょうね! 指定した区切り文字で分割して文字列の配列にしましょう。 ということで使うのは、splitですね。 ということでやっていきましょう。 api/src/Common/Database/PutFavorite.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const putFavorite = ( data: string, timestamp: number, userId: string | undefined, googleMapApi: string ) => { return new Promise((resolve, reject) => { // data const dataArray = data.split('&'); const lat = dataArray[0].split('=')[1]; const lng = dataArray[1].split('=')[1]; const name = dataArray[2].split('=')[1]; const photo = dataArray[3].split('=')[1]; const rating = dataArray[4].split('=')[1]; const vicinity = dataArray[5].split('=')[1]; // Create a URL for a store photo const photoURL = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photoreference=${photo}&key=${googleMapApi}`; // Create a URL for the store details const encodeURI = encodeURIComponent(`${name} ${vicinity}`); const storeDetailsURL = `https://maps.google.co.jp/maps?q=${encodeURI}&z=15&iwloc=A`; // Create a URL for store routing information const storeRoutingURL = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`; const params = { Item: { user_id: userId, timestamp: timestamp, photo_url: photoURL, name: name, rating: rating, store_details_url: storeDetailsURL, store_routing_url: storeRoutingURL, }, ReturnConsumedCapacity: 'TOTAL', TableName: 'Gourmets_Favorite', }; docClient.put(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; これで完了です。 それでは次に、お気に入りのお店を探しましょう。 お気に入り店を探す 機能 こちらも先にどのような機能かお見せします。 「行きつけ」をタップしたら、お気に入り登録したお店の一覧が表示されます。 ①「行きつけ」をタップする こちらはクライアント側の操作なので特にすることはありません。 ②user_idを元にDynamoDBから検索を行う DynamoDBからお気に入りのお店の情報を取得しましょう。 今回は複数取得する可能性が高いのでqueryを使用します。 ということでやっていきましょう。 api/src/Common/TemplateMessage/Favorite/QueryDatabaseInfo.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const queryDatabaseInfo = async (userId: string | undefined) => { return new Promise((resolve, reject) => { const params = { TableName: 'Gourmets_Favorite', ExpressionAttributeNames: { '#u': 'user_id' }, ExpressionAttributeValues: { ':val': userId }, KeyConditionExpression: '#u = :val', }; docClient.query(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; ③FlexMessageを作成する DynamoDBから取得した値を使用してFlexMessageを作成していきましょう。 api/src/Common/TemplateMessage/Favorite/MakeFlexMessage.ts // Load the package import { FlexMessage, FlexCarousel, FlexBubble } from '@line/bot-sdk'; // Load the module import { queryDatabaseInfo } from './QueryDatabaseInfo'; // types import { Item, QueryItem } from './types/MakeFlexMessage.type'; export const makeFlexMessage = async (userId: string | undefined): Promise<FlexMessage> => { return new Promise(async (resolve, reject) => { try { // modules queryDatabaseInfo const query: any = await queryDatabaseInfo(userId); const queryItem: QueryItem = query.Items; // FlexMessage const FlexMessageContents: FlexBubble[] = await queryItem.map((item: Item) => { const flexBubble: FlexBubble = { type: 'bubble', hero: { type: 'image', url: item.photo_url, size: 'full', aspectMode: 'cover', aspectRatio: '20:13', }, body: { type: 'box', layout: 'vertical', contents: [ { type: 'text', text: item.name, size: 'xl', weight: 'bold', }, { type: 'box', layout: 'baseline', contents: [ { type: 'icon', url: 'https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gold_star_28.png', size: 'sm', }, { type: 'text', text: `${item.rating}`, size: 'sm', margin: 'md', color: '#999999', }, ], margin: 'md', }, ], }, footer: { type: 'box', layout: 'vertical', contents: [ { type: 'button', action: { type: 'uri', label: '店舗詳細', uri: item.store_details_url, }, }, { type: 'button', action: { type: 'uri', label: '店舗案内', uri: item.store_routing_url, }, }, { type: 'button', action: { type: 'postback', label: '行きつけを解除', data: `timestamp=${item.timestamp}`, displayText: '行きつけを解除する', }, }, ], spacing: 'sm', }, }; return flexBubble; }); const flexContainer: FlexCarousel = { type: 'carousel', contents: FlexMessageContents, }; const flexMessage: FlexMessage = { type: 'flex', altText: 'お気に入りのお店', contents: flexContainer, }; resolve(flexMessage); } catch (err) { reject(err); } }); }; 独自の型定義があるのでファイルを作成しましょう。 api/src/Common/TemplateMessage/Favorite/types/MakeFlexMessage.type.ts export type Item = { user_id: string; photo_url: string; rating: string; timestamp: number; name: string; store_routing_url: string; store_details_url: string; }; export type QueryItem = Item[]; ④お店の情報をFlexMessageで送る 最後にFlexMessageで送信しましょう。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage'; + import { makeFlexMessage } from './Common/TemplateMessage/Favorite/MakeFlexMessage'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; import { putFavorite } from './Common/Database/PutFavorite'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; const LINE_GOURMET_GOOGLE_MAP_API = { Name: 'LINE_GOURMET_GOOGLE_MAP_API', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // console.log(JSON.stringify(response)); // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); await actionPutFavoriteShop(response, googleMapApi); + await actionTapFavoriteShop(client, response); } catch (err) { console.log(err); } }; // 位置情報もしくはエラーメッセージを送る const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else if (text === '行きつけのお店') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; // 移動手段の「車もしくは徒歩」かを尋ねるメッセージを送る const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; // 上記の選択を経て、おすすめのお店をFlex Messageにして送る const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const isCar = event.message.text; // Perform a conditional branch if (isCar === '車' || isCar === '徒歩') { // Register userId, isCar in DynamoDB await updateIsCar(userId, isCar); const flexMessage = await createFlexMessage(userId, googleMapApi); if (flexMessage === undefined) { return; } await client.replyMessage(replyToken, flexMessage); } else { return; } } catch (err) { console.log(err); } }; // FlexMessageの「行きつけ」をタップしたらそのお店が登録される const actionPutFavoriteShop = async (event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'postback') { return; } // Retrieve the required items from the event const data = event.postback.data; const timestamp = event.timestamp; const userId = event.source.userId; // conditional branching const isFavorite = data.indexOf('timestamp'); if (isFavorite === -1) { // Register data, userId in DynamoDB await putFavorite(data, timestamp, userId, googleMapApi); } } catch (err) { console.log(err); } }; + // リッチメニューの「行きつけ」をタップしたらメッセージが送られる + const actionTapFavoriteShop = async (client: Client, event: WebhookEvent) => { + // If the message is different from the target, returned + if (event.type !== 'message' || event.message.type !== 'text') { + return; + } + + // Retrieve the required items from the event + const replyToken = event.replyToken; + const userId = event.source.userId; + const text = event.message.text; + + if (text === '行きつけのお店') { + const flexMessage = await makeFlexMessage(userId); + if (flexMessage === undefined) { + return; + } + await client.replyMessage(replyToken, flexMessage); + } else { + return; + } + }; お気に入り店の解除 機能 「行きつけを解除」をタップするとデータが消去されます。 ①「行きつけを解除」をタップする こちらはクライアント側の操作なので特にすることはありません。 ②user_idとtimestampを元にDynamoDBからデータを削除する こちらも同様にポストバックを使用します。 { "type": "postback", "label": "行きつけを解除", "data": `timestamp=${item.timestamp}`, "displayText": "行きつけを解除する", } こちらもsplitを使って値を取得しましょう。 次にDynamoDBの削除は、deleteを使用します。 api/src/Common/Database/DeleteFavorite.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const deleteFavorite = (data: string, userId: string | undefined) => { return new Promise((resolve, reject) => { // data const timestamp: number = Number(data.split('=')[1]); const params = { TableName: 'Gourmets_Favorite', Key: { user_id: userId, timestamp: timestamp, }, }; docClient.delete(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; ではこの関数を読み込みましょう。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage'; import { makeFlexMessage } from './Common/TemplateMessage/Favorite/MakeFlexMessage'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; import { putFavorite } from './Common/Database/PutFavorite'; + import { deleteFavorite } from './Common/Database/DeleteFavorite'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; const LINE_GOURMET_GOOGLE_MAP_API = { Name: 'LINE_GOURMET_GOOGLE_MAP_API', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); await actionPutFavoriteShop(response, googleMapApi); await actionTapFavoriteShop(client, response); + await actionDeleteFavoriteShop(response); } catch (err) { console.log(err); } }; // 位置情報もしくはエラーメッセージを送る const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else if (text === '行きつけのお店') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; // 移動手段の「車もしくは徒歩」かを尋ねるメッセージを送る const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; // 上記の選択を経て、おすすめのお店をFlex Messageにして送る const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const isCar = event.message.text; // Perform a conditional branch if (isCar === '車' || isCar === '徒歩') { // Register userId, isCar in DynamoDB await updateIsCar(userId, isCar); const flexMessage = await createFlexMessage(userId, googleMapApi); if (flexMessage === undefined) { return; } await client.replyMessage(replyToken, flexMessage); } else { return; } } catch (err) { console.log(err); } }; // FlexMessageの「行きつけ」をタップしたらそのお店が登録される const actionPutFavoriteShop = async (event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'postback') { return; } // Retrieve the required items from the event const data = event.postback.data; const timestamp = event.timestamp; const userId = event.source.userId; // conditional branching const isFavorite = data.indexOf('timestamp'); if (isFavorite === -1) { // Register data, userId in DynamoDB await putFavorite(data, timestamp, userId, googleMapApi); } } catch (err) { console.log(err); } }; // リッチメニューの「行きつけ」をタップしたらメッセージが送られる const actionTapFavoriteShop = async (client: Client, event: WebhookEvent) => { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const text = event.message.text; if (text === '行きつけのお店') { const flexMessage = await makeFlexMessage(userId); if (flexMessage === undefined) { return; } await client.replyMessage(replyToken, flexMessage); } else { return; } }; + // FlexMessageの「行きつけを解除」をタップしたらそのお店がDBから削除される + const actionDeleteFavoriteShop = async (event: WebhookEvent) => { + try { + // If the message is different from the target, returned + if (event.type !== 'postback') { + return; + } + + // Retrieve the required items from the event + const data = event.postback.data; + const userId = event.source.userId; + + // conditional branching + const isFavorite = data.indexOf('timestamp'); + if (isFavorite !== -1) { + // Delete Gourmets_Favorite + await deleteFavorite(data, userId); + } + } catch (err) { + console.log(err); + } + }; これで完了です。 すべての機能を盛り込みました。 これでアプリとしては十分使えると思います。 まぁまだ問題点はあります。 FlexMessageは1度で12個しかスクロールできません。 なので、お気に入り店舗が12以上になると表示する方法がありません。 12以上の場合は複数回返信を行うように設定してもいいのですが、 店舗数が増えれば増えるほど見辛くなる問題も孕んでいます。 ただでさえ1つで画面占有の6割以上です。 これを2つ、3つと増やした場合はユーザビリティの悪化に繋がります。 なので残念ながらこれ以上の対応は思いつかないということで、簡易的なお気に入り機能として使っていただけると幸いです。 終わりに LINE Messaging APIを使うことでフロントの開発から解放されます。 LINEという身近なツールを使うことでインストールなどの手間もなく、これがあると便利だなってものを簡単に制作することができます。 ぜひ皆さんもLINE Bot開発をしてみてください。 ここまで読んでいただきありがとうございました。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【個人開発/LINE Messaging API】Node.js, TypeScriptで現在地から美味しいお店を探すアプリを作ってみた(①)

LINE Messaging APIを使って現在地から美味しいお店を探すアプリを作ってみました。 完成形としては以下の通りです。 アーキテクチャ 今回はサーバーレスアーキテクチャの根幹の機能である、「AWS Lambda」, 「Amazon API Gateway」, 「Amazon DynamoDB」の3つを使用してアプリを作成します。 また、プロビジョニングやデプロイに関してはAWS SAM(Serverless Application Model)を使用します。 対象読者 ・ Node.jsを勉強中の方 ・ TypeScriptを勉強中の方 ・ インフラ初心者の方 ・ ポートフォリオのデプロイ先をどうするか迷っている方 作成の難易度は低めです。 理由は、必要なパッケージも少ないため要件が複雑ではないからです。 また、DynamoDBの操作は、CRUD(作成・読み込み・更新・削除)のうち、C・R・Uの3つを使用するので、初学者の踏み台アプリとして優秀かと思います。 記事 今回は2つの記事に分かれています。 お店の検索を行うところまでを今回の記事で行っています。 お気に入り店の登録や解除などを次の記事で行います。 どのようなアプリか 皆さんは、どのようにして飲食店を探しますか? 私は、食べログなどのグルメサイトを使わずに Google Mapで探します。 以前食べログで「星 3.8 問題」がありました。 これだけではなく、食べログで見つけた行ったお店がイマイチだったこともあり、 グルメサイトはお店を探す場所ではなく、お店を予約するためのサイトと私は割り切りました。 電話が苦手な自分としては、まだまだ飲食店で独自の予約サイトを持っている企業も少ないので、食べログやホットペッパーで予約が可能なのはすごく助かっています。 Google Mapでお店を探すのもなかなか手間がかかるので、今回はGoogle Mapを使って近くの名店を10個教えてくれるアプリを作成しました。 Github 完成形のコードは以下となります。 アプリQR こちらから触ってみてください。 アプリの流れ クライアント LINE Messaging API(バックエンド) ①「お店を探す」をタップ ②「現在地を送る」ためのボタンメッセージを送信、例外時にはエラーメッセージを送信 ③ 現在地を送る ④「車か徒歩どちらですか?」というメッセージを送る ⑤ 車か徒歩を選択 ⑥ お店の配列を作成する(車の場合現在地から 14km 以内、徒歩の場合 0.8km 以内) ⑦ 必要なデータのみにする ⑧ 評価順に並び替えて上位 10 店舗にする ⑨ Flex Message を作成する ⑩ お店の情報を Flex Message で送る ハンズオン! 前提 初めてAWSを使う方に対しての注意です。 ルートユーザーで行うのはよろしくないので、全ての権限を与えたAdministratorユーザーを作っておいてください。 公式サイトはこちらです。 文章は辛いよって方は、初学者のハンズオン動画があるのでこちらからどうぞ。 sam initを実行する ゼロから書いていってもいいのですが、初めての方はまずはsam initを使いましょう。 以下のように選択していってください。 ターミナル $ sam init Which template source would you like to use? 1 - AWS Quick Start Templates 2 - Custom Template Location Choice: 1 What package type would you like to use? 1 - Zip (artifact is a zip uploaded to S3) 2 - Image (artifact is an image uploaded to an ECR image repository) Package type: 1 Which runtime would you like to use? 1 - nodejs14.x 2 - python3.8 3 - ruby2.7 4 - go1.x 5 - java11 6 - dotnetcore3.1 7 - nodejs12.x 8 - nodejs10.x 9 - python3.7 10 - python3.6 11 - python2.7 12 - ruby2.5 13 - java8.al2 14 - java8 15 - dotnetcore2.1 Runtime: 7 Project name [sam-app]: Gourmet AWS quick start application templates: 1 - Hello World Example 2 - Step Functions Sample App (Stock Trader) 3 - Quick Start: From Scratch 4 - Quick Start: Scheduled Events 5 - Quick Start: S3 6 - Quick Start: SNS 7 - Quick Start: SQS 8 - Quick Start: App Backend using TypeScript 9 - Quick Start: Web Backend Template selection: 1 ここまでできれば作成されます。 このような構成になっていればOKです。 .Gourmet ├── events/ │ ├── event.json ├── hello-world/ │ ├── tests │ │ └── integration │ │ │ └── test-api-gateway.js │ │ └── unit │ │ │ └── test-handler.js │ ├── .npmignore │ ├── app.js │ ├── package.json ├── .gitignore ├── README.md ├── template.yaml 必要ないファイルなどがあるのでそれを削除していきましょう。 .Gourmet ├── hello-world/ │ ├── app.js ├── .gitignore ├── README.md ├── template.yaml また、ディレクトリ名やファイル名を変えましょう。 .Gourmet ├── api/ │ ├── index.js ├── .gitignore ├── README.md ├── template.yaml 次は、template.yamlを修正して、SAMの実行をしてみたいところですが、一旦後回しにします。 先にTypeScriptなどのパッケージを入れ、ディレクトリ構造を明確にした後の方が理解しやすいので。。 ということでパッケージを入れていきましょう。 package.jsonの作成 以下のコマンドを入力してください。 これで、package.jsonの作成が完了します。 ターミナル $ npm init -y 必要なパッケージのインストール dependencies dependenciesはすべてのステージで使用するパッケージです。 今回使用するパッケージは以下の4つです。 ・@line/bot-sdk ・aws-sdk ・axios 以下のコマンドを入力してください。 これで全てのパッケージがインストールされます。 ターミナル $ npm install @line/bot-sdk aws-sdk axios --save devDependencies devDependenciesはコーディングステージのみで使用するパッケージです。 今回使用するパッケージは以下の5つです。 ・typescript ・@types/node ・ts-node ・rimraf ・npm-run-all 以下のコマンドを入力してください。 これで全てのパッケージがインストールされます。 ターミナル $ npm install -D typescript @types/node ts-node rimraf npm-run-all package.jsonにコマンドの設定を行う npm run buildでコンパイルを行います。 package.json { "scripts": { "clean": "rimraf dist", "tsc": "tsc", "build": "npm-run-all clean tsc" }, } tsconfig.jsonの作成 以下のコマンドを実行しTypeScriptの初期設定を行います。 ターミナル $ npx tsc --init それでは、作成されたtsconfig.jsonの上書きをしていきます。 tsconfig.json { "compilerOptions": { "target": "ES2018", "module": "commonjs", "sourceMap": true, "outDir": "./api/dist", "strict": true, "moduleResolution": "node", "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["api/src/*"] } 簡単にまとめると、 api/srcディレクトリ以下を対象として、それらをapi/distディレクトリにES2018の書き方でビルドされるという設定です。 tsconfig.jsonに関して詳しく知りたい方は以下のサイトをどうぞ。 また、この辺りで必要ないディレクトリはGithubにpushしたくないので、.gitignoreも作成しておきましょう。 .gitignore node_modules package-lock.json .aws-sam samconfig.toml dist 最終的にはこのようなディレクトリ構成にしましょう。 .Gourmet ├── api/ │ ├── dist(コンパイル後) │ │ └── node_modules(コピーする) │ │ └── package.json(コピーする) │ ├── src(コンパイル前) │ │ └── index.ts ├── node_modules(コピー元) ├── .gitignore ├── package.json(コピー元) ├── package-lock.json ├── README.md ├── template.yaml ├── tsconfig.json やるべきことは以下の2つです。 ①distディレクトリを作成する ②distディレクトリに、node_modules, package.jsonをコピーする 次に、template.yamlを書いていきましょう。 SAM Templateを記載する ファイル内にコメントを残しています。 これで大まかには理解できるかと思います。 詳しくは公式サイトを見てください。 template.yaml # AWS CloudFormationテンプレートのバージョン AWSTemplateFormatVersion: '2010-09-09' # CloudFormationではなくSAMを使うと明記する Transform: AWS::Serverless-2016-10-31 # CloudFormationのスタックの説明文(重要ではないので適当でOK) Description: > LINE Messaging API + Node.js + TypeScript + SAM(Lambda, API Gateway, DynamoDB, S3)で作った飲食店検索アプリです Globals: # Lambda関数のタイムアウト値(3秒に設定) Function: Timeout: 3 Resources: # API Gateway GourmetAPI: # Typeを指定する(今回はAPI Gateway) Type: AWS::Serverless::Api Properties: # ステージ名(APIのURLの最後にこのステージ名が付与されます) StageName: v1 # Lambda GourmetFunction: # Typeを指定する(今回はLambda) Type: AWS::Serverless::Function Properties: # 関数が格納されているディレクトリ(今回はコンパイル後なので、distディレクトリを使用する) CodeUri: api/dist # ファイル名と関数名(今回はファイル名がindex.js、関数名がexports.handlerなので、index.handlerとなります) Handler: index.handler # どの言語とどのバージョンを使用するか Runtime: nodejs12.x # ポリシーを付与する(今回はLambdaの権限とSSMの読み取り権限を付与) Policies: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole - arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess # この関数をトリガーするイベントを指定します Events: # API Gateway GourmetAPI: Type: Api Properties: # どのAPIを使用するか(!Refは値の参照に使用します) RestApiId: !Ref GourmetAPI # URL Path: / # POSTメソッド Method: post Outputs: GourmetAPI: Description: 'API Gateway' Value: !Sub 'https://${GourmetAPI}.execute-api.${AWS::Region}.amazonaws.com/v1' GourmetFunction: Description: 'Lambda' Value: !GetAtt GourmetFunction.Arn GourmetFunctionIamRole: Description: 'IAM Role' Value: !GetAtt GourmetFunctionRole.Arn LINE Developersにアカウントを作成する LINE Developersにアクセスして、「ログイン」ボタンをクリックしてください。 その後諸々入力してもらったら以下のように作成できるかと思います。 注意事項としては、今回Messaging APIとなるので、チャネルの種類を間違えた方は修正してください。 チャネルシークレットとチャネルアクセストークンが必要になるのでこの2つを発行します。 これで必要な環境変数は取得できました。 それでは、これをSSMを使ってLambda内で使えるようにしていきましょう。 SSMパラメータストアで環境変数を設定 なぜSSMパラメータストアを使うのか? SAMのLambda設定にも、環境変数の項目はあります。 しかし、2点問題点があります。 ①Lambdaの環境変数の変更をしたいとき、Lambdaのバージョンも新規発行をしなければならない ②Lambdaのバージョンとエイリアスを紐付けて管理をするとき、もし環境変数にリリース先環境別の値をセットしていると、リリース時に手動で環境変数の変更をしなければならないケースが発生する 簡単にまとめると、「リアルタイムで反映できないし、人為的なミスのリスクもあるよ」ということです。 SSMパラメータストアで値を管理すると以下の3点のメリットがあります。 ①Lambdaの環境変数の管理が不要 ②Lambdaも含めた値関連情報を一元管理できる ③Lambda外部からリアルタイムに環境変数を変更制御できる ということで、SSMパラメータストアを使用しましょう。 みんな大好きクラスメソッドの記事にやり方が書いてあります。 こちらの記事が完璧なのでこちらを見てやってみてください。 私は以下のように命名して作成しました。 SSMパラメータが取得できているかconsole.logで検証 api/src/index.ts // import import aws from 'aws-sdk'; // SSM const ssm = new aws.SSM(); exports.handler = async (event: any, context: any) => { const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; console.log('channelAccessToken: ' + channelAccessToken); }; これをコンパイルしてデプロイしていきましょう。 ターミナル // コンパイル $ npm run build // ビルド $ sam build // デプロイ $ sam deploy --guided Configuring SAM deploy ====================== Looking for samconfig.toml : Not found Setting default arguments for 'sam deploy' ========================================= // CloudFormation スタック名の指定 Stack Name [sam-app]: Gourmet // リージョンの指定 AWS Region [us-east-1]: ap-northeast-1 // デプロイ前にCloudformationの変更セットを確認するか #Shows you resources changes to be deployed and require a 'Y' to initiate deploy Confirm changes before deploy [y/N]: y // SAM CLI に IAM ロールの作成を許可するか(CAPABILITY_IAM) #SAM needs permission to be able to create roles to connect to the resources in your template Allow SAM CLI IAM role creation [Y/n]: y // API イベントタイプの関数に認証が含まれていない場合、警告される HelloWorldFunction may not have authorization defined, Is this okay? [y/N]: y // この設定を samconfig.toml として保存するか Save arguments to samconfig.toml [Y/n]: y これでデプロイが完了します。 では、API GatewayのURLを確認しましょう。 Webhook URLの登録 先ほどAPI Gatewayで作成したhttpsのURLをコピーしてください。 これをLINE DevelopersのWebhookに設定します。 それではSSMパラメータが正しく取得できているか確認しましょう。 CloudWatchで確認しましょう! 取得できていますね! これで準備は完了です。 ここから飲食店検索の仕組みを作っていきましょう! アプリの流れ クライアント LINE Messaging API(バックエンド) ①「お店を探す」をタップ ②「現在地を送る」ためのボタンメッセージを送信、例外時にはエラーメッセージを送信 ③ 現在地を送る ④「車か徒歩どちらですか?」というメッセージを送る ⑤ 車か徒歩を選択 ⑥ お店の配列を作成する(車の場合現在地から 14km 以内、徒歩の場合 0.8km 以内) ⑦ 必要なデータのみにする ⑧ 評価順に並び替えて上位 10 店舗にする ⑨ Flex Message を作成する ⑩ お店の情報を Flex Message で送る ①「お店を探す」をタップ こちらに関してはクライアント側の操作なので作業することはありません。 ②「現在地を送る」ためのボタンメッセージを送信、例外時にはエラーメッセージを送信 「現在地を送る」ためのボタンメッセージ api/src/Common/TemplateMessage/YourLocation.ts // Load the package import { TemplateMessage } from '@line/bot-sdk'; export const yourLocationTemplate = (): Promise<TemplateMessage> => { return new Promise((resolve, reject) => { const params: TemplateMessage = { type: 'template', altText: '現在地を送ってください!', template: { type: 'buttons', text: '今日はどこでご飯を食べる?', actions: [ { type: 'uri', label: '現在地を送る', uri: 'https://line.me/R/nv/location/', }, ], }, }; resolve(params); }); }; ちなみに以下のURLですが、LINEで利用できるURLスキームというもので位置情報を送れるものです。 https://line.me/R/nv/location/ 詳しくは以下をご確認ください。 エラーメッセージ api/src/Common/TemplateMessage/Error.ts // Load the package import { TextMessage } from '@line/bot-sdk'; export const errorTemplate = (): Promise<TextMessage> => { return new Promise((resolve, reject) => { const params: TextMessage = { type: 'text', text: 'ごめんなさい、このメッセージには対応していません', }; resolve(params); }); }; メッセージの送信 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); } catch (err) { console.log(err); } }; const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; ③ 現在地を送る こちらに関してもクライアント側の操作なので作業することはありません。 ④「車か徒歩どちらですか?」というメッセージを送る LINE Messaging APIにキャッシュの機能などはありません。 なので、③の「現在地を送る」のデータはどこかに格納しないと値が消えてしまいます。 ということで、今回はサーバーレスと相性の良い「DynamoDB」を使用します。 DynamoDB 以下のテーブルを作成します。 PK K K K user_id latitude longitude is_car ユーザー ID 緯度 経度 車か徒歩か それぞれのデータ取得方法 ユーザーIDは、event.source.userIdから取得できます。 緯度、経度は、【クライアント】③ 現在地を送るから取得できます。 車か徒歩かは、【クライアント】⑤ 車か徒歩を選択から取得できます。 SAMテンプレートにDynamoDBの記載を行う template.yaml # AWS CloudFormationテンプレートのバージョン AWSTemplateFormatVersion: '2010-09-09' # CloudFormationではなくSAMを使うと明記する Transform: AWS::Serverless-2016-10-31 # CloudFormationのスタックの説明文(重要ではないので適当でOK) Description: > LINE Messaging API + Node.js + TypeScript + SAM(Lambda, API Gateway, DynamoDB, S3)で作った飲食店検索アプリです Globals: # Lambda関数のタイムアウト値(3秒に設定) Function: Timeout: 3 Resources: # API Gateway GourmetAPI: # Typeを指定する(今回はAPI Gateway) Type: AWS::Serverless::Api Properties: # ステージ名(APIのURLの最後にこのステージ名が付与されます) StageName: v1 + # DynamoDB + GourmetDynamoDB: + # Typeを指定する(今回はDynamoDB) + Type: AWS::Serverless::SimpleTable + Properties: + # テーブルの名前 + TableName: Gourmets + # プライマリキーの設定(名前とプライマリキーのタイプ) + PrimaryKey: + Name: user_id + Type: String + # プロビジョニングされたキャパシティの設定(今回の要件では最小の1でOK) + ProvisionedThroughput: + ReadCapacityUnits: 1 + WriteCapacityUnits: 1 # Lambda GourmetFunction: # Typeを指定する(今回はLambda) Type: AWS::Serverless::Function Properties: # 関数が格納されているディレクトリ(今回はコンパイル後なので、distディレクトリを使用する) CodeUri: api/dist # ファイル名と関数名(今回はファイル名がindex.js、関数名がexports.handlerなので、index.handlerとなります) Handler: index.handler # どの言語とどのバージョンを使用するか Runtime: nodejs12.x # ポリシーを付与する(今回はLambdaの権限とSSMの読み取り権限とDynamoDBのフルアクセス権限を付与) Policies: - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole - arn:aws:iam::aws:policy/AmazonSSMReadOnlyAccess + - arn:aws:iam::aws:policy/AmazonDynamoDBFullAccess # この関数をトリガーするイベントを指定します Events: # API Gateway GourmetAPI: Type: Api Properties: # どのAPIを使用するか(!Refは値の参照に使用します) RestApiId: !Ref GourmetAPI # URL Path: / # POSTメソッド Method: post Outputs: GourmetAPI: Description: 'API Gateway' Value: !Sub 'https://${GourmetAPI}.execute-api.${AWS::Region}.amazonaws.com/v1' GourmetFunction: Description: 'Lambda' Value: !GetAtt GourmetFunction.Arn GourmetFunctionIamRole: Description: 'IAM Role' Value: !GetAtt GourmetFunctionRole.Arn 現在地が送信されたらDynamoDBのuser_id, latitude, longitudeが入力されるようにする 今回はDynamoDBに新規のレコードを追加します。 新規追加はputを使用します。 api/src/Common/Database/PutLocation.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const putLocation = (userId: string | undefined, latitude: string, longitude: string) => { return new Promise((resolve, reject) => { const params = { Item: { user_id: userId, latitude: latitude, longitude: longitude, }, ReturnConsumedCapacity: 'TOTAL', TableName: 'Gourmets', }; docClient.put(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; この関数をindex.tsで読み込みましょう。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; // Database + import { putLocation } from './Common/Database/PutLocation'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); + await actionIsCar(client, response); } catch (err) { console.log(err); } }; const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; + const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { + try { + // If the message is different from the target, returned + if (event.type !== 'message' || event.message.type !== 'location') { + return; + } + + // Retrieve the required items from the event + const replyToken = event.replyToken; + const userId = event.source.userId; + const latitude: string = String(event.message.latitude); + const longitude: string = String(event.message.longitude); + + // Register userId, latitude, and longitude in DynamoDB + await putLocation(userId, latitude, longitude); + } catch (err) { + console.log(err); + } + }; これでDynamoDBへの登録が完了です。 次にメッセージを作成しましょう。 api/src/Common/TemplateMessage/IsCar.ts // Load the package import { TemplateMessage } from '@line/bot-sdk'; export const isCarTemplate = (): Promise<TemplateMessage> => { return new Promise((resolve, reject) => { const params: TemplateMessage = { type: 'template', altText: 'あなたの移動手段は?', template: { type: 'confirm', text: 'あなたの移動手段は?', actions: [ { type: 'message', label: '車', text: '車', }, { type: 'message', label: '徒歩', text: '徒歩', }, ], }, }; resolve(params); }); }; 最後にこちらの関数をindex.tsに読み込みましょう。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; + import { isCarTemplate } from './Common/TemplateMessage/IsCar'; // Database import { putLocation } from './Common/Database/PutLocation'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); } catch (err) { console.log(err); } }; const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); + } else if (text === '車' || text === '徒歩') { + return; + } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); + // modules + const isCar = await isCarTemplate(); + // Send a two-choice question + await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; ⑤ 車か徒歩を選択 こちらに関してもクライアント側の操作なので作業することはありません。 ⑥ お店の配列を作成する 車の場合現在地から 14km以内、徒歩の場合 0.8km以内で検索することとします。 車は20分程度、徒歩は10分程度で着く範囲を検索対象としています。 移動手段が送信されたらDynamoDBのis_carが入力されるようにする 今回はDynamoDBにuser_idをキーとして、レコードを更新します。 更新はupdateを使用します。 ではやっていきましょう。 api/src/Common/Database/UpdateIsCar.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const updateIsCar = (userId: string | undefined, isCar: string) => { return new Promise((resolve, reject) => { const params = { TableName: 'Gourmets', Key: { user_id: userId, }, UpdateExpression: 'SET is_car = :i', ExpressionAttributeValues: { ':i': isCar, }, }; docClient.update(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; このDB処理をindex.tsで読み込みます。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; // Database import { putLocation } from './Common/Database/PutLocation'; + import { updateIsCar } from './Common/Database/UpdateIsCar'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response); } catch (err) { console.log(err); } }; const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; + const actionFlexMessage = async (client: Client, event: WebhookEvent) => { + try { + // If the message is different from the target, returned + if (event.type !== 'message' || event.message.type !== 'text') { + return; + } + + // Retrieve the required items from the event + const replyToken = event.replyToken; + const userId = event.source.userId; + const isCar = event.message.text; + + // Perform a conditional branch + if (isCar === '車' || isCar === '徒歩') { + // Register userId, isCar in DynamoDB + await updateIsCar(userId, isCar); + } else { + return; + } + } catch (err) { + console.log(err); + } + }; お店の配列を作成するまでのステップ 1. DynamoDBのデータを取得する api/src/Common/TemplateMessage/Gourmet/GetDatabaseInfo.ts // Load the package import aws from 'aws-sdk'; // Create DynamoDB document client const docClient = new aws.DynamoDB.DocumentClient(); export const getDatabaseInfo = async (userId: string | undefined) => { return new Promise((resolve, reject) => { const params = { TableName: 'Gourmets', Key: { user_id: userId, }, }; docClient.get(params, (err, data) => { if (err) { reject(err); } else { resolve(data); } }); }); }; 2. Google Map APIを取得して、SSMパラメーターストアに登録する Google MapのAPIを取得しましょう。 まずはGCPのコンソール画面に入って下さい。 コンソールに入ったらプロジェクトを作成しましょう! 私は、LINE-Node-TypeScript-Gourmetで作成しました。 では、ライブラリを有効化しましょう! 使うライブラリは2つです。 Map JavaScript API Places API お店検索をするAPIは「Places API」ですが、 JavaScriptから呼び出すために「Map JavaScript API」が必要となります。 ここまでできたら次にAPIを作成しましょう。 これからの開発はこちらのAPIキーを使います。 セキュリティ的には制限をつけたほうがいいのですが、今回はつけずに行います。 上記の説明でわからなければ以下のサイトを参考にされて下さい。 では取得したAPIをSSMパラメーターストアに登録しましょう。 方法は以下の通りです。 私はこのように命名しました。 ではこの値を関数内で使えるようにしましょう。 api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; + const LINE_GOURMET_GOOGLE_MAP_API = { + Name: 'LINE_GOURMET_GOOGLE_MAP_API', + WithDecryption: false, + }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); + const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; + const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); } catch (err) { console.log(err); } }; const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; + const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const isCar = event.message.text; // Perform a conditional branch if (isCar === '車' || isCar === '徒歩') { // Register userId, isCar in DynamoDB await updateIsCar(userId, isCar); } else { return; } } catch (err) { console.log(err); } }; 3. お店の配列を作成する 近隣のお店を調べるので、Place SearchのNearby Search requestsを使います。 ここが正直イマイチなコードかもしれません。 setTimeoutを頻発しているからです。 Nearby Search requestsは20店舗しか取り出すことができないのですが、 pagetokenを使用することで60店舗取り出すことができます。 このpagetokenを使って再度呼び出しを行うのですが、その時に待ち時間が必要になります。 最初は、async, awaitの非同期で対応できると思っていたのですが、この待ち時間だけでは足りないようでsetTimeoutが必要になりました。 こちらはコードがイマイチなので、対応を考えて他の方法があれば修正いたします。 ここはこんなコードの書き方もあるんだ程度にしていただけますと幸いです。 api/src/Common/TemplateMessage/Gourmet/GetGourmetInfo.ts // Load the package import axios, { AxiosResponse } from 'axios'; // Load the module import { getDatabaseInfo } from './GetDatabaseInfo'; export const getGourmetInfo = async (user_id: string | undefined, googleMapApi: string) => { return new Promise(async (resolve, reject) => { // modules getDatabaseInfo const data: any = await getDatabaseInfo(user_id); const isCar = data.Item.is_car; const latitude = data.Item.latitude; const longitude = data.Item.longitude; // Bifurcate the radius value depending on whether you are driving or walking let radius = 0; if (isCar === '車') { radius = 1400; } else { radius = 800; } let gourmetArray: any[] = []; const url = `https://maps.googleapis.com/maps/api/place/nearbysearch/json?location=${latitude},${longitude}&radius=${radius}&type=restaurant&key=${googleMapApi}&language=ja`; new Promise(async (resolve) => { const gourmets: AxiosResponse<any> = await axios.get(url); const gourmetData = gourmets.data.results; gourmetArray = gourmetArray.concat(gourmetData); const pageToken = gourmets.data.next_page_token; resolve(pageToken); }) .then((value) => { return new Promise((resolve) => { setTimeout(async () => { const addTokenUrl = `${url}&pagetoken=${value}`; const gourmets = await axios.get(addTokenUrl); const gourmetData = gourmets.data.results; gourmetArray = gourmetArray.concat(gourmetData); const pageToken = gourmets.data.next_page_token; resolve(pageToken); }, 2000); }); }) .then((value) => { setTimeout(async () => { const addTokenUrl = `${url}&pagetoken=${value}`; const gourmets = await axios.get(addTokenUrl); const gourmetData = gourmets.data.results; gourmetArray = gourmetArray.concat(gourmetData); }, 2000); }); setTimeout(() => { resolve(gourmetArray); }, 8000); }); }; ⑦ 必要なデータのみにする 使うデータは以下の通りです。 必要なデータ 理由 geometry_location_lat 店舗案内のURLで使うため geometry_location_lng 店舗案内のURLで使うため name Flex Message内と店舗詳細のURLで使うため photo_reference 店舗写真を生成するために使う rating Flex Message内で使うため vicinity 店舗詳細のURLで使うため 店舗詳細と店舗案内、店舗写真のURLはこの後解説します。 ということで必要なデータのみを抜き出して配列を再生成しましょう。 api/src/Common/TemplateMessage/Gourmet/FormatGourmetArray.ts // Load the module import { getGourmetInfo } from './GetGourmetInfo'; // types import { RequiredGourmetArray } from './types/FormatGourmetArray.type'; export const formatGourmetArray = async ( user_id: string | undefined, googleMapApi: string ): Promise<RequiredGourmetArray> => { return new Promise(async (resolve, reject) => { // modules getGourmetInfo const gourmetInfo: any = await getGourmetInfo(user_id, googleMapApi); // Extract only the data you need const sufficientGourmetArray: any = gourmetInfo.filter( (gourmet: any) => gourmet.photos !== undefined || null ); // Format the data as required const requiredGourmetArray: RequiredGourmetArray = sufficientGourmetArray.map( (gourmet: any) => { return { geometry_location_lat: gourmet.geometry.location.lat, geometry_location_lng: gourmet.geometry.location.lng, name: gourmet.name, photo_reference: gourmet.photos[0].photo_reference, rating: gourmet.rating, vicinity: gourmet.vicinity, }; } ); resolve(requiredGourmetArray); }); }; 上記で、RequiredGourmetArrayという型を使用しているので型定義ファイルを作ります。 api/src/Common/TemplateMessage/Gourmet/types/FormatGourmetArray.type.ts export type RequiredGourmetArray = { geometry_location_lat: number; geometry_location_lng: number; name: string; photo_reference: string; rating: number; vicinity: string; }[]; ⑧ 評価順に並び替えて上位10店舗にする sortで並び替えて、sliceで新たな配列を作ってあげましょう! api/src/Common/TemplateMessage/Gourmet/SortRatingGourmetArray.ts // Load the module import { formatGourmetArray } from './FormatGourmetArray'; // types import { GourmetData, GourmetDataArray } from './types/SortRatingGourmetArray.type'; export const sortRatingGourmetArray = async ( user_id: string | undefined, googleMapApi: string ): Promise<GourmetDataArray> => { return new Promise(async (resolve, reject) => { try { // modules formatGourmetArray const gourmetArray: GourmetDataArray = await formatGourmetArray(user_id, googleMapApi); // Sort by rating gourmetArray.sort((a: GourmetData, b: GourmetData) => b.rating - a.rating); // narrow it down to 10 stores. const sortGourmetArray: GourmetDataArray = gourmetArray.slice(0, 10); console.log(sortGourmetArray); resolve(sortGourmetArray); } catch (err) { reject(err); } }); }; 型定義を行いましょう。 api/src/Common/TemplateMessage/Gourmet/types/SortRatingGourmetArray.type.ts export type GourmetData = { geometry_location_lat: number; geometry_location_lng: number; name: string; photo_reference: string; rating: number; vicinity: string; }; export type GourmetDataArray = GourmetData[]; ⑨ Flex Messageを作成する ⑦で説明した必要なデータについて解説します。 必要なデータ 理由 geometry_location_lat 店舗案内のURLで使うため geometry_location_lng 店舗案内のURLで使うため name Flex Message内と店舗詳細のURLで使うため photo_reference 店舗写真を生成するために使う rating Flex Message内で使うため vicinity 店舗詳細のURLで使うため nameとratingはFlex Message内で使います。 店舗詳細に関してですが、こちらのURLは以下となります。 https://maps.google.co.jp/maps?q=${店舗名}${住所}&z=15&iwloc=A 店舗案内に関しては以下のURLとなります。 https://www.google.com/maps/dir/?api=1&destination=${緯度},${経度} 店舗写真に関しては以下のURLとなります。 https://maps.googleapis.com/maps/api/place/photo?maxwidth=${任意の幅}&photoreference=${photo_reference}&key=${Google_API} ということで、Flex Message内でこれらのURLを生成していけば完成です。 やっていきましょう! api/src/Common/TemplateMessage/Gourmet/CreateFlexMessage.ts // Load the package import { FlexMessage, FlexCarousel, FlexBubble } from '@line/bot-sdk'; // Load the module import { sortRatingGourmetArray } from './SortRatingGourmetArray'; // types import { Gourmet, RatingGourmetArray } from './types/CreateFlexMessage.type'; export const createFlexMessage = async ( user_id: string | undefined, googleMapApi: string ): Promise<FlexMessage | undefined> => { return new Promise(async (resolve, reject) => { try { // modules sortRatingGourmetArray const ratingGourmetArray: RatingGourmetArray = await sortRatingGourmetArray( user_id, googleMapApi ); // FlexMessage const FlexMessageContents: FlexBubble[] = await ratingGourmetArray.map((gourmet: Gourmet) => { // Create a URL for a store photo const photoURL = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photoreference=${gourmet.photo_reference}&key=${googleMapApi}`; // Create a URL for the store details const encodeURI = encodeURIComponent(`${gourmet.name} ${gourmet.vicinity}`); const storeDetailsURL = `https://maps.google.co.jp/maps?q=${encodeURI}&z=15&iwloc=A`; // Create a URL for store routing information const storeRoutingURL = `https://www.google.com/maps/dir/?api=1&destination=${gourmet.geometry_location_lat},${gourmet.geometry_location_lng}`; const flexBubble: FlexBubble = { type: 'bubble', hero: { type: 'image', url: photoURL, size: 'full', aspectMode: 'cover', aspectRatio: '20:13', }, body: { type: 'box', layout: 'vertical', contents: [ { type: 'text', text: gourmet.name, size: 'xl', weight: 'bold', }, { type: 'box', layout: 'baseline', contents: [ { type: 'icon', url: 'https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gold_star_28.png', size: 'sm', }, { type: 'text', text: `${gourmet.rating}`, size: 'sm', margin: 'md', color: '#999999', }, ], margin: 'md', }, ], }, footer: { type: 'box', layout: 'vertical', contents: [ { type: 'button', action: { type: 'uri', label: '店舗詳細', uri: storeDetailsURL, }, }, { type: 'button', action: { type: 'uri', label: '店舗案内', uri: storeRoutingURL, }, }, ], spacing: 'sm', }, }; return flexBubble; }); const flexContainer: FlexCarousel = { type: 'carousel', contents: FlexMessageContents, }; const flexMessage: FlexMessage = { type: 'flex', altText: '近隣の美味しいお店10店ご紹介', contents: flexContainer, }; resolve(flexMessage); } catch (err) { reject(err); } }); }; 型定義を行いましょう。 api/src/Common/TemplateMessage/Gourmet/types/CreateFlexMessage.type.ts export type Gourmet = { geometry_location_lat: number; geometry_location_lng: number; name: string; photo_reference: string; rating: number; vicinity: string; }; export type RatingGourmetArray = Gourmet[]; ⑩ お店の情報をFlex Messageで送る api/src/index.ts // Load the package import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk'; import aws from 'aws-sdk'; // Load the module // TemplateMessage import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation'; import { errorTemplate } from './Common/TemplateMessage/Error'; import { isCarTemplate } from './Common/TemplateMessage/IsCar'; + import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage'; // Database import { putLocation } from './Common/Database/PutLocation'; import { updateIsCar } from './Common/Database/UpdateIsCar'; // SSM const ssm = new aws.SSM(); const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = { Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN', WithDecryption: false, }; const LINE_GOURMET_CHANNEL_SECRET = { Name: 'LINE_GOURMET_CHANNEL_SECRET', WithDecryption: false, }; const LINE_GOURMET_GOOGLE_MAP_API = { Name: 'LINE_GOURMET_GOOGLE_MAP_API', WithDecryption: false, }; exports.handler = async (event: any, context: any) => { // Retrieving values in the SSM parameter store const CHANNEL_ACCESS_TOKEN: any = await ssm .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN) .promise(); const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise(); const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise(); const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value; const channelSecret: string = CHANNEL_SECRET.Parameter.Value; const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value; // Create a client using the SSM parameter store const clientConfig: ClientConfig = { channelAccessToken: channelAccessToken, channelSecret: channelSecret, }; const client = new Client(clientConfig); // body const body: any = JSON.parse(event.body); const response: WebhookEvent = body.events[0]; // action try { await actionLocationOrError(client, response); await actionIsCar(client, response); await actionFlexMessage(client, response, googleMapApi); } catch (err) { console.log(err); } }; const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const text = event.message.text; // modules const yourLocation = await yourLocationTemplate(); const error = await errorTemplate(); // Perform a conditional branch if (text === 'お店を探す') { await client.replyMessage(replyToken, yourLocation); } else if (text === '車' || text === '徒歩') { return; } else { await client.replyMessage(replyToken, error); } } catch (err) { console.log(err); } }; const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'location') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const latitude: string = String(event.message.latitude); const longitude: string = String(event.message.longitude); // Register userId, latitude, and longitude in DynamoDB await putLocation(userId, latitude, longitude); // modules const isCar = await isCarTemplate(); // Send a two-choice question await client.replyMessage(replyToken, isCar); } catch (err) { console.log(err); } }; const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => { try { // If the message is different from the target, returned if (event.type !== 'message' || event.message.type !== 'text') { return; } // Retrieve the required items from the event const replyToken = event.replyToken; const userId = event.source.userId; const isCar = event.message.text; // Perform a conditional branch if (isCar === '車' || isCar === '徒歩') { // Register userId, isCar in DynamoDB await updateIsCar(userId, isCar); + const flexMessage = await createFlexMessage(userId, googleMapApi); + if (flexMessage === undefined) { + return; + } + await client.replyMessage(replyToken, flexMessage); } else { return; } } catch (err) { console.log(err); } }; デプロイ まずは、npm run buildでコンパイルしましょう。 ターミナル $ npm run build コンパイルされた後は、ビルドしてデプロイしていきましょう。 ターミナル // ビルド $ sam build // デプロイ $ sam deploy --guided DynamoDBも確認しましょう。 しっかり保存されていますね! 最後に 追加する要件として、今後はお気に入りのお店を登録する機能なども足していこうと思います。 ここまで読んでいただきありがとうございました!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Node.jsで簡易なWebシステムの構築②

目的 Node.jsを用いて簡易なWebシステムを構築する。Node.jsで簡易なWebシステムの構築①のアプリの拡張で、今回はデータの登録を可能にする。 環境条件 Node.jsで簡易なWebシステムの構築①で作業した環境をそのまま利用。 構築手順 ec2-userでログイン # rootユーザにスイッチ sudo su - 1.基本的な環境設定 #/opt/nodejsのディレクトリに移動 cd /opt/nodejs express-generatorを用いてアプリケーションのベースを構築。 #expressのインストール npm install express --save npm WARN saveError ENOENT: no such file or directory, open '/opt/nodejs/package.json' npm notice created a lockfile as package-lock.json. You should commit this file. npm WARN enoent ENOENT: no such file or directory, open '/opt/nodejs/package.json' npm WARN nodejs No description npm WARN nodejs No repository field. npm WARN nodejs No README data npm WARN nodejs No license field. express@4.17.1 added 50 packages from 37 contributors and audited 50 packages in 2.319s found 0 vulnerabilities #express-generatorのインストール npm install -g express-generator npm WARN deprecated mkdirp@0.5.1: Legacy versions of mkdirp are no longer supported. Please update to mkdirp 1.x. (Note that the API surface has changed to use Promises in 1.x.) /usr/local/bin/express -> /usr/local/lib/node_modules/express-generator/bin/express-cli.js + express-generator@4.16.1 added 10 packages from 13 contributors in 0.715s #myapp2という名前でアプリケーションのベースを構築 express -e myapp2 warning: option --ejs' has been renamed to--view=ejs' create : myapp2/ create : myapp2/public/ create : myapp2/public/javascripts/ create : myapp2/public/images/ create : myapp2/public/stylesheets/ create : myapp2/public/stylesheets/style.css create : myapp2/routes/ create : myapp2/routes/index.js create : myapp2/routes/users.js create : myapp2/views/ create : myapp2/views/error.ejs create : myapp2/views/index.ejs create : myapp2/app.js create : myapp2/package.json create : myapp2/bin/ create : myapp2/bin/www change directory: $ cd myapp2 install dependencies: $ npm install run the app: $ DEBUG=myapp2:* npm start #myapp2ディレクトリに移動 cd myapp2/ #npmパッケージのインストール npm install npm notice created a lockfile as package-lock.json. You should commit this file. added 54 packages from 38 contributors and audited 55 packages in 2.187s found 0 vulnerabilities #mysql関連ライブラリとexpress-validatorのインストール npm install --save mysql express-validator 2.アプリケーションの開発 今回のアプリケーションは、http://ホスト名:3000にアクセスするとNode.jsで簡易なWebシステムの構築①と同様に商品(果物)リストが表示され、その画面上にボタンを追加し、当該ボタンから商品登録画面に遷移し、登録が完了すると、登録後の情報を元の画面を更新して表示するという仕様とする。遷移先のURLはhttp://ホスト名:3000/insertとする。 express-generatorで生成された各種ファイルに対し、追記や変更、ファイル追加を実施することで構築している。 追記・変更したもの public/stylesheets/style.css routes/index.js views/index.ejs 追加したもの views/insert.ejs routes/index.js // 必要なライブラリの呼び出し const express = require('express'); const router = express.Router(); const mysql = require('mysql'); const { check, validationResult } = require('express-validator/check'); // mysql接続用の設定定義 const mysql_setting = { host: 'localhost', user: 'root', password: 'password', database: 'myappdb' } // http://<hostname>:3000にアクセスがきた際のレスポンス router.get('/', function(req, res, next) { // DBコネクションの生成 const connection = mysql.createConnection(mysql_setting); connection.connect(); // SQLの実行と結果の取得とindex.ejsへの伝達 connection.query('select * from myapptbl1', function (err, results, fields) { if (err) throw err res.render('index', { content: results }) }); // DBコネクションの破棄 connection.end(); }); // http://<hostname>:3000/insertにアクセスがきた際のレスポンス(insert.ejs) router.get('/insert', function (req, res, next) { const data = { errorMessage: '' } res.render('./insert', data); }); // http://<hostname>:3000/insertへアクセスが来た際のレスポンス // web画面上で入力された値が空になっていないかを確認し、エラーメッセージを表示 router.post('/insert', [check('name').not().isEmpty().trim().escape().withMessage('名前を入力して下さい'),check('price').not().isEmpty().trim().escape().withMessage('値段を入力して下さい'),], (req, res, next) => { const errors = validationResult(req); // 値が空の場合 if (!errors.isEmpty()) { const errors_array = errors.array(); res.render('./insert', { errorMessage: errors_array, }) } else { // 値が入力されている場合 // 画面上で入力された値を変数として定義 const name = req.body.name; const price = req.body.price; // SQL用に配列の作成と変数の入力 const post = { 'name': name, 'price': price }; // DBコネクションの生成 const connection = mysql.createConnection(mysql_setting); connection.connect(); // プレースホルダを用いてSQLを発行し、データを登録する connection.query('INSERT INTO myapptbl1 SET ?', post, function (error, results, fields) { if (error) throw error; // http://<ホスト名>:3000にリダイレクト(Insert後のデータを出力) res.redirect('./'); console.log('ID:', results.insertId); }); // DBコネクションの破棄 connection.end(); } }) module.exports = router; 以下、ejsファイルは大したこと書いてないので、細かい説明は割愛 views/index.ejs <!DOCTYPE html> <html> <head> <link rel='stylesheet' href='/stylesheets/style.css' /> </head> <body> <table> <thead> <tr> <th scope="col">id</th> <th scope="col">name</th> <th scope="col">price</th> </tr> </thead> <% for(let i in content) { %> <tr> <% let obj = content[i]; %> <th> <%= obj.id %> </th> <th> <%= obj.name %> </th> <th> <%= obj.price %> </th> </tr> <% } %> </table> <p class="bottom_space"></p> <button class="b1" onclick="location.href='./insert'">商品登録画面</button> </body> </html> views/insert.ejs <!DOCTYPE html> <html> <head> <link rel='stylesheet' href='/stylesheets/style.css' /> </head> <body> <p class="p1">商品を登録してください</p> <form action="/insert" method="post"> <input type="text" name="name" value="名前"> <input type="text" name="price" value="値段"> <button class="b2">登録</button> </form> <% if(errorMessage) { %> <ul> <% for (let n in errorMessage) { %> <li> <%= errorMessage[n].msg %> </li> <% } %> </ul> <% } %> </body> </html> 最低限の見栄えのために以下追記 (aタグまではデフォルト) public/stylesheets/style.css body { padding: 50px; font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; } a { color: #00B7FF; } table, tr, th { border-collapse: collapse; border:1px solid; font-size: 30px; } th { width: 400px; height: 50px; } table thead tr th { color: #fff; background: #000000; } .bottom_space { margin-bottom: 1em; } .b1 { width: 1204px; height: 50px; font-size: 30px; } .b2 { width: 400px; height: 50px; font-size: 30px; } .p1 { font-size: 50px; } input { width: 400px; height: 50px; font-size: 30px; } 3.画面イメージ http://<ホスト名>:3000 http://<ホスト名>:3000/insert 値の入力 登録後のリダイレクト
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

気軽にbitwardenをセルフホストする

最近色々あるので、「パスワードくらいは自分のところにおいておくか。」と思い、セルフホストとするのを決めた。 利用したサービスについて 公式については結構リソースが必要になるっぽいので、vaultwardenで。 VPSだと色々心配なのでAWSでバックアップ含め色々仕込む。 メモリはUbuntuServerを使う限りは500MBあれば足りる。 でも、無料期間があるのでt2.microを使ってみる。 来年になったらt2.nanoに変更する予定。 構築手順 構築についてはこちらのページが非常に参考になります。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AzureADからSnowflakeへのプロビジョニング

はじめに AD(ActiveDirectory)を利用している企業が、別のクラウドサービスを使おうとするとクラウドサービスのユーザ運用の手間がボディブローのように人的リソースを圧迫します。 本記事ではAzure ADからSnowflakeへのユーザプロビジョニング、グループプロビジョニングについて手順を説明します。 Azure AD側で追加したユーザ/グループが自動的にSnowflakeにも作成されて(プロビジョニング)、SnowflakeにログインするときはAzure ADのユーザ・パスワードでログインできれば(SSO)、運用は楽になるはずです。 SnowflakeはAzure ADへのSSO(SAML)も対応していますし、プロビジョニング(SCIM)も対応しています。 SCIMとはプロビジョニング機能を担うオープンなプロトコルです。フェデレーションを担うSAMLとは目的が異なるため、SAMLとSCIMは同時に使うことができます。 プロビジョニングを利用する場合、どのような属性がマッピングされるのか、どのような挙動になるのかイメージが沸かなかったため、そのあたりも検証を行いました。 まず留意事項を記載します。 コスト面 6ヶ月ごとにSCIMトークンの更新が必要です。運用コストを見込んでおく必要があります。 トークンの更新はSnowflake側もAzureAD側も作業が必要になります。 一般論としてプロビジョニング自体の運用コストも発生します。 「Snowflakeにログインできない」といった問い合わせ対応でAzure AD側のログを確認する作業が発生します。 企業規模も踏まえて全体的なベネフィットがいつ得られるのか、そのベネフィットはどの部署で得られるものなのか、といった視点も加えて各機能を検証することをおすすめします。 機能面 ユーザプロビジョニングもグループプロビジョニングも可能です。 グループプロビジョニングを使うにはAzure AD Premium P1以上である必要があります。 ただしSnowflakeにはグループという概念がありません。詳しくは後ほど説明します。 今回の検証はAzure AD Premium P2 試用版を利用しています。 プロビジョニング間隔は40分毎の固定です。 SnowflakeからAzure ADへのプロビジョニングはできません。 そのためSSOでプロビジョニングしたいユーザについてはSnowflake側でユーザ作成するのではなく、AzureADか、もしくは[AzureAD Connect]と連携されたオンプレミスのAD側のいずれかで作成する必要があります。 1. Azure AD と SnowflakeのSSOについて まず、SSOを設定していない場合は以下の手順を参照し、SSO(シングルサインオン)を設定してください。 後ほど記載するプロビジョニングを行うにはSSOのセットアップが必須です。 上記手順はSSOとしてはSAMLを使ったAzureAD(IdP)、Snowflake(SP)な構成です。 つまりSnowflakeのログイン画面からスタートする、SP Initiatedなものになっています。本検証も同様です。 なおAzureからも同様の手順が提供されていますので以下も参照ください。 こちらの手順ではIdP InitiatedもSP Initiatedも区別されて記載されています。 補足1. AzureADの最小限設定 今回の検証のような SP Initiatedの場合、Azure AD側では「識別子」と「応答URL」だけ設定していれば機能します。 補足2. SAML署名証明書データをしっかり確認する また、Snowflake側で設定するSAML署名証明書は、コピペミスなどでデータが間違っているとSnowflakeからAzure ADユーザでログインしようとした際にブラウザが真っ白になります。 補足3. AzureADの証明書更新などの運用タスク 運用タスクとして、AzureAD側でもSAML署名証明書の更新が必要になります。 デフォルトかつ最大で3年の期限で設定されており、AzureADでSAML署名証明書を作成する際に期限を短縮することはできますが延ばすことはできないようです。 以下スクリーンショットは2021年8月3日にSSOをセットアップしたケースのSAML署名証明書期限です。 つまり、以下のような運用が発生します。 SSOを利用する場合は最大3年毎のSAML署名証明書更新 さらにプロビジョニングを利用する場合は6ヶ月毎のSCIMトークン更新 AzureADのSAML署名証明書ロールオーバー手順などは以下を参照してください。 更新後、取得した証明書をSnowflake側に再適用することも必要になります。 2. Azure AD からSnowflakeへのプロビジョニング まずはSnowflakeの手順を参考にして進めていきます。 リンク先に記載された「制限事項」「サポート対象外」「前提条件」の項目をよく確認しておきます。 とりあえず私は以下のポイントだけ留意しました。 同時リクエストは500まで。 Azure ADのネストされたグループはプロビジョニングできない。 SnowflakeでIP制限をかけている場合は、Azure AD側の諸々のIPを許可しておく必要がある。 SCIMのSnowflakeベースURLを用意しておく必要がある。東京リージョンだと以下の形式。 https://(アカウントロケータ).ap-northeast-1.aws.snowflakecomputing.com/scim/v2/ 2-1. Snowflakeでプロビジョニングを有効化 以下のコマンドをSnowflakeのワークシートでそのまま実行します。 プロビジョングで内部的に利用するSnowflakeのカスタムロールを作成し、プロビジョニングの設定を行っています。 use role accountadmin; create or replace role aad_provisioner; grant create user on account to role aad_provisioner; grant create role on account to role aad_provisioner; grant role aad_provisioner to role accountadmin; create or replace security integration aad_provisioning type = scim scim_client = 'azure' run_as_role = 'AAD_PROVISIONER'; select system$generate_scim_access_token('AAD_PROVISIONING'); 最終行のselectを実行すると、以下のようにトークンが出力されます。 のちほど利用するためコピーしておきます。 ※このアクセストークンは6ヶ月後に期限が切れます。 2-2. Azure ADでプロビジョニングを有効化 ここからはAzure側の手順を参考にしていきます。 以下手順の前半は完了しているため、「手順 5:Snowflake への自動ユーザー プロビジョニングを構成する」のところから進めます。 ※リンクの冒頭に記載されているとおり、Azure AD側のSnowflakeプロビジョニングコネクタは2021年8月現在、パブリックプレビュー中ですので今後仕様が変わる可能性をご認識ください。 まずAzureポータルにログインし、「Azure AD」→「エンタープライズアプリケーション」→「Snowflake for AAD」を開きます。 ※「Snowflake for AAD」が未設定の場合は本記事上部に記載した手順を元に設定してください。 初回セットアップが開始します。 プロビジョニングモードを「自動」に設定します。 管理者資格情報に「テナントのURL」と「シークレットトークン」を入力します。 テナントのURL これは2.の冒頭に記載した以下の形式の「SCIMのSnowflakeベースURL」を指定します。 https://(アカウントロケータ).ap-northeast-1.aws.snowflakecomputing.com/scim/v2/ シークレットトークン 2-1.で取得したトークンを指定します。 私が取得した時はver:1-hint:で始まり、=で終わるトークンでした。ver:1-hint:の部分も含めた全体を指定します。 指定後、「テスト接続」をクリックすると、Azure ポータルの右上に進行中である表示がされます。 テスト接続をクリアできたら、ポータル左上の「保存」をクリックします。 保存すると、属性値を確認できる「マッピング」やエラー発生時にメール通知できる「設定」項目を確認することができるようになります。 グループとユーザの両方のプロビジョニングが有効化されていることを確認します。 マッピングについては青文字のリンクをクリックすると詳細を確認することができます。 「Provision Azure Active Directory Users」を表示したものが下図になります。 上図は2021年8月にセットアップした結果ですが、Azure ADのドキュメントと差異があり、属性が不足しています。 この問題は以下のissueとして2020年8月に起票されていますが、2021年8月の時点では修正されていません(このあたりがパブリックプレビューな所以でしょうか)。 不足している属性 説明 urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:defaultRole Snowflakeにおけるデフォルトのロール urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:defaultWarehouse Snowflakeにおけるデフォルトのウェアハウス 一方、Snowflake側のドキュメントでは、標準的なユーザ属性としては定義されていないものの、カスタム属性としては定義が可能と記載されています。 Snowflakeは、 defaultRole や defaultWarehouse など、 RFC 7643で定義されていないカスタム属性の設定をサポートしています。 ドキュメントを見るとSnowflakeのカスタム属性の名前空間は2種類あり、それぞれ利用可能なIdPが異なるようです。 項番 名前空間 説明 1 urn:ietf:params:scim:schemas:extension:enterprise:2.0:User OktaのみSCIMで利用できる 2 urn:ietf:params:scim:schemas:extension:2.0:User Azure ADのSCIMでも利用できる しかしながらAzure ADのドキュメントに記載されているのは項番1の形式の「enterprise」がついた名前空間であり、どうにもAzure AD側の対応が追いついていない印象を受けます。 ためしにAzure AD側で「新しいマッピングの追加」を試みましたが、下図のとおり、ターゲットの属性(Snowflake側)で指定できる属性が「id」しか表示されず、defaultRole,defaultWarehouseと思われるものを追加することはできないように見えました。 AzureのFeedback Forumsにもこの課題があがっていたため、票を投じておきました。 改善が見られた場合、投票時に指定したメールアドレスに連絡がくるようです。 そもそもSnowflakeにおけるデフォルトロール、デフォルトウェアハウスは、ログイン時の初期値になります。 デフォルトロールは指定しなければ最低限の権限のpublicロールが割り当てられます。 Snowflakeのユーザに複数のロールがアタッチされていれば別のロールに切り替えられるため、少なくとも人間によるSnowflakeコンソールのログイン時では多少不便ではあるものの大きな問題にはならないため、いったんこのまま進めます。 それでは、属性値は何も変更せずに「プロビジョニング状態」をオンに切り替えます。 画面左上の「保存」をクリックします。これで初回のプロビジョニングが開始します。 設定はいったんこれで完了です。 2-3. Azure ADにSnowflakeのSSOを利用するユーザを登録する ではAzure ADに、SnowflakeへのSSOを行いたいAzure ADグループを追加していきます。 プロビジョニングされるユーザ数を事前に把握しておいたほうがよいため、この作業はプロビジョニング開始前の実施が望ましいのですが、本手順ではわかりやすくするため、プロビジョニング開始後に設定することにします。 「Azure AD」→「エンタープライズアプリケーション」→「Snowflake for AAD」を開きます。 「ユーザーとグループ」を選択します。 「ユーザーまたはグループの追加」を選択します。 私の環境では以下のように表示され、グループの選択ができませんでした。 お客様のActive Directoryプランレベルでは、グループを割り当てることができません。個々のユーザーをアプリケーションに割り当てることはできます。 ではプランレベルを変更していきます。 「Azure AD」→「ライセンス」を開きます。 ライセンス画面右の「無料試用版を入手する」を開きます。 Microsoft 365のEMS E5 試用版をアクティブ化します。 グループも割り当てられるようになりました。 まずはグループではなくユーザを割り当ててみます。 2-4. プロビジョニングのテスト 「プロビジョニング」画面に戻り、「プロビジョニングの詳細の表示」をクリックします。 2-4-1. 手動プロビジョニング 次回の自動プロビジョニングの実行時間まで待っているのも手間なので、手動でプロビジョニングします。 「プロビジョニング」画面右上の、「要求時にプロビジョニングする」を選択します。 先程指定したユーザを検索し、「プロビジョニング」を押します。 2-4-2. Snowflake側に同名ユーザが存在する場合 プロビジョニングの結果が出力されました。何かエラーが出ているようです。 じつはこのテストケースは、snowflake側にすでに同名のユーザが存在していた場合の異常系の挙動を確認しています。 プロビジョニングを利用しない通常のSSOでは、Azure ADユーザと同じメールアドレスをLOGIN_NAMEとして持つユーザを手動でSnowflake側に作成する必要があります。 事前に以下のSQL文でSnowflake側にユーザを作成してあったので、Azure ADからのプロビジョニングが失敗していたわけです。 use role accountadmin; CREATE USER jnit02 PASSWORD = '' LOGIN_NAME = 'jnit02@beexjnit.onmicrosoft.com' DISPLAY_NAME = 'jnit02_display'; grant role sysadmin to user jnit02; alter user jnit02 set default_role=sysadmin; プロビジョニングを利用しないSSOでは、Snowflake側のパスワード名は空にして作成する必要があります。 プロビジョニングを利用する場合は、当然ですがSnowflake側にユーザを作成しておく必要はありません。 Snowflake側にユーザが存在すれば、AzureADからのプロビジョニングも正しく失敗し、異常に気づけることがわかりました。 なお、プロビジョニングのログは後から確認することも可能です。 実際の運用では以下の「プロビジョニングログの表示」を見る機会が多く発生します。 2-4-3. Snowflake側に同名ユーザが存在しない場合 こちらが正常系のテストケースになります。 グループプロビジョニングもあわせてテストしてみます。 Azure ADに予め "group03"というグループ(種類は「セキュリティ」)を作成し、そこにjnit03というユーザを所属させておきます(省略)。 group03を [Snowflake for AAD]の「ユーザーとグループ」に追加しておきます。 手順[2-4-1.]と同様に手動でプロビジョニングを行います。 手動の場合はグループを指定することができないため、 ユーザ名を前方参照で検索してユーザ指定で同期します。 結果を見ると成功したようです。 今度はSnowflakeのコンソールを開き、ACCOUNTADMINロールでユーザー一覧をみてみます。 jnit03ユーザがちゃんと出来ていました。プロビジョニングで作成されたSnowflakeのユーザー名は、[JNIT03@BEEXJNIT.ONMICROSOFT.COM]となっており、これはAzureAD側のUPN(ユーザプリンシパル名)にマッピングされています。 ではSnowflakeのログインを試してみます。 SSOが有効化されている環境では「AzureADを使用してサインイン」というボタンが表示されています。 これをクリックします。 Microsoft側のログイン画面に切り替わりました。Azure ADのユーザ名でログインします。 初回はAzure ADのパスワードも入力します。 一定期間キャッシュが効くため、次回以降はユーザ名選択のみでログインできます。 Snowflakeにログインできました! プロビジョニングの正常系の動作確認の初歩をクリアできました。 2-4-4. グループプロビジョニング 上図をよく見るとロールは"public"になっています。 スイッチできるロールはpublic以外は「group03」のみとなっていました。 (以下のスクリーンショットは別途取得したものであるため、カスタムロール[SNOWFLAKE-GROUP03]は[group03]に脳内変換してください) 今回、Snowflakeのロールの設定は、AzureAD側もSnowflake側も行っていません。 つまり[Azure ADのグループ名]が[Snowflakeのロール]としてプロビジョニング(新規作成)されるという挙動のようです。 AzureADのグループ「group03」が、Snowflakeのロール「group03」として作成されたということは、グループプロビジョニングができたことを示しています。 Snowflakeでは運用上、publicではない特定のロールにユーザを紐付けることは必須です。 SSOでユーザプロビジョニングとグループプロビジョニングができたこと自体は喜ばしいことですし、それだけで運用の手間も多少は減りますが、Snowflakeのユーザ管理の大きな関門の一つである、カスタムロールへの権限付与(grant)をなんとか自動化したいところです。 結論からいうと、Azure ADのプロビジョニングの処理で、Snowflakeの[権限付与されたカスタムロール]にユーザをマッピングすることはできません。 グループプロビジョニングは(少なくとも2021年8月現在では)、Snowflakeロールがプロビジョニングされた後に、Snowflakeのロールへの権限付与(grant)を行う必要があります。 つまり下図のように①のあとに②を実施する必要があります。①と同時に②を行う、または先に②を行うことはできません。 この挙動は検証済みです。試しにAzure ADのグループ名と同一名のSnowflakeロールを事前に作成し、Azure ADからユーザプロビジョニングで新規作成されるユーザに、そのSnowflakeロールがアタッチされるか試してみましたが、アタッチはされませんでした。つまり既存のカスタムロールにグループを紐付けることができないようです。 また、Snowflakeサポートからも「ロールがプロビジョニングされてからgrantを行う必要がある」と回答がありました。いずれは改善されるかもしれません。この制限は、すでにがっつりsnowflakeを使っている企業がSSOのプロビジョニングを導入する際などに特に影響を与えると思います。 2-4-5. SnowflakeにプロビジョニングされたユーザをAzure AD側でSSO対象から外す 実際の運用を想定して、Azure AD側でSSOに登録済みのグループに所属しているユーザを、グループから除外してみます。 除外後、SnowflakeからADユーザでSSOログインしようとすると、以下のようなエラーが表示されました。 The signed in user '<username>' is not assigned to a role for the application ユーザをSSOの対象グループから外したわけですから、これは正しい挙動になります。 ユーザを元のグループに戻せばSSOログインが使えるようになります。 まとめ 本記事ではAzure ADからSnowflakeへのプロビジョニングについて、手順の説明と以下の仕様を確認しました。 Snowflake のdefaultRole , defaultWarehouse はマッピングできない(2021年8月時点)。 Snowflake側にすでにユーザ(※)が存在する場合はユーザプロビジョニングに失敗する。 ※Azure ADユーザと同じメールアドレスをLOGIN_NAMEとして持つSnowflakeユーザ グループプロビジョニングは[Azure ADグループ]が[Snowflakeカスタムロール]として作成される。 カスタムロールへのgrant設定はプロビジョニング後に設定する必要がある。 所感 現在のプロビジョニングの仕様では運用コストの激減には至らない印象です。 基本的にSSOはIdP側でログを確認する業務が発生しがちなため、プロビジョニングにそれを上回るメリットが欲しいところなのですが、グループとロールのマッピングが中途半端なところなどが惜しい感じです。 この問題がSnowflake側に起因するのかAzureAD側に起因するのかは分かりませんが、SnowflakeはREST API周りがどんどん充実してきましたし、Azure AD側も属性値マッピングを少し直すだけでよさそうに思えるので、そう遠くない未来に改善されるのではと個人的に思います。 以上です。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Snowflake] AWS PrivateLink経由でAzure ADのSSOを利用する

はじめに 以前、SnowflakeでAWS PrivateLinkを利用するための記事を作成しました。 今回はさらに、PrivateLink経由でAzureADのSSOを利用してみようと思います。 本記事は以下記事の内容が東京リージョンの環境で実施済みであることを前提としています。 以下のイメージの通り、ユーザとSnowflakeの間をPrivateLinkで閉域化し、ユーザとAzureADの間はインターネット接続とします。 SSOでPrivatelinkを使う場合、単純に上図の緑線の通信をPrivatelink用のドメイン名に変更すればよいだけのように思えます。 詳細は省きますが実際に試してみたところ、ブラウザからSnowflakeのログイン画面を開き、Azure ADのユーザでログインしようとしたところ以下のエラーが出て失敗しました。 AADSTS700016: Application with identifier 'https://XX12345.ap-northeast-1.aws.snowflakecomputing.com' was not found in the directory 'XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX'. This can happen if the application has not been installed by the administrator of the tenant or consented to by any user in the tenant. You may have sent your authentication request to the wrong tenant. Snowflakeサポートに問い合わせてみた結果、PrivateLink下でSSOを利用するには、「SAML2.0セキュリティ統合(SAML2 Security Integration)」を設定する必要があるとのことでした。 「SAML2.0セキュリティ統合」は、SAMLアサーションの暗号化を行う処理のようです。 PrivateLink下でSSOを利用する際は必須の処理のようです。 インターネット経由でのSSOではこのセキュリティ統合が不要なのに対して、PrivateLink経由でのSSOでは必要な理由がよくわかりませんが、Snowflakeの仕様だと捉えることにします。 「SAML2.0セキュリティ統合」の手順は以下に記載されています。本記事は以下URLの手順に依拠しています。 1. Azure AD の設定値確認 まずAzure ADから「エンタープライズアプリケーション」→「Snowflake for AAD」→「シングルサインオン」とたどっていき、表示される「Azure AD 識別子」の値をコピーします。 2. Snowflakeで[SAML2.0セキュリティ統合]のセットアップ Snowflakeのワークシートを開き、以下のクエリを環境毎の値に読み替えて実行します。 use role accountadmin; select system$migrate_saml_idp_registration('azureadsaml2integration', 'https://sts.windows.net/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX/'); alter security integration azureadsaml2integration set saml2_snowflake_acs_url = 'https://xx12345.ap-northeast-1.privatelink.snowflakecomputing.com/fed/login'; alter security integration azureadsaml2integration set saml2_snowflake_issuer_url = 'https://xx12345.ap-northeast-1.privatelink.snowflakecomputing.com'; 各クエリを簡単に説明します。 select system$migrate_saml_idp_registration('azureadsaml2integration', 'https://sts.windows.net/XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX/'); 既存のIDpの設定をセキュリティ統合に移行するクエリです。 第一引数は任意の名前をつけます。 第二引数は手順1.で取得したAzureADの「Azure AD 識別子」を指定します。 クエリの詳細は以下を参照してください。 https://docs.snowflake.com/en/sql-reference/functions/system_migrate_saml_idp_registration.html alter security integration azureadsaml2integration set saml2_snowflake_acs_url = 'https://xx12345.ap-northeast-1.privatelink.snowflakecomputing.com/fed/login'; ACS(Assertion Consumer Service) URL をprivatelink用のものに変更します。 アカウントロケータの部分はご自身の環境に読み替えてください。 alter security integration azureadsaml2integration set saml2_snowflake_issuer_url = 'https://xx12345.ap-northeast-1.privatelink.snowflakecomputing.com'; issuer URL をprivatelink用のものに変更します。 アカウントロケータの部分はご自身の環境に読み替えてください。 3. 動作確認 ブラウザからprivatelink用のドメインを使ってSnowflakeログイン画面を開きます。 「Azure ADを使用してサインイン」からAzureADのユーザとパスワードを入力すると、Snowflakeのコンソールログインできました。 リダイレクト後のURLもprivatelink用のドメインになっており、閉域でSnowflakeに接続できていることがわかります。 以上です。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AWSで放置してたEC2から請求が上がってて損した話

前書き タイトルの通りです。 2000円くらい損したのでQiitaのネタにします。 見つけた不審な詐欺メールっぽい請求メール たまたまGmailを確認したときに見つけました。 以下メール Title: Amazon Web Services Billing Statement Available Greetings from Amazon Web Services, This e-mail confirms that your latest billing statement, for the account ending in ********, is available on the AWS web site. Your account will be charged the following: Total: $17.36 You can see a complete break down of all charges on the Billing & Cost Management page located here: 最初詐欺メールかと思ったんですが、 どうもそうではなくVプリカの期限が切れてた上に付けっぱなしにしていた物が課金されてて しかも未払いだったみたいです(大変だ) 案内通りコンソールを開いてみると 先月の未払いの請求+今月の請求まで入ってきてるーという状況で払わなきゃいけないし止めなきゃいけないという状況でした。 どう対応したかを以下に書き記していきます。 未払いを払った 払ったのでもう表示されてないんですが 左のメニューから「支払い」を押下して画像の画面に遷移すると未払いの請求が出てきます。 払いましょう。 課金されてるサービスを特定して止めた 左のメニューから「請求書」を押下して画像の画面に遷移すると請求されてるサービスが分かるようになります。 この画像で見ると請求が発生してるのは ・Elastic Compute Cloud (EC2) ・Relational Database Service (RDS) の二つです。 それぞれの画面に遷移してインスタンスを止めるなりなんなりしましょう。 再発防止のためにアラートの設定を変えといた 左のメニューから「Budgets」をクリックして予算の設定が出来ます。 実は最初から作ってたんですが、20$までアラート出さないようになってたので0.01$に変更しました。 といってもあまりメール見ないので関係は無いんですが。。。 請求ちゃんと止まってるか経過観察した 上の画像をみてほしいのですが、 実は日々日々おいくらかかってるかをグラフで表示できます。 「AWS Cost Explorer」というやつですね。 検索したら出てきます。 3日目くらいにデータベース系のやつ全部止めて(S3から削除したり色々しました)、 7日目に忘れてたEC2を止めたので8日には請求が上がってない形になってます。 月末予想コストが$2.44と書いててまだ止め忘れがあるのかと不安になりますが、恐らく大丈夫でしょう。 あとがき 使わない物はちゃんと止めよう。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AWS NATゲートウェイを使ってプライベートサブネットからインターネットにアクセスする

AWS資格取得に向けて実際にAWSを利用してみるシリーズの投稿です。 今回はAWSを使っていてVPCのプライベートサブネットからインターネットにアクセスするために利用するNATゲートウェイ(NAT Gateway)を利用してみる編です。15分程度でNATゲートウェイを利用してプライベートサブネットからインターネットアクセスができるようになります。 資格勉強的にはNATゲートウェイの利用手順およびインターネットに出るために手順を学ぶといったところでしょうか。興味がある方は読んでみて下さい。 資格試験の勉強法は記事は以下を参照。 AWS初心者がAWS 認定ソリューションアーキテクト – アソシエイト資格試験に合格した時の勉強法 AWS初心者がAWS 認定ソリューションアーキテクト – プロフェッショナル資格試験に合格した時の勉強法 想定読者 プライベートサブネットからインターネットにアクセスしたい人 NATゲートウェイを使ってみたい人 料金 NATゲートウェイあたりの料金 (USD/時):0.045USD 処理データ 1 GB あたりの料金 (USD):0.045USD あとは、データ転送料金として標準のEC2データ転送料金と同等の料金がかかりますが、これはEC2を使うと思えば一緒なので割愛します。 上記の通り、NATゲートウェイ意外と高いです。。。 特に1つ目の「NATゲートウェイあたりの料金 (USD/時)」が、使わない時はEC2みたいに一時停止ができればいいのですができないので、作ったら削除するまでこの料金がかかります。。。  1時間:0.045USD、1日:1.08USD、1週間:7.56USD、1ヶ月:33.48USD ですね。個人運用にはうっ、、、て感じがします。 実は今回の投稿にはこの部分に投稿意図があり、自分で使いたい時だけ作り直す手順の確立として投稿します。 公式ページ 作業時間 約15分 では、実際に手順に従ってやってみましょう。 作業手順 作業手順は以下の2つだけになります。 すべてAWSコンソールで完結する手順になります。 1.NATゲートウェイの作成(同時にElastic IPの作成) 2.ルートテーブルにNATゲートウェイへのルーティング追加 どこかのAWSのマニュアルに書いてある内容そのままみたいな感じです。そのままです。 1.NATゲートウェイの作成(同時にElastic IPの作成) まずはAWSコンソールのVPCのサービス画面からNATゲートウェイの作成です。 画面操作だけで簡単にできます。 以下のNATゲートウェイの作成をクリック。 表示された画面で 好きな名前を入力。 入力したらインターネットゲートウェイが設定されているパブリックサブネットをサブネット項目で選択します。 Elastic IP 割り当て IDで事前に準備していたElastic IP があれば選択します。 事前に準備していなければElastic IP の割り当てをクリックするとすぐに作成されます。 (タグは名前を入力すると自動で入力されます) 一通り入力したらNATゲートウェイを作成をクリック。 これだけでNATゲートウェイが作成されます。 作成直後は状態がPendingのため、Availableになるまで2,3分待ちます。 ここまででNATゲートウェイの作成が完了です。 2.ルートテーブルにNATゲートウェイへのルーティング追加 NATゲートウェイができたらプライベートサブネットのルートテーブルにNATゲートウェイのルーティングを追加します。 VPCのルートテーブルメニューからプライベートサブネットのルートテーブルを選択しルートを編集をクリック。 表示された画面で 送信先0.0.0.0/0に対し、ターゲットで作成したNATゲートウェイを選択し、変更を保存をクリック。 作成されたルートがアクティブであることを確認したら完了です。 以上でNATゲートウェイの作成完了です。 プライベートサブネットからインターネットと通信できるか試してみましょう。 今回は以上です。簡単ですね。 補足:NATゲートウェイ削除手順 個人運用している方のために削除手順も記載しておきます。 実は削除の方がひと手間かかります。 まずはNATゲートウェイの削除。 削除対象を選択してNATゲートウェイを削除をクリック。 確認画面が出るので削除を入力して、削除ボタンをクリック。 削除処理が始まるので完了まで2,3分待ちます。 次はElastic IPの削除です。作成時はNATゲートウェイの作成の中で自動で作成されましたが、自動で削除されないので手動で削除する必要があります。 手順は簡単です。 NATゲートウェイが削除されると、EC2のElastic IPメニューでElastic IP アドレスの解放が選択できるようになるためクリック。 これでElastic IPの削除が完了です。 最後に作成時の同じようにVPCのルートテーブルメニューからプライベートサブネットのルートテーブルを選択し、バックホールになっているルートテーブルのルートをルートを編集から削除すれば削除完了です。 まとめ 今回はNATゲートウェイの手順でした。かなり簡単だったのではないでしょうか? 作成してそのままにしておければいいのですが、料金がそれなりにかかってしまうので削除手順と合わせてみました。 作業15分と書きましたが、1,2回やってみて慣れれば2,3分といったところなので、必要な時に作成、使い終わったら削除という運用が出来れば思います。 ここまで読んで頂き、ありがとうございました! 資格取得に向けてAWSサービスを実際に利用してみるシリーズの投稿一覧 とりあえず30分でAWS利用料金の高額請求に備える~予算アラート設定・MFA・料金確認~ AWS ECSでDocker環境を試してみる Amazon Cognitoを使ってシンプルなログイン画面を作ってみる AWS NATゲートウェイを使ってプライベートサブネットからインターネットにアクセスする(本記事)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【備忘録】Terraformの仕組みを整理する

本記事の目的 社内でのクラウド検証に合わせて、Terraformの基礎知識を学び、アウトプットとして備忘録を記す。 本記事ではTerraformの基本操作、基本構文、及び基本的なAWSリソースの作成方法について説明する。 主要コマンド 1.terraform init terraform実行に必要なバイナリファイルを準備する。 このコマンドを最初に実施する事で、他のterraformコマンドが実施できるようになる。 2.terraform validate 設定ファイルが有効かどうかをチェックする。 3.terraform plan 設定ファイルに従い、リソースの実行計画を出力する。 4.terraform apply 設定ファイルに従い、リソースを作成・変更する。 5.terraform destroy 設定ファイルに従って作成したリソースを削除する。 設定ファイル 1.tfファイル terraformで作成・変更するリソースを定義したファイル。ユーザー自身が作成する。 2.tfvarsファイル tfファイルに読み込ませたい変数を定義したファイル。ユーザー自身が作成する。 3.tfstateファイル 現時点でのterraformで作成・変更されたリソースの状態を記録したファイル。terraform applyコマンドを実行すると、自動的に作成・変更される。 tfファイルにおける基本ブロック 1.resource 作成・変更するリソースを定義する。 resource.tf resource "aws_instance" "web" { ami = "ami-a1b2c3d4" instance_type = "t2.micro" } 2.data データソースを定義し、外部データの参照する。 data.tf data "aws_ami" "example" { most_recent = true owners = ["self"] tags = { Name = "app-server" Tested = "true" } } 3.provider リソースを作成するのに必要なクラウドプロバイダー、Saasプロバイダー、その他APIを定義する。 provider.tf provider "aws" { region = "ap-northeast-1" } 4.variable グローバル変数を定義する。 variable.tf variable "image_id" { type = string } variable "availability_zone_names" { type = list(string) default = ["ap-northeast-1"] } variable "docker_ports" { type = list(object({ internal = number external = number protocol = string })) default = [ { internal = 80 external = 10080 protocol = "tcp" } ] } 5.locals ローカル変数を定義する。 locals.tf locals { service_name = "forum" owner = "Community Team" } 6.output 出力値を定義し、terraform applyコマンド実行時に特定のデータを出力する。 output.tf output "instance_ip_addr" { value = aws_instance.server.private_ip } 7.module モジュールを定義し、特定のディレクトリ配下の設定ファイルを利用する。 module.tf module "servers" { source = "./app-cluster" servers = 5 } 代表的なAWSリソースの作成方法 1.IAM 主要resource名 機能 aws_iam_user IAMユーザの作成  aws_iam_user_login_profile ログインプロファイルの作成 aws_iam_access_key アクセスキーの作成 aws_iam_group IAMグループの作成 aws_iam_policy 管理ポリシーの作成 aws_iam_group_policy_attachment IAMグループに対する管理ポリシーのアタッチ aws_iam_user_group_membership IAMグループへのIAMユーザの追加 aws_iam_role IAMロールの作成 aws_iam_role_policy_attachment IAMロールに対する管理ポリシーのアタッチ 例①:IAMユーザ/ログインプロファイル/アクセスキーの作成(ログインパスワードとシークレットアクセスキーを出力) example_iam_user.tf resource "aws_iam_user" "test-user" { name = "taro" path = "/" } resource "aws_iam_access_key" "test-user" { user = aws_iam_user.test-user.name pgp_key = "keybase:some_person_that_exists" } resource "aws_iam_user_login_profile" "taro" { user = aws_iam_user.test-user.name pgp_key = "keybase:some_person_that_exists" } output "secret" { value = aws_iam_access_key.test-user.encrypted_secret description = "IAMユーザの暗号化されたパスワード" } output "password" { value = aws_iam_user_login_profile.test-user.encrypted_password description = "IAMユーザの暗号化されたシークレットキー" } 例②:IAMグループを作成、IAMユーザを作成しIAMグループに追加、管理ポリシーを作成しIAMグループにアタッチ example_iam_group.tf resource "aws_iam_user_group_membership" "test-membership" { user = aws_iam_user.test-user.name groups = [ aws_iam_group.test-group.name, ] } resource "aws_iam_user" "test-user" { name = "jiro" } resource "aws_iam_group" "test-group" { name = "engineer" } resource "aws_iam_policy" "test-policy" { name = "EC2 describe policy" description = "A test policy" policy = <<EOF { "Version": "2012-10-17", "Statement": [ { "Action": [ "ec2:Describe*" ], "Effect": "Allow", "Resource": "*" } ] } EOF } resource "aws_iam_group_policy_attachment" "test-attach" { group = aws_iam_group.test-group.name policy_arn = aws_iam_policy.test-policy.arn } 例③:IAMロールを作成、管理ポリシーを作成しIAMロールにアタッチ example_iam_role.tf resource "aws_iam_role" "test-role" { name = "test-role" } resource "aws_iam_policy" "test-policy" { name = "EC2 describe policy" description = "A test policy" policy = <<EOF { "Version": "2012-10-17", "Statement": [ { "Action": [ "ec2:Describe*" ], "Effect": "Allow", "Resource": "*" } ] } EOF } resource "aws_iam_role_policy_attachment" "test-attach" { role = aws_iam_role.test-role.name policy_arn = aws_iam_policy.test-policy.arn } 2.EC2/VPC 主要resource名 機能 aws_instance EC2インスタンスの作成 aws_key_pair キーペアの作成 aws_vpc VPCの作成  aws_subnet サブネットの作成 aws_internet_gateway インターネットゲートウェイの作成 aws_route_table ルーティングテーブルの作成 aws_route ルーティングテーブルに対するルートの追加 aws_route_table_association ルーティングテーブルとサブネットの関連付け aws_security_group セキュリティグループの作成 aws_security_group_rule セキュリティグループに対するルールの追加 例①:VPC作成~EC2インスタンス起動 example_instance.tf resource "aws_instance" "test-instance" { ami = "ami-0fccdb46e227b9538" # ap-northeast-1 instance_type = "t2.micro" subnet_id = aws_subnet.test-subnet.id vpc_security_group_ids = [aws_security_group.test-sg.id] key_name = aws_key_pair.test-key.id } resource "aws_key_pair" "test-key" { key_name = "test-key" public_key = file("./tf-test.pub") # `ssh-keygen`コマンドで作成した公開鍵を指定 } resource "aws_vpc" "test-vpc" { cidr_block = "10.0.0.0/16" enable_dns_support = true # DNS解決有効化 enable_dns_hostnames = true # DNSホスト名有効化 tags = { Name = "my-vpc" } } resource "aws_subnet" "test-subnet" { vpc_id = aws_vpc.test-vpc.id cidr_block = "10.0.10.0/24" map_public_ip_on_launch = true #インスタンス起動時におけるパブリックIPアドレスの自動割り当ての有効化 tags = { Name = "my-subnet" } } resource "aws_internet_gateway" "test-gw" { vpc_id = aws_vpc.test-vpc.id tags = { Name = "my-gw" } } resource "aws_route_table" "test-rt" { vpc_id = aws_vpc.test-vpc.id tags = { Name = "my-route-table" } } resource "aws_route" "test-route" { route_table_id = aws_route_table.test-rt.id gateway_id = aws_internet_gateway.test-gw.id destination_cidr_block = "0.0.0.0/0" } resource "aws_route_table_association" "test-subrt" { subnet_id = aws_subnet.test-subnet.id route_table_id = aws_route_table.test-route.id } resource "aws_security_group" "test-sg" { name = "test-sg" vpc_id = aws_vpc.test-vpc.id tags = { Name = "test-sg" } } resource "aws_security_group_rule" "in_ssh" { security_group_id = aws_security_group.test-sg.id type = "ingress" cidr_blocks = ["0.0.0.0/0"] from_port = 22 to_port = 22 protocol = "tcp" } resource "aws_security_group_rule" "in_icmp" { security_group_id = aws_security_group.test-sg.id type = "ingress" #インバウンドルール cidr_blocks = ["0.0.0.0/0"] from_port = -1 to_port = -1 protocol = "icmp" } resource "aws_security_group_rule" "out_all" { security_group_id = aws_security_group.test-sg.id type = "egress" #アウトバウンドルール cidr_blocks = ["0.0.0.0/0"] from_port = 0 to_port = 0 protocol = "-1" } output "public_ip" { value = aws_instance.test-instance.public_ip description = "EC2インスタンスのパプリックIP" } 3.S3 主要resource名 機能 aws_s3_bucket S3バケットの作成  aws_s3_bucket_object オブジェクトのアップロード 例①:S3バケット作成、バージョニング有効化、暗号化 example_s3.tf resource "aws_s3_bucket" "test-bucket" { bucket = "s3-test-bucket" acl = "private" versioning { enabled = true } # バージョニング server_side_encryption_configuration { rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } } # 暗号化 } tags = { Name = "My bucket" } } 例②:S3バケット作成、静的Webサイトホスティング有効化、オブジェクトアップロード example_s3_website.tf resource "aws_s3_bucket" "test-website-bucket" { bucket = "s3-website-test.com" acl = "public-read" #静的Webサイトホスティング有効化 website { index_document = "index.html" error_document = "error.html" } tags = { Name = "My website bucket" } } resource "aws_s3_bucket_object" "test-index" { bucket = aws_s3_bucket.test-bucket.id key = "index.html" source = "/work/index.html" # 作成した`index.html`を指定 } resource "aws_s3_bucket_object" "test-error" { bucket = aws_s3_bucket.test-bucket.id key = "error.html" source = "/work/error.html" # 作成した`error.html`を指定 } 感想 Terraformに限らないが、IaCツールは同じインフラ構成を再現する際には非常に有用であるものの、既存のインフラ構成を変化させる場合に柔軟に対応しにくいという欠点も存在する。 そのためインフラの自動化を追求する場合は、変化が少ないインフラ構成は再現性の高いIaCツールで実現し、変化が多いインフラ構成は柔軟性の高いCLIツールで都度実現するというように、インフラ構成に応じて使い分ける方が重要であると考える。 今後もIaCとCLI両面で、インフラ自動化について探求してみたい。 (ちなみに「代表的なAWSリソースの作成方法」の章については、もう少し別のAWSサービスのtfファイルもまとめてみようか検討中・・・) 参考文献 公式サイト:Terraform by HashiCorp 書籍:実践Terraform AWSにおけるシステム設計とベストプラクティス Udemy:米シリコンバレーDevOps監修!超Terraform完全入門(0.14) + AWS DevOps IaCをマスター!
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

CircleCIでECRのログインができない

CircleCIで以下のエラーが出た時にめちゃめちゃ悩みました。 #!/bin/bash -eo pipefail # get-login-password returns a password that we pipe to the docker login command aws ecr get-login-password --region $AWS_DEFAULT_REGION --profile default | docker login --username AWS --password-stdin $AWS_ECR_ACCOUNT_URL usage: aws [options] <command> <subcommand> [<subcommand> ...] [parameters] To see help text, you can run: aws help aws <command> help aws <command> <subcommand> help aws: error: argument operation: Invalid choice, valid choices are: batch-check-layer-availability | batch-delete-image batch-get-image | complete-layer-upload create-repository | delete-repository delete-repository-policy | describe-images describe-repositories | get-authorization-token get-download-url-for-layer | get-repository-policy initiate-layer-upload | list-images put-image | set-repository-policy upload-layer-part | get-login help Error: Cannot perform an interactive login from a non TTY device Exited with code exit status 1 CircleCI received exit code 1 その時の設定は以下の感じです。 orbs: aws-cli: circleci/aws-cli@2.0.3 aws-ecr: circleci/aws-ecr@7.0.0 commands: build_and_push: parameters: dockerfile: { type: string } repo: { type: string } build_args: { type: string, default: "" } steps: - aws-cli/install - checkout - run: echo "export DATE_TAG=`date +%Y%m%d%H%M%S`" >> $BASH_ENV - aws-ecr/build-and-push-image: checkout: false region: AWS_DEFAULT_REGION dockerfile: << parameters.dockerfile >> extra-build-args: << parameters.build_args >> repo: << parameters.repo >> tag: latest - aws-ecr/build-and-push-image: checkout: false region: AWS_DEFAULT_REGION dockerfile: << parameters.dockerfile >> extra-build-args: << parameters.build_args >> repo: << parameters.repo >> tag: ${DATE_TAG} 原因 原因は、最初のaws-cli/installでバージョン2系を使っているのに、既に1系がインストールされていて、 aws ecr get-login-passwordというコマンドが存在しないことでした。 1系がインストール済みで2系のインストールがスキップされていることのスクショ 対応 以下のようにして回避しました。 - aws-cli/install: override-installed: true
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

40 代おっさん 冗長性のあるブログを構築してみた ②

本記事について 本記事は AWS 初学者の私が学習していく中でわからない単語や概要をなるべくわかりやすい様にまとめたものです。 もし誤りなどありましたらコメントにてお知らせいただけるとありがたいです。 前回の記事 https://qiita.com/kou1121/items/1e2660087e09f0070a6d AMIを使ってインスタンスを作成 AWSマネジメントコンソールからEC2を選択 左ペインよりインスタンスを選択 インスタンスを起動クリック 左ペインのマイAMIを選択すると 前回作ったAMI(WebServer)があると思いますのでそちらを選択 インスタンスタイプはt2.microでよいです ネットワークはMyVPC サブネットはPublicSubnet2 自動割り当てパブリックIPは有効で あとはデフォルトのままでよいので次のステップで ストレージはデフォルトの8GiBで良いです タグの追加を押してください タグの追加は キー Name 値  WebServer2 セキュリティグループの設定をクリック 既存のセキュリティグループを選択を選んで Web-SG-1を選択 確認と作成をクリック 最期にサマリーが出ますので起動をクリック キーペアはWebServer1と同じものにしておいてください インスタンスの画面に飛んだらWevServer1をインスタンスを開始しておきましょう どちらのインスタンスもステータスチェックが2/2のチェックに合格しましたとなっていましたら大丈夫です。 次は前回 index.phpを編集してWevServer1としたところをWevServer2と変更します 詳しくは https://qiita.com/kou1121/items/1e2660087e09f0070a6d できましたら次の工程に進みます。 ELBを作成していく AWSマネジメントコンソールからEC2を選択 左ペインよりロードバランサーを選択 上にあるロードバランサーの作成をクリック 下の画面になると思います。(勉強してたのと違いGateway Load Balancerと言うのがありますが不勉強のため説明できません。) 一番左にある(赤枠)ALBの作成ボタンをクリックしてください 次の画面に遷移しますので 名前はLB-1(こちらは好きなので) スキームは インターネット向け IPアドレスタイプは ipv4 リスナーは HTTP 80番ポートで 下に行き アベイラビリティーゾーンの設定で VPCは MyVPC アベイラビリティーゾーン ap-northeast-1a ap-northeast-1c サブネットの選択でどちらも PublicSubnet を選んでください この状態で下にあるセキュリティ設定の構成をクリック 警告がでますが今回はHTTPを使うのでそのままで大丈夫です 次にセキュリティグループの設定です こちらは新しいセキュリティグループを作成するを選んで 名前はLB-SG-1 他はそのままでよいで下にあるルーティングの設定をクリック ルーティングの設定に行きましたら 名前を入れてください自分はTG-1(これは好きな名前で良いです) ターゲットの種類はインスタンス プロトコル HTTP ポートは  80番 プロトコルバージョン HTTP1で行きます 下に行きましてヘルスチェックがあります プロトコル HTTP パスは   \ でもいいんですが \readme.html にしました。 ヘルチェックの招請設定を開いてください 今回はテストのため 正常のしきい値 2 間隔を     10 にしました 他はそのままで 出来ましたら下にあるターゲットの登録をクリック ターゲットの登録に行きましたら 最初は上のインスタンスには何もありませんので まずは下のインスタンスのWebServer1 WebServer2 チェック(赤枠) 上にある登録済みに追加をクリック(黒枠) そしたら上のインスタンスにWebServer1 WebServer2 が出てきます(青枠) これでWebServer1 WebServer2 に通信が振り分けられる設定になります 終わりましたら確認をクリック サマリー画面が出てきますので問題なければ作成をクリック これでできたと思います。 念のために確認 左ペインよりターゲットグループを選んで TG-1 チェック 下にあるTargets を見てください WebServer1 WebServer2 のHealth status が healthyになっていれば問題ないです (最初はinitialかもしれませんのでその場合は少し時間をおいてください) 最後に まだ続きますが今回はここで切りたいと思います。 色々な機能を使いだして勉強を色々しないとわからなくなってきた~~ でもがんばるぞ~~ またこの記事は AWS 初学者を導く体系的な動画学習サービス「AWS CloudTech」の課題カリキュラムで作成しました。 https://aws-cloud-tech.com/
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Cognito認証を経てS3バケットにアップロード

はじめに Cognitoでユーザープールでの認証完了後、IDプールと連携してS3バケットにアップロードするために必要なAWS設定を調べてみました。Cognitoユーザープールの設定やSwiftでのCognitoによるサインインやS3バケットへのアップロードを行うコードについては大幅に割愛していますのでご了承ください。 AWS設定手順 S3:バケットの作成 デフォルトの設定でバケットを作成します。 Cognito:IDプールの設定 CognitoのIDプールの管理画面から新しい ID プールの作成を押してIDプールを作成します。IDプール名を入力後、認証プロバイダーに作成済みのユーザープールIDとアプリクライアントIDを指定しプールの作成を押します。 次に表示される画面ではロールを作成します。上の段(Your authenticated identities...)のポリシードキュメントを表示し、編集ボタンを押して編集します。今回はアップロードすることが目的なので、"s3:PutObject*"を追加します。編集が終わったら画面下方の許可ボタンを押して次に進みます。 { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "s3:PutObject*", "mobileanalytics:PutEvents", "cognito-sync:*", "cognito-identity:*" ], "Resource": [ "*" ] } ] } サンプルコードが表示されひとまず設定終了です。identityPoolIdが後々必要になりますが、この画面は後からでも確認することができます。 Swiftによる実装 パッケージの追加 podで以下のパッケージを追加します。 # For Amazon Cognito. pod 'AWSCognitoIdentityProvider', '~> 2.12.0' pod 'AWSS3' AppDelegate リージョンやIDなどを指定します。 import AWSCognitoIdentityProvider import UIKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { private let REGION: AWSRegionType = リージョン private let USER_POOL_ID: String = ユーザープールID private let ID_POOL_ID: String = IDプールのサンプルコードのidentityPoolId private let APP_CLIENT_ID: String = アプリクライアントID private let APP_CLIENT_SECRET: String? = nil private let KEY: String = "UserPool" func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { let serviceConfiguration = AWSServiceConfiguration( region: REGION, credentialsProvider: nil ) let userPoolConfigration = AWSCognitoIdentityUserPoolConfiguration( clientId: APP_CLIENT_ID, clientSecret: APP_CLIENT_SECRET, poolId: USER_POOL_ID ) AWSCognitoIdentityUserPool.register( with: serviceConfiguration, userPoolConfiguration: userPoolConfigration, forKey: KEY ) // Amazon Cognito 認証情報プロバイダーを初期化します let pool = AWSCognitoIdentityUserPool(forKey: KEY) // IDプールで表示されるサンプルはidentityProviderManagerは指定されていませんが、指定した方が良いようです let credentialsProvider = AWSCognitoCredentialsProvider( regionType: REGION, identityPoolId: ID_POOL_ID, identityProviderManager:pool ) let configuration = AWSServiceConfiguration( region: REGION, credentialsProvider:credentialsProvider ) AWSServiceManager.default().defaultServiceConfiguration = configuration return true } CognitoユーザーによるサインインやS3アップロードのコードは割愛させていただきます。 その他 認証しないでアップロードを試みるとtransferUtility.uploadDataの実行時に以下のようなエラーが発生するのでおそらくうまく行っているのではないかと思っています。 Authentication delegate not set ツッコミどころ満載と思いますが、不備等あればご指摘いただければ幸いです。 参考文献 Amazon Cognito デベロッパーガイド
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む