クラウドの力でロボットを遠隔から操作してみよう!

Part 3: コントロールダッシュボードを作成

2023-10-03
日常で楽しむクラウドテクノロジー

Author : Muhammad Fikko Fadjrimiratno(ふぃっこ)

こんにちは。この記事は、「クラウドの力でロボットを遠隔から操作してみよう !」の続編 (Part 3) です。

Part 1 では、AWS IoT Core を活用し、シミュレーション上のロボットをクラウドから操作できるようになりました。Part 2 では、 Amazon Kinesis Video Streams (KVS) で、ロボットが取得している映像も簡単にリアルタイムで見れるようになりました。

いよいよ、今回の Part 3 を通じて、これまで構築したロボットの遠隔操作とリアルタイム映像の確認を統合し、コントロールダッシュボードのためのサーバーレスウェブアプリで楽しくロボットを操作してみましょう !

今回作成していくのはアーキテクチャの以下の部分です。

それでは、Part 3 をはじめましょう !

ご注意

本記事で紹介する AWS サービスを起動する際には、料金がかかります。builders.flash メールメンバー特典の、クラウドレシピ向けクレジットコードプレゼントの入手をお勧めします。

*ハンズオン記事およびソースコードにおける免責事項 »

このクラウドレシピ (ハンズオン記事) を無料でお試しいただけます »

毎月提供されるクラウドレシピのアップデート情報とともに、クレジットコードを受け取ることができます。 


1. ロボット操作のためのバックエンドを作成

このステップでは、ロボット操作用の API 提供のためのサーバーレスバックエンドを AWS LambdaAmazon API Gateway で構築していきます。

1-1. バックエンド (Lambda 関数) から AWS IoT Core にアクセスする権限を追加

1. AWS マネジメントコンソールの検索欄に IAM と入力し、検索結果として表示された IAM にマウスカーソルを移動し、「ポリシー」をクリックします。

クリックすると拡大します

2. 「ポリシーを作成」ボタンをクリックします。

3. JSON を選択し、ポリシーエディタ に以下の JSON コードを貼り付けます。<アカウントID> をご自身のものに変更し、最後に「次へ」ボタンをクリックします。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "iot:Publish"
            ],
            "Resource": "arn:aws:iot:ap-northeast-1:<アカウントID>:topic/turtlebot3-sim/*",
            "Effect": "Allow"
        },
        {
            "Action": [
                "iot:DescribeEndpoint"
            ],
            "Resource": "*",
            "Effect": "Allow"
        }
    ]
}

4. 次のページで、ポリシー名lambda-turtlebot3-policy と入力し、「ポリシーの作成 」ボタンをクリックします。

5. 左のナビゲーションペインから「アクセス管理ロール」を選択し、「ロールを作成」ボタンをクリックします。

6. 信頼されたエンティティタイプでは「AWS のサービス」を選択し、一般的なユースケース で「Lambda」を選択し、右下のページにある「次へ」ボタンをクリックします。

7. 検索欄で lambda-turtlebot3-policy と入力し、lambda-turtlebot3-policy にチェックを付けます。そして、再び検索欄を使って、AWSLambdaBasicExecutionRole を探し、検索結果にチェックを付けます。

両方のポリシーにチェックを付けたあと、「次へ」ボタンをクリックします。

クリックすると拡大します

クリックすると拡大します

8. 次のページで ロール名 lambda-turtlebot3-role と入力し、ページの右下にある「ロールを作成」ボタンをクリックします。

1-2. Lambda 関数を作成

1. AWS マネジメントコンソールの検索欄に lambda と入力し、検索結果として表示された「Lambda」をクリックします。

クリックすると拡大します

2.「関数の作成」をクリックします。

クリックすると拡大します

3. 次のページで 「一から作成」を選択します。

次に、関数名publish-control-turtlebot3 と入力し、ランタイム で「Node.js 14.x」と選択、アーキテクチャ では「arm64」と選択します。

デフォルトの実行ロールの変更」ドロップダウンをクリックし、実行ロールで「既存のロールを使用する」を選択し、既存ロールで「lambda-turtlebot3-role」を選択します。

最後に、「関数の作成」ボタンをクリックします。

クリックすると拡大します

クリックすると拡大します

