🐛

TerraformとAWS IoT CoreとGoでスマホから自宅のデバイスに通信するシステムを構築してみる

に公開

はじめに

前回の記事では、『自宅の外から自宅にあるIoTデバイスに対してデータを送るのってそもそもどうやるんだ?』という素朴で初歩的な疑問から、それを実現するための仕組みについて調査をしなんとなく理解が出来ました。

今回は前回学んだ内容をベースに、実際に自宅のLAN外から自宅LAN内のデバイスに対してデータを送る、という流れまでを実践してみたいと思います。

目指すゴール

スマホから自宅LAN内のデバイスに向けてデータを送信する仕組みを構築する。

具体的に何をやるか

必要な部品は以下の通りなので、これらを順に実装していきたいと思います。

①MQTTのBroker

これはIoTCoreに担当してもらいます。

②Brokerからメッセージを受け取るためのクライアントアプリ(Subscriber)

自宅LAN内のデバイスに搭載する用のアプリをGoで実装します。
いったんはBrokerから送られてきたメッセージをそのままコンソールに表示するだけのごく単純なものとします。
なお、今回はまだラズパイなどのデバイスが手元にないため、開発用のノートPCをIoTデバイスの代わりとします。

③Brokerにメッセージを送るためのAPI(Publisher)

これはGoで実装したうえでAPI Gateway+Lambdaでデプロイしようと思います。
スマホ側で送信用のクライアントアプリを実装する手間を省くため、URLパラメータとしてデバイス側に送信したい文字列を送れるような形のAPIとし、スマホのブラウザからAPIを叩く形としてみます。

手順①:IoTCore用のTerraformコードを実装する

1.1:証明書類の準備

IotCoreでは相互TLSを強制するので、AWS外のローカルデバイスが接続するためには

  • AWS側の証明書をクライアント側にデバイス側でもっておくこと
  • デバイス側の証明書をAWS側に持たせておくこと

の両方が必要です。
前者はAWSCLIを使ってダウンロードしてデバイス側に配置(手順②参照)し、後者はデバイス側で秘密鍵を作ったうえでCSR(証明書署名要求)をTerraformコードに盛り込んで実現します。

まずはプロジェクトのディレクトリにinfraディレクトリを切って移動し、以下のコマンドでデバイス側の秘密鍵を用意します。

openssl genrsa -out private.key 2048

infraディレクトリ直下にprivate.keyというファイルが生成されます。

次に、上記の秘密鍵を利用してcsrを作成します。

# -subj以下は、このオプションなしでコマンドをたたいた際にされるいくつかの質問をスキップし、証明書の判別のために入力しておくべきCNだけを設定した形のコマンドです
openssl req -new -key private.key -out device.csr -subj "/CN=my-iot-device"

infraディレクトリ直下にdevice.csrというファイルが生成されます。

次に、AWS側の証明書を以下のコマンドで取得しておきます。

curl -o root-CA.crt https://www.amazontrust.com/repository/AmazonRootCA1.pem

infraディレクトリ直下にroot-CA.crtというファイルがダウンロードされます。

1.2:Terreformコードの実装

iot_core.tfファイルを以下のように作成します。

infra/iot_core.tf
provider "aws" {
  region = "ap-northeast-1"
}

# 現在実行しているAWSアカウントの情報を取得
data "aws_caller_identity" "current" {}

# リージョン情報を取得
data "aws_region" "current" {}

# 1. IoT Thing (デバイス) の作成
resource "aws_iot_thing" "my_device" {
  name = "my-device-subscriber"
}

# 2. IoT Policy の作成
resource "aws_iot_policy" "device_policy" {
  name = "MyDevicePolicy"

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action   = ["iot:Connect"]
        Effect   = "Allow"
        Resource = ["arn:aws:iot:${data.aws_region.current.id}:${data.aws_caller_identity.current.account_id}:client/$${iot:Connection.Thing.ThingName}"]
      },
      {
        Action   = ["iot:Subscribe"]
        Effect   = "Allow"
        Resource = ["arn:aws:iot:${data.aws_region.current.id}:${data.aws_caller_identity.current.account_id}:topicfilter/my/iot/topic"]
      },
      {
        Action   = ["iot:Receive"]
        Effect   = "Allow"
        Resource = ["arn:aws:iot:${data.aws_region.current.id}:${data.aws_caller_identity.current.account_id}:topic/my/iot/topic"]
      }
    ]
  })
}

