こんにちは。音声UIの開発を担当している武田です。Alexaのスキル「コーデ相談 by WEAR」はスキルの応答から分析まで、ほぼ全てAmazon Web Services(以下、AWS)のみを使って構成されています。今回は処理の種類ごとにAWSの構成の内容を、CloudFormationのコードとともに紹介していきます。
ユーザーに応答を返す
Alexaでカスタムスキルを作成する際に、応答の内容を決める処理は任意のサーバーで行うことができます。コーデ相談はAWS Lambdaを採用しており、以下のようなサービスがさらに連なっています。
Alexaからのリクエストは基本的に全てLambdaで受け付け、必要な情報や処理に応じてDynamoDBやWEARのAPIを叩く形です。最初に「ほぼ全てAWSのサービス」と表現した理由は、WEARのAPIの部分だけAWS以外のリソースを頼っているためです。サービスごとに役割と採用した理由を説明していきます。
AWS Lambda
LambdaはユーザーがAlexaに話しかけたりした際に、Alexaから送られてくるリクエストの窓口です。このリクエストにはユーザーによる入力の内容やデバイスの情報が特定のフォーマットで含まれています。それらの情報をもとに返事の内容を決定し、Alexaが理解できる形でレスポンスを返すことがLambdaの役割となります。
リクエストを受け付けるエンドポイントは自由に指定できるので、EC2のインスタンスを立てたり、すでに稼働している自社のサーバーに向けても問題はありません。コーデ相談でLambdaを採用している理由はAlexaとの相性が良いためです。もう少し詳細に説明します。
本来LambdaはHTTPS等のエンドポイントを提供する場合、API GatewayやApplication Load Balancer(以下、ALB)を挟む必要があります。しかしAlexaのリクエストを受け付ける場合は、LambdaのARN(Amazonリソースネーム)をAlexaのコンソールで指定するだけで接続が完了します。つまり、インターネットにエンドポイントを公開する必要がないため、セキュリティに関して考えることが減ります。リクエストを許可するスキルをIDで指定することもできるので、関係ないスキルのリクエストを受け付けてしまうことも防げます。
Alexaのカスタムスキルの開発を助けてくれるAlexa Skills Kit SDK(以下、ASK SDK)というものがあります。このSDKではDynamoDBを用いてユーザーの情報を保存する仕組みも提供されており、Lambdaでのロールの管理とも相性が良いです。
LambdaにはAmazon Virtual Private Cloud内のリソースにアクセス可能なVPCモードが存在しますが、今回はこのモードにしていません。ALBを用いている場合など関連しているサービス次第ではLambdaをVPCに置く必要があります。しかし、コーデ相談はこれらのサービスを使っていませんし、VPCモードではコールドスタンバイが発生してしまうためVPCに置いていません。
Amazon DynamoDB
Lambdaからユーザー情報の読み書きを行うために用います。コーデ相談では性別やチュートリアルのフラグなど、ユーザーIDからkey-valueの形で取得できる情報のみ扱うのでRDBは使用していません。DynamoDBをASK SDKから使うことで、ユーザーのIDなどを意識せずにユーザーの情報の書き込みと読み込みが可能となります。開発のハードルが下がりますし、データ操作の記述を行う必要も無くなるため、スキル内でユーザーの情報を保持しておくレベルであればおすすめです。
Amazon CloudWatch
CloudWatchはスキルに関わるログを保存するために用います。
Lambdaからはログの書き込みのみを行い、コーデ相談では以下のログを保存しています。
・スキルの起動や終了
・デバッグ用のログ
・ユーザーの行動のログ
Lambda自体が自動で書き込む受動的なログも、デバッグログといった能動的に取ろうとしているログもCloudWatchにまとめて保存しています。こうすることでログがごちゃ混ぜになりますが、データの流れがシンプルになりCloudWatchを見に行けば全てのログが保存されている安心感があります。CloudWatch自体の検索機能や、後述するAthenaを用いることで状況に応じて関係するログのみを抜き出すことも可能なので、現時点までで困ったことはありません。
Amazon S3
S3には画面付きデバイスで用いるアイコンやチュートリアル動画などのアセットを置きます。AlexaのデバイスからAWSサービスへアクセスするときには基本的に全てLambdaを経由しますが、S3に置いたアセットへのアクセスのみLambdaを経由せず直接参照します。
この部分はHTTPSでアクセスできる形であれば何でも大丈夫です。動画などはそれなりの容量になりますし、しっかりとした配信を意識するならCDNやDNSの設計が必要になると思います。しかしAlexaスキルからの参照に限定した場合、配信するデータが少量の静的ファイルのみということもあり、CloudFrontは置かずS3を直接参照する形にしています。
CloudFormation
ユーザーがスキルを使う際に必要なAWSのリソースは以下のテンプレートで作成できます。
AWSTemplateFormatVersion: 2010-09-09 Parameters: GlobalEnvironment: Type: 'String' AllowedValues: - 'production' - 'development' SkillId: Type: 'String' S3BucketNameLambdaSource: Type: 'String' S3KeyLambdaSource: Type: 'String' Resources: S3BucketSkillAssets: Type: 'AWS::S3::Bucket' DeletionPolicy: 'Retain' Properties: AccessControl: 'PublicRead' CorsConfiguration: CorsRules: - AllowedHeaders: - '*' AllowedMethods: - GET AllowedOrigins: - http://ask-ifr-download.s3.amazonaws.com - https://ask-ifr-download.s3.amazonaws.com MaxAge: 3000 BucketName: !Sub ${GlobalEnvironment}-skill-assets DynamoDBTableUsers: Type: 'AWS::DynamoDB::Table' Properties: BillingMode: 'PAY_PER_REQUEST' AttributeDefinitions: - AttributeName: 'id' AttributeType: 'S' KeySchema: - AttributeName: 'id' KeyType: 'HASH' TableName: !Sub ${GlobalEnvironment}-users IAMRoleLambdaAlexaSkillEndpoint: Type: 'AWS::IAM::Role' Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: 'Allow' Principal: Service: 'lambda.amazonaws.com' Action: 'sts:AssumeRole' ManagedPolicyArns: - 'arn:aws:iam::aws:policy/CloudWatchLogsFullAccess' Policies: - PolicyDocument: Version: '2012-10-17' Statement: - Effect: 'Allow' Action: - 'dynamodb:*' Resource: - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${DynamoDBTableUsers} - !Sub arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/${DynamoDBTableUsers}/* PolicyName: !Sub ${GlobalEnvironment}-lambda-alexa-skill-endpoint LambdaFunctionAlexaSkillEndpoint: Type: 'AWS::Lambda::Function' Properties: Code: S3Bucket: !Ref S3BucketNameLambdaSource S3Key: !Ref S3KeyLambdaSource Runtime: 'nodejs8.10' Timeout: 120 Environment: Variables: ASSETS_BUCKET_NAME: !Ref S3BucketSkillAssets TABLE_NAME: !Ref DynamoDBTableUsers FunctionName: !Sub ${GlobalEnvironment}-skill-endpoint Handler: 'index.handler' Role: !GetAtt IAMRoleLambdaAlexaSkillEndpoint.Arn LambdaPermissionAlexaSkillEndpoint: Type: 'AWS::Lambda::Permission' Properties: FunctionName: !Ref LambdaFunctionAlexaSkillEndpoint Action: 'lambda:InvokeFunction' Principal: 'alexa-appkit.amazon.com' EventSourceToken: !Ref SkillId LogsLogGroupLambdaFunctionAlexaSkillEndpoint: Type: 'AWS::Logs::LogGroup' Properties: LogGroupName: !Sub /aws/lambda/${GlobalEnvironment}-skill-endpoint
全体的にですが、GlobalEnvironment
というパラメータを使ってリソース名をまとめて変えられるようにしています。リリース後は本番環境と開発環境を区別したくなるはずなのでつけておきましょう。
S3BucketSkillAssets
はAlexaから直接参照される部分なので、AccessControl
はPublicRead
にしてCORSの設定をしておきます。この辺りはこちらの記事に詳しく書かれています。
DynamoDBのBillingMode
をPAY_PER_REQUEST
にしているのは、Alexaのスキルの起動回数が読めないためです。ある程度アクセスが落ち着いてきたらPROVISIONED
に変更します。テーブルのスキーマはASK SDKで保存されるフォーマットに合わせています。
DynamoDBのテーブルとS3のバケットはそれぞれ、Lambdaの関数内で名前が必要になるので、環境ごとの切り替えのしやすさを考慮して環境変数で渡すようにしています。
CloudWatchの部分はLambdaが1回でも実行されれば自動で作られますが、あとで他のリソースから参照が発生するので明示的に作っておきます。
問題があった時にアラートを飛ばす
スキル内でエラーを検知した場合に、Slackにメッセージを流せるようにします。処理の流れはこのようになります。
流れ自体はかなりオーソドックスで、CloudWatchのログを元にエラー数を調べ、閾値を超えた時にLambda関数を実行するという流れです。Lambda関数内でメッセージを成形してSlackのIncoming Webhooksを叩きます。使っているサービスを見ていきましょう。
Amazon CloudWatch
スキルのログを流すために使っているCloudWatchですが、ログを元にエラー数を調べることも可能です。具体的には以下のことをやっています。
・特定の文字列を含むログが一定の期間で何度発生しているかを計算
・計算結果が指定した閾値を超えているかチェック
・閾値を超えた場合にトピックを発行
トピックは他のサービスで処理を始めるためのトリガーとなるもので、この後に出てくるSNSでどのサービスを動かすか指定していきます。メールを送るだけならトピックを発行せずにCloudWatchだけで完結しますが、状況によってメンションをつけたり、異なるメッセージを流すためにSNSを経由するようにしました。
Amazon SNS
SNSはSimple Notification Serviceの略で、トピックに応じてLambda関数を実行する以外にも、プッシュ通知やメールを送信できます。今回、SNSはトピックとLambdaを繋ぐためだけに用いているため、Slackにメッセージを流す具体的な処理は全てLambdaで記述します。
AWS Lambda
Slackにメッセージを飛ばします。エラーの種類に応じてメッセージや色を変更しています。詳しい内容は、後述するテンプレートに直接載せてありますのでそちらを見てください。
CloudFormation
ログを元にエラーなどのアラートを飛ばすリソースは以下のテンプレートで作成できます。
AWSTemplateFormatVersion: 2010-09-09 Parameters: GlobalEnvironment: Type: 'String' AllowedValues: - 'production' - 'development' SlackChannel: Type: 'String' SlackWebhookUrl: Type: 'String' Resources: LogsMetricFilterSkillError: Type: 'AWS::Logs::MetricFilter' Properties: FilterPattern: 'SKILL_ERROR' # アプリケーション側で指定しているログのプレフィックス LogGroupName: !Ref LogsLogGroupLambdaFunctionAlexaSkillEndpoint MetricTransformations: - MetricName: 'skill-error' MetricNamespace: !Sub ${GlobalEnvironment}-metric MetricValue: '1' CloudWatchAlarmSkillError: Type: 'AWS::CloudWatch::Alarm' Properties: AlarmActions: - !Ref SNSTopicSkillError AlarmName: !Sub ${GlobalEnvironment}-skill-error-alarm ComparisonOperator: 'GreaterThanThreshold' EvaluationPeriods: 1 # 最初は少しでも発生したら飛ばすようにする MetricName: 'skill-error' Namespace: !Sub ${GlobalEnvironment}-metric Period: 60 Statistic: 'Sum' Threshold: 0 SNSTopicSkillError: Type: 'AWS::SNS::Topic' Properties: Subscription: - Endpoint: !GetAtt LambdaFunctionSlackNotification.Arn Protocol: 'lambda' TopicName: !Sub ${GlobalEnvironment}-skill-error-topic IAMRoleLambdaAlexaSlackNotification: Type: 'AWS::IAM::Role' Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: 'Allow' Principal: Service: 'lambda.amazonaws.com' Action: 'sts:AssumeRole' ManagedPolicyArns: - 'arn:aws:iam::aws:policy/CloudWatchLogsFullAccess' LambdaFunctionSlackNotification: Type: 'AWS::Lambda::Function' Properties: Code: ZipFile: | import json import logging import os from urllib.request import Request, urlopen from urllib.error import URLError, HTTPError SLACK_CHANNEL = os.environ['SLACK_CHANNEL'] SLACK_WEBHOOK_URL = os.environ['SLACK_WEBHOOK_URL'] logger = logging.getLogger() logger.setLevel(logging.INFO) def lambda_handler(event, context): logger.info("Event: " + str(event)) message = json.loads(event["Records"][0]["Sns"]["Message"]) logger.info("Message: " + str(message)) alarm_name = message['AlarmName'] reason = message['NewStateReason'] text = "想定していないアラームが発生しました: %s" % (alarm_name) if '-error-alarm' in alarm_name: text = '<!channel> コーデ相談スキルでエラーが発生しています。ログを確認してください。' slack_message = { 'channel': SLACK_CHANNEL, 'text': text, 'color': '#FF0000' if ('-error-alarm' in alarm_name) else '#FFFF00', 'fields': [ { 'title': alarm_name, 'value': reason }, ] } req = Request(SLACK_WEBHOOK_URL, json.dumps(slack_message).encode('utf-8')) try: response = urlopen(req) response.read() logger.info("Message posted to %s", slack_message['channel']) except HTTPError as e: logger.error("Request failed: %d %s", e.code, e.reason) except URLError as e: logger.error("Server connection failed: %s", e.reason) Environment: Variables: SLACK_CHANNEL: !Ref SlackChannel SLACK_WEBHOOK_URL: !Ref SlackWebhookUrl FunctionName: !Sub ${GlobalEnvironment}-slack-notification Handler: 'index.lambda_handler' Role: !GetAtt IAMRoleLambdaAlexaSlackNotification.Arn Runtime: 'python3.6' Timeout: 10 LambdaPermissionSlackNotificationSkillError: Type: 'AWS::Lambda::Permission' Properties: FunctionName: !Ref LambdaFunctionSlackNotification Action: 'lambda:InvokeFunction' Principal: 'sns.amazonaws.com' SourceArn: !Ref SNSTopicSkillError
AWS::Logs::MetricFilter
の部分でどのようなログを数えるかを指定しています。上記の指定ですと SKILL_ERROR
という文字列を含むログを数えてくれますので、同じフォーマットでエラーのログを吐き出せるようにしておけば検知するようになります。今回はエラーだけですが、WARNや警告用の設定も用意しておけば異なるメッセージも簡単に流せます。
アラートを出すときの閾値はかなり下げてあります。Alexaのスキルは基本的にシンプルですし、大量にエラーが出るようなバグを仕込むことは少ないだろうということで、最初はこれくらいで良いと思います。
スキルの処理のように、複雑な関数になる場合はLambda用のコードを別のファイルで管理すると思います。ただ、今回のようにSlackのAPIを叩く程度であれば、テンプレートの中にLambdaのコードを直接記述した方が管理や更新が楽です。
分析する
分析と言っても色々あると思いますが、今回はSQLで集計し、その結果をBIツールで可視化できるようにします。例によって分析に関わる処理の流れがこちらです。
AWSにはAthenaというサービスで集計などを行うことができます。Athenaでクエリを叩くためにはある程度データを整形する必要があるため、Kinesis Data Firehoseで定期的にログを読み込み、S3に書き出します。サービスごとに具体的に説明していきましょう。
Amazon Kinesis Data Firehose
Kinesis Data Firehoseはログなどのストリーミングデータを、別のサービスに配信できるサービスです。コーデ相談の場合ですと、CloudWatchのログを読み込み、整形した結果をS3に保存しています。
ログが追加されるタイミングでS3にファイルを追加する方法もあります。しかし、その方法ではログの量が増えるにつれて処理自体の回数が増えてしまうため、指定した間隔でまとめて処理を実行できるKinesis Data Firehoseを採用しました。
AWS Lambda
ログの読み込みや、データの書き出しはKinesis Data Firehoseがやってくれますので、このLambdaではデータの整形のみ行います。CloudWatchのログをS3に置くだけであれば整形は必要ありませんが、Athenaでクエリを叩くために以下の形にします。
・1つのレコードを1行にまとめる
・ログの中身をJSONのkey-valueの形にする
AthenaはJSON形式以外にもCSVやParquetも指定できます。今回はカラム名をKey名でマッピングでき、整形が楽そうと言うことでJSONにしました。
Amazon S3
Athenaから叩くデータの置き場として用います。BigQueryの場合はBigQueryにデータを読み込ませることになりますが、Athenaの場合S3など他のサービスに置いてあるデータをそのまま参照できます。
Amazon Athena
Athenaはクエリを書いて集計するために用います。AthenaはSQLのエンジンがPrestoになっており、JSONを扱える関数やWindow関数なども用意されているので、一通りの集計は可能です。
設定としてテーブルを作成(Create table)する必要があります。テーブルを作成とは言っていますが、この作業ではどこにあるデータをどのようなカラムとして扱うかを定義する作業になります。Athenaは現時点でCloudFormationに対応していないので、GUIかCLIで設定していきましょう。
具体的な設定方法は記事がいくつか見つかりますので、割愛します。
Amazon QuickSight
QuickSightはデータを可視化するダッシュボードが作成できるBIツールです。可視化するデータはAthenaによる集計結果を用いることができますので、リアルタイムに溜まっていくデータを表示することが可能です。
QuickSightにはSPICEという機能が備わっています。この機能を用いると集計対象のデータをインポートしてインメモリで集計するようになるので、表示が高速となります。インポートしたデータの更新のタイミングは自由に指定できるので、表示するたびにS3などへのアクセスが発生することも防げます。
Athenaと同様にCloudFormationには対応していないため、初期設定などはGUIで行うようにしましょう。
CloudFormation
分析のために必要なデータをS3に溜め込む処理は、以下のテンプレートで作成できます。
AWSTemplateFormatVersion: 2010-09-09 Parameters: GlobalEnvironment: Type: 'String' AllowedValues: - 'production' - 'development' Resources: S3BucketLogs: Type: 'AWS::S3::Bucket' DeletionPolicy: 'Retain' Properties: AccessControl: 'LogDeliveryWrite' BucketName: !Sub ${GlobalEnvironment}-logs LogsSubscriptionFilterAccessLog: Type: 'AWS::Logs::SubscriptionFilter' Properties: DestinationArn: !GetAtt 'KinesisFirehoseDeliveryStreamAccessLog.Arn' FilterPattern: 'ACCESS_LOG' # アプリケーション側で指定しているログのプレフィックス LogGroupName: !Ref LogsLogGroupLambdaFunctionAlexaSkillEndpoint RoleArn: !GetAtt 'IAMRoleLogsSubscriptionAccessLog.Arn' IAMRoleLogsSubscriptionAccessLog: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: 'Allow' Principal: Service: !Sub 'logs.${AWS::Region}.amazonaws.com' Action: 'sts:AssumeRole' Policies: - PolicyDocument: Version: '2012-10-17' Statement: - Effect: 'Allow' Action: - 'firehose:PutRecord' - 'firehose:PutRecords' Resource: - !GetAtt 'KinesisFirehoseDeliveryStreamAccessLog.Arn' PolicyName: !Sub ${GlobalEnvironment}-policy-log-subscription-access-log KinesisFirehoseDeliveryStreamAccessLog: Type: 'AWS::KinesisFirehose::DeliveryStream' Properties: ExtendedS3DestinationConfiguration: BucketARN: !GetAtt S3BucketLogs.Arn BufferingHints: IntervalInSeconds: 60 SizeInMBs: 50 CompressionFormat: 'GZIP' Prefix: 'skillAccessLog/' ProcessingConfiguration: Enabled: true Processors: - Parameters: - ParameterName: 'LambdaArn' ParameterValue: !GetAtt 'LambdaFunctionKinesisFirehoseTranslate.Arn' - ParameterName: 'BufferSizeInMBs' ParameterValue: 3 - ParameterName: 'BufferIntervalInSeconds' ParameterValue: 60 Type: 'Lambda' RoleARN: !GetAtt 'IAMRoleKinesisFirehoseAccessLog.Arn' IAMRoleKinesisFirehoseAccessLog: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: firehose.amazonaws.com Action: sts:AssumeRole Policies: - PolicyDocument: Version: '2012-10-17' Statement: - Effect: 'Allow' Action: - 's3:AbortMultipartUpload' - 's3:GetBucketLocation' - 's3:GetObject' - 's3:ListBucket' - 's3:ListBucketMultipartUploads' - 's3:PutObject' Resource: - !Sub 'arn:aws:s3:::${S3BucketLogs}' - !Sub 'arn:aws:s3:::${S3BucketLogs}/*' - Effect: 'Allow' Action: - 'lambda:InvokeFunction' - 'lambda:GetFunctionConfiguration' Resource: - !GetAtt 'LambdaFunctionKinesisFirehoseTranslate.Arn' PolicyName: !Sub ${GlobalEnvironment}-policy-kinesis-firehose-access-log LambdaFunctionKinesisFirehoseTranslate: Type: 'AWS::Lambda::Function' Properties: Code: ZipFile: | import base64 import gzip import StringIO import json def lambda_handler(event, context): output = [] for record in event['records']: payload = base64.b64decode(record['data']) fileobj = StringIO.StringIO(payload) logs = None with gzip.GzipFile(fileobj=fileobj, mode="r") as f: logs = f.read() fileobj.close() # Athena用にデータを整形 log_dict = json.loads(logs) logGroup = log_dict.get("logGroup") log_list = [] for log_event in log_dict.get("logEvents"): log_values = log_event["message"].split('\t') if len(log_values) >= 4: log_event["request_type"] = log_values[3] if len(log_values) >= 5: log_event["user_id"] = log_values[4] if len(log_values) >= 6: log_event["supported_interfaces"] = log_values[5] log_list.append(json.dumps(log_event)) # new line per record log_txt = log_list[0] + "\n" if len(log_list) >= 2: log_txt = "\n".join(log_list) + "\n" output_record = { 'recordId': record['recordId'], 'result': 'Ok', 'data': base64.b64encode(log_txt) } output.append(output_record) print('Successfully processed {} records.'.format(len(event['records']))) return {'records': output} FunctionName: !Sub ${GlobalEnvironment}-kinesis-firehose-translate Handler: 'index.lambda_handler' Role: !GetAtt IAMRoleLambdaKinesisFirehoseTranslate.Arn Runtime: 'python2.7' Timeout: 120 IAMRoleLambdaKinesisFirehoseTranslate: Type: 'AWS::IAM::Role' Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: 'Allow' Principal: Service: 'lambda.amazonaws.com' Action: 'sts:AssumeRole' ManagedPolicyArns: - 'arn:aws:iam::aws:policy/CloudWatchLogsFullAccess'
コーデ相談ではユーザーの行動ベースで分析がしたかったので、専用のログをACCESS_LOG
から始まるようにして流し、データとして蓄積するようにしました。
ログの加工の流れの注意点としては、それぞれの出力の形式です。Kinesis Data Firehoseに対してCloudWatchからログを送る場合、gzipで圧縮された状態になっています。また、Athenaで集計できるデータとして扱う場合拡張子が*.gz
になっている必要があるなど注意が必要です(Athenaでサポートする圧縮形式)。この辺りはAWS::KinesisFirehose::DeliveryStream
リソースやLambda関数のコードで吸収しています。
まとめ
バケットやパラメータなどの名前は変えてありますが、上記のテンプレートからスタックを作ることで、実際に稼働しているコーデ相談と同じ構成を作ることが可能です。Alexaのスキルで多機能なことは少ないですし、アクセス数も膨大に増えるとは考えづらいので今回紹介した構成でほとんどの場合は事足りると思います。
Alexaだけで見るとコンソールから設定するだけで十分かもしれませんが、CloudFormationはコードベースでのレビューができたり、同じ構成を再現できるメリットがあります。特にアラートや分析の部分はAlexa特有の話ではありません。一般的なサービスでも同じ構成が使えますので、次の開発でも活用し改善していく予定です。
さいごに
AWSを意識せずにスキルを提供できるAlexa-hostedスキルなども発表され、Alexaの開発ハードルはどんどん下がっています。しかしサービスとして提供しグロースさせようとしたり、環境が複数になると管理するリソースは増えてきますので、
今回紹介したテンプレートが役に立てば幸いです。
ZOZOテクノロジーズのInnvation Initiative部では今後普及するであろう技術を先行研究し、様々な技術を用いたサービスを開発しています。よりよいユーザー体験を提供するために、技術を駆使して最高のプロダクトを作りませんか?