amazon-ssm-agent のハードウェアフィンガープリントの仕組み

この記事は Calendar for Akerun | Advent Calendar 2021 - Qiita の 22日目の記事です。

どうもおなじみ daikw - Qiita です。

Amazon Systems Manager の動作を調べているとき、「ハードウェアフィンガープリント」という言葉で厨二病を悪化させてしまいました。

つい時間をかけて調べてしまったため、記事にまとめて懺悔します。

契機

弊チームでは、raspiマシンの複製や、バックアップからの復元をすることがよくあります。

Hybrid Activation 済のマシンの復元をしようとしている最中に、ふと 「Systems Manager は混乱しないのかな ...?」と疑問に思ったことがきっかけで調べ始めました。

調査

概ね 抽象 -> 具体 の流れで書いてあります。あたかもこの通り綺麗に調べたように見えますが、実際にはあっちに行ったりこっちに行ったりと迷いながらやっています。探索的な調査は割と好きな daikw です。

ドキュメント

まずは最も抽象的なドキュメントから当たって行きましょう。テクニカルリファレンスにそれっぽい記述がありました!

SSM Agent technical reference - AWS Systems Manager#fingerprint-validation より、

When running on-premises servers, edge devices, and virtual machines (VMs) in a hybrid environment, SSM Agent gathers a number of system attributes (referred to as the hardware hash) and uses these attributes to compute a fingerprint. The fingerprint is an opaque string that the agent passes to certain Systems Manager APIs. This unique fingerprint associates the caller with a particular on-premises managed node. The agent stores the fingerprint and hardware hash on the local disk in a location called the Vault.

If the current machine attributes don't match the stored hardware hash, SSM Agent computes a new fingerprint, ~. This causes RequestManagedInstanceRoleToken to fail, and the agent won't be able to obtain a role token to connect to the Systems Manager service. This failure is by design and is used as a verification step to prevent multiple on-premises managed nodes from communicating with the Systems Manager service as the same managed node.

内容をざっくりまとめると、

  • hardware hash なるものを使い、サーバ固有の fingerprint が計算される
  • hardware hashfingerprintVault に保管される
  • fingerprintVault の値と一致していれば、 RequestManagedInstanceRoleToken なるリクエストを成功として捌いてくれる

ということがわかります。ここでさらに次のような疑問が湧きます。

  • hardware hashfingerprint の実体は何か?
  • Vault とはどういうものか?
  • 構成変更によって fingerprint はどの程度変わるのか?(similarityThreshold はどうチューンされるのか)

フィンガープリント

フィンガープリント(fingerprint)とは - IT用語辞典 e-Words より、

フィンガープリントとは、指紋(を採る)、拇印という意味の英単語で、IT分野では人物や端末などの識別や同定、真正性の確認に用いられる短いデータ列などを指すことが多い。

です。

「マシンのフィンガープリントをする」というのは、「マシン同士の識別・同定が可能な情報を採取する」という意味になります。

https://www.saitolab.org/fp_site/img/catchy3.png
Fingerprint解説サイト より

静的解析

実装が公開されていたので、疑問の回答を見つけることができるかもしれません。 GitHub - aws/amazon-ssm-agent を見ると Go で開発されていました。じっくり読んでいきましょう。

まず、 vendorextra は vendoring されたソースコードに見えるため、除外しながら調べます。ソースコードは主に agent / common / core の三つのディレクトリに収められているようです。

雑に fingerprintgrepすると、 InstanceFingerprint という関数が目につきました。 これは内部関数 generateFingerprint にキャッシュとリソースロックの機能をつけるラッパー関数で、他モジュールに対し公開されています。

func InstanceFingerprint(log log.T) (string, error) {
    if isLoaded() {
        return fingerprint, nil
    }

    lock.Lock()
    defer lock.Unlock()

    var err error
    fingerprint, err = generateFingerprint(log)
    if err != nil {
        return "", err
    }

    loaded = true
    return fingerprint, nil
}

また、同じファイルを眺めると、先頭に核心となる構造体 hwInfo があるのが目につきます。この構造体の全てのフィールドが説明できるようになれば、当初の疑問の答えになりそうです。

type hwInfo struct {
    Fingerprint         string            `json:"fingerprint"`
    HardwareHash        map[string]string `json:"hardwareHash"`
    SimilarityThreshold int               `json:"similarityThreshold"`
}

さて、 generateFingerprint を詳しく読む前に、試しに参照元の一覧を追ってみましょう。

┬─[daiki~@photosyth~:~/g/g/a/amazon-ssm-agent]─[18:41:09]─[G:mainline=]
╰─>$ rg -g !extra -g !vendor InstanceFingerprint
agent/managedInstances/registration/instance_info.go
80:     return fingerprint.InstanceFingerprint(log)