# 3. CSRを使用して証明書を発行
# opensslコマンドで事前に生成しておいたCSRファイルを使用
resource "aws_iot_certificate" "cert" {
  csr    = file("${path.module}/device.csr")
  active = true
}

# 4. ポリシーを証明書に紐付ける
resource "aws_iot_policy_attachment" "policy_attach" {
  policy = aws_iot_policy.device_policy.id
  target = aws_iot_certificate.cert.arn
}

# 5. 証明書をThingに紐付ける
resource "aws_iot_thing_principal_attachment" "thing_attach" {
  principal = aws_iot_certificate.cert.arn
  thing     = aws_iot_thing.my_device.id
}

# エンドポイント取得
data "aws_iot_endpoint" "endpoint" {
  endpoint_type = "iot:Data-ATS"
}

# --- 出力設定 ---
output "iot_endpoint" {
  value = data.aws_iot_endpoint.endpoint.endpoint_address
}

# 発行された証明書(PEM)のみ出力
output "certificate_pem" {
  value = aws_iot_certificate.cert.certificate_pem
  sensitive = true
}

# 発行された証明書をローカルファイルとして保存
resource "local_file" "cert_pem" {
  content  = aws_iot_certificate.cert.certificate_pem
  filename = "${path.module}/certs/cert.pem"
}


terraform init,terraform applyを順に実行し、リソースを作成します。
以下のような表示がされれば作成成功です。

実行後、infraディレクトリ直下にcert.pem(デバイス側の証明書)がダウンロードされています。

手順②:Subscriber側の実装

次に、デバイスで動かすためのSubscriber側のGoコードを実装します。
プロジェクト直下にsubscriberというディレクトリを切って移動し、以下のファイルを作成します。

subscriber/main.go
package main

import (
	"crypto/tls"
	"crypto/x509"
	"fmt"
	"os"
	mqtt "github.com/eclipse/paho.mqtt.golang"
)

func main() {
	// 接続設定(エンドポイントは環境変数で渡しこむ形に)
	endpoint := os.Getenv("IOT_ENDPOINT")
	topic := "my/iot/topic"

	// TLS証明書の読み込み(エラー処理をインライン化して短縮)
	cert, err := tls.LoadX509KeyPair("../infra/cert.pem", "../infra/private.key")
	if err != nil {
		fmt.Println("証明書の読み込みに失敗:", err)
		return
	}
	// CA証明書の読み込み
	ca, err := os.ReadFile("../infra/root-CA.crt")
	if err != nil {
		fmt.Println("CA証明書の読み込みに失敗:", err)
		return
	}
	pool := x509.NewCertPool()
	pool.AppendCertsFromPEM(ca)

	// AWS側証明書、デバイス側の秘密鍵と証明書をセットしてTLS設定を作成
	tlsConf := &tls.Config{
		RootCAs: pool, 
		Certificates: []tls.Certificate{cert},
		ServerName:   endpoint,
	}

	// クライアント作成用のオプション生成
	opts := mqtt.NewClientOptions().
		AddBroker("tls://" + endpoint + ":8883").
		SetTLSConfig(tlsConf).
		SetClientID("my-device-subscriber") // Terraformで作成したThing名と一致させる

	// クライアント作成
	client := mqtt.NewClient(opts)
	if token := client.Connect(); token.Wait() && token.Error() != nil {
		fmt.Println("接続失敗:", token.Error())
		return
	}

	// SubScribe開始(受信したら標準出力に表示する関数をセット)
	client.Subscribe(topic, 1, func(c mqtt.Client, m mqtt.Message) {
		fmt.Printf("Received: %s\n", m.Payload())
	})

	fmt.Println("待機中... (Ctrl+C で終了)")
	select {} // プログラムを終了させないための永久待ち
}

以下のコマンドでプロジェクトを初期化し、依存モジュールを取得します。

go mod init subscriber
go mod tidy

