はじめに
こんにちは。@komasayukiです。
この記事は、Alibaba CloudのkubernetesやServerless製品(SAEなど)でサービス立ち上げてみよう by Alibaba Cloud Advent Calendar 2023 の18日目です。
家のハムスターが年老いてきて、カリカリ(ドライフード)の餌を食べられなくなってしまいました。
そのため、カリカリを粉にして水に溶かしてあげています。ゼリーも食べられないようで、液体食が必須です。ハムスターなのに餌をほっぺに貯められないので死活問題です。
残念ながら、私が家に帰れない日があり、愛ハムスターに遠隔で餌をあげるシステムを作ることにしました。
以下が完成したハムスター餌やり機です。
システム構成は以下のような感じです。
今回初めてAlibaba Cloudを使ってみました。
自宅側はRaspberry Piです。
コードはGitHubで公開しています。
液カリ(カリカリの水溶き)をどうやって出すか?
犬猫用の自動餌やり機はAmazonにありました。
しかし、液体の餌に対応したものは見つかりません。
自作するしかないようです。
調べていくと、粘度の高い液カリを出すにはソープディスペンサーなどで使われる、蠕動(ぜんどう)ポンプが衛生面でも最適だとわかりました。
しかし液カリはすぐに乾いてしまうため、コップ+ポンプでは液カリが固まってしまいポンプが詰まります。
ここは既製品のソープディスペンサー新品(送料込み約1500円)をフリマで買いました。ソープを入れるものなので簡単には乾きませんし、ポンプ付きです。下手に蠕動ポンプ単体で買うより安いです。
ソープディスペンサーの改造
このソープディスペンサーは赤外線センサーに手を近づけたらソープが出る仕組みで、手動ソープONボタンはありません。
これをRaspberry PiからソープON/OFF操作するのが目標となります。
分解して解析しましたが、センサーがシリアル通信で距離を吐いているようです。双方向の通信をしているようで、シリアル通信に介入するのは時間がかかりそうです。リレーで内蔵乾電池とポンプをON/OFFすることにしました。
Raspberry Piでリレーを操作、液カリを出す
Raspberry piからはGPIO(3.3V出力)を使って、トランジスタ(2SC1815)で5Vリレー(オムロン G5V-1)を操作します。回路図は以下のような感じです。
手持ちにリレーがあったのでこのようにしましたが、モーター(蠕動ポンプ)が動けば何でも大丈夫です。
ソフトウェアの開発
ハードウェアの準備ができました。後はソフトウェアです。
以下の3種類のソフトが必要になります。
- スマホ
- Alibaba CloudにRESTでPOST(餌やり指示)する
- Alibaba Cloud
- POSTを受信して、Raspberry Piから繋いであるWebSocketに餌やりを通知する
- Raspberry Pi
- Alibaba CloudにWebSocketを繋いで、餌やり指示が届いたら一定時間リレーをONにする = ポンプを動かす
スマホ側 - Alibaba CloudにRESTでPOST(餌やり指示)する
スマホ側はソフトを自作せずに、API Testerという無料iPhoneアプリを使いました。
設定を入れるだけでREST操作ができるので便利です。
このアプリはiOSのショートカットからも呼び出せるため、ホーム画面からワンタッチで(アプリ遷移なしで)実行できます。
Alibaba Cloud側 - POSTを受信して、Raspberry Piから繋いであるWebSocketに餌やりを通知する
コンテナの作成
Alibaba CloudのServerless App Engineは、AWS ECS+Fargateに似ています。
しかし、こちらはコンテナだけでなく、WAR、JAR、PHPはコンテナ化せずにデプロイ可能です。
今回はTypescriptで開発しますので、Dockerコンテナを動かします。
認証はシークレットとして固定文字列を使います。
シークレットはスマホからはクエリーで渡します。
ハムスターをいじめられたくないので、fail2banっぽく認証に失敗すると一定時間無視するようにしています。
以下、POSTを受け付けるコードの抜粋です。
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と同一ポートで対応するようにします。
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を実行用イメージとして使います。
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を作ります。
% docker build -t feed-server .
Alibaba Cloud側の前準備
Serverless App Engineの利用前に
- VPC
- vSwitch (= AWSではサブネット)
- Security Group
を先に作る必要があります。VPCの画面に辿り着けば簡単に作れます。
AWSと違い、VPCやSecurity Groupは最初から存在しません。しかし、近年、AWSでもデフォルトセキュリティグループは使わないプラクティスが流行っていますし、これらは意識して作ると安全かもしれません。
コンテナレジストリにPush
Docker imageはContainer Registry(AWSではECR)にpushします。
AWS ECRと同じくブラウザでサンプルコマンドが表示され、dockerコマンドでpushできます。
Container RegistryにはPersonalとEnterpriseの2種類があり、Personalを使いました。
Serverless App Engineのセットアップ
Create Applicationを押すと、アプリを簡単に立ち上げできます。
最安のインスタンスで、1分当たり0.0005292ドルかかると表示されています。
1ヶ月で3500円ぐらいですね。
(AWS ECSの最弱インスタンスだと6500円ぐらいなので安いです)
Nextを押します。
次の画面では、先程pushしたコンテナイメージを選択します。
あと環境変数SECRET
を設定して、ヘルスチェックを指定しました。
これでインスタンスが立ち上がります。
Server Load Balancerの追加
しかし、まだインターネットからアクセスできません。SAEのBasic Informationから、
Public Endpoint: Add Internet-facing SLB Instance
を押して、SLB(Server Load Balancer)を追加します。
ここではhttpのPortバインディングを入力します。
SLBはWebSocketに対応しています。
(ws -> http, wss -> httpsで設定できます)
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のインスタンス内で行うと安価になります。
では、これで構築が完成していますので、コマンドラインでテストしましょう。
# Websocketで接続、次のcurl POSTコマンドを実行した後に、feedと表示されたらOK
% wscat -c "ws://12.34.56.78?secret=YOUR-SECRET-TOKEN"
Connected (press CTRL+C to quit)
< feed
# スマホ側で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経由で)
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にします。
ws.on('message', (message: string) => {
console.log(`Received message from server: ${message}`);
if(message === 'feed'){
startFeeding();
}
});
実際のリレーON処理は以下のようになっています。
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
にします。
いろいろ試した結果、1秒間リレーをオンにしてポンプを動かすと、ちょうどいい具合に餌やりできました。(餌が少なければ複数回指示をすればいい)
ここまで読んだ方は
「液カリをハムスターの頭上にぶちまけないか?」 と不安になったんじゃないでしょうか?
安心してください。ハムスターのケージにWifiカメラをつけています。いつもニヤニヤしながら愛ハムを見ています。タイミングよく、液カリを出すためにポーリングではなくWebsocketなんです。
Alibaba Cloudを初めて使ってみて
私は普段、AWSを使っており、GCPやAzureの経験も少しあります。
Alibaba Cloudを使ったのは初めてでしたが、今回使ったServerless App Engineはとてもスムーズに使えて驚きました。仕組みがAWSに似ているので、AWSを使っている人は違和感なく利用できるんじゃないかと思います。
Serverless App Engine以外のコストも比較してみたのですが、全体的にコストがAWSより安くなっていて、コストメリットもありそうです。
最後に
実はAlibaba Cloudのアカウント作成で、本人確認がうまくいかず、サポートと10回以上やり取りをしました。
その中で、Alibaba Cloudのサポートが概ね1時間ぐらいで返事をしてくれるのに本当に驚きました。私はAWSのエンタープライズサポートを使っていますが、こんなに早くは返事がきません。
熱心なサポートをしてくれた Alibaba Cloud International Support と、問題解決をしてくれたBackend Teamの方に感謝します。