agent/managedInstances/fingerprint/fingerprint_test.go
33:func ExampleInstanceFingerprint() {
58:     val, _ := InstanceFingerprint()

agent/managedInstances/fingerprint/fingerprint.go
44:     vaultKey            = "InstanceFingerprint"
54:func InstanceFingerprint(log log.T) (string, error) {
178:            _ = log.Warnf("Could not read InstanceFingerprint file: %v", err)

これと同様にコードジャンプやgrepで頑張って追っていくと、 amazon-ssm-agentmainまで辿っていけます。

-> `fingerprint.InstanceFingerprint` (`agent/managedInstances/fingerprint/fingerprint.go`)
-> `onpremRegistation#Fingerprint` (`agent/managedInstances/registration/instance_info.go`)
-> `onpremCredentialsProvider#Retrieve` (`agent/managedInstances/registration/instance_info.go`) *ここでやや複雑なことをしている
-> `credentialsRefresher#retrieveCredsWithRetry` (`core/app/credentialrefresher/credentialrefresher.go`)
-> `credentialsRefresher#credentialRefresherRoutine` (`core/app/credentialrefresher/credentialrefresher.go`)
-> `credentialsRefresher#Start` (`core/app/credentialrefresher/credentialrefresher.go`)
-> `SSMCoreAgent#Start` (`core/app/agent.go`)
-> `start` (`core/agent.go`)
-> `run` (`core/agent.go`)
-> `main` (`core/agent_unix.go`)

agent/managedInstances/fingerprint/fingerprint.go に戻りましょう。generateFingerprint の骨子を抜き出して抜粋すると以下のようになります。

func generateFingerprint(log log.T) (string, error) {
    var hardwareHash map[string]string
    var savedHwInfo hwInfo
    var err error
    var hwHashErr error

    for attempt := 1; attempt <= 3; attempt++ {
        hardwareHash, hwHashErr = currentHwHash()
        savedHwInfo, err = fetch(log)
        <omit>
    }

    <omit>

    uuid.SwitchFormat(uuid.CleanHyphen)
    new_fingerprint := ""

    if !hasFingerprint(savedHwInfo) {
        new_fingerprint = uuid.NewV4().String()
    } else if !isSimilarHardwareHash(log, savedHwInfo.HardwareHash, hardwareHash, savedHwInfo.SimilarityThreshold) {
        new_fingerprint = uuid.NewV4().String()
    } else {
        return savedHwInfo.Fingerprint, nil
    }

    updatedHwInfo := hwInfo{
        Fingerprint:         new_fingerprint,
        HardwareHash:        hardwareHash,
        SimilarityThreshold: savedHwInfo.SimilarityThreshold,
    }

    // save content in vault
    if err = save(updatedHwInfo); err != nil {
        log.Errorf("Error while saving fingerprint data from vault: %s", err)
    }
    return new_fingerprint, err
}

ここで fingerprint の正体がわかりました。ただの uuid です。 hardwareHashfingerprint は生成タイミングが同じであること以外に共有する情報はありません。

確かに考えてみると、fingerprintを再計算して、一致するかを調べる必要は特になく(hardwareHashの比較で十分である)、これらの生成タイミングが同じであるのを保証できれば良いはずです。わかりやすい。

さらに掘っていきます。 hwInfo のうち、HardwareHash が何者なのかわかっていません。

agent/managedInstances/fingerprint/hardwareInfo_unix.go に実装があります。

const (
    systemDMachineIDPath = "/etc/machine-id"
    upstartMachineIDPath = "/var/lib/dbus/machine-id"
    dmidecodeCommand     = "/usr/sbin/dmidecode"
    hardwareID           = "machine-id"
)

var currentHwHash = func() (map[string]string, error) {
    hardwareHash := make(map[string]string)
    hardwareHash[hardwareID], _ = machineID()
    hardwareHash["processor-hash"], _ = processorInfoHash()
    hardwareHash["memory-hash"], _ = memoryInfoHash()
    hardwareHash["bios-hash"], _ = biosInfoHash()
    hardwareHash["system-hash"], _ = systemInfoHash()
    hardwareHash["hostname-info"], _ = hostnameInfo()
    hardwareHash[ipAddressID], _ = primaryIpInfo()
    hardwareHash["macaddr-info"], _ = macAddrInfo()
    hardwareHash["disk-info"], _ = diskInfoHash()

    return hardwareHash, nil
}


func processorInfoHash() (value string, err error) {
    value, _, err = commandOutputHash(dmidecodeCommand, "-t", "processor")
    return
}

<omit>
func commandOutputHash(command string, params ...string) (encodedValue string, value string, err error) {
    var contentBytes []byte
    if contentBytes, err = exec.Command(command, params...).Output(); err == nil {
        value = string(contentBytes) // without encoding
        sum := md5.Sum(contentBytes)
        encodedValue = base64.StdEncoding.EncodeToString(sum[:])
    }
    return
}

実装から、以下のようなことがわかります。

  • 特定のコマンドを実行したり、特定のファイルの値を持ってきた結果の md5.Sumhardware hash の値となる
  • currentHwHashhash は、 ハッシュマップ とも チェックサム とも取れる
  • /usr/sbin/dmidecode を主に使っていて、/etc/machine-id といったファイルも使う(ちなみに hardwareinfo_windows.go の場合は wmic.exe

dmidecode

dmidecodeSMBIOS - Wikipedia を取得するための *nix 系OSに提供されるユーティリティです。

適当な EC2 サーバで叩くとこんな感じの出力がとれます。

[ec2-user@ip-172-31-47-131 ~]$ sudo dmidecode -t
dmidecode: option requires an argument -- 't'
Type number or keyword expected
Valid type keywords are:
  bios
  system
  baseboard
  chassis
  processor
  memory
  cache
  connector
  slot

[ec2-user@ip-172-31-47-131 ~]$ sudo dmidecode -t processor
# dmidecode 3.2
Getting SMBIOS data from sysfs.
SMBIOS 2.7 present.

Handle 0x0401, DMI type 4, 35 bytes
Processor Information
    Socket Designation: CPU 1
    Type: Central Processor
    Family: Other
    Manufacturer: Intel
    ID: F2 06 03 00 FF FB 8B 17
    Version: Not Specified
    Voltage: Unknown
    External Clock: Unknown
    Max Speed: 2394 MHz
    Current Speed: 2394 MHz
    Status: Populated, Enabled
    Upgrade: Other
    L1 Cache Handle: Not Provided
    L2 Cache Handle: Not Provided
    L3 Cache Handle: Not Provided
    Serial Number: Not Specified
    Asset Tag: Not Specified
    Part Number: Not Specified

試しに raspi で叩いてみたら、うまく動作しません。 dmidecode - Raspberry Pi Forums に、 It is not available on non-x86 architectures. との記述がありました。

pi@raspberrypi:~ $ sudo dmidecode -t processor
# dmidecode 3.3
# No SMBIOS nor DMI entry point found, sorry.

似たような情報を得るには、/proc/sys の情報をうまく利用する必要があるようです *1

pi@raspberrypi:~ $ cat /proc/cpuinfo | grep Hardware -A10
Hardware    : BCM2835
Revision    : a020d3
Serial      : 000000009cc7694f
Model       : Raspberry Pi 3 Model B Plus Rev 1.3

複製されたraspiマシン同士の区別

複製されたraspiが区別されることは 以前の調査 でわかっています。

従って、 dmidecode が使えないとなると、raspiの区別をしている部分は別にあるようです。agent/managedInstances/fingerprint/hardwareInfo_unix.go をよく読むと、

func diskInfoHash() (value string, err error) {
    value, _, err = commandOutputHash("ls", "-l", "/dev/disk/by-uuid")
    return
}

がありました。これだ!

pi@raspberrypi:~ $ ls -l /dev/disk/by-uuid
total 0
lrwxrwxrwx 1 root root 15 Dec 20 01:17 568caafd-bab1-46cb-921b-cd257b61f505 -> ../../mmcblk0p2
lrwxrwxrwx 1 root root 15 Dec 20 01:17 C839-E506 -> ../../mmcblk0p1

<omit>

pi@raspberrypi-2:~ $ ls -l /dev/disk/by-uuid
total 0
lrwxrwxrwx 1 root root 15 Dec  8 06:17 7616-4FD8 -> ../../mmcblk0p1
lrwxrwxrwx 1 root root 15 Dec  8 06:17 87b585d1-84c3-486a-8f3d-77cf16f84f30 -> ../../mmcblk0p2

複製を作る場合、ほぼ確実にディスクを変更することになるので、この出力が変わって区別されるのだと考えられます。

Vault

vaultの意味・使い方・読み方 | Weblio英和辞書 より、

アーチ形天井、アーチ形天井のようなおおい、(食料品・酒類などの)地下貯蔵室、(地下)金庫室、(銀行などの)貴重品保管室、(教会・墓所の)地下納骨所

金庫室という意味があるので、暗号化や鍵のかかったイメージを持ってソースコードを読んでみましょう。agent/managedInstances/vault/fsvault/fsvault.go に実装があります。

func Store(key string, data []byte) (err error) {

    lock.Lock()
    defer lock.Unlock()

    if err = ensureInitialized(); err != nil {
        return
    }

    p := filepath.Join(storeFolderPath, key)

    if err = fs.HardenedWriteFile(p, []byte(data)); err != nil {
        return fmt.Errorf("Failed to write data file for %s. %v\n", key, err)
    }

    manifest[key] = p
    if err = saveManifest(); err != nil {
        delete(manifest, key)
        return fmt.Errorf("Failed to save manifest when storing %s. %v\n", key, err)
    }

    return
}

fs.HardenedWriteFile がそれらしい名前ですね。さらに追ってみましょう。 agent/fileutil/harden.go を見ると

const (
    RWPermission = 0600
)

// HardenedWriteFile calls ioutil.WriteFile and guarantees a hardened permission
// control. If the file already exists, it hardens the permissions before
// writing data to it.
func HardenedWriteFile(filename string, data []byte) (err error) {

    if _, err = os.Stat(filename); err != nil {
        if os.IsNotExist(err) {
            f, err := os.Create(filename)
            if err != nil {
                return fmt.Errorf("Failed to create the file, %s", err)
            }
            defer f.Close()
        } else {
            return
        }
    }

    if err = Harden(filename); err != nil {
        return
    }

    if err = ioutil.WriteFile(filename, data, RWPermission); err != nil {
        return
    }

    return
}

これで harden の意味がわかりました。ファイルパーミッションを適切に設定すること(ここでは 600)ですね。

saveManifest の方も追ってみましたが、 json.Marshalシリアライズしているだけでした。

つまり Vault は、暗号化なしの、パーミッション 600 が維持されるただのファイルである、ということがわかりました。

Similarity

最後に、 isSimilarHardwareHash を、ログやコメントを一部抜粋して引用します。

func isSimilarHardwareHash(log log.T, savedHwHash map[string]string, currentHwHash map[string]string, threshold int) bool {
    var totalCount, successCount int
    isSimilar := true

    // similarity check is disabled when threshold is set to -1
    if threshold == -1 {
        return true
    }

    // check input
    if len(savedHwHash) == 0 || len(currentHwHash) == 0 {
        return false
    }

    // check whether hardwareId (uuid/machineid) has changed
    // this usually happens during provisioning
    if currentHwHash[hardwareID] != savedHwHash[hardwareID] {
        isSimilar = false
    } else {
        // check whether ipaddress is the same - if the machine key and the IP address have not changed, it's the same instance.
        if currentHwHash[ipAddressID] == savedHwHash[ipAddressID] {
        } else {
            // identify number of successful matches
            for key, currValue := range currentHwHash {
                if prevValue, ok := savedHwHash[key]; ok && currValue == prevValue {
                    successCount++
                }
            }

            // check if the changed match exceeds the minimum match percent
            totalCount = len(currentHwHash)
            if float32(successCount)/float32(totalCount)*100 < float32(threshold) {
                isSimilar = false
            }
        }
    }

    return isSimilar
}

ロジックはドキュメントそのままでしたが、以下の点が新たにわかりました。

  • threshold は -1 ~ 100 の 整数であること
  • hash を取得する項目の総数に占める、一致した hash の数の割合を一致率 (similarity) としていること

この similarityThreshold を設定できる機能は、ソースを読まないと使うの難しそうですね。ただ、使うことは滅多になさそうです。

まとめ

冒頭の疑問に回答する形でまとめます。

  • hardware hashfingerprint の実体は、 /usr/sbin/dmidecode 等の出力のチェックサムである。
  • Vault は暗号化されていない、パーミッション 600 のファイルである。
  • 構成変更による similarity の差の出方は、アーキテクチャやOSによってかなり異なる。 similarityThresholdのチューンは、結構使い込んでみないと難しいと予想される。

nmap *2hping *3で使われるような、TCP の実装差を利用した特殊な fingerprinting はなくて、割と単純な仕組みになっていることがわかりました。

あとがき

windowslinux のビルドスイッチが見つけられなかったです。悲しい。Golang力が足りませんでした。

参考リンク


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

iPhone X RasPiで外出先で使える高機能なモバイルルーターを自作する

この記事は Akerun Advent Calendar 2021 - Qiita の20日目の記事です。

ハードウェアグループの石井 @ishturk です

弊社フォトシンスが提供しているAkerun入退室管理システム

  • オフィス
  • プライベートサロン
  • シェアスペース
  • 無人のフィットネスジム
  • etc...

と、いろいろなところで使われています。安定したサービスを提供するべく開発部・カスタマーサクセス部で協力しています。

顧客から問い合わせがあった場合、設置先に出向いて調査することもしばしば発生します。

そんなときに使える便利ネットワークデバイスがあったらいいのにな ((=^♀^=))

f:id:photosynth-inc:20211221003203j:plain

こんなこといいな

( ³з³):PCをインターネットに繋げられて、設置している弊社機器に同一ネットワークになるといいな

( ³з³):あ、ネットワークはWi-Fiと有線どっちも

( ³з³):会社の携帯がiPhoneだからiPhoneテザリングがいい

( ³з³):電源とれないこともあるので電池じゃないと

( ³з³):客先に行くのはエンジニアじゃないこともあるから、スクリプト仕込めるといい

レシピ

できたらいいな

RasPiのiPhoneテザリング

Wi-FiはPCからの接続に使用したいので、USBテザリングを選択します。 Apple公式には iTunes が必要と書いています。が、ありがたいことにLinux向けにパッケージ開発されているので、それを使用します。

sudo apt-get install ipheth-utils libimobiledevice-utils ifuse usbmuxd
sudo mkdir /media/iphone
sudo ifuse /media/iphone

何度か抜き挿ししているとiPhone側に 「信頼する」のポップアップが出てくるので選択

eth1: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 172.20.10.3  netmask 255.255.255.240  broadcast 172.20.10.15
        inet6 fe80::b3a4:f864:f44a:cbe  prefixlen 64  scopeid 0x20<link>

eth1 として認識されました!

WiFi設定・ルーティング設定

hostapd と dnsmasq をインストール

sudo apt install hostapd dnsmasq

ポートフォワーディング、IPマスカレードなどを設定 このあたりは情報充実しているのでそのあたりを参照ください 有線とWiFiをどちらも使うためにハマりました。その記事はまたどこかで、、、、

www.itmedia.co.jp

qiita.com

また、ルーターとして機能させてるのであれば OpenWrtという選択も良いと思います。RaspberrPi向けにもイメージが配布されており、GUIで設定するだけでネットワークルーターを立ち上げることができます。

[OpenWrt Wiki] Welcome to the OpenWrt Project

今回は、サポート作業用のスクリプトを実行したいという事情があったため、より汎用的なRaspberryPiOSを採用しました。

シャットダウン機能

RaspberryPiは物理スイッチがついておらず、通電で起動しますが、シャットダウンする機能はハードウェア提供されていません。 電源ぶちぎりでもよいのですが、こはちゃんと作りましょう。

今回はスイッチとパイロットランプで実現します。LEDは5Vそのままではとても眩しかったのでPWMで輝度を落としてます。 スイッチは内部プルアップでアクティブロー(スイッチ押下で0)にしてます。

f:id:photosynth-inc:20211221003210p:plain

#!/usr/bin/env python3

import RPi.GPIO as GPIO
import time
import os
from multiprocessing import Process, Condition

gpio_led = 18
gpio_sw = 24

GPIO.setmode(GPIO.BCM)

# LEDピンを出力に設定
GPIO.setup(gpio_led, GPIO.OUT)
GPIO.setup(gpio_sw, GPIO.IN, pull_up_down=GPIO.PUD_UP)
c = Condition()
def led():
    pin = GPIO.PWM(gpio_led, 2000)
    pin.start(0)
    pin.ChangeDutyCycle(5)
    c.acquire()
    c.wait()
    c.release()
def sw():
    while True:
       sw = GPIO.input(gpio_sw)
       if sw == 0 :
          c.acquire()
          c.notify()
          c.release()
          break
       time.sleep(1)

if __name__ == '__main__':
    p1 = Process(target=led)
    p2 = Process(target=sw)
    p1.start()
    p2.start()
    p1.join()
    p2.join()
GPIO.cleanup(gpio_led)
GPIO.cleanup(gpio_sw)
os.system( "sudo shutdown -h now" )
time.sleep(1000)

完成

f:id:photosynth-inc:20211221003203j:plain

LEDとスイッチがぷらんぷらんしているので、このままではまだ持っていくのは難しいですね。 社内に3Dプリンタ(form3)が2台あるので、いい感じにケースを自作しても良さそうです。

https://formlabs.com/jp/


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

やっすいデバッガでnRF52のデバッグしてみる (Jeff Probe編)

この記事は Akerun Advent Calendar 2021 - Qiita の18日目の記事です。

こんにちは。 いとう です。ファームウェアやってます。

普段フォトシンスのお仕事では Seggerの J-Link を使っているのですが、個人の趣味プロジェクトで使用しようとすると少々お高いのでFlirc社のBlackMagic ProbeクローンのJeff ProbeをAmazonで¥1800だったの妻に内緒でブラックフライデーセールのどさくさに紛れて購入してみました。

https://flirc.tv/more/flirc-jeff-probe-bmp-jtag-black-magic-probe

https://www.amazon.co.jp/dp/B07ZK4LFTQ

可愛い小箱で届きます。内容物は Jeff Probe本体と お馴染みのSWDデバッグ用の10ピンリボンケーブル、UART接続に使えるプローブです。USB mini-B ケーブルは付属しません。

f:id:photosynth-inc:20211220112336j:plain
JeffProbe

ものすごくコンパクトです。基板剥き出しなので取扱注意。

セットアップ

ハードウェア

今回は Nordic semiconductor のnRF52832搭載の nRF52 dkボードを使用しました。

f:id:photosynth-inc:20211220112410j:plain
dkboard

書き込むファームウェア

とりあえずのテストなので Zephyr のサンプルに添付されている Lチカ "blinky" を使ってみました。Zephyr環境で dkボード用にビルドします

west build -b nrf52dk_nrf52832  zephyr/samples/basic/blinky

デバッガインストール

デバッガにはgdbを使用していますが arm用をARM社が用意してくれていますのでこちらをダウンロード。ホストはmacOSを使用しました。

https://developer.arm.com/tools-and-software/open-source-software/developer-tools/gnu-toolchain/gnu-rm/downloads

接続

おもむろにJeff ProbeをmacにUSBケーブルで接続してみましょう。うまく接続できていると /dev/にttyデバイスとしてマウントされます。

バイス名はシリアル番号によって変わるようで私の環境では下記のようなデバイスとして見えていました。 同時にusbmodemが二つありますが数値が若い方を指定するとデバッガとして使用できています。

$ ls /dev/ | grep tty.usbmodem
tty.usbmodem34C775A71
tty.usbmodem34C775A73

接続確認

デバッガを立ち上げて

arm-none-eabi-gdb

繋がっているか確認してみましょう。先程のttyデバイスを指定します

(gdb) target extended-remote /dev/tty.usbmodem34C775A71

下記のコマンドでJeff Probe経由で電源供給ができます。Lチカへのファームウェア書き換えだけであればdkボードに別途電源供給不要でした。

(gdb) monitor tpwr enable

接続してます。

(gdb) monitor swdp_scan
Target voltage: 3.2V
Available Targets:
No. Att Driver
 1      ARM Cortex-M
(gdb) attach 1
Attaching to Remote target
warning: No executable has been specified and target does not support
determining executable automatically.  Try using the "file" command.
0x0000176a in ?? ()

J-Linkでは書き換えるROMがイレースされてなければ自動的にイレースしてくれていたんですがgdbはそこまではしてくれないようで毎回 monitor erase_mass してからリセットしないと次のファームウェアは書き込めませんでした。

(gdb) monitor erase_mass
erase..
(gdb) r
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program:  
warning: No executable has been specified and target does not support
determining executable automatically.  Try using the "file" command.

Program received signal SIGSEGV, Segmentation fault.
0x00000000 in ?? ()

loadコマンドでファイルを読み込んでみましょう。

(gdb) load build/blinky/zephyr/zephyr.hex 
Loading section .sec1, size 0x43c8 lma 0x0
Start address 0x1704, load size 17352
Transfer rate: 19 KB/sec, 964 bytes/write.
(gdb) run
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Starting program:  
warning: No executable has been specified and target does not support
determining executable automatically.  Try using the "file" command.

無事にLチカできています。

あとは普通に使えると思うのですが慣れていないとcliは辛いので今回はvscode上からデバッガとして使えるかを確認してみます。

vscodeでのデバッグ

Cortex-Debug

こちらのcoretex-debugを使用するとvscodeをからJ-Linkや他のデバッガを用いてIDEのようにデバッグができます。

https://marketplace.visualstudio.com/items?itemName=marus25.cortex-debug

Cortex-Debug 設定

Jeff ProbeはBlackMagic Probeから派生している製品ですのでservertypebmpを使用するとCoretex-Debugから使用できます。あとは毎回erase_massを実行するようにoverrideLaunchCommandsを使用してデバッガ起動時の処理をオーバーライドしています。

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "JeffProbeDebug",
      "cwd": "${workspaceRoot}",
      "executable": "build/button/zephyr/zephyr.elf",
      "request": "launch",
      "type": "cortex-debug",
      "servertype": "bmp",
      "device": "nrf52",
      "BMPGDBSerialPort": "/dev/tty.usbmodem34C775A71",
      "targetId": 1,
      "powerOverBMP": "disable",
      "showDevDebugOutput": true,
      "interface": "swd",
      "armToolchainPath": "/usr/local/bin/",
      "breakAfterReset": false,
      "overrideLaunchCommands": [
        "monitor erase_mass",
        "SoftwareReset",
        "load",
        "SoftwareReset"
      ],
      "postLaunchCommands": [
        "set substitute-path /workspaces/ak /Users/yuito/repo/ak"
      ]
    }
  ]
}