以下のコマンドで先ほどTerraformで設定したIOT_ENDPOINTを環境変数で設定しつつgoを実行します。

IOT_ENDPOINT="Terraformで指定したendpointの文字列" go run main.go

AWSCLIのMQTTテストクライアント機能を使って、SubscriberがきちんとBrokerからのデータを受信できる状態か確認しておきます。別のターミナルを立ち上げて以下コマンドを実行します。

aws iot-data publish \
    --topic "my/iot/topic" \
    --payload "Activate device" \
    --cli-binary-format raw-in-base64-out

以下のように表示され、無事Subscriberとして機能していることが確認できます。

手順③:Publisher側の実装

3.1:Goコードの実装

プロジェクトディレクトリ直下にpublisherディレクトリを切って移動し、以下のmain.goを作成します。

publisher/main.go
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"

	// AWS SDK v2
	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/config"
	"github.com/aws/aws-sdk-go-v2/service/iotdataplane"
)

func main() {
	// 1. AWS SDKの初期化 (環境変数やIAMロールから自動で認証情報を読み込む)
	ctx := context.Background()
	cfg, err := config.LoadDefaultConfig(ctx)
	if err != nil {
		log.Fatalf("SDK初期化失敗: %v", err)
	}

	// 2. IoT Data Plane クライアントの作成
	// エンドポイントは環境変数から受け取るようにするとポータビリティが上がります
	endpoint := os.Getenv("IOT_ENDPOINT")
	client := iotdataplane.NewFromConfig(cfg, func(o *iotdataplane.Options) {
		o.EndpointResolver = iotdataplane.EndpointResolverFromURL("https://" + endpoint)
	})

	// 3. HTTPハンドラーの定義
	http.HandleFunc("/publish", func(w http.ResponseWriter, r *http.Request) {
		// URLパラメータ ?content=hello を取得
		content := r.URL.Query().Get("content")
		if content == "" {
			content = "No content"
		}

		topic := "my/iot/topic"

		// AWS IoT Coreへ送信
		_, err := client.Publish(r.Context(), &iotdataplane.PublishInput{
			Topic:   aws.String(topic),
			Payload: []byte(fmt.Sprintf(`{"message": "%s"}`, content)),
			Qos:     1,
		})

		if err != nil {
			log.Printf("Publish失敗: %v", err)
			http.Error(w, "Failed to publish", http.StatusInternalServerError)
			return
		}

		fmt.Fprintf(w, "Message sent: %s", content)
	})

	// 4. サーバー起動
	// Lambda Web Adapterはデフォルトで 8080 ポートを期待します
	port := "8080"
	fmt.Printf("Server starting on port %s...\n", port)
	log.Fatal(http.ListenAndServe(":"+port, nil))
}

以下のコマンドを順に実行し、コードをビルドしたうえでzip化しておきます。

# プロジェクトの初期化
go mod init publisher
# 依存モジュールを取得
go mod tidy 
# 環境変数をセットしてビルド
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 IOT_ENDPOINT=Terraformで指定したendpointの文字列 go build -o bootstrap main.go
#zip化
zip function.zip bootstrap 

publisherディレクトリ直下にfunction.zipという名前でビルド結果をzip化したものが出力されます。

3.2:Lambda定義用のTerraformコードを実装

前の記事と同様の流れでAPI GatewayとLambdaを使って先ほどのgoコードをAPI化するためのコードを実装します。

infra/lambda.tf

resource "aws_iam_role" "lambda_role" {
  name = "iot_publisher_lambda_role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRole"
      Effect = "Allow"
      Principal = { Service = "lambda.amazonaws.com" }
    }]
  })
}

resource "aws_iam_role_policy" "lambda_policy" {
  name = "iot_publisher_policy"
  role = aws_iam_role.lambda_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        # IoT CoreへのPublish許可
        Action   = "iot:Publish"
        Effect   = "Allow"
        Resource = "arn:aws:iot:${data.aws_region.current.id}:${data.aws_caller_identity.current.account_id}:topic/my/iot/topic"
      },
      {
        # CloudWatch Logsへのログ出力許可
        Action   = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents"]
        Effect   = "Allow"
        Resource = "arn:aws:logs:*:*:*"
      }
    ]
  })
}

