半空洞男女関係

思ったこととかプログラミングしてるときのメモとか色々かいてます。メールはidそのままgmail

Google Cloud FunctionsとGitHub Pagesを使ってMinecraftサーバーのインスタンスを制御するページを作ってる

久々の技術ネタ。

MinecraftサーバーをGCPに立てようとしているが、GCEは起動時間で課金されるので、ゲームを遊んでいない間落としておくと節約になる。我が家ではDiscordを使っているのでDiscordで上げ下げできるようにしたかったが、botが必要になるので結局インスタンスを立ち上げっぱなしにする必要が出てくる。

そこでインスタンスの上げ下げはCloud Functionsで作って流用できるようにして、フロントエンドとして認証付きのGitHub Pagesを置いていつでも上げ下げできるようにしようと考えた。*1

解説記事を書こうと思ったが分量が多くなるのと、途中まで書いてそんなに特別なことはないなと思ったので参考にした記事とかだけ貼っておく。

できたもの

gyazo.com

Cloud Functionsの開発環境

Cloud FunctionsのNode.jsランタイムを使うときに毎回設定していること - yashiganiの英傑になるまで死ねない日記gts で TypeScirpt をすぐ書き始める - ぽ靴な缶を見ればバッチリだ!

Cloud FunctionsのCORS対応について

HTTP Functions  |  Cloud Functions Documentation  |  Google Cloud をよく読むんだ。基本的に押さえるべきポイントは二つ。

  • CORSリクエストをする時には2回リクエストが飛んでいる
    • OPTIONS methodのpreflight request
    • その後に続くmain request
  • preflight requestには認証キーが乗らない
    • すなわちAuthentication Headerを設定していても無意味!!!

それらを踏まえると、GitHub PagesからCloud Functionsを叩きたいときの基本的な構成としては次のようにするのが良さそう。

  • デプロイ時に --allow-unauthenticated を指定してCloud Functions自体は全開放
  • Cloud Functions側で認証をする(後述)

Next.jsで開発中Access-Control-Allow-Origin: *しておく

next.config.jsheaders()で諸々設定できるのでこんな感じにしておけば良い。

module.exports = {
 headers() {
    return [
      {
        source: '/(.*)',
        headers: [
          {
            key: 'Access-Control-Allow-Origin', // Matched parameters can be used in the key
            value: '*',
          }
        ],
      },
    ]
  },
}

Cloud Functionsで認証の仕組みを作る