postLaunchCommands はビルドをDockerコンテナの中で実行しているのでパスを差し替えるために行っています。

なぜかファームウェアをダウンロードするたびにProbeの抜き差しをしないといけませんでしたが、これでステップ実行とかブレイクポイントとかも動く立派なデバッグ環境が整えられました。

まとめ

他にも中華系偽J-Link、CMSIS-DAPなどありますがお値段と気軽にAmazonで買えるのでJeffProbe、個人のプロジェクトにはいいのではないでしょうか?

(番外)Jeff Probeのアップデート

手元に届いた時にはv1.6.2だったのですがgithub上で最新バージョンのビルド済みファームウェアバイナリが配布されていたのでアップデートしました。

https://github.com/flirc/blackmagic/releases/tag/v1.6.3

brew install dfu-util
dfu-util --device 1d50:6017 -s 0x00002000:leave -D ./blackmagic.bin

GDB経由でバージョンを確認してみます。

(gdb) monitor version
Black Magic Probe (Firmware v1.6.3) (Hardware Version 0)
Copyright (C) 2015  Black Sphere Technologies Ltd.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>

:+1:


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

AWS Systems Managerを使ってraspiマシンを運用する時の注意点

この記事は Calendar for Akerun | Advent Calendar 2021 - Qiita の 19日目の記事です。