resource "aws_lambda_function" "publisher" {
  function_name    = "iot-publisher-api"
  role             = aws_iam_role.lambda_role.arn
  
  filename         = "../publisher/function.zip"
  source_code_hash = filebase64sha256("../publisher/function.zip")

  runtime          = "provided.al2023"
  handler          = "bootstrap" # Goバイナリの名前をbootstrapにする

  # アーキテクチャをx86_64に設定
  architectures = ["x86_64"]

  # レイヤーをX86用のものに設定
  layers = ["arn:aws:lambda:${data.aws_region.current.id}:753240598075:layer:LambdaAdapterLayerX86:23"]

  environment {
    variables = {
      # Web Adapter用の環境変数設定
      AWS_LAMBDA_EXEC_WRAPPER = "/opt/bootstrap"
      # Lambda関数内で使用するIoTエンドポイント情報を設定
      IOT_ENDPOINT = data.aws_iot_endpoint.endpoint.endpoint_address
      PORT="8080"
    }
  }
}

resource "aws_apigatewayv2_api" "api" {
  name          = "iot-publisher-gateway"
  protocol_type = "HTTP"
}

resource "aws_apigatewayv2_stage" "default" {
  api_id      = aws_apigatewayv2_api.api.id
  name        = "$default"
  auto_deploy = true
}

resource "aws_apigatewayv2_integration" "lambda_int" {
  api_id           = aws_apigatewayv2_api.api.id
  integration_type = "AWS_PROXY"
  integration_uri  = aws_lambda_function.publisher.invoke_arn
}

resource "aws_apigatewayv2_route" "publish_route" {
  api_id    = aws_apigatewayv2_api.api.id
  route_key = "GET /publish"
  target    = "integrations/${aws_apigatewayv2_integration.lambda_int.id}"
}

# API Gateway から Lambda を呼び出す権限
resource "aws_lambda_permission" "api_gw" {
  statement_id  = "AllowExecutionFromAPIGateway"
  action        = "lambda:InvokeFunction"
  function_name = aws_lambda_function.publisher.function_name
  principal     = "apigateway.amazonaws.com"
  source_arn    = "${aws_apigatewayv2_api.api.execution_arn}/*/*"
}

# apply完了後、APIのURLが分かりやすく表示されるように指定
output "api_url" {
  description = "このURLに ?content=hello をつけて叩く"
  value       = "${aws_apigatewayv2_api.api.api_endpoint}/publish"
}

上記を作成した後、applyを実行します。
以下のように表示されればpublisher側もデプロイ成功です。

確認用に以下のコマンドをたたいてみます。

curl "https://j2akfzxakc.execute-api.ap-northeast-1.a
mazonaws.com/publish?content=hogehogehogehoge"

publisher側のターミナルで以下のように表示され、無事送信されていそうです。

そのままsubscriber側のターミナルを確認してみると、以下のように表示され、受信が出来ていることが確認できました。

そのまま、同じAPIをスマホ(wifiを切ってモバイル回線につないだ状態)からブラウザのURL欄で叩いてみても、URLのパラメータに指定した文字列がデバイス側(自宅Wifiにつないだ開発PC)で受信できていることが確認できました!

まとめ

手順は少し多かったですが、無事なんとか外部インターネットから自宅LAN内に向けて、自宅PCやWifiルーターの設定を一切変えることなく通信する仕組みを作ることが出来ました。

まだ現状は開発PCに向けてテキストを送るというごく単純なものですが、こうして大枠の流れされつくれてしまえば、そこから送信する内容を複雑化させたり、Subscriberを実際のラズパイなどのデバイスに移行させたりするのは容易なように感じています。

次にやりたいこと

ラズパイzero2Wとそれにつなげる用のスピーカーなどを買ってみたので、今回つくった仕組み拡張して、subscriberを実際にデバイスに変え、またデバイス側で受信したテキストを自動音声で読み上げるような仕組みを作ってみたいと考えています。

また、あわよくばデバイス側からも何かのデータ(センサーデータなど?)をBrokerに送信してスマホ側で閲覧するようなこともやってみたいです。

Discussion