4. コード のタブを選択し、以下のソースコードを貼り付けます。

そして、「Deploy」をクリックします。

var AWS = require('aws-sdk');
var iot = new AWS.Iot();
var publishTopic = `turtlebot3-sim/control`

exports.handler = (event, context, callback) => {
  console.log("message received:", JSON.stringify(event.body, null, 2))

  var params = {
       topic: publishTopic,
       payload: event.body,
       qos: 0
  }
  
  publishMessage(params, (err, res) => {
    callback(null, {
        statusCode: err ? '400' : '200',
        body: err ? err.message : JSON.stringify(res),
        headers: {
            'Content-Type': 'application/json',
            "Access-Control-Allow-Origin": "*"
        }
    })
  })
}

const publishMessage = (params, callback) => {
  iot.describeEndpoint({}, (err, data) => {
    if(err){
      console.log(err)
      callback(err)
    }else{
      var iotdata = new AWS.IotData({endpoint: data.endpointAddress});
      iotdata.publish(params, (err, data) => {
        if(err){
          console.log(err)
          callback(err)
        }else{
          console.log("success?")
          callback(null, params)
        }
      })
    }
  })
}

クリックすると拡大します

1-3. API Gateway を作成

1. AWS マネジメントコンソールの検索欄に api gateway と入力し、検索結果として表示された「API Gateway」をクリックします。

クリックすると拡大します

2. 「API を作成」ボタンをクリックし、「REST API で 構築」ボタンをクリックします。

次のページで、こちらの設定に従って、API を作成します。

クリックすると拡大します

3. アクション を選択し、「リソースの作成」をクリックします。

クリックすると拡大します

4. リソース名* リソースパス*publish と入力し、「リソースの作成」ボタンをクリックします。

クリックすると拡大します

5. アクション を選択し、「メソッドの作成」をクリックします。そして、ドロップダウンメニューから「OPTIONS」を選択し、チェックマークボタンをクリックします。

クリックすると拡大します

クリックすると拡大します

6. 統合タイプ で「Mock」を選択し、「保存」をクリックします。

7. 「メソッドレスポンス」をクリックし、「ヘッダーの追加」をクリックすることで、以下のそれぞれのヘッダーを追加します。

最後に、「メソッドの実行」をクリックします。

Access-Control-Allow-Headers
Access-Control-Allow-Methods
Access-Control-Allow-Origin

クリックすると拡大します

8. 「統合レスポンス」をクリックし、ヘッダーのマッピング でそれぞれの レスポンスヘッダー に対して、マッピングの値 に「*」と入力します。

クリックすると拡大します

9. アクション を選択し、「メソッドの作成」をクリックします。そして、ドロップダウンメニューから、「POST」を選択し、チェックマークボタンをクリックします。

クリックすると拡大します

クリックすると拡大します

10. 「Lambda 関数」を選択し、「Lambda プロキシ統合の使用」にチェックをつけます。

Lambda 関数 で作成された「publish-control-turtlebot3」を選択し、最後に「保存」ボタンをクリックします。

クリックすると拡大します

11. API Gateway に、Lambda 関数を呼び出す権限を与えようとしています: と表示されたら、「OK」ボタンをクリックします。

12. 「テスト」ボタンをクリックします。

クリックすると拡大します

13. ヘッダーリクエスト本文 に それぞれの以下のコードを貼り付け、ページの下にある「テスト」ボタンをクリックします。

すると、Ubuntu デスクトップ上の ROS シミュレーションを見ると、ロボットが右に回転することが確認できます。ロボットを止めるには、リクエスト本文の "control":"stop" でテストしてください。

ヘッダー:

Content-Type: application/json

リクエスト本文:

{
"control":"right"
}

クリックすると拡大します

14. アクション から「API のデプロイ」をクリックします。

クリックすると拡大します

15. デプロイされるステージ に「新しいステージ」と選び、ステージ名* test と入力し、「デプロイ」ボタンをクリックします。

注意事項 : プロダクション向けの場合は、API へのアクセスを Cognito オーソライザーなど認証の仕組みで制御することがおすすめです。

16. ダッシュボードのウェブアプリで使われるため、URL の呼び出し で表示される URL を API Gateway エンドポイント としてメモします。

クリックすると拡大します