今年の初めに AWS Solution Architect Associate を取得した daikw - Qiita です 🎉

今日は AWS Systems Manager (SSM) に関する話をします。

はじめに

SSM で解決したいこと

クラウド側で全てprovisionしてくれる仮想マシンとは別に、オンプレマシン 扱いたい、というのは、実は結構よくある要求ではないでしょうか。

弊社のFW開発チームも、raspiをあちこちで使っていて、正直管理が辛いところがあります。

クラウド・オンプレどちらもできるだけ同じように扱うことができると、管理コストが減ってとてもハッピーになれそうです。

ところで、 AWS Systems Manager(運用時の洞察を改善し、実行)| AWS によれば、

AWS Systems Manager は、ハイブリッドクラウド環境のための安全なエンドツーエンドの管理ソリューションです。

とあります。もしや、これを使ったらハッピーになれるのでは???

https://d1.awsstatic.com/AWS%20Systems%20Manager/product-page-diagram-AWS-Systems-Manager_how-it-works.e9ba8cbeff1249c8cc24db4737d03648a1a073f6.png
https://aws.amazon.com/jp/systems-manager/ より

raspi x SSM

クラスメソッドさんの記事で、 raspi x SSM カテゴリのものがかなり豊富にありました。

本記事では、これらを参考に運用を試みた際に感じた、導入にあたって注意すべき点をまとめておきます。 また、記事の後半に用語リストをまとめてあります。参考までに。

運用して気がついたこと

セッションログは、セッションの開始方法によっては残せない

AWS Console の Session Manager を経由した SSHセッションログを、S3 や Cloudwatch logs に流すことができます。

AWS Systems Manager Session Managerのシェル操作をログ出力する | DevelopersIO

しかし、 aws ssm start-session コマンドによるログインセッションのログは同じログストリームに流すことができません。

f:id:photosynth-inc:20211218133733p:plain
No s3 log when ssh ubuntu@i-xxxxxxxxxx · Issue #194 · aws/amazon-ssm-agent より

不便といえば不便ですが、当たり前といえば当たり前(接続元と接続先で鍵交換するので、session-manager は復号できない、タブン)で、ややもどかしくもあります。

Quick Setup を組み合わせる場合、セッションログを取得するために小細工が必要

Quick Setup は基本 EC2 インスタンスに対して行うものですが、ターゲットに Managed Instance を選択することもできます。ハイブリッド環境もまとめて扱えるのが Systems Manager のイイトコロなので、まとめて選択してしまうこともあるでしょう。

デフォルトでは、Hybrid Activationの際に AmazonEC2RunCommandRoleForManagedInstances が Managed Instance にアタッチされます。これは Identity Provider が ssm になっています。 Quick Setupの後に、それらしい Role (AmazonSSMRoleForInstancesQuickSetup)が作成されるのですが、これは Managed Instance に自動でアタッチされません。さらにこちらの Identity Provider は ec2 になっています。

以下のポリシーを含む ssm-managed-instance role を作成して、

  • CloudWatchAgentServerPolicy
  • AmazonSSMManagedInstanceCore
  • AmazonSSMPatchAssociation

ssm-managed-instance を Fleet Manager (AWS Systems Manager - Managed Instances) から 対象の Managed Instance にアタッチし、さらにこの際、RoleのIdentity Provider を ssm にすることで、cloudwatch logs に ssm session のログが流せるようになります。

この辺りは IAM Role, Policy の構成をよく調べないとうまく使うことができなそうです。来れ、aws猛者たちよ。

ssm-user の権限が広い

Session Manager を利用する場合、特に aws-console 上からのログインを許可する場合は、デフォルトで ssm-user が利用されます。

この ssm-usersudoers なので、Session Manager を下手に許可してしまうと 最小権限の原則 - Wikipedia に簡単に反します。

「Session Manager を利用するのは、特権管理者である」と想定されているのかと思います。

チームで Session Manager を共有して使う場合は、コンプラという言葉が飛び交う現代IT産業界の皆様が口から泡を吹いて倒れることのないように、 ssm-user を sudoers から除去しておくのが無難です。

ついでに pi ユーザの nopasswd も除去しておきましょう。

sudo su
cd /etc/sudoers.d
echo "#User rules for ssm-user" > ssm-agent-users
rm 010_pi-nopasswd

参考:

インスタンスのクローンを作るときは、再度アクティベーションする

Raspberry Pi で、既にあるマシンと同等のマシンを作りたい(複製する)場合は、 MicroSDカードからイメージを dd コマンド等で吸い出し、別のカードに書き込んで用意することがあります。

アクティベート済みのマシンを複製する場合、 ssm-agent によるハードウェアフィンガープリント に引っかかるため、そのままでは使えないであろうことが予想できました。

自分でも挙動を調べたんですが、クラスメソッドさんの記事が僕よりよくまとまっていました。

[AWS Systems Manager] アクティベーションの終わったRaspberryPiのイメージをコピーして、複数起動した時の挙動を確認してみました | DevelopersIO より引用すると、

アクティベーションが済んだイメージを複製する場合、 - 1台だけ起動する場合、どちらでも接続可能 - 複数台起動した場合、後から起動したものが接続される - 複数台起動した場合、リブートすると、どちらに接続されるかは不定 - 複数台起動した場合、セッションマネージャから再アクティベートすると別のインスタンスとして管理される

session-manager を利用できるIAM Roleの設定

セッション開始には、"ssm:StartSession" アクションの許可が必要になります。

例えば、 Session Manager の追加サンプル IAM ポリシー - AWS Systems Manager から抜粋すると、

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ssm:StartSession"
            ],
            "Resource": [
                "arn:aws:ec2:us-east-2:123456789012:instance/i-1234567890EXAMPLE",
                "arn:aws:ec2:us-east-2:123456789012:instance/i-abcdefghijEXAMPLE",
                "arn:aws:ec2:us-east-2:123456789012:instance/i-0e9d8c7b6aEXAMPLE"
            ]
        },
        {
            "Effect": "Allow",
            "Action": [
                "ssm:TerminateSession",
                "ssm:ResumeSession"
            ],
            "Resource": [
                "arn:aws:ssm:*:*:session/${aws:username}-*"
            ]
        }
    ]
}

.ssh/config を適切に設定する

いろいろと試した結果、以下2点わかっています。

  1. AWS-StartSSHSession document は、 ProxyCommand を使わないと動作しない
  2. aws ssmコマンドを利用する場合、2段以上の ProxyCommand チェーンは効かない

