個人開発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円アプリケーション構成は、以下の通りとなりました。

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を利用して対応します。

CDNTLS証明書

リソースに余裕を持たせるために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)形式の字幕ファイルを生成します。

まだまだブラッシュアップする必要がありますが、こんな感じ

github.com

特徴3行

  • 音声ファイルの分割: Whisperライブラリは30秒以上の音声を一度にテキスト変換するとメモリ不足(OOM)が発生する可能性があります。おそらくAPIの制限が30秒なため、その時間を基準に設計されているのかな?と思ってます。この問題を回避するために、私たちは音声ファイルを分割して処理するようにしました。
  • リジューム機能: 変換プロセスが途中で失敗した場合でも、そこから再開できるようにリジューム機能を実装しました。
  • Docker化: PythonベースのAIツールを色々使用していると、CUDAやPyTorchなどのGPU利用ライブラリを都度切り替える必要があり、それが手間になることがあります。Dockerで立ち上がるようにし、ライブラリの切り替えを簡単に行えるようにしました。

構築したマシン環境

そもそも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)にしてみます。

www.youtube.com

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できるようにします。

takapi86.hatenablog.com

結論から言うと手順はすごく簡単でした。

以下のディレクトリに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ディストリビューションがあるようです。

k0sproject.io

自前の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形式で記述したらコンテナが停止しなくなってしまったので直した

UbuntuPHP-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/shdash を参照しています。

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/shbash を参照しています。

[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をご確認ください。

github.com

こういった定義ファイルを書いて、その定義が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としたものでも❌が付いてしまうことでしょうか。 見た目オールグリーンにしたいですよね。