SORACOM Funkで自回線の通信量を取得する

# はじめに

SORACOM Funkが2019年7月にリリースされてから、ちゃんと試せていなかったので試してみて記事を書きました。色々な使い方が考えられますが、今回はよくあるユースケースの割に意外と大変な「自回線の通信量を取得する」ことを目標にします。

# SORACOM Funkを使う構成のメリット

SORACOMでは各回線の通信量がコンソール上から簡単に見え、さらにAPIでより詳細に取得できるのですが、デバイスから自回線の通信量を取得しようとすると結構大変です。

SORACOMにはSORACOM Air メタデータサービスという便利な機能があり、SIMにつけた名前やタグなど自回線に関わる情報は、
http://metadata.soracom.io/v1/subscriber
というURLにアクセスすると、認証情報なしで取得できます。そのため、デバイスに認証情報を持たせる必要がありません。このURLは
https://api.soracom.io/v1/subscribers/{IMSI}
というWebAPIのURLにマッピングされており、このURL以下のAPIであれば全て使用することができます。

ところが通信量は[通信量取得API](https://dev.soracom.io/jp/docs/api/#!/Stats/getAirStats)にて取得するのですが、このURLは
https://api.soracom.io/v1/stats/air/subscribers/{IMSI}
であり、subscribers系のAPIでは無く、2019/12/29現在ではSORACOM Airメタデータサービスにはマッピングされていません。
従って、SORACOMの認証情報を使って認証して通常のWebAPIを使う必要があります。

普通に実装するとデバイス側にSAM(SORACOM Access Management)ユーザーの認証情報を保存してそれを使うところなのですが、共通の認証情報だと１つのデバイスで認証情報が漏れると全てのデバイスに影響してしまいますし、デバイスごとに認証情報を作成するとなると、[アカウントあたりのユーザー上限数:50](https://soracom.zendesk.com/hc/ja/articles/227349748-SAM-%E3%83%A6%E3%83%BC%E3%82%B6%E3%83%BC%E3%81%AB%E9%96%A2%E3%81%99%E3%82%8B%E4%B8%8A%E9%99%90%E6%95%B0%E3%81%AF%E3%81%82%E3%82%8A%E3%81%BE%E3%81%99%E3%81%8B-)(2019/12/29現在)にすぐ引っかかってしまいます。あと単純にユーザーの作成とデバイスへの書き込みが大変です。

以前まではこのような場合はSORACOM Beamの署名ヘッダ機能を使ってどこかのHTTPSエンドポイントに対してアクセスし、エンドポイントは署名ヘッダを用いてデバイスを認証し、認証できたらそのエンドポイントが持つSAMユーザー情報を使って通信量を取得してデバイスに返す、という形を取っていたかと思います。

![beam-get-traffic.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/52c0c0b0-84c4-fb15-d137-476c99a4a3b9.png)

このようにすると、署名ヘッダにはIMSI(SIMを識別するためのID)が入っており、事前共有鍵を用いた署名ヘッダによって正当なデバイスからのアクセスであることは検証できるため、デバイスに認証情報を持たせること無くHTTPSエンドポイントはデバイスを認証(正当なデバイスであることが検証でき、かつどのデバイスからのアクセスかを識別)することができます。素晴らしい。

ただこの構成にも大きく分けて２つの厄介なところがあります。

1. HTTPSエンドポイントを用意し、運用しなければならない
2. SORACOM Beamからのアクセスを受け付けるため、HTTPSエンドポイントをインターネットに晒す必要がある

1に関しては、Amazon API Gateway と AWS Lambdaの組み合わせのサーバレス構成を取ることにより、運用負担はかなり軽減できます。ですが、構成要素が増えると構成管理や構築、運用の手間がかかることには変わりません。

2に関してはインターネットからアクセスされてしまうことは許容して、Beamの署名機能にてセキュリティを確保する、ということになるでしょう。

そこで2019/7のSORACOM Discoveryで発表されたSORACOM Funkならどうでしょう。Funkを使うと以下のようになります。

![funk-get-traffic.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/c3d9a1db-3ce8-2d47-a009-c731fa10034b.png)

まず、デバイス側に認証情報を持たせなくてもよい、という点では先ほどと同じです。

先ほどの1に関しては、デバイス以外はSORACOMやAWSのサービスであり、自身でサーバーなどを運用する必要はなく構築、運用の手間は小さくなります。また、Amazon API Gatewayを使う場合と比較すると、Amazon API Gatewayを通さない分コストを抑えることができます。(Lambdaの起動に比べるとAPI Gatewayはそこそこコストがかかります。あとAPI Gatewayは設定の仕方が独特で、初見だと結構設定が大変です)

2.に関しては、AWS IAMやAWS Lambdaはインターネットからアクセス可能なサービスですが、[AWSの強固な認証方法](https://docs.aws.amazon.com/ja_jp/general/latest/gr/signature-version-4.html)を用いているため、より安全に通信されます。（ここはSORACOM - AWS間の問題なので、こちら側で関知しなくともよいですし）

このように、AWS Lambdaを利用するユースケースでは、SORACOM Funkを利用すると運用コストを抑え、かつセキュリティを高めることが出来そうですね。

それでは実際やってみましょう。

# 構築

用意するものは以下の６つです。

1. SORACOM APIを呼び出すためのSAMユーザー
2. Lambdaの関数設定
3. Lambdaを呼び出すためのIAMユーザー
4. SORACOMの認証情報
5. SORACOM Funkの設定
6. Lambdaの関数のコード

まず設定系の1-5を準備します。

1. 以下の手順でSAMユーザーを作成します。

<img width="1262" alt="スクリーンショット 2019-12-29 20.25.15.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/3f45cd70-89ea-2522-3b2a-f0c051cde226.png">
<img width="1263" alt="スクリーンショット 2019-12-29 20.27.57.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/8f8890c2-652f-c6a6-3a57-7f22445ef92a.png">
<img width="1259" alt="スクリーンショット 2019-12-29 20.30.40.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/0fd29086-2d32-1ac8-de81-96422fea6f58.png">
<img width="1214" alt="スクリーンショット 2019-12-29 20.33.00.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/90caddea-8a43-1fc8-b1b8-a3a647cf6d59.png">

できるだけ権限は狭くしておいた方がよいでしょう。最低限必要なAPI設定は以下になります。

```json
{
  "statements": [
    {
      "api": [
        "Stats:getAirStats"
      ],
      "effect": "allow"
    }
  ]
}
```

<img width="1238" alt="スクリーンショット 2019-12-29 20.35.11.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/2db10adb-cfc8-1558-40ea-9d40faac54ac.png">

SAMユーザーの認証キーを作成したら、2.でLambda関数の環境変数に入力するため、記録しておきます。

2.Lambdaの関数設定

Lambdaの関数を作成します。
<img width="1250" alt="スクリーンショット 2019-12-29 20.55.39.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/22aa5d3b-09d4-30cc-f147-81c9cd666b44.png">

Lambdaは「サーバーのことを考えずにコードを実行する」か。。AWSもだいぶカジュアルな説明しますね。

<img width="1267" alt="スクリーンショット 2019-12-29 21.00.33.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/c0f05e9e-c719-7c97-dca8-82f61a29665a.png">
<img width="1196" alt="スクリーンショット 2019-12-29 21.03.40.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/88647264-2a6f-fe5a-d425-f1a2dc10d2b2.png">

作成したLambda関数のARNは3.と5.で使用するため、記録しておきます。
<img width="1274" alt="スクリーンショット 2019-12-29 21.52.59.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/e15553a9-9250-edd3-6ffb-759d4e30088e.png">

Lambda関数作成後、先ほど作成したSAMユーザーの認証情報を環境変数として設定します。

<img width="1141" alt="スクリーンショット 2019-12-29 21.07.55.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/a0217124-1d73-df73-f7b2-8921c3dc3e63.png">

これでコード以外のLambdaの設定は終了です。ランタイムは好きなものを選びます。余談ですが僕は今のところ以下のような感じでプログラムを使い分けるので、LambdaはRubyになることが多いです。

- 単一の処理を実行するサーバーサイドのプログラム：Ruby
- 複数の処理を実行するサーバーサイドのプログラム：C#
- クライアントサイド(デバイスもPCも)のプログラム：Go

サーバーサイドが分かれるのは、Rubyの方がパッと書きやすいけど、ある程度規模が大きくなってくると静的型付き言語じゃないとコーディングや修正が大変になってくるためです。

クライアントサイドがGoなのは、様々な環境で動作するプログラムをユーザーに実行させるのに、実行環境を用意させるのが一番大変なのですが、Goであれば各環境でのシングルバイナリで配布できるためです。

サーバーサイドはこちらの都合で環境が作れるため開発の都合だけで決められますが、クライアントサイドは環境構築はユーザー任せになってそこが一番の問題になるので、一番問題が小さい方法を取ることになるんですね。どのOSでも最初からDockerが動いてくれていれば良いのに。。

3.Lambdaを呼び出すためのIAMユーザー

まずIAMポリシーを作成します。
<img width="1256" alt="スクリーンショット 2019-12-29 21.24.28.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/e57710ae-1b17-bc59-dae0-9dcc0a4e4a2b.png">
<img width="1273" alt="スクリーンショット 2019-12-29 21.27.24.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/8ba83fd1-47e8-179a-984b-239982c0bad2.png">
<img width="1181" alt="スクリーンショット 2019-12-29 21.29.57.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/33a8c386-c550-c8d6-0c43-0a0f6a6970a2.png">
<img width="1213" alt="スクリーンショット 2019-12-29 21.33.24.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/7580282c-efdb-ad0d-6714-9f6e423afa3f.png">

アタッチするポリシーは以下のようになります。

```json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "GetSoracomTrafficPolicy",
            "Effect": "Allow",
            "Action": "lambda:InvokeFunction",
            "Resource": "2.で作成したLambda関数のARN"
        }
    ]
}
```

続いてIAMユーザーを作成します。

<img width="1266" alt="スクリーンショット 2019-12-29 21.37.02.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/f57eba86-38e4-e0ce-19d0-7ecd06c7bf81.png">
<img width="1205" alt="スクリーンショット 2019-12-29 21.41.39.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/ee450589-5bb9-727e-b581-4ff106870e62.png">
<img width="1197" alt="スクリーンショット 2019-12-29 21.44.46.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/96bdc398-37c0-0044-d333-2462f5c4cafc.png">
<img width="1235" alt="スクリーンショット 2019-12-29 21.46.21.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/eb596772-cb11-ee90-204f-1ff44f75954a.png">
<img width="1214" alt="スクリーンショット 2019-12-29 21.47.39.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/c0487b74-a0fe-bbf8-1f18-1a342d550fdc.png">

作成したIAMユーザーのアクセスIDとアクセスキーは4. で使用します。

4.SORACOMの認証情報

以下の画面で認証情報を作成します

<img width="1263" alt="スクリーンショット 2019-12-29 20.10.18.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/3e16aca4-d69e-21a9-9622-3f8dc4169333.png">

<img width="1270" alt="スクリーンショット 2019-12-29 20.15.22.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/4acae352-f075-4525-d9d2-ef01bcd383f4.png">

5.SORACOM Funkの設定

SIMグループから対象SIMのグループを選択し、以下のようにFunkの設定をします。

<img width="952" alt="スクリーンショット 2019-12-29 20.19.54.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/17eb6735-2a09-f643-f683-bc39cf94a1b7.png">

以上で設定は終わります。

# Lambda関数のコード

Lambda関数がやることは、

- SORACOM Funkからの入力の受け取り
- SORACOM APIからの通信量の取得
- SORACOM Funkへの応答

の３つになります。

入力の受け取りは、[Funkのドキュメント](https://dev.soracom.io/jp/funk/how-it-works/)によると以下の形式で送られます。

> AWS Lambda に渡されるデータ
event ： ユーザーが指定したデータ形式でリクエストBodyの値が設定されます。
context ： 以下のデータフォーマットが設定されます。（以下はSORACOM LTE-M Button for Enterprise からのリクエスト例となります）

```json
{
  "custom": {
    "srn": "srn:soracom:OP00XXXXXXXX:jp:Subscriber:44052XXXXXXXXXXX",
    "operatorId": "OP00XXXXXXXX",
    "sourceProtocol": "udp",
    "resourceId": "44052XXXXXXXXXXX",
    "resourceType": "Subscriber",
    "imsi": "44052XXXXXXXXXXX",
    "imei": "35956XXXXXXXXXX"
  }
}
```

Funkの送信データ形式を「JSON」としたので、eventにはJＳＯＮで渡されたパラメータがHashクラスとして渡されます。具体的には、通信量のperiod(dayもしくはmonth)を

```json
{"period":"month"}
```

として送信すると、lambda上のRubyのコードでは、

```ruby
event['period']
```

として参照できます。また、SIMを識別する情報であるIMSIはcontextの中に入っており、

```ruby
context.client_context['imsi']
```

として参照できます。

SORACOM Funkへの応答は、ハンドラー(lambda_handler)の返り値がそのままFunkへの応答になります。

SORACOM APIに対して認証し、通信量を取得する部分は普通の方法であれば[APIリファレンス](https://dev.soracom.io/jp/docs/api/#/)を見ながら実装することになるのですが、SORACOMでは全てのAPIを網羅している[SORACOM CLI](https://github.com/soracom/soracom-cli)が提供されています。認証やアクセスの面倒な部分はこのCLIがやってくれているので、できるだけ利用したいものです。

Lambdaでそんなことできるの？という疑問はありますが、できます。Lambdaはデプロイパッケージをカレントディレクトリとして実行されるため、その中にSORACOM CLIのバイナリを入れておけば、パス指定で実行できるのです。（SORACOM CLIがシングルバイナリで提供されており、依存する実行環境やライブラリなどがほぼ無いためにこのような対応がしやすいです。そういう意味でもGo言語は使いやすいですね。ただし、Lambdaの実行環境は64bit Linux OSである、という前提で動作しているので、その部分が変更されると環境に合わせたバイナリに変更する必要はあります）

[SORACOM CLIのリリースページ](https://github.com/soracom/soracom-cli/releases)から、最新の64bit Linux OS対応のバイナリ(soracom_X.Y.Z_linux_amd64.tar.gzという名前のファイル
)をダウンロードしておきましょう。

そして以下のようなディレクトリ構成を用意します。「soracom」ファイルはSORACOM CLIのバイナリファイルです。

```bash
user$ ls -l
total 27176
-rw-r--r--  1 user  user      2352 12 29 14:54 lambda_function.rb
-rwxr-xr-x@ 1 user  user  13909290 12 23 15:22 soracom
```

```ruby
require 'fileutils'
require 'json'
require 'date'
require 'securerandom'

def extract_imsi(context)
  return nil if context.client_context.class != Hash
  return nil if !context.client_context.key?('imsi')
  context.client_context['imsi']
end

def create_config_file
  soracom_config = { sandbox: false, coverageType: 'jp', authKeyId: ENV['SORACOM_AUTH_KEY_ID'], authKey: ENV['SORACOM_AUTH_KEY'], registerPaymentMethod: false }
  tmp_dir_path = File.join('/tmp', 'get_soracom_traffic', SecureRandom.uuid)
  config_file_path = File.join(tmp_dir_path, 'default.json')
  FileUtils.mkdir_p(tmp_dir_path)
  File.write(config_file_path, JSON.generate(soracom_config))
  File.chmod(0600, config_file_path)
  ENV['SORACOM_PROFILE_DIR'] = tmp_dir_path
  config_file_path
end

def delete_config_file(config_file_path)
  FileUtils.rm_rf(config_file_path)
end

def get_utc_timestamps(period)
  timezone_now = ENV['TZ']
  ENV['TZ'] = 'UTC'
  if period == 'day'
    from = Date.today.to_time.to_i
    to = Date.today.next_day.to_time.to_i
  elsif period == 'month'
    from = (Date.today - Date.today.mday + 1).to_time.to_i
    to = (Date.today.next_month - Date.today.next_month.mday + 1).to_time.to_i
  end
  ENV['TZ'] = timezone_now
  {
    'from' => from,
    'to' => to
  }
end

def summarize(traffic_stats)
  total = 0
  download = 0
  upload = 0
  traffic_stats.each do |stat|
    stat['dataTrafficStatsMap'].each do |key, value|
      total += value['downloadByteSizeTotal']
      total += value['uploadByteSizeTotal']
      download += value['downloadByteSizeTotal']
      upload += value['uploadByteSizeTotal']
    end
  end
  { 'total' => total, 'download' => download, 'upload' => upload }
end

def lambda_handler(event:, context:)
  period = event['period'] || 'month'
  return { 'error' => "period must be 'month' or 'day'"} if !['month', 'day'].include?(period)
  timestamps = get_utc_timestamps(period)
  imsi = extract_imsi(context)
  return { 'error' => 'IMSI must not be empty' } if imsi.nil?

  begin
    config_file = create_config_file
    ret_text = `./soracom stats air get --imsi #{imsi} --period #{period} --from #{timestamps['from']} --to #{timestamps['to']}`
    traffics = JSON.parse(ret_text.strip)
  rescue => e
    p e
    return { 'error' => e.message }
  ensure
    delete_config_file(config_file)
  end

  summarize(traffics)
end
```

SORACOM CLIは環境変数SORACOM_PROFILE_DIRで指定したディレクトリにあるdefault.jsonファイルにある情報をもとにSORACOM APIにアクセスします。従って、環境変数で設定されたSORACOM_AUTH_KEY_IDとSORACOM_AUTH_KEYをもとにLambda実行環境内の/tmpディレクトリ内にランダムなパスのディレクトリを作り、その中にdefault.jsonを都度作成しています。すごいんだかすごくないんだかわからないですね。。当然CLI使用後に情報は削除します。(一時的にでもファイルが出来てしまうのはセキュリティ的にいまいちです。できればSORACOM CLIで環境変数かパラメータでSORACOM_AUTH_KEY_IDとSORACOM_AUTH_KEYを直接指定したいところ)

通信量はJSON配列の文字列で取得されるので、それをJSONでパースし、集計を取ってFunkに返しています。

プログラムができたら、ディレクトリ内の全ファイルをまとめてZIPにします。

```bash
user$ zip get_soracom_traffic.zip *
```

注意点としては、ディレクトリをZIPアーカイブするのではなく、ディレクトリの中でディレクトリ内の全ファイルをZIPアーカイブするということです。ディレクトリをZIPアーカイブするとLambdaの環境で展開した際に、トップがそのディレクトリになってしまい、ハンドラが見つからなくなってしまうためです。

最後に作成したZIPをLamｂdaにアップロードします。

<img width="1197" alt="スクリーンショット 2019-12-29 22.34.00.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/542185/5ca4089a-083c-00ac-0e02-76e640560bbd.png">

これでコードの準備は終了です。実行してみましょう。

# 実行

Raspberry Pi3B+にて実行しました。

period = dayを指定すると、当日の通信量を取得できます。

```bash
pi@raspberrypi:~ $ echo -n '{"period":"day"}' | nc funk.soracom.io 23080
200 {"total":205332,"download":124384,"upload":80948}
```

取得できました。SORACOMでは時間の扱いがUTCなので、1日の区切りがUTCの0時(日本時間の9時)であることには注意しましょう。
period = monthを指定すると、当月の通信量を取得できます。

```bash
pi@raspberrypi:~ $ echo -n '{"period":"month"}' | nc funk.soracom.io 23080
200 {"total":206636581,"download":195877314,"upload":10759267}
```

問題なく動作してそうですね。デバイスに認証情報がなくてもSORACOM APIを利用した処理ができること、回線に紐付いたIMSIが特定できること、入力に対して処理が変えられること、同期的に応答があることが確認できました。

# まとめ

SORACOM Funkを使って簡単・安全にクラウドに処理が委譲できることが確認できました。AzureやGCPとの組み合わせはやっていないので分かりませんが、Lambdaとの組み合わせに関する限りは簡単に連携できましたね。クラウドになにかさせる時にはBeam -> API Gateway -> Lambdaではなく、基本Funkで考えた方が運用、コスト、セキュリティといった面でよさそうです。

クラウドならではの高負荷の処理、ビッグデータが必要になる処理がユースケースとして上げられていますが、今回のようにデバイスには置いておけない認証情報が必要な処理をSIM認証と組み合わせてクラウドに任せるユースケースもありかなと思いました。

気になったというか、現状の厳しい点はFunkの設定が１つしかできないことですね。便利なので色々使いたいけど１個しかないので、現状ではパラメータで分けられるようにしておいて、１つめのLambdaは処理の切り替え用と割り切って、そこから別のLambdaを呼び出すというような構成になりそうです。

以上です。