1 は、ターミナルにコマンド直打ちする時以外は特に困らないですが、 Protocol mismatch となります。

┬─[daiki~@photosyth~:~]─[15:23:10]
╰─>$ aws ssm start-session --target mi-xxxxxxxxx --document-name AWS-StartSSHSession --parameters 'portNumber=22222'

Starting session with SessionId: daiki.watanabe@photosynth.co.jp-0b95bd5ed82475bae
SSH-2.0-OpenSSH_7.9p1 Raspbian-10+deb10u2+rpt1

ls
Protocol mismatch.


Exiting session with sessionId: daiki.watanabe@photosynth.co.jp-0b95bd5ed82475bae.

2 は少しだけ困っていて、以下のような記法でのログインに失敗します。

# SSH over Session Manager
host i-* mi-*
    ProxyCommand sh -c "aws ssm start-session --profile akerun --target %h --document-name AWS-StartSSHSession --parameters 'portNumber=%p'"

Host target
    User pi
    ProxyCommand ssh mi-xxxxxxxxx -l pi -p 22222
┬─[daiki~@photosyth~:~]─[10:52:12]
╰─>$ ssh target
Pseudo-terminal will not be allocated because stdin is not a terminal.
daikiwaranabe@mi-01630ac873d5fe084: Permission denied (publickey).
kex_exchange_identification: Connection closed by remote host

config の設定をもっと頑張ればいけるかもしれません。ホストが増えないうちは特に困らないので、緩めのマサカリが来ることを期待しつつ。

その他参考記事

用語リスト

SSM固有の用語が多く、概念になれるのに少し時間がかかるので、先んじてまとめておきます。

Hybrid Activation

用意したマシンを Systems Manager に登録する作業のことを、ハイブリッドアクティベーションと呼びます。

ステップ 4: ハイブリッド環境のマネージドインスタンスのアクティベーションを作成する - AWS Systems Manager より、

ハイブリッド環境でサーバーと仮想マシン (VM) をマネージドインスタンスとして設定するには、マネージドインスタンスアクティベーションを作成する必要があります。アクティベーションが完了するとすぐに、アクティベーションコードとアクティベーション ID が送信されます。ハイブリッド環境でサーバーと VMAWS Systems Manager SSM Agent をインストールするときに、このコードと ID の組み合わせを指定します。このコードと ID は、マネージドインスタンスから Systems Manager サービスへの安全なアクセスを提供します。

つまり、以下を実行するだけ。

sudo service amazon-ssm-agent stop
sudo amazon-ssm-agent -register -code $activation_code -id $activation_id -region $region
sudo service amazon-ssm-agent start

Managed Instance

Systems Manager で管理下にある、自分で用意したマシンのことを言います。オンプレミスサーバであることもあるし、何か別の形態でプロビジョンされたサーバであることもあり得ます。

SSM Agent

エージェント(agent)とは - IT用語辞典 e-Words によれば、

エージェントとは、代理人、代理店、仲介人、取次業者、などの意味を持つ英単語。ITの分野では、利用者や他のシステムの代理として働いたり、複数の要素の間で仲介役として機能するソフトウェアやシステムなどを指すことが多い。

SSM Agent は ManagedInstance に常駐し、Systems Manager にインスタンスの状態を渡したり、Systems Managerからのリクエストを処理する機能を持ちます。 SSM Agent の使用 - AWS Systems Manager によれば、

エージェントは、 AWS クラウド で Systems Manager サービスからのリクエストを処理し、リクエストに指定されたとおりに実行します。SSM Agent は、Amazon Message Delivery Service (サービスプレフィックス: ec2messages) を使用して、Systems Manager サービスにステータスと実行情報を返します。

Session Manager

Systems Manager の一つの機能で、対象ホストとのSSHログインセッションを 直接 貼ることができるようになります。

AWS Systems Manager Session Manager - AWS Systems Manager より *1

Session Manager はフルマネージド型 AWS Systems Manager 機能で、インタラクティブなワンクリックブラウザベースのシェルや AWS Command Line Interface (AWS CLI)を介して Amazon Elastic Compute Cloud (Amazon EC2) インスタンス、オンプレミスインスタンス、および仮想マシン (VM) を管理できます。Session Manager を使用すると、インバウンドポートを開いたり、踏み台ホストを維持したり、SSH キーを管理したりすることなく、監査可能なインスタンスを安全に管理できます。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

*1:AWSのドキュメントは、5回くらい読まないとちゃんと真意が掴めないのは僕だけでしょうか...

パフォーマンスチューニング: 手を動かす前に考える

この記事は Akerun Advent Calendar 2021 - Qiita の16日目の記事です。

こんにちは。@ps-tsh です。API Server などバックエンドシステムの開発を担当しています。

フォトシンスも創業から8年目に突入し、サービスを安定稼働させるためにパフォーマンスチューニングを行う機会も増えてきました。開発当時はしっかりテストしてリリースしたプロダクトであっても、運用が長期化すると「ファーストビューは速いが、ページングするとだんだん遅くなっていく」「1分で終了していたバッチ処理に15分かかるようになってきた」といった課題が発生します。特に、ビジネス面がうまくいくほどユーザ数や履歴データの蓄積ペースも上がるので、システムにかかる負荷は急激に増大します。

そんなわけで今回は、パフォーマンスチューニングの話、とくに「コードを直す前」の話をしようと思います。

取り組む前に

パフォーマンスチューニングに取り組んだエンジニアに話を聞いてみると、よく「N+1が発生していたので解消しました」「インデックスを貼って検索を速くしました」といった回答が返ってきます。決して間違いではないのですが、ときどき「ちょっと手段の実践に傾倒しているな」と思うことがあります。手段の実践が先行すると、原因がわからない問題を調べるときにも「N+1がないか確認します」「インデックスが貼られているか確認します」のように、手段を軸とした調査になってしまい、当てが外れた時に打ち手がなくなってしまいます。つまり、手持ちの道具が限られていることで、対応できる課題の幅も狭まってしまうという罠に陥るのです。パフォーマンス問題を解決する方法は、DBクエリのチューニングに限られません。

チューニングで行き詰まったら、まずは一歩引いて問題を俯瞰してみましょう。いちど席を立って深呼吸し、コーヒーを淹れて飲んだりするのも良いかもしれません。

性能目標を確認する

チューニングに取り組むにあたり、最初にやることは「性能目標を確認する」です。これ、意外とみんなやっていないんですよね。ひとくちに「遅い」「重い」というのは簡単ですが、よく話を聞いてみたら「処理データ件数を考えると妥当な水準」「決して早くはないが、特段の実害もない」というケースも結構あります。あるいは、もともと性能目標が定められていないケースもあると思います。そんな場合でも「どれくらいになればひとまずOKといえるのか」を明確にしましょう。現在地と目的地があって初めて、取れる手段を適切に選べるのです。なお、性能目標はレスポンスタイムやスループットだけではありません。たとえば「表面上は性能に問題がなくても、裏ではCPU利用率が100%に張り付いてしまうことがあり、常時50%以下に押さえたい」といった状況の解消が目標になることもあります。

最初に目標を確認するのには、もう一つ理由があります。「チューニングは楽しい」のです。具体的に困っている人がいる状況で、解決すれば感謝される。技術的スキルを発揮する場としては申し分ありませんし、エンジニアとして最善の解決策を追求したいというマインドも理解できます。しかし、チューニング作業には時間的な制約を伴うトラブルシュートという側面もあります。

常時稼働のSaaSにおいては1週間かけて100%の解決策を提供するより、まず2時間で70%を救えるアプローチがあるならそちらを選ぶ状況もあります(この場合、最終的には暫定対応と恒久対応の二段構えで100%にもっていきます)。チューニングの実施においては技術的な正解ばかりを優先するのではなく、現実的な解決策としての有効性が求められます。「いま最優先で求められているのは何か」の視点をもち、本来の目標にフォーカスしましょう。

測定する

次にやることは「問題の事象を測定する、再現性を確認する」です。実際に起こっていない問題を解決することはできないからです。測定の過程では実測値と一緒に、問題となっている事象の発生条件も確認します。「特定のデータ入力で起こる」「特定の時間帯に起こる」「処理データ量が一定を超えると起こる」など、ここで得られる情報の質が高ければ、問題に対してよりよい解決方法を選択することができます。

ひと昔前のエンタープライズシステムであれば、ユーザに影響が出ないよう休日や深夜に検証する」という方法がとられることもありましたが、インターネットに公開された現在の SaaS においては24時間365日の連続稼働が前提です。本番システムにデバッグ用のコードを仕込んで経過を見る、試行錯誤をするといった時間的な余裕がないこともあります。パフォーマンス問題が起きる前に、普段から運用するシステムの各種指標を収集できる体制を構築しておくことが大事です。

