個人開発Webアプリケーションを0円で運用する
こんにちは、takapi86です。
最近は、ジョジョの奇妙な冒険にハマっています。9月ごろから1部を見始めて、現在は6部のシーズン2まできました。 好きなスタンドはクレイジー・ダイヤモンドです。
これはGMOペパボ EC Advent Calendar 2023 12/16 の記事です。
本日は、自己学習・自己成長に大きく良い影響を与えた、「個人開発のWebアプリケーションを0円で運用する方法」についてお話ししたいと思います。
はじめに
さて、みなさんは個人開発のWebアプリケーションにどれくらいのコストをかけていますか?学習目的でクラウドサービスを使ってアプリケーションを立ち上げることが多いですよね。しかし、その運用コストが積み重なると、維持が難しくなり、結局サービスを削除してしまうことがあります。初期の低コストで始めたとしても、使わない期間の継続的なコストは負担に感じられることでしょう。
しかし、もし0円で運用できるとしたらどうでしょう?その心理的な障壁は大幅に低減され、運用がずっと容易になります。個人開発のWebアプリケーションは、人に見せたり、使ってもらうことで自己成長に繋がることが多いですから、できるだけ削除したくないですよね。
そこで今回は、私が0円で運用した経験をもとに、その構成を皆さんにお伝えしようと思います。
この他にも、コストがかからないサービスや工夫などあれば、ぜひ教えてくださいね。
なぜやろうとおもったのか?
私が所属しているバドミントンサークルがきっかけです。サークルでは、シャトルを用意するために前日までに参加者の人数を把握しておく必要があります。しかし、従来の方法では、掲示板に参加表明をしてもらい、その後で参加者の人数を確認するのが非常に手間がかかる作業でした。掲示板を利用すると、どの日に誰が参加するかを確認するために、全体を上から順に見ていく必要があり、参加者を一人ずつ数えるという煩雑さがありました。
これを解決するために、私はWebアプリケーションの開発に乗り出しました。開発には、仕事で使用している技術に加えて、当時さらに学びを深めたいと考えていたRuby on Railsを使い実装することにしました。
高スペックのサーバーが不要なこと、またサークルでは以前、独自ドメインとレンタルサーバを使って年間1万円以上のコストで掲示板を運用していましたが、運用費削減の要望に応えるため、コストがかからないアプローチに切り替えました。
0円アプリケーション構成
0円アプリケーション構成は、以下の通りとなりました。
- CDNおよびTLS証明書: Cloudflare
- アプリケーションサーバ: Google Cloud Compute Engine上のe2-microインスタンス
- データベースサーバ: ClearDB
- ドメイン管理: ムームードメイン
Web&Applicationサーバ
Web&Applicationサーバは、Google CloudのCompute Engineを使って構築しました。 インスタンスはe2-microを選択しました。このインスタンスはGoogle Cloudの無料枠内で利用できるため、月額の料金が発生することはありません。
e2-microインスタンスのスペックは、2つの仮想CPU(vCPU)、1GBのRAM、そして30GBのストレージを持っています。 Railsアプリケーションをアプリケーションサーバ(unicron)として運用していますが、このスペックは、Railsアプリケーションを動かすには十分です。
リソースに余裕を持たせるために、前段のWebプロキシとしてnginxなどのWebサーバを導入せず、unicronだけをアプリケーションサーバとして動かしています。 このシンプルな構成でも、小規模なWebアプリケーションの運用には十分です。
静的ファイルのキャッシュなどの扱い関しては、次のセクションで説明するCDNを利用して対応します。
CDN、TLS証明書
リソースに余裕を持たせるためにunicronにリクエストをプロキシする構成にし、変わりに直接CDNを通じてキャッシュ等の設定を行いWebサイトの配信速度を向上させるようにしました。 これは、Cloudflareの無料CDNプランを利用して実現しました。
TLSについては、無料のものといえばLet's Encryptですが、Cloudflareを導入することでなにもしなくてもTLSでの通信も可能になります。 Let's Encryptは更新の処理などの設定がそこそこ大変なので、その煩雑さを回避することができました。
また、Cloudflareからunicornサーバへの通信はHTTPとなりますが、この経路のみにセキュリティグループを設定してアクセスを制限することで、Cloudflare以外からのHTTPアクセスを防ぎます。これにより、HTTPSからの通信のみを許可し、セキュリティをさらに強化しています。
データベース
このプロジェクトでは、データベースサービスとしてClearDBの無料プランを採用しました。
ClearDBは、MySQLデータベースをクラウド上で提供するサービスです。無料プランには最大5MBのデータストレージが含まれており、私が今回運用する小規模なWebアプリケーションには十分な容量でした。
ドメイン
ドメインに関しては、私たちの会社であるGMOペパボが提供するムームードメインを利用しました。
弊社の社員には福利厚生として、好きなドメインを一つ無料で取得することができます。 そのため、ドメインのコストも節約することができました。
もしドメインを無料で手に入れたい場合は、弊社に入社してくださいね。(突然の宣伝!)
構築してよかったこと
小規模ながらも、実際にユーザーに使用してもらえるWebアプリケーションを構築した経験は、私にとって大きな学びとなりました。この経験により、開発において適切な緊張感を保ちつつ、「しっかりと運用しよう」という意識を高めることができました。適当な設定や手抜きをせず、しっかりとしたアプローチを取ることで、より良い学習経験につながりました。
また、これをコストゼロで行うことで、心理的な負担も大きく軽減されました。学習する上で、費用の心配をせずに済むのは非常に大きなメリットです。
詳しくは述べていませんが、このアーキテクチャ上には常に変更を行っていて「現在学習している内容」に基づいてサンドボックスのように様々試しながら運用しています。 これにより、実際に運用し学習を楽しみながら進めることができています。
皆さんも、もしよければこちらを参考にし、コストを気にせずに技術的な挑戦を楽しんでいただければ幸いです。
Whisperを活用して長時間の音声ファイルを字幕ファイルに変換する君を作ってみた
ローカル環境で、Whisperを使って長時間の音声ファイルを字幕ファイルに変換する仕組みを構築しました! 具体的には、WAV形式の音声ファイルを入力として受け取り、出力としてWebVTT(Web Video Text Tracks)形式の字幕ファイルを生成します。
まだまだブラッシュアップする必要がありますが、こんな感じ
特徴3行
- 音声ファイルの分割: Whisperライブラリは30秒以上の音声を一度にテキスト変換するとメモリ不足(OOM)が発生する可能性があります。おそらくAPIの制限が30秒なため、その時間を基準に設計されているのかな?と思ってます。この問題を回避するために、私たちは音声ファイルを分割して処理するようにしました。
- リジューム機能: 変換プロセスが途中で失敗した場合でも、そこから再開できるようにリジューム機能を実装しました。
- Docker化: PythonベースのAIツールを色々使用していると、CUDAやPyTorchなどのGPU利用ライブラリを都度切り替える必要があり、それが手間になることがあります。Dockerで立ち上がるようにし、ライブラリの切り替えを簡単に行えるようにしました。
構築したマシン環境
- オペレーティングシステム: Windows 11上のWSL2で動作するUbuntu 20.04
- コンテナ化ツール: Dockerおよびdocker-compose
- 使用したライブラリ: Python、CUDA、PyTorch、その他
- 音声認識エンジン: Whisper
- ハードウェアスペック:
- プロセッサー: Intel Core i5-13400F
- メモリ: 16GB
- グラフィックスカード: NVIDIA GeForce RTX 3080
そもそもWhisperってなに?
「Whisper」はOpenAIが開発した音声認識AIモデルで、多数の言語の音声をテキストに変換することができます。 API形式で提供されており、低コストで高精度な音声テキスト変換を実現することが特徴ですが、実はOpenAIがMITライセンスのもとで公開しているため、個々のマシンで自由に利用することができます。
作った背景・モチベーション
Youtube動画のチャプターとそれぞれの概要を自動生成する君を作りたかった。 その材料として、動画の音声から字幕ファイルを作り、それをOpenAIのAPIへLlamaIndex経由でデータを適当なプロンプトとなげれば自動要約できるんじゃない? と思い試してみたかったので、最初のステップとしてローカルでこのような仕組みを作る必要がありました。 APIだと色々試すとコストがそれなりにかかっちゃうからね...
作り始めた理由は、YouTube動画のチャプターとそれぞれの概要を自動生成する君を作りたかったからです。
流れ的には・・・こんな感じの想定
動画の音声から字幕ファイルを作成 ↓ 字幕ファイルをLlamaIndexを通じてOpenAIのAPIに投入 ↓ 適切なプロンプトと共に送信 ↓ _人人人人人人人人人人人_ > 自動的に要約を生成 <  ̄Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^Y^ ̄
一方で、APIを使用すると多くの試行が必要になると、それなりのコストがかかってしまいます。そのため、ローカル環境でOSS版のWhisperを使ってツールを構築することで、自由に試行錯誤を行うことができるようにしました
ハマったこと
- Pythonなにもわからない問題
- Pythonなにもわからない。だったので、ChatGPT君にベースのコードを書いてもらいました。
- ちょっとしたツールなら構文わからなくてもなんとなく書けてしまうの助かってます。
- CUDAとPyTorchのバージョンを合わせるのが難しかった
- CUDAとPyTorchのバージョンを合わせるのが難しく、この問題がバージョンに関連していると気づくまでに時間がかかりました。このページを見つけて、バージョン互換性の問題を解決することができました。
- https://pytorch.org/get-started/previous-versions/
動かしてみる
この動画を字幕ファイル(VTT)にしてみます。
WEBVTT 00:00:00.000 --> 00:00:30.000 さあ、台丸埋め辺の多分と同時に、光星の拡が押し寄せたこちらの売り場。人展堂の公式ショップ、人展堂大阪にはある商品を求めて大根雑になりました。回転からわずかご分で、かなから商品がなくなることも。 00:00:30.000 --> 00:01:00.000 全部はあればいいですか全部はございますさらにその5分後には電内に40人を超える大業率が発生超弾の業率の先にあるのはこちらゼルダの全スチリーズのグッズリバです中でも人気なのはこちらのフィギュアです薬のお見当ては今日発売された人展動数一用の大ヒットゲーム 00:01:00.000 --> 00:01:30.000 の電池、キヤーゾークザキングダムの関連グッズゲーム次回の人気が高い上多くのグッズは人展堂の公式ショップと公式サイトだけで買えるためたくさんのファンが詰めかけましたグッズが新作が出るということで今日狙って今日保鋼きました今日フィギュアは11種類ですねもう再風が身体ですもう金がないです 00:01:30.000 --> 00:02:00.000 本作も数年会えてるんで、ゲームも楽しみですし、やっぱり身を見てて、うきうきするんで。人展堂が今月発表した昨年度の決算は、原修ゲイエキ。主力のスイッチの売り浮きは世界的な反動タイブ速の影響などがあり、前の年より落ち込みました。 00:02:00.000 --> 00:02:30.000 戦列後悔したマリオの映画は高調で、全世界の工業収入が今月曜日までに1500億円を突破するなど、コンメンドの行席の構造料となっています。そこに発売されたライフィットゲームゼルダの新作、人展堂の更なるおいかぜとなるでしょうか。ご視聴ありがとうございましたけど、 00:02:30.000 --> 00:03:00.000 MBC 뉴스 김
一部会社名や商品名が怪しいですがwなんとなくできました。
次のアクション
作成した字幕ファイルをLlamaIndexを通じて要約できるようにしてみようと思います。
1時間以上の長さや大きな字幕ファイルとか、言語モデルのトークンの上限に達してしまい、うまく要約できない可能性がありそう... 現状あまりこの辺は詳しくないので、引き続きチャレンジしていきます。
Docker ComposeでNFSをマウントしてみる
環境
- ホストOS: CentOS7
- コンテナイメージ: Ubuntu 22.04
今回は簡易的にCentOS7のホスト上にあるボリュームをコンテナからNFS経由でマウントする方式で検証したので記録に残しておきます。
ホスト側の準備
検証のため簡易的な設定にします。
インストール〜起動設定
sudo yum install nfs-tools sudo systemctl start nfs sudo systemctl start rpcbind sudo systemctl enable nfs sudo systemctl enable rpcbind
NFS設定
sudo mkdir /nfs # マウントするディレクトリを作成 sudo vim /etc/exports
以下を追加
/nfs *(rw,no_root_squash)
※検証のため/nfsのディレクトリにどのホストからでも書き込み読み込みができる設定にしました。本番で使う際は適切に設定するようにしましょう。
設定を反映
sudo exportfs -ra
Docker Compose側の準備
このような感じで設定します。
services: app: image: appimage volumes: - nfs-volume:/tmp/volumes volumes: nfs-volume: driver_opts: type: nfs o: "addr=127.0.0.1,rw,nfsvers=4" device: ":/nfs"
詳細な設定については以下ドキュメントをご参照ください
https://docs.docker.jp/compose/compose-file/compose-file-v3.html?highlight=nfs#driver-opts https://docs.docker.jp/storage/volumes.html
Virtualbox/Vagrantに構築したk0s環境へホストからkubectlできるようにする
前回はk0sでKubernetes環境をVirtualbox/Vagrantに構築し、nginxのデフォルトページを表示させるところまで行いました。
今回はこの構築した環境にkubectlできるようにします。
結論から言うと手順はすごく簡単でした。
以下のディレクトリにkubeconfigの情報があります。
/var/lib/k0s/pki/admin.conf
これをホスト側に落としてきて、この設定でkubectlできるようにします。
vagrant ssh -c bash -c "sudo cat /var/lib/k0s/pki/admin.conf" > /Users/takapi/k0s/admin.conf export KUBECONFIG=/Users/takapi/k0s/admin.conf
設定はデフォルトで、このような形になっているので、server
をホスト側から接続できるようIPアドレスを書き換えます。
apiVersion: v1 clusters: - cluster: server: https://localhost:6443 certificate-authority-data: XXXXXXXXXXXX name: local contexts: - context: cluster: local namespace: default user: user name: Default current-context: Default kind: Config preferences: {} users: - name: user user: client-certificate-data: XXXXXXXXXXXX client-key-data: XXXXXXXXXXXX
apiVersion: v1 clusters: - cluster: - server: https://localhost:6443 + server: https://192.168.35.101:6443
これで完了。kubectlしてみます。
kubectl get pods NAME READY STATUS RESTARTS AGE nginx-7848d4b86f-cnrff 1/1 Running 4 19h nginx-7848d4b86f-nj6qm 1/1 Running 4 19h nginx-7848d4b86f-t9zhw 1/1 Running 4 19h
podを5台にしてapplyします。
kubectl apply -f nginx.yaml deployment.apps/nginx configured service/nginx unchanged kubectl get pods NAME READY STATUS RESTARTS AGE nginx-7848d4b86f-cnrff 1/1 Running 4 19h nginx-7848d4b86f-mzghv 1/1 Running 0 9s nginx-7848d4b86f-nj6qm 1/1 Running 4 19h nginx-7848d4b86f-t9zhw 1/1 Running 4 19h nginx-7848d4b86f-xsvnq 1/1 Running 0 9s
増えましたね、良さそう。
今回はこの辺で。
k0sでKubernetes環境をVirtualbox/Vagrantに構築する
k0sという軽量Kubernetesディストリビューションがあるようです。
自前のWebアプリを安価にk8sクラスタで運用できると嬉しいなぁということでまずはローカル環境で試してみます。
この記事のゴール
Kubernetes環境でNginxを立て、ホストからNginxのデフォルトページアクセスできる
参考にした記事
https://www.creationline.com/lab/47938 https://www.niandc.co.jp/sol/tech/date20201124_1935.php
k0sのインストール
この記事(k0sインストール)を参考にインストールしていきます。 https://www.creationline.com/lab/47938
- 記事では、private_networkが
192.168.56.101
となっていますが、以下のエラーが出てしまったので、192.168.35.101
に変更しました。
The IP address configured for the host-only network is not within the allowed ranges. Please update the address used to be within the allowed ranges and run the command again. Address: 192.168.56.101 Ranges: 192.168.35.0/24
この環境では、/usr/local/bin
のパスがsudo経由では通っていないので、パスを通すか毎度 /usr/local/bin/k0s
と入力します。
マニフェストを用意・適用する
DeploymentとServiceを用意します。
nginx.yaml
apiVersion: apps/v1 kind: Deployment metadata: name: nginx spec: replicas: 3 selector: matchLabels: app: nginx template: metadata: labels: app: nginx spec: containers: - name: nginx image: nginx ports: - containerPort: 80 --- apiVersion: v1 kind: Service metadata: name: nginx spec: selector: app: nginx ports: - port: 80 targetPort: 80 nodePort: 30007 type: NodePort
以下のコマンドでapplyします。
sudo /usr/local/bin/k0s kubectl apply -f nginx.yaml
正しくapplyされていそうか確認
[vagrant@node1 ~]$ sudo /usr/local/bin/k0s kubectl get all NAME READY STATUS RESTARTS AGE pod/nginx-7848d4b86f-cnrff 1/1 Running 0 3m48s pod/nginx-7848d4b86f-nj6qm 1/1 Running 0 3m48s pod/nginx-7848d4b86f-t9zhw 1/1 Running 0 3m48s NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE service/kubernetes ClusterIP 10.96.0.1 <none> 443/TCP 78m service/nginx NodePort 10.98.152.172 <none> 80:30007/TCP 3m48s NAME READY UP-TO-DATE AVAILABLE AGE deployment.apps/nginx 3/3 3 3 3m48s NAME DESIRED CURRENT READY AGE replicaset.apps/nginx-7848d4b86f 3 3 3 3m48s
ホストからNginxのデフォルトページへアクセス
VMのIPは192.168.35.101で、NodePortのIPは30007で固定しているため、以下のURLでアクセスします。
curl http://192.168.35.101:30007/
デフォルトページっぽいものが返ってくればOK
まとめ
Kubernetes環境をさくっと立てられて便利でした! 次回は外部からkubectlを実行できるようにしていく予定です!
DockerfileのCMDをShell形式で記述したらコンテナが停止しなくなってしまったので直した
UbuntuでPHP-FPMのイメージを作成する際に、CMDにPHP-FPMの起動コマンドをShell形式で書いたらdocker kill, Ctrl-Cでコンテナが終了しなくなってしまったので調べて修正しました。
FROM ubuntu:20.04 RUN apt update -qq && apt -y upgrade -qqy \ && DEBIAN_FRONTEND=noninteractive apt -qqy install php \ php-fpm \ && apt clean \ && rm -rf /var/lib/apt/lists/* CMD /usr/sbin/php-fpm7.4 -F -y /etc/php/7.4/fpm/php-fpm.conf
こういう感じでDockerfileを書いていたのですが、
docker run --rm ubuntu-php-fpm
のような形で起動したところ、docker killやCtrl-Cでコンテナが終了しなくなってしまいました。 CentOS7のイメージではうまくいったのに...!?
- 起動コマンドをShell形式で書いていたこと
- Ubuntuのイメージを使用していたこと
上記に点が今回の事象原因のようでした。
起動コマンドをShell形式で書いていた
CMD 命令にはShell形式とExec形式があります。 ※今回はENTRYPOINTを使用する例についての話は省略します。
https://docs.docker.jp/engine/reference/builder.html#cmd
今回の例だと、以下がShell形式
CMD /usr/sbin/php-fpm7.4 -F -y /etc/php/7.4/fpm/php-fpm.conf
以下がExec形式となります。
CMD ["/usr/sbin/php-fpm7.4","-F","-y","/etc/php/7.4/fpm/php-fpm.conf"]
Shell形式は内部で /bin/sh -c
を使用しています。
そのため、実際に起動するコマンドはこうなります。
/bin/sh -c "/usr/sbin/php-fpm7.4 -F -y /etc/php/7.4/fpm/php-fpm.conf"
docker kill, Ctrl-CはPID1のプロセスにシグナルを送ります。 そのため、ここで受け取るプロセスは/bin/shになります。
Ubuntuのイメージを使用していた
Ubuntuの /bin/sh
は dash
を参照しています。
root@0bc44db82ffa:/# ls -la /bin/sh lrwxrwxrwx 1 root root 4 Jul 18 2019 /bin/sh -> dash
そのため、docker killやCtrl-Cはdashに送られます。 このことからわかるように、シェルは子プロセスにシグナル伝搬しないので、PHP-FPMまでシグナルが届かず今回のようにコンテナが終了しないということになります。
なぜCentOSなら終了するのか??
CentOSの /bin/sh
は bash
を参照しています。
[root@af2a4533ad51 /]# ls -la /bin/sh lrwxrwxrwx 1 root root 4 Nov 13 2020 /bin/sh -> bash
bashは-cで起動したプロセスにPIDが置き換わります。 今回のケースではPID1になるため、シグナルを受け取ることができコンテナを終了することができていたようでした。
まとめ
CMDで指定するコマンドは推奨されているExec形式を使っていこうず https://docs.docker.jp/engine/reference/builder.html#cmd
参考
https://www.creationline.com/lab/39662 https://qiita.com/ukinau/items/410f56b6d777ad1e4e90
heroku-guardianを試した
heroku-guardianってなに?
Herokuのセキュリティチェックツールです。 Herokuプラットフォーム自体の攻撃対象領域を減らすためのチェックをしてくれます。
何をチェックするの?
ユーザの認証設定や意図してないビルドパック使っていないか、その他諸々見てくれるようです。 詳しくは次のURLをご確認ください。
こういった定義ファイルを書いて、その定義がHerokuの設定にが沿っているかを見てくれるらしいです。
[AUTH] api_key = "<your API token>" # trusted Heroku add-ons [ADDONS] heroku_addon_providers = ["heroku-postgresql","heroku-kafka","heroku-redis"] # multi-tenant heroku add-ons are untrusted [PLANS] untrusted_plans = ["hobby","basic","standard","premium", "developer", "dev"] # Heroku official build-packs should be used [BUILDPACKS] allowed_buildpacks = ["heroku/","https://github.com/heroku/"] # Allowed IP ranges [RANGES] allowed_ranges = ["52.47.73.72/29", "13.55.255.216/29", "52.15.247.208/29"] # Company email domain [USER] email_domain = "@salesforce.com"
インストールする
pip経由でインストールします。
https://github.com/heroku/heroku-guardian#usage--installation
コマンド例
https://github.com/heroku/heroku-guardian#commands
実際に実行してみる
個人のテスト用アプリで確認してみます。
heroku-guardian app -a takapitest1
✅ Build pack for takapitest1 Heroku approved: heroku-20 ✅ Heroku app not in maintenance mode: takapitest1 ✅ Heroku approved stack image used: takapitest1: heroku-20 ❌ App is externally routable: takapitest1 ❌ App is not in a space: takapitest1 ❌ Config vars not assigned to app: takapitest1 ❌ No CNAME assigned for app: takapitest1 ❌ TLS config 1.2+ not enabled: takapitest1 ❌ Team app not locked for: takapitest1
環境変数を使用していないと、 Config vars not assigned to app
が ❌ になるようです。
理由はソースコードにシークレットや機密性の高い情報を書くべきでないことから警告が出ているようです。
今回は特にソースコードにそのような情報は描かれていないので、適当に環境変数を設定してもう一度試してみます。
✅ Build pack for takapitest1 Heroku approved: heroku-20 ✅ Config vars assigned to app: takapitest1 ✅ Heroku app not in maintenance mode: takapitest1 ✅ Heroku approved stack image used: takapitest1: heroku-20 ❌ App is externally routable: takapitest1 ❌ App is not in a space: takapitest1 ❌ No CNAME assigned for app: takapitest1 ❌ TLS config 1.2+ not enabled: takapitest1 ❌ Team app not locked for: takapitest1
今回は✅になりましたね。
次はユーザの設定を確認してみます。
heroku-guardian user
Performing health checks for user XXXX@example.com ✅ User email set correctly. ✅ User has MFA enabled. ❌ Non-approved email domain is being used: XXXX@example.com ❌ User is not using federated login. ❌ SSO not is preferred.
今回は個人アカウントのため、federated loginやSSOは利用していませんのでその辺の項目は❌になっています。
試してみた所管
こういった形でセキュリティチェックできるのは非常に良いですね。 日々チェックできるようバッチやCIに組み込んで回しても良さそう。
一方で、こうなっているとよかったなという点としては、検査項目をignoreできないので、運用上OKとしたものでも❌が付いてしまうことでしょうか。 見た目オールグリーンにしたいですよね。