今回はGoogleの認証を使うことにした。今回使った認証方法はGoogle Sign-In for Websites  |  Google Developersなのだが、これは2023年にディスコンになるので、Sign In With Google  |  Google Developersに乗り換える必要がある。要注意!(// TODO: あとで乗り換える)

クライアント側

SDKはnpmで配布はしていなくて、<script> で読み込む仕組み。動的にロードすることに関してReact周りのあれこれを考えるのが煩雑だったので GitHub - anthonyjgrove/react-google-login: A React Google Login Componentを使って手早く済ませることにした。

ログインが成功するとコールバックにID TokenというGoogleのJWTが入っている(response.tokenId)ので、それを適当にHeaderなんかに入れてサーバー側で検証する。

正しく設定しているのに popup_closed_by_user が出る

OAuthのClient IDをcloud.google.comで取得して使うことになるが、このときに「承認済みの JavaScript 生成元」を新しく追加するとログイン完了時にこのエラーが発生することがある。

確実な再現手順は作れていないものの、

  • 一度ログインを試す
  • その後「承認済みの JavaScript 生成元」を追加する
  • もう一度ログインを試す

をすると再現する感じがする。検索するとキャッシュ削除で治ったという話もあるのだけど、このためにいちいちブラウザのキャッシュを吹っ飛ばさないといけないのも面倒(他サイトログインし直しがだるい)。Client IDを新しく生成して設定しなおせば一旦治るので、これでOK。

しかしこれproductionで起こっちゃったらどうするんだろう...。新しいSDKなら発生してないのかな。

サーバー側

サーバー側ではクライアントから受け取ったJWTを検証する。今回はAuthorization HeaderにJWTを入れてやりとりしている。JWTの検証はgoogle-auth-library-nodejsを使うと簡単にできる。ちゃんとGoogleの鍵を取ってきて検証までやってくれるので安心感ある。

Authenticate with a backend server  |  Google Sign-In for Websites  |  Google Developers がよくまとまっているので参考にすると良い。

総じて認証の仕組みを作るのは非常に簡単だった。GoogleのID Token(というかJWTかも)をベースにした認証はめちゃめちゃ簡単で良い。

デプロイ

Cloud Functions

デプロイに関してはDeploying from Your Local Machine  |  Cloud Functions Documentation  |  Google Cloudを読もう。

基本的には package.jsonmain に書いてあるところを読みに行ってくれる*2。不要なファイルは .gcloudignore に書いて gcloud functions deploy しましょう。

ポイントとしては環境変数の扱いで、 --env-vars-file オプションを使うとyamlファイルを読み込んで環境変数をセットできるので、よしなに設定しておくと便利です。一つのfunctionデプロイするのにしばらくかかるので、私は雑にバックグラウンド実行させて並列にデプロイしています。

deploy: compile
  gcloud functions deploy start-mc --allow-unauthenticated --entry-point start --runtime nodejs16 --trigger-http --env-vars-file .env.yaml &
  gcloud functions deploy stop-mc --allow-unauthenticated --entry-point stop --runtime nodejs16 --trigger-http --env-vars-file .env.yaml &
  gcloud functions deploy info --allow-unauthenticated --entry-point getInfo --runtime nodejs16 --trigger-http --env-vars-file .env.yaml &

mc-bot/Makefile at 31cc52f14c8ca23c74a246489ba0cd20060a7e71 · mactkg/mc-bot · GitHub

GitHub Pages

GitHub - peaceiris/actions-gh-pages: GitHub Actions for GitHub Pages 🚀 Deploy static files and publish your site easily. Static-Site-Generators-friendly. を使えば簡単にDeployできる。next build && next export したものをアップするようにする。

Next.jsにサイトのルートパスを伝える

GitHub Pagesを使ってあるリポジトリのページを公開する場合は、 ${username}.github.io/repo-name/ のようなURLになるが*3、アセットはデフォルトで /_next/static を読みに行こうとするのでエラーになる。 /repo-name/_next/static を読みにいくよう変える必要がある。これも next.config.js で設定することができる。

開発中はprefixがなくても良いので、NODE_ENVを読んで出し分ける。

const prod = process.env.NODE_ENV === 'production';

module.exports = {
  basePath: prod ? '/mc-bot' : '',
}

next.config.js: Base Path | Next.js を参考にすること。ちなみにCDNなんかで違う場所にassetを置いている場合は、 assetPrefix*4 を活用すると良いらしい。

ふりかえり

  • RTFM.
  • monorepoで作ってみたのはよかった
    • repo作ったりGitHub Actions作ったりみたいなことをいちいちやらなくて済む
  • Cloud Functionsとクライアント側のURL紐付けをうまくやる方法は気になる
    • 実際のところはOpen APIとかでAPI定義書を書いてやっていくといいのか? この辺りは気になりますね
  • Cloud Functionsは起動時間が結構かかる
    • Node.jsでもGoでもそんなに起動時間は変わらないっぽかった
    • 起動時間を抑える小技は結構存在しているらしいけど可読性とかを維持しながらちゃんとやるのは結構難しい
      • 基本的には不要なモジュールを入れないとか地道な作業になってくる
    • 定期的に呼ばれてコールドスタートにならないAPIはいいみたいだけど今回みたいに1日に一回呼ぶか程度だとあまりやれることはなさそうだった
    • 無料なので文句は言いません!
  • Cloud Functionsは認証さえしているもののとりあえず起動はしてしまうので、無限に呼ぶいたずらをされてしまうと結局課金されまくってしまうのではないかという気がするがみんなどうしているのか?
  • Next.jsしか使えない体になってきた
  • Google ID Token周り最高!

残りやりたいこと

  • UIをいい感じにする
  • docker-composeでMinecraftサーバーが立つところまではできているので、GCEで動かすようにする
  • MinecraftサーバーをCDする
  • Minecraftから人がいなくなったら自動でシャットダウンする仕組みを作りたい

おまけ

コードはここにあります。

github.com

しかしやりたいことは無限にあるのにこういうどうでもいいところから作っちゃうんだよなあ・・・。やりたかったからいいんだ。

*1:それってもはやGCPでボタンポチポチしてるのと変わらなくね?は言わないでください!

*2:Writing Cloud Functions  |  Cloud Functions Documentation  |  Google Cloud

*3:ただし、 ${username}.github.io というリポジトリの場合は${username}.github.ioに置ける

*4:next.config.js: CDN Support with Asset Prefix | Next.js