こういった道具立てを自分たちで全て用意するのは大変なので、外部の APM(Application Performance Management: アプリケーションパフォーマンス管理)サービスを使うのもひとつの選択肢です。

ボトルネックを特定する

パフォーマンス低下に影響を与えている要因のうち、最も問題視される箇所のことをボトルネックといいます。測定が終わったら、パフォーマンスを低下させている要因を列挙し、性能にもっとも大きな影響を与えているのが何なのかを明らかにします。「APIサーバのレスポンスが遅い」という問題ひとつとっても、パフォーマンス劣化の原因にはデータベースの検索クエリが遅い、外部API呼び出しのレスポンス待ちが長い、排他制御のロック解放待ちが長い…と、いろいろあるわけです。冒頭にも説明した通り、パフォーマンスチューニングは時間との戦いなので、複数の要因がある場合は最も大きな影響があるところから解決に着手していきます。パフォーマンス低下要因としてよく挙げられるN+1やフルテーブルスキャンが実際に起こっていたとしても、処理プロセス全体に占める割合が少ないのであればいったん後回しにする、という判断もありえます。

対応策を選ぶ

ボトルネックを特定したら、負荷の種類に合わせた対策を検討します。システムの負荷は CPU の計算速度に依存するもの(CPU bound) と、入出力に依存するもの(I/O bound) の2種類に大別されます。I/O は狭義にはディスクの読み書きを指しますが、広義には通信速度(ネットワークのI/O)なども含まれます。

CPU bound の負荷が問題になっている場合は「計算量を減らす」または「計算資源を追加する」アプローチで対策します。具体的には「効率の良いアルゴリズムを採用して計算量を減らす」「サーバ増設でCPUリソースを追加する」といった方法が取られます。

I/O bound の負荷が問題になっている場合は「入出力の総量を減らす」アプローチで対策します。こちらも「キャッシュを活用し、APIやクエリの呼び出し回数を減らす」「APIやクエリで出力するデータ量(HTTP(S)のレスポンスボディ、クエリの結果セット)を減らす」「インデックスを追加して検索のために走査するデータ量を減らす」「逐次処理の書き込みを一括登録に変更する」など、さまざまな方法があります。

終わりに

対応策が決まったら具体的な実装に落とし込み、効果を検証し、テストしてリリースという流れになります。このあたりも掘り下げると色々なネタがあるのですが、今回は「手を動かす前に考えてほしいこと」という形でいくつかのポイントを紹介してみました。手を動かすこれらを確認しておくことで対応の幅も広がりますし、完了時の報告もスムーズに行うことができます。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

便利な解錠方法-まばたき解錠

この記事は Akerun Advent Calendar 2021 - Qiita の15日目の記事です。

WebエンジニアのBunです。主にiOSアプリの開発を担当しています。

日々いろんな解錠方法について考えています。特にiOSアプリで使えるいろんな便利な機能を使った解錠方法とその実装手順をまとめます。 今回はiOS(iPhone / Apple Watch) + JINS MEMEでの解錠について書きます。

JINS MEME

JINS MEMEはスマホ、スマートウォッチとつながるウェアラブルメガネです。つい最近最新版がリリースされました。

jinsmeme.com

JINS MEMEは鼻当て部分のCOREに2つのセンサーが搭載されたウェアラブルバイスです。COREに内蔵されたBluetoothスマホアプリと連携。センサーが取得したさまざまな情報をスマホで解析し、あなたのカラダとココロの状態を計測します。

6軸モーションセンサー、眼電位センサーから体と目の動きのデータが取れるので、姿勢チェック、集中度計測などヘルスケアに関連する色々面白いことができそうです。

ちなみに、以前初代JINS MEMEのハッカソンに参加し、JINS MEMEと連携したアプリ(the zen)を作ったことがあります。

news.livedoor.com

JINS MEMEで解錠

JINS MEMEと連携したアプリを使えば、Akerunの近くてまばたきするだけで解錠できます。

f:id:photosynth-inc:20211211154636g:plain

弊社の「タッチレスエントリー・ソリューション」と組み合わせると、完全タッチレスでより快適な解錠を実現できます。

f:id:photosynth-inc:20211216182336g:plain

JINS MEMEとの連携

JINS MEMEから「20Hzデータ」、「15秒間隔データ」、「60秒間隔データ」を取得できますが、 リアルタイムでAkerunを制御するには、JINS MEMEからBluetooth経由で20Hzデータを取得する必要があります。

jins-meme.github.io

f:id:photosynth-inc:20211211160109p:plainhttps://jins-meme.github.io/sdkdoc2/

JINS MEME SDK

SDK共通事項 - JINS MEME DEVELOPER DOCUMENTS

JINS MEMEを購入する前にちゃんと調べていなかったので、購入後SDKが使えないことを知って少しがっかりしましたが、お問い合わせしたところ、期間限定でご提供いただきました。有り難く使わせていただきます。

非公開情報なので、JINS MEME SDKの詳細については触れません。JINS MEME Platformに公開されている範囲内の内容についてご説明します。

JINS MEME SDK(framework)導入

SDKは一般的なframeworkとして提供されるので、Targetの「Frameworks, Libraries, and Embedded Content」にframeworkを追加すれば使えます。

f:id:photosynth-inc:20211211162018p:plain

Akerun施錠解錠実装

施錠解錠API

今回はAkerunアプリで使われているBluetooth専用APIを使っていますが、Akerun公開APIからも施錠解錠が可能です。 具体的な実装は割愛します。弊社のAkerun Developersと下記の関連記事をご参照ください。

developers.akerun.com

akerun.hateblo.jp

施錠解錠トリガー

下記通りシンプルです。

JINS MEMEから取得したモーションセンサーデータ(頭の動き)と眼電位センサーデータ(まばたき)から施錠解錠コマンドを判定し、Akerunにコマンドを送信するだけです。

施錠解錠判定

以下の二つの方法で試してみました。

  • 頭の動き(傾き)とまばたき回数の組み合わせ

  • まばたき回数と間隔

頭の動き(傾き)とまばたき回数の組み合わせ

単純にまばたきだけだと、意図しない施錠解錠処理が走る可能性が高いので、頭の動き(傾き)も判定するようにしました。

f:id:photosynth-inc:20211212102404p:plainhttps://jins-meme.github.io/sdkdoc2/basics/definition.html

  • 頭の動き

f:id:photosynth-inc:20211212102727p:plain

普段の動きから見ると、前後の傾きとか左右回転は多少不自然な動きになるので、左右傾きのrollのデータを使います。

右方向への傾きを解除とし、左方向への傾きを施錠とします。それぞれrollの範囲は「-180-0」と「0-180」になります。

閾値ですが、小さすぎると、誤動作が発生やすいし、大きい過ぎると、不自然な動きになります。何より首が痛い。。。

色々試した結果20前後が割と良さそうなので、今回は20を取ることにしました。

if data.roll > 20 {
    // 右傾き:解錠判定
} else if data.roll < -20 {
    // 左傾き:施錠判定
} else {
    // クリア
}
  • まばたきと回数

まばたき速度(blinkSpeed)とまばたき強度(blinkStrength)両方を見てまばたきの判定をします。

こちらについても色々試した結果、まばたき速度は60以上、まばたき強度は30以上にすれば問題なさそうです。

f:id:photosynth-inc:20211212104216p:plain

まばたき回数ですが、1回だと誤動作が発生しやすいので、2回まで見ることにしました。

ただ、普段も頭を傾いてまばだきする時もよくあるので、意図しない施錠解錠を減らすために、さらにタイマーを掛けて、「2s以内にまばたき2回」の条件をつけました。

最初は1s以内にしてみましたが、JINS MEMEかSDKの制限(?)で1s以内に複数回まばたきしても検出できないようなので、2sにしています。

多分、下記だと思うので、来年改善を期待します。

まばたきの検出が遅い→2022年に対応ファームウェアを公開する方向で検討中

まばたきの検出は前世代では0.3s、現世代では0.5sのディレイがあります。これは「ライフログで一番精度が上がる」ように誤判定の原因になりそうな前後のシグナル推移のウィンドウを確保しているのが理由です。こちらは2022年以降に対応ファームウェアを公開する方向で検討しております。

https://jins-meme.github.io/sdkdoc2/basics/motion-tracking-howto.html

解錠判定処理を下記になります(一部抜粋)。

if data.roll < -20  {
    if self.unlockBlinkCount < 2 {
        if data.blinkSpeed > 60 || data.blinkStrength > 30 {
            self.unlockBlinkCount += 1
        
            if self.unlockBlinkCount == 1 {
                self.startSignalTimer()
            } else if self.unlockBlinkCount == 2 {
                self.stopSignalTimer()
                return .unlock
            }
        }
    }
}

private func startSignalTimer() {
    self.signalTimer = Timer.scheduledTimer(
        timeInterval: self.signalTimeout,
        target: self,
        selector: #selector(self.notifySignalTimeout(_:)),
        userInfo: nil,
        repeats: false
    )
}

