老ハムスターに遠隔で餌をあげる by Alibaba Cloud Serverless App Engine

## はじめに

こんにちは。@komasayukiです。

この記事は、Alibaba CloudのkubernetesやServerless製品（SAEなど）でサービス立ち上げてみよう by Alibaba Cloud Advent Calendar 2023 の18日目です。

https://qiita.com/advent-calendar/2023/alibaba1


家のハムスターが年老いてきて、カリカリ（ドライフード）の餌を食べられなくなってしまいました。

そのため、カリカリを粉にして水に溶かしてあげています。ゼリーも食べられないようで、液体食が必須です。ハムスターなのに餌をほっぺに貯められないので死活問題です。

残念ながら、私が家に帰れない日があり、愛ハムスターに遠隔で餌をあげるシステムを作ることにしました。

以下が完成したハムスター餌やり機です。

![ハムスターの自動餌やり機](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141013/33709e82-ae13-bedf-e96a-efc33ce900bb.png)

システム構成は以下のような感じです。

![システム構成](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141013/53019877-8330-0f89-75ac-4442dd100116.png)



今回初めてAlibaba Cloudを使ってみました。
自宅側はRaspberry Piです。

コードはGitHubで公開しています。

https://github.com/komasayuki/hamster-feeder


## 液カリ（カリカリの水溶き）をどうやって出すか？

犬猫用の自動餌やり機はAmazonにありました。
しかし、液体の餌に対応したものは見つかりません。

自作するしかないようです。

調べていくと、粘度の高い液カリを出すにはソープディスペンサーなどで使われる、蠕動（ぜんどう）ポンプが衛生面でも最適だとわかりました。

しかし液カリはすぐに乾いてしまうため、コップ+ポンプでは液カリが固まってしまいポンプが詰まります。

ここは既製品のソープディスペンサー新品（送料込み約1500円）をフリマで買いました。ソープを入れるものなので簡単には乾きませんし、ポンプ付きです。下手に蠕動ポンプ単体で買うより安いです。

<img width=50% alt="Pump" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141013/3f0d1b64-4904-8b64-cba5-2fc505649374.png">


## ソープディスペンサーの改造

このソープディスペンサーは赤外線センサーに手を近づけたらソープが出る仕組みで、手動ソープONボタンはありません。

これをRaspberry PiからソープON/OFF操作するのが目標となります。

分解して解析しましたが、センサーがシリアル通信で距離を吐いているようです。双方向の通信をしているようで、シリアル通信に介入するのは時間がかかりそうです。リレーで内蔵乾電池とポンプをON/OFFすることにしました。

## Raspberry Piでリレーを操作、液カリを出す

Raspberry piからはGPIO(3.3V出力)を使って、トランジスタ（2SC1815）で5Vリレー(オムロン G5V-1)を操作します。回路図は以下のような感じです。

![回路図](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141013/02f701a1-625f-7f5c-205d-9cfd0db95ebd.png)

手持ちにリレーがあったのでこのようにしましたが、モーター（蠕動ポンプ）が動けば何でも大丈夫です。

## ソフトウェアの開発

ハードウェアの準備ができました。後はソフトウェアです。

以下の３種類のソフトが必要になります。

- スマホ
    - Alibaba CloudにRESTでPOST（餌やり指示）する
- Alibaba Cloud
    - POSTを受信して、Raspberry Piから繋いであるWebSocketに餌やりを通知する
- Raspberry Pi
    - Alibaba CloudにWebSocketを繋いで、餌やり指示が届いたら一定時間リレーをONにする = ポンプを動かす


## スマホ側 - Alibaba CloudにRESTでPOST（餌やり指示）する