2. コントロールダッシュボードのフロントエンドを作成

このステップでは、フロントエンド部分を Vue.js で構築していきます。AWS Amplify を使いますので、ユーザー認証や管理 (裏では Amazon Cognito で構築されます) 及びウェブホスティング (裏では Amazon S3Amazon CloudFront で構築されます) が簡単に実装できます。

2-1. ソースコードの準備

1. ローカル PC やその他のウェブアプリ開発用の環境を使い、以下のコマンドでサンプルアプリのソースコードをクローンし、web-app ディレクトリに行きます。

git clone https://github.com/aws-samples/aws-serverless-telepresence-robot.git
cd aws-serverless-telepresence-robot/web-app

2. web-app の中のソースコードを以下のソースコードに書き換えます。

src/App.vue

<template>
    <div id="app">
      <div v-if="!signedIn">
         <amplify-authenticator></amplify-authenticator>
      </div>
      <div v-if="signedIn">
        <amplify-sign-out class="signout" v-bind:signOutOptions="signOutOptions"></amplify-sign-out>
        <VideoViewer msg="Control Dashboard" />
        <Interface />
      </div>
    </div>
</template>

<script>
import Interface from './components/Interface.vue'
import VideoViewer from './components/VideoViewer.vue'
// eslint-disable-next-line no-unused-vars
import { AmplifyEventBus, components } from 'aws-amplify-vue'
import { Auth } from 'aws-amplify'

// eslint-disable-next-line no-unused-vars
import * as AmplifyVue from 'aws-amplify-vue'

const signOutOptions = {
  msg: 'You are currently signed in.',
  signOutButton: 'Sign Out'
}

export default {
  name: 'app',
  components: {
    VideoViewer,
    Interface
  },
  async beforeCreate() {
    try {
      // eslint-disable-next-line no-unused-vars
      const user = await Auth.currentAuthenticatedUser()
      this.signedIn = true
    } catch (err) {
      this.signedIn = false
    }
    AmplifyEventBus.$on('authState', info => {
      console.log(info)
      if (info === 'signedIn') {
        this.signedIn = true
      } else {
        this.signedIn = false
      }
    });
  },
  data () {
    return {
      signOutOptions,
      signedIn: false
    }
  }
}
</script>

<style>
body {
  margin: 0
}
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}
.signout {
  background-color: #ededed;
  margin: 0;
  padding: 11px 0px 1px;
}
</style>

src/components/interface.vue

<template>
  <div class="hello">
    <button v-on:click="forward">
      <i class="arrow up" />
    </button>
    <div class="btn-group">
      <button v-on:click="left">
        <i class="arrow left" />
      </button>
      <button v-on:click="stop">
        STOP
      </button>
      <button v-on:click="right">
        <i class="arrow right" />
      </button>
    </div>
    <button v-on:click="backward">
        <i class="arrow down" />
    </button>
  </div>
</template>

<script>
import { Auth, API } from 'aws-amplify'

Auth.currentCredentials().then((info) => {
     console.log(info)
   });
   
async function postData(dir) {
   let apiName = 'SendAction';
   let path = '/publish';
   let myInit = {
     body: {
       control: dir
     }
   }
   return await API.post(apiName, path, myInit);
}