@objc func notifySignalTimeout(_ tm: Timer) {
    self.unlockBlinkCount = 0
}

まばたき回数と間隔

詳細は割愛しますが、解錠だけの場合まばたき回数と間隔を調整すれば、頭の動きを見なくても特に問題なく解錠ができます。

例えば、1s前後の間隔でまばたき3回で解錠します。

この方法だと、JINS MEMEのジャイロセンサーを使わないので、バッテリー持ちが良くなります。また、首の負担が大分減ると思います。これ大事です。

Akerunはautolock機能があるので、解錠さえできれば十分だと思います。

今後の改善

  • 今回はシンプルにまばたき回数しか使っていないですが、さらにまばたきの詳細パターン(回数、間隔、速度、強度)をチューニング、学習すれば、精度を上げられそうです。

  • まばたきは「静止指標」で、頭の動きも含めて歩いている状態だと少しノイズが入るので、精度が低くなります。noiseStatusを使って補正ロジックを入れるなど工夫が必要そうです。

  • まばたきの検出が遅い問題について、次バージョンファームウェアを待つしかないですが、検出が早くなると、施錠解錠精度がもっと上がるように色々工夫ができそうです。

  • 視線の動きも入れることで誤動作などを減らせそうですが、うまく検出できない時もあるので、今後ファームウェア改善されたら試してみます。

まとめ

  • 非公開のため詳細は割愛しましたが、JINS MEME SDKはシンプルで使いやすかったです。サンプルコードもあったので、すぐアプリに組み込むことができました。

  • スマホの場合、画面をみないと処理が始まったのか、検出と判定にエラーが出たのか分からないので、Apple Watchのバイブレーションを使えば、分かりやすくなると思います。次回試してみたいと思います。

  • 日常で使えるウェアラブルバイスとしてJINS MEMEはとても素晴らしい製品なので、今後も色々連携してみたいと思います。SDKが一般公開されれば、さらに広がりそうですね。


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com

施解錠遅延の限度見本

この記事は Calendar for Akerun | Advent Calendar 2021 - Qiita の 12日目の記事です。

最近は越境活動が板についてきた daikw - Qiita です。

部署や役職を跨いで共通の認識を作ることで、プロジェクトが円滑に回る、というのはあるあるです。 今日は、この共通認識を作るために造ったものを、一つ取り上げます。

結論

3行でまとめると、

  • 「包括的なドキュメントよりも動くソフトウェアを」の実践は、越境的活動に良い効果があった。
  • 生産現場では、「動くソフトウェア」に近い概念の「限度見本」を使っている。
  • ngrok は良い、vercel も良い。

限度見本とは

僕はソフトウェア技術者でして、なるべくテストコードを書きながら開発しています。 いちいち手動でテストするのは面倒だけど、作ったものが意図通りに動くことは確かねばならないわけでして、 つまり ソフトウェアの品質 を保つために、テストコードを書いて自動で走らせます。

一方で、製造した 製品の品質 を保つための活動として、検査治具を設けたり、外観検査をしたりします。 外観検査ではヒトの五感を使うため、感覚によるバラツキが発生しないようなわかりやすい判定基準を設けるのが望ましく、基準となるサンプルを用意して比較対象とします。

この基準となるサンプルを、「限度見本」と一般的に呼んでいるようです。 いくつかweb上のコンテンツから引用すると、

外観検査を始める前に | 外観検査の基本 | 外観検査.com | キーエンス によれば、

目視による外観検査は、人間に依存する割合が高く、ヒューマンエラーや検査員によるばらつきが発生しやすい工程です。それらを防ぐためには、良品と不良品の境界線を明確に決めておくことが大切です。 仕様書・検査基準書のような文字と写真ではなく、合否の判定基準を目で見てわかるようにする「限度見本」

限度見本の意味と定義とは|限度見本を英語でいうと によれば、

ある製品を品質上合格とするか、不合格とするかの限度を示した製品見本 boundary sampleやlimit sampleもしくはcriteria sampleといった表現がよく使われます。

ソフトウェア限度見本

つまり限度見本は、「検査員」と「設計者・技術者」との間のコミュニケーションツールとして機能しています。 しかも文字ベースの情報伝達と比べて、ディティールを書き下す・読む必要がなく、とても低コストです。

ここで アジャイルソフトウェア開発宣言 より、「包括的なドキュメントよりも動くソフトウェアを」を引用しましょう。

御託を並べるより、動くものをスパッと見せてやれば、コミュニケーション齟齬は一発で無くなったりするものです。

よってここでは、「限度見本」の定義を少し緩和して、「ソフトウェア限度見本」を

「品質を敢えて落とした製品・サービスであって、品質基準の検討のための見本となるもの」

と定義し、それを満たすようなものを作ってみました。

スコープ・要件

ここでは試みとして、弊社スマートロックサービスの「施解錠速度」の品質基準検討に使えるようなものを目指しましょう。

要件をざっくり洗い出し、実装中に目標を見失わないようにします。

  • Akerunに遠隔でコマンドを送信する
    • コマンドの種類
      • 施錠
      • 解錠
      • 状態取得
    • コマンドの性質
      • コマンドの実行完了を検知できる
      • コマンドの予定の実行時間を変更できる
      • コマンドの実際の実行時間を測定できる

実装

なんやかんやあってできました! 施解錠遅延によって、どの程度違和感が発生するのか、それを体感してもらうのが目的です。

右上の delay input でどのくらいの遅延が発生したか、を模擬します。

f:id:photosynth-inc:20211210130353p:plain
限度見本

設計ざっくり

技術的に新しいところは一切なく、Web開発で使いそうな基本的な技術だけで作っています。 Akerun Remote 内部に Akerunの管理コンソール画面 Akerun Connect の機能縮小版を作ったようなものです。

  • 通信の流れは、ブラウザ -http-> Remote -ble-> Akerun
  • Remote 実機内部にAPIサーバ / フロントエンドサーバ を立てて、ブラウザからの解錠指示をAPIサーバで受け取り、Akerunへのコマンド送信を直接行う
  • ブラウザで遅延時間の調整ができる
  • ngrok を活用して、ブラウザへの公開を簡単にする
  • ついでに雑にレスポンシブにして、適当なサイズのスマホと、PCブラウザどちらからも操作可

作り方

追加で作るものは、ざっくり2つだけでした。

  • APIサーバ
  • フロントエンドサーバ

あとは、APIサーバからAkerunのコマンド送信を直接行うツールが必要です。今回はCLIツールを事前にご用意しました。3分クッキングでは定番です。

APIサーバ

こちらは複雑さをCLIに押し込んだので、とても簡単な構成です。

  • 僕がセットアップに慣れている flask を使い、
  • レスポンスはフロントエンドで扱いやすいように json 化し、
  • 事前に用意したCLIに繋ぎこみました。
┬─[daiki~@photosyth~:~/g/s/w/r/api]─[15:40:51]─[G:main=]
╰─>$ tree -I '__pycache__|venv'
.
├── README.md
├── app.py
├── appstart.sh
├── docs
│   └── package.json
└── requirements.txt

1 directory, 5 files

app.py はごく単純で

import os
import json
import subprocess as sp

from flask import abort
from flask import Flask
from flask import jsonify

from flask_cors import CORS

app = Flask(__name__)
CORS(app)

akerun_id = os.environ.get("AKERUN_ID")
cwd = os.environ.get("CLI_DIR")

# remote_cli's bleMode proxys
def bleMode_call(cmd):
    if akerun_id:
        command = ["node", "cli", "ble", cmd, "-a", akerun_id]
    else:
        command = ["node", "cli", "ble", cmd]

    res = sp.run(command, cwd=cwd, stdout=sp.PIPE, stderr=sp.STDOUT)
    print(res.stdout)
    return json.loads(res.stdout.decode('utf-8'))


@app.route('/ble/toggle', methods=['POST'])
def bleMode_toggle():
    res = bleMode_call('toggle')

    if res["level"] == "error":
        return abort(500, res)
    else:
        return jsonify(res)


@app.route('/ble/infopro', methods=['POST'])
def bleMode_infopro():
    res = bleMode_call('infopro')

    if res["level"] == "error":
        return abort(500, res)
    else:
        return jsonify(res)

フロントエンドからのリクエストを、Akerun本体にプロキシするような作りになっています。簡単ですね。

フロントエンドサーバ

  • 僕が謎にセットアップに慣れている nextjs を使い、
  • さらに僕が謎になんとなく好きな material-ui を使って

作りました。単一ページで、少しコンポーネントに分けていますが、ごく単純な設計です。