スマホ側はソフトを自作せずに、[API Tester](https://apps.apple.com/jp/app/api-tester-scripts-terminal/id1575521212)という無料iPhoneアプリを使いました。

設定を入れるだけでREST操作ができるので便利です。

<img width=50% alt="API Tester" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141013/43b24b43-2ef9-208b-a014-a45a3e3fa3c0.png">


このアプリはiOSのショートカットからも呼び出せるため、ホーム画面からワンタッチで（アプリ遷移なしで）実行できます。


## Alibaba Cloud側 - POSTを受信して、Raspberry Piから繋いであるWebSocketに餌やりを通知する</INS>

### コンテナの作成

Alibaba CloudのServerless App Engineは、AWS ECS+Fargateに似ています。

しかし、こちらはコンテナだけでなく、WAR、JAR、PHPはコンテナ化せずにデプロイ可能です。

今回はTypescriptで開発しますので、Dockerコンテナを動かします。

認証はシークレットとして固定文字列を使います。
シークレットはスマホからはクエリーで渡します。

ハムスターをいじめられたくないので、fail2banっぽく認証に失敗すると一定時間無視するようにしています。

以下、POSTを受け付けるコードの抜粋です。

```typescript
import express from "express";

const app = express();

//...

// RESTでPOSTされたときの処理
app.post('/', (req, res) => {
    const body = req.body; //POSTされたBody
    const secret = req.query.secret; //secretクエリ（認証に使う）

    const remoteAddress = req.socket.remoteAddress as string;

    if(isBanned(remoteAddress)) { //Banリストに接続IPが含まれているか確認
        res.status(401).send();
        console.log("POST / :", remoteAddress, "is in ban list");
        return;
    }

    if (secret !== SECRET) { //secretクエリが指定した固定文字列と一致するか確認
        console.log("POST / :", remoteAddress, "sent wrong secret:", secret);
        banRemoteAddress(remoteAddress);
        res.status(401).send("Unauthorized");
    }
    else { 
        // secretクエリが一致したので、WebSocketコネクションにブロードキャスト
        connections.forEach((conn) => {
            conn.send(body);
        });

        res.send("Feeding..."); 
    }
});
```

WebSocketの受付には`express-ws`を使い、RESTと同一ポートで対応するようにします。

```typescript
import http from "http";
import expressWs from "express-ws";
import * as ws from "ws";

//...

const httpServer = http.createServer(app);
const appWs = expressWs(app, httpServer);

appWs.app.ws('/', function (ws, req) {

    const remoteAddress = req.socket.remoteAddress as string;

    if(isBanned(remoteAddress)) { //Banリストに接続IPが含まれているか確認
        ws.close();
        console.log(remoteAddress, "is in ban list");
        return;
    }

    if(req.query.secret !== SECRET) { //secretクエリが指定した固定文字列と一致するか確認
        console.log("Websocket", remoteAddress, "sent wrong secret:", req.query.secret, req.query);
        banRemoteAddress(remoteAddress);
        ws.close();
        return;
    }

    console.log("Websocket", remoteAddress, "connected");
    connections.push(ws);

    ws.on('close', function (msg) { //WebSocketが切断されたらブロードキャスト先から削除
        console.log('close');
        connections.splice(connections.indexOf(ws), 1);
    });

});

```


Typescriptをビルドして、以下のDockerfileでコンテナ化します。脆弱性を減らすためにdistrolessを実行用イメージとして使います。

```Dockerfile
ARG BUILD_IMAGE=node:20.1.0
ARG RUN_IMAGE=gcr.io/distroless/nodejs20-debian11

# Build stage
FROM $BUILD_IMAGE AS build-env
COPY . /app
WORKDIR /app
RUN npm ci && npm run build

# Prepare production dependencies
FROM $BUILD_IMAGE AS deps-env
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# Create final production stage
FROM $RUN_IMAGE AS run-env
WORKDIR /usr/app
COPY --from=deps-env /node_modules ./node_modules
COPY --from=build-env /app/dist/index.js ./index.js
COPY package.json ./

ENV NODE_ENV="production"
EXPOSE 8080
CMD ["/usr/app/index.js"]
```

あとはdocker buildしてimageを作ります。

```shell
% docker build -t feed-server .
```

### Alibaba Cloud側の前準備

Serverless App Engineの利用前に

- VPC
- vSwitch (= AWSではサブネット)
- Security Group

を先に作る必要があります。VPCの画面に辿り着けば簡単に作れます。

![VPC](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141013/1a357e5e-2c9b-4bac-ec91-a1a0035cd240.png)

AWSと違い、VPCやSecurity Groupは最初から存在しません。しかし、近年、AWSでもデフォルトセキュリティグループは使わないプラクティスが流行っていますし、これらは意識して作ると安全かもしれません。

### コンテナレジストリにPush

Docker imageはContainer Registry(AWSではECR)にpushします。
AWS ECRと同じくブラウザでサンプルコマンドが表示され、dockerコマンドでpushできます。
Container RegistryにはPersonalとEnterpriseの２種類があり、Personalを使いました。

![Registry](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141013/ecfb29b5-9efa-3574-a30b-3dd7241fa40c.png)


### Serverless App Engineのセットアップ

Create Applicationを押すと、アプリを簡単に立ち上げできます。

![SAE1](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141013/080ce231-a625-9f32-226f-3b4312e6dfa3.png)

最安のインスタンスで、1分当たり0.0005292ドルかかると表示されています。
1ヶ月で3500円ぐらいですね。
（AWS ECSの最弱インスタンスだと6500円ぐらいなので安いです）

Nextを押します。


![Screenshot 2023-12-14 at 17.11.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141013/29fccf43-799a-3b63-dd9e-9c854f0689c3.png)

次の画面では、先程pushしたコンテナイメージを選択します。
あと環境変数`SECRET`を設定して、ヘルスチェックを指定しました。

これでインスタンスが立ち上がります。

### Server Load Balancerの追加

しかし、まだインターネットからアクセスできません。SAEのBasic Informationから、
`Public Endpoint: Add Internet-facing SLB Instance`を押して、SLB(Server Load Balancer)を追加します。

![SLB](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141013/2669f301-1431-1e6f-ca51-53c2f1257ab7.png)

ここではhttpのPortバインディングを入力します。
SLBはWebSocketに対応しています。
(ws -> http, wss -> httpsで設定できます)

![Port](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/141013/765df975-1af3-9f2d-add3-2833879488ed.png)

8080でRESTとWebSocketを両方使えるようにしているので、Portバインディングは1つだけです。実運用前にはhttpsに切り替えてください。同じ画面で設定できます。（証明書が必要です）

SLBが作れたら、Public IPが表示されます。インターネットからアクセス可能になっています。

Serverless App Engineにインターネットから直アクセスしたい場合は、EIPをアタッチする方法もあります。(AWS ECSと違い、インスタンス=TaskにPublic IPは発行してないため、設定が必要)

これはAWSのEIP的なものですが、Alibaba CloudのEIPは通信帯域 or データ転送量で課金されます。

小規模にhttpsを利用したい場合、SLB経由より、EIP（データ転送量課金）でインターネットアクセス可能にして、httpsの構成はSLBのインスタンス内で行うと安価になります。

では、これで構築が完成していますので、コマンドラインでテストしましょう。

```shell
# Websocketで接続、次のcurl POSTコマンドを実行した後に、feedと表示されたらOK
% wscat -c "ws://12.34.56.78?secret=YOUR-SECRET-TOKEN"
Connected (press CTRL+C to quit)
< feed
```

```shell
# スマホ側でPOSTする操作と同じことをcurlで実行する
% curl -X POST -d 'feed' "http://12.34.56.78?secret=YOUR-SECRET-TOKEN"
```

これでAlibaba Cloud側は準備完了です。

Serverless App Engineはアプリケーションを簡単にStopできますが、SLBは残りますので、課金を完全に止めたい場合はSLBを削除しましょう。


## Raspberry Pi側 - Alibaba CloudにWebSocketを繋いで、餌やり指示が届いたら一定時間リレーをONにする

Raspberry Piからは、まずWebSocketでAlibaba CloudのServerless App Engineに接続します。（Server Load Balancer経由で）

```typescript
import WebSocket from 'ws';

//urlにはServer Load Balancerのws://IP-ADDRESS?secret=YOUR-SECRET-TOKENが入っている
const ws = new WebSocket(url);

ws.on('open', () => {
    console.log('Connected to the server');
});
```

スマホからPOSTすると、Serverless App EngineがWebSocketでRaspberry Piにリレーします。メッセージが来たら一定時間リレーをONにします。

```typescript

ws.on('message', (message: string) => {
    console.log(`Received message from server: ${message}`);

    if(message === 'feed'){
        startFeeding();
    }
});
```


実際のリレーON処理は以下のようになっています。

```typescript
import GPIO from 'rpi-gpio'

const PIN_GPIO_4 = 7; //Raspberry PiのGPIO4は7番ピンに規定されている

function startFeeding(){

    GPIO.write(PIN_GPIO_4, true);

    setTimeout(() => {
        GPIO.write(PIN_GPIO_4, false);
    }, 1000);
}
```

Raspberry PiのGPIO4がトランジスタのBaseに繋がっています。
`GPIO.write`で`true`にしてあげると、「リレーON = 餌が出る」　になります。

止めないと餌が出っぱなしになりますので、一定時間で`false`にします。

いろいろ試した結果、１秒間リレーをオンにしてポンプを動かすと、ちょうどいい具合に餌やりできました。（餌が少なければ複数回指示をすればいい）

ここまで読んだ方は
**「液カリをハムスターの頭上にぶちまけないか？」** と不安になったんじゃないでしょうか？

安心してください。ハムスターのケージにWifiカメラをつけています。いつもニヤニヤしながら愛ハムを見ています。タイミングよく、液カリを出すためにポーリングではなくWebsocketなんです。

## Alibaba Cloudを初めて使ってみて

私は普段、AWSを使っており、GCPやAzureの経験も少しあります。

Alibaba Cloudを使ったのは初めてでしたが、今回使ったServerless App Engineはとてもスムーズに使えて驚きました。仕組みがAWSに似ているので、AWSを使っている人は違和感なく利用できるんじゃないかと思います。

Serverless App Engine以外のコストも比較してみたのですが、全体的にコストがAWSより安くなっていて、コストメリットもありそうです。


## 最後に

実はAlibaba Cloudのアカウント作成で、本人確認がうまくいかず、サポートと10回以上やり取りをしました。

その中で、Alibaba Cloudのサポートが概ね１時間ぐらいで返事をしてくれるのに本当に驚きました。私はAWSのエンタープライズサポートを使っていますが、こんなに早くは返事がきません。

熱心なサポートをしてくれた Alibaba Cloud International Support と、問題解決をしてくれたBackend Teamの方に感謝します。