export default {
  name: 'Interface',
  methods: {
    forward: function () {
      postData('forward')
    },
    stop: function () {
      postData('stop')
    },
    backward: function () {
      postData('backward')
    },
    left: function () {
      postData('left')
    },
    right: function () {
      postData('right')
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
i {
  border: solid white;
  border-width: 0 3px 3px 0;
  display: inline-block;
  padding: 3px;
}

.right {
  transform: rotate(-45deg);
  -webkit-transform: rotate(-45deg);
}

.left {
  transform: rotate(135deg);
  -webkit-transform: rotate(135deg);
}

.up {
  transform: rotate(-135deg);
  -webkit-transform: rotate(-135deg);
}

.down {
  transform: rotate(45deg);
  -webkit-transform: rotate(45deg);
}
button {
  min-width: 45px;
  display: inline-block;
  margin-bottom: 0;
  margin: 3px;
  vertical-align: middle;
  touch-action: manipulation;
  cursor: pointer;
  user-select: none;
  color: #fff;
  background-color: #f90;
  border-color: #ccc;
  padding: 14px 0;
  border: none;
  border-radius: 2px;
}
button:active {
  opacity: 1;
  background-color: var(--button-click);
}
button:hover {
  opacity: 0.8;
}
</style>

src/components/VideoViewer.vue

<template>
  <div class="viewer">
    <h1>{{ msg }}</h1>
    <div id="video-container">
      <div id="video-controls">
        <button type="button" id="open-stream" v-on:click="openStream" v-if="!isStreamActive">Open Video</button>
        <div id="active-video-controls" v-if="isStreamActive">
          <button type="button" id="stop-stream" v-on:click="stopStream">Stop Video</button>
        </div>
        <input type="checkbox" id="logChoice" v-model="showLogs">
        <label for="logChoice">Logs</label>
      </div>
      <video class="remote-view" id="myVideoEl" v-bind:hidden="!isStreamActive" autoplay playsinline  />
      <div id="logs" v-show="showLogs" />
    </div>
  </div>
</template>

<script>

import { SignalingClient } from 'amazon-kinesis-video-streams-webrtc'
import { Auth } from 'aws-amplify'
import { v4 as uuid } from 'uuid'
import AWS from 'aws-sdk'
import * as config from '../config.json'

function logger(msg) {
  console.log(msg)
  document.getElementById('logs').innerHTML += msg + '<br />'
}

function openStreamViewer() {
  Auth.currentCredentials().then((info) => {
       startViewer({
         channelARN: config.channelARN,
         credentials: Auth.essentialCredentials(info),
         region: info.sts.config.region,
         clientId: uuid()
       })
     })
}

let viewer = {}

async function startViewer(config) {
  logger('Starting Viewer')
  let { credentials, channelARN, region, clientId } = config

  const kinesisVideoClient = new AWS.KinesisVideo({
      region,
      credentials
  });

  const getSignalingChannelEndpointResponse = await kinesisVideoClient
      .getSignalingChannelEndpoint({
          ChannelARN: channelARN,
          SingleMasterChannelEndpointConfiguration: {
              Protocols: ['WSS', 'HTTPS'],
              Role: 'VIEWER'
          }
      })
      .promise()

  const endpointsByProtocol = getSignalingChannelEndpointResponse.ResourceEndpointList.reduce((endpoints, endpoint) => {
      logger('Get siganling channel endpoint')
      endpoints[endpoint.Protocol] = endpoint.ResourceEndpoint
      return endpoints
  }, {})

  const kinesisVideoSignalingChannelsClient = new AWS.KinesisVideoSignalingChannels({
      region,
      credentials,
      endpoint: endpointsByProtocol.HTTPS
  })

  const getIceServerConfigResponse = await kinesisVideoSignalingChannelsClient
      .getIceServerConfig({
          ChannelARN: channelARN
      })
      .promise()

  const iceServers = [
      { urls: `stun:stun.kinesisvideo.${region}.amazonaws.com:443` }
  ]

  getIceServerConfigResponse.IceServerList.forEach(iceServer =>
      iceServers.push({
          urls: iceServer.Uris,
          username: iceServer.Username,
          credential: iceServer.Password
      })
  )

  const platform = navigator.platform
  logger(`Platform: ${platform}`)

  if(platform == 'iPhone') {
    try {
        viewer.localStream = await navigator.mediaDevices.getUserMedia(
          {
            video: true,
            audio: true
          })
    } catch (e) {
        logger(`Could not get usermedia ${JSON.stringify(e)}`);
        return
    }
  }

  viewer.peerConnection = new RTCPeerConnection({ iceServers })
  logger('Create peerConnection')

  viewer.signalingClient = new SignalingClient({
      channelARN,
      channelEndpoint: endpointsByProtocol.WSS,
      clientId,
      role: 'VIEWER',
      region,
      credentials
  })

  viewer.signalingClient.on('open', async () => {
      logger('Creating SDP offer')
      // Create an SDP offer and send it to the master
      const offer = await viewer.peerConnection.createOffer({
          offerToReceiveAudio: true,
          offerToReceiveVideo: true,
      })
      await viewer.peerConnection.setLocalDescription(offer)
      viewer.signalingClient.sendSdpOffer(viewer.peerConnection.localDescription)
  })
  
  // When the SDP answer is received back from the master, add it to the peer connection.
  viewer.signalingClient.on('sdpAnswer', async answer => {
      await viewer.peerConnection.setRemoteDescription(answer)
      logger('Received sdpAnswer')
  })

  // When an ICE candidate is received from the master, add it to the peer connection.
  viewer.signalingClient.on('iceCandidate', candidate => {
      logger('Candidate received from Master')
      viewer.peerConnection.addIceCandidate(candidate)
  })

  viewer.signalingClient.on('close', () => {
      logger('Signaling Client Closed')
      stopViewer()
  })

  viewer.signalingClient.on('error', error => {
      logger(`ERROR: ${JSON.stringify(error)}`)
  })

  viewer.peerConnection.addEventListener('icecandidate', ({ candidate }) => {
      if (candidate) {
        logger('Received iceCandidate')
        viewer.signalingClient.sendIceCandidate(candidate)
      }
  })

  // As remote tracks are received, add them to the remote view
  viewer.remoteView = document.querySelector('#myVideoEl')
  viewer.remoteView.play()
  logger('Start playing incoming video')

  viewer.peerConnection.addEventListener('track', event => {
      if (viewer.remoteView.srcObject) {
          return
      }
      viewer.remoteView.srcObject = event.streams[0]
  })

  viewer.signalingClient.open()

}

function stopViewer() {
  logger('[VIEWER] Stopping viewer connection')
  if (viewer.signalingClient) {
      viewer.signalingClient.close()
      viewer.signalingClient = null
  }

  if (viewer.peerConnection) {
      viewer.peerConnection.close()
      viewer.peerConnection = null
  }

  if (viewer.remoteView) {
      viewer.remoteView.srcObject = null
  }

  if (viewer.localStream) {
    viewer.localStream = null
  }
}

export default {
  name: 'WebRTC',
  props: {
    msg: String
  },
  data() {
    return {
      isStreamActive: false,
      showLogs: false
    }
  },
  methods: {
    openStream: function () {
      openStreamViewer()
      this.isStreamActive = true
    },
    stopStream: function () {
      stopViewer()
      this.isStreamActive = false
    },
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
  margin: 40px 0 0;
}
#logs {
  max-height: 100px;
  min-height: 50px;
  max-width: 370px;
  overflow: auto;
  border-style: solid;
  border-width: thin;
  margin: auto;
}
video {
  width:50%;
  visibility: visible;
  background: black;
}
button {
  min-width: 80px;
  display: inline-block;
  margin: 5px;
  vertical-align: middle;
  touch-action: manipulation;
  cursor: pointer;
  user-select: none;
  color: #fff;
  background-color: #f90;
  border-color: #ccc;
  padding: 14px 0;
  border: none;
  border-radius: 2px;
}
button:active {
  opacity: 1;
  background-color: var(--button-click);
}
button:hover {
  opacity: 0.8;
}
button:disabled {
 opacity: 0.6;
 cursor: not-allowed;
}

</style>

3. src ディレクトリの中で config.json というファイルを作成し、以下のコードを貼り付けます。<> をご自身のもにに置き換えます。

src/config.json

{
  "endpoint": "< API Gateway エンドポイント >",
  "channelARN": "< Part 2でメモした シグナリングチャネル ARN >"
}

4. 以下のコマンドで依存パッケージをインストールします。

npm install

2-2. Amplify の設定とデプロイ

1. ローカル PC やその他のウェブアプリ開発用の環境を使い、Amplify CLI をインストールし、セットアップするためのガイド に従います。

2. Amplify CLI のセットアップが完了できたら、以下のコマンドで Amplify のプロジェクトを初期化します。

Enter a name for the project と聞かれたら、robotdashboard と入力します。

Initialize the project with the above configuration? と聞かれたら、Y を選択します。そのほかはデフォルトのままにします。

amplify init

3. 以下のコマンドで認証方法を追加します。

Do you want to use the default authentication and security configuration? と聞かれたら、Default configuration を選択します。

How do you want users to be able to sign in? と聞かれたら、Username を選択します。

Do you want to configure advanced settings? と聞かれたら、No, I am done を選択します。 

amplify add auth

4. 以下のコマンドで Amplify のプロジェクトをクラウドにプッシュします。

amplify push

5. AWS マネジメントコンソールの検索欄に cognito と入力し、検索結果として表示された cognito をクリックします。

クリックすると拡大します

6. 左のナビゲーションペインから「ID プール」をクリックし、作られた Cognito の ID プール (robotdashboardxxx_identitypool_xxx__dev) をクリックします。

クリックすると拡大します

7.「ID プールの編集」をクリックし、認証されたロール (amplify-robotdashboard-dev-xxx-authRole) の名前をメモします。

クリックすると拡大します

8. IAM のコンソールページに行き、左のナビゲーションペインから「アクセス管理ロール」を選択し、メモした 認証されたロール(amplify-robotdashboard-dev-xxx-authRole)をロールの検索欄に入力し、当てはまるロールをクリックします。

クリックすると拡大します

9. 許可を追加 から「インラインポリシーを作成」を選択します。そして、JSON タブをクリックし、以下の JSON コードを貼り付け、「ポリシーの確認」ボタンをクリックします。<アカウント ID> はご自身のアカウント ID に変更してください。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "VisualEditor0",
            "Effect": "Allow",
            "Action": [
                "kinesisvideo:GetSignalingChannelEndpoint",
                "kinesisvideo:GetIceServerConfig",
                "kinesisvideo:ConnectAsViewer"
            ],
            "Resource": "arn:aws:kinesisvideo:ap-northeast-1:<アカウントID>:channel/turtlebot3-sim/*"
        }
    ]
}