┬─[daiki~@photosyth~:~/g/s/w/j/t/remote-instructor]─[15:11:25]─[G:master=]
╰─>$ tree -I node_modules
.
├── README.md
├── jest.config.js
├── next-env.d.ts
├── next.config.js
├── package.json
├── public
│   └── favicon.ico
├── src
│   ├── components
│   │   ├── large_text_field.tsx
│   │   ├── popup.tsx
│   │   ├── progress_bar.tsx
│   │   ├── pseudo_delay_input.tsx
│   │   ├── record_table.tsx
│   │   └── timer.tsx
│   ├── layouts
│   │   └── main.tsx
│   ├── modules
│   │   ├── logger.ts
│   │   └── utils.ts
│   ├── pages
│   │   ├── _app.tsx
│   │   ├── _document.tsx
│   │   └── index.tsx
│   └── styles
│       └── theme.ts
├── tsconfig.json
└── yarn.lock

package.json には特に特徴はなく、

{
  "name": "remote-instructor",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "DEBUG=remote-instructor-* next dev",
    "debug": "NODE_OPTIONS='--inspect' next dev",
    "test": "jest",
    "build": "next build",
    "start": "DEBUG=remote-instructor-audit-* next start -p 80"
  },
  "dependencies": {
    "@material-ui/core": "^4.11.4",
    "@material-ui/icons": "^4.11.2",
    "@mui/x-data-grid": "^4.0.1",
    "debug": "^4.3.1",
    "next": "10.0.8",
    "react": "17.0.1",
    "react-dom": "17.0.1"
  },
  "devDependencies": {
    "@types/debug": "^4.1.5",
    "@types/jest": "^26.0.23",
    "@types/node": "^14.14.37",
    "@types/react": "^17.0.3",
    "@types/react-dom": "^17.0.3",
    "dotenv": "^8.2.0",
    "jest": "^26.6.3",
    "ts-jest": "^26.5.5",
    "typescript": "^4.2.3"
  }
}

nextjs に依存しているため、pages/index.tsx にほとんど全て詰まっています(省略するところがほとんどなかった ... )。

// Framework
import React, { useState, useRef } from "react"

// UI
import { makeStyles } from "@material-ui/styles"

import Grid from "@material-ui/core/Grid"

import CircularProgress from "@material-ui/core/CircularProgress"
import Icon from "@material-ui/core/Icon"
import IconButton from "@material-ui/core/IconButton"

import { sleep } from "@/modules/utils"
import { Timer, timeMeasureAround, getTime } from "@/components/timer"
import { PseudoDelayInput } from "@/components/pseudo_delay_input"
import { RecordTable, pushToTable } from "@/components/record_table"

const useStyles = makeStyles({ ... })

const LockState = {
  Unknown: 0, // default
  Indeterminate: 1,
  Open: 2,
  Close: 3,
} as const
type LockState = typeof LockState[keyof typeof LockState]

const LockStateKeys = {
  0: "unknown",
  1: "indeterminate",
  2: "open",
  3: "close",
}

export default function Home(props: any) {
  const classes = useStyles(props)

  const timer = useRef<any>() // reference to the Timer Element
  const table = useRef<any>() // reference to the RecordTable Element

  const [pseudoDelay, setPseudoDelay] = useState<number>(0) // [sec]. input by the user
  const [lockState, setLockStateRaw] = useState<LockState>(LockState.Unknown)

  const lockStateTimeLimit = useRef(null)
  const setLockState = (input: LockState) => {
    setLockStateRaw(input)

    clearTimeout(lockStateTimeLimit.current)
    if (input === LockState.Open || input === LockState.Close) {
      lockStateTimeLimit.current = setTimeout(() => {
        setLockState(LockState.Unknown) // clear lockState after 30 sec
      }, 30 * 1000)
    }
  }

  const processing = useRef(false)
  const iconClickAround = (func: () => Promise<LockState>) => {
    return async () => {
      if (processing.current) return
      processing.current = true
      setLockState(LockState.Indeterminate)

      const nextState = await timeMeasureAround(async () => {
        await sleep(pseudoDelay * 1000) // wait for `pseudoDelay`, to simulate the Akerun Remote delay time
        return await func()
      }, timer)()

      processing.current = false
      setLockState(nextState as LockState)
    }
  }

  const onReplayIconClick = iconClickAround(async () => {
    const url = `/ble/infopro`
    console.log(url)

    return await fetch(url, { method: "POST" })
      .then((resp) => {
        if (!resp.ok) {
          console.error(resp)
          pushToTable({ state: "unknown", cmd: "infopro", time: getTime(timer) }, table)
          throw new Error("server error")
        }
        return resp.json()
      })
      .then((body) => {
        const lock_state = body["message"]["lock_state"]["data"][0]
        const nextState = {
          "0": LockState.Open,
          "1": LockState.Close,
        }[lock_state]

        pushToTable({ state: LockStateKeys[nextState], cmd: "infopro", time: getTime(timer) }, table)
        return nextState
      })
      .catch((err) => {
        console.error(err)
        return LockState.Unknown
      })
  })

  const onLockIconClick = iconClickAround(async () => {
    const url = `/ble/toggle`
    console.log(url)

    return await fetch(url, { method: "POST" })
      .then((resp) => {
        if (!resp.ok) {
          console.error(resp)
          pushToTable({ state: "unknown", cmd: "infopro", time: getTime(timer) }, table)
          throw new Error("server error")
        }
        pushToTable({ state: "open", cmd: "toggle", time: getTime(timer) }, table)
        return LockState.Open
      })
      .catch((err) => {
        console.error(err)
        return LockState.Unknown
      })
  })

  const onUnlockIconClick = iconClickAround(async () => {
    const url = `/ble/toggle`
    console.log(url)

    return await fetch(url, { method: "POST" })
      .then((resp) => {
        if (!resp.ok) {
          console.error(resp)
          pushToTable({ state: "unknown", cmd: "infopro", time: getTime(timer) }, table)
          throw new Error("server error")
        }
        pushToTable({ state: "close", cmd: "toggle", time: getTime(timer) }, table)
        return LockState.Close
      })
      .catch((err) => {
        console.error(err)
        return LockState.Unknown
      })
  })

  return (
    <Grid container direction="row">
      <Grid container direction="row" className={classes.indicatorContainer}>
        <Timer ref={timer} />
        <PseudoDelayInput
          value={pseudoDelay}
          handleChange={(ev: React.ChangeEvent<HTMLInputElement>) => {
            setPseudoDelay(Number(ev.target.value))
          }}
        />
      </Grid>

      <Grid container direction="column" className={classes.iconContainer}>
        {(() => {
          switch (lockState) {
            case LockState.Open:
              return (
                <IconButton onClick={onUnlockIconClick}>
                  <Icon className={classes.bigIcon}>lock_open</Icon>
                </IconButton>
              )
            case LockState.Close:
              return (
                <IconButton onClick={onLockIconClick}>
                  <Icon className={classes.bigIcon}>lock</Icon>
                </IconButton>
              )
            case LockState.Indeterminate:
              return <CircularProgress />
            case LockState.Unknown:
            default:
              return (
                <IconButton onClick={onReplayIconClick}>
                  <Icon className={classes.bigIcon}>replay</Icon>
                </IconButton>
              )
          }
        })()}
      </Grid>

      <Grid container direction="column" className={classes.tableContainer}>
        <RecordTable ref={table} />
      </Grid>
    </Grid>
  )
}

少々複雑ですが、保持する状態は少なく、フロントエンド特有のものです。仕方ない。 ただ一応、 react-hook を使って多少イイカンジに書いています。素敵!!

ngrok

ngrok は、ローカルPC(この場合、Akerun Remote内部)で動作させているTCPアプリケーションを簡単に外部公開できるサービスです。

プログラマ3大美徳 を黙らせてくれる大変便利なツール。

  • ただのデモアプリだろうと、関係者はまとめて触るのが当然(傲慢)
  • 本番相当のホスト環境を用意するなんて面倒なことをするはずが(怠惰)
  • そもそもそんなことに時間をかけるのはいやだ(短気)

このデモを作った際に個人的に課金しました。

f:id:photosynth-inc:20211210153523p:plain
左: nextjsの動作環境 / 右: ngrokでサービス提供

え? vercel を使えばいいじゃないかって?ソウデスネ .....

CORS対策

さて、実はこのままでは動かないです…

nextjs と別の APIサーバ を共有するのに、クロスオリジン制約を突破する必要があります。

nextjs の rewrite を使うか、nextjs API Routeで一旦受け取ってプロキシすると良いです。そのうち追記するかも。。

限度見本が与えた影響

目の前に動くものがあることで、障害発生時の振る舞いを(開発・サポート・営業の誰もが)想像しやすくなり、コミュニケーションが簡単になりました。 おかげで、部署・関係者間の齟齬なく、やるべきことだけに取り組めるようになりました。

副次的な効果として、アドベントカレンダーのネタになったことに感謝しつつ、今日は終わり。

参考リンク


株式会社フォトシンスでは、一緒にプロダクトを成長させる様々なレイヤのエンジニアを募集しています。 hrmos.co

Akerun Proの購入はこちらから akerun.com