クリックすると拡大します

10. ポリシーの確認 のページで 名前dashboard-kvs-policy と入力し、「ポリシーの作成」ボタンをクリックします。

2-3. Amplify でダッシュボードのウェブアプリをホスティング

1. ローカル PC の端末に戻り、以下のコマンドでダッシュボードアプリをホスティングします。

Select the plugin module to execute とプロンプトされたら、Amazon CloudFront and S3 と選択します。その他はデフォルトのままにします。

amplify add hosting
amplify publish

2. 以上のコマンドを実行したら、CloudFront の URL が出力されます(https://****.cloudfront.net)。その URL をコピーし、PC やスマートフォンなど、ロボットを操作するための任意の端末のブラウザで開きます。その前に、Part 1 と Part 2 で使ったロボットシミュレーションと Python スクリプト (main.py) を起動してください。

3. CloudFront の URL を開いたら、以下のような画面が表示されます。「Create Account」をクリックし、必要なデータを入力し、「CREATE ACCOUNT」をクリックします。

クリックすると拡大します

4. アカウント登録で指定した Username を入力します。Confirmation Code はアカウント登録後指定されたメールアドレスに届くはずなので、届いたものをコピペし、「CONFIRM」をクリックします。

5. ログインページで UsernamePassword を入力し、「SIGN IN」をクリックします。

6. 「Open Video」ボタンをクリックすると、ロボットが取得している映像がリアルタイムに見れます。その映像を見ながら、ロボットがぶつからずに、矢印とストップボタンで上手く操作してみましょう!


3. まとめ

お疲れ様でした。ロボットをうまく操作できましたでしょうか ? このように、クラウドの力で、リアルタイム映像を見ながら、遠隔からロボットを操作するためのダッシュボードがサーバーレスで構築できました。実機でもやり方はほぼ同じなので、是非実機の Turtlebot 3 や他の ROS のロボットでもチャレンジしてみてください。


builders.flash メールメンバーへ登録することで
AWS のベストプラクティスを毎月無料でお試しいただけます

筆者プロフィール

Muhammad Fikko Fadjrimiratno(ふぃっこ)
アマゾン ウェブ サービス ジャパン合同会社
ソリューションアーキテクト

不動産・建設業界のお客様を中心に、AWS 利用をご支援しているソリューションアーキテクトです。ロボット、IoT、機械学習を得意領域としており、AWS に入社する前にも大学院の研究室や様々な業界でロボットと機械学習の研究開発に携わっていました。

AWS を無料でお試しいただけます

AWS 無料利用枠の詳細はこちら ≫
5 ステップでアカウント作成できます
無料サインアップ ≫
ご不明な点がおありですか?
日本担当チームへ相談する