サークル内製PaaSを支える技術

はじめに

こんにちは、traPで主にWebバックエンドの開発をしている @pirosiki です。

デジタル創作同好会traPは、東京科学大学の創作・プログラミングの総合サークルです。アプリ・ゲームの制作を中心に、音楽(DTM)、グラフィック(イラスト、3DCG、ドット絵、動画)などの創作活動に加え、Webインフラや競技プログラミング・サイバーセキュリティ(CTF)、機械学習(Kaggle)などに関する活動も行っています。

traPはさくらインターネットから支援を受けており、現在traPのサービスはさくらインターネットへと移行が進んでいます。

この記事では、traPで開発・運用しているNeoShowcaseというサービスを支える技術を紹介します。

NeoShowcaseとは

https://github.com/traPtitech/NeoShowcase

NeoShowcaseはtraP内製の作品公開プラットフォーム (PaaS) です。traP部員であれば無料で利用でき、複雑な設定なしで自分が作成したアプリを外部へ公開することができます。ユーザーは動的なRuntime Appか静的なサイトを公開できるようになっています。部内ハッカソンの作品など、現在600を超えるアプリがNeoShowcase上で動いており、部員の創作活動を支援しています。

NeoShowcaseについて詳しくはこちらのブログをぜひ読んでみてください。

【NeoShowcase】traPには内製の作品公開プラットフォームがあります

NeoShowcaseは自分が入部した頃に完成し、初めてのハッカソンで簡単にアプリを動かせたのが感動でした。

アーキテクチャの概要

以下の図はNeoShowcaseのアーキテクチャを表したものです。

各コンポーネントの役割

上の図にあるように、NeoShowcaseは複数のコンポーネントから構成されています。

ns-gateway

ユーザー向けのAPI serverです。ユーザーからのリクエストを受けて、DBを書き換えます。
Connectを採用しており、型安全な通信を可能にしています。ビルドログのようなリアルタイムのコンテンツとも相性が良いです。ns-gatewayはユーザーからのリクエストに対して、Internal StateであるDBの状態を変化させます。また、ns-controllerにイベントを発火することもあります。

ns-controller

NeoShowcaseのコアです。

  • Git Providerを通じた最新コミットの取得(polling & webhook)
  • アプリコンテナのデプロイ
  • Runtimeアプリコンテナへのssh接続
  • ビルドキューの管理
  • ルーティングの設定

などを行っています。
バックエンドとしてDockerKubernetesを利用することができ、traPの本番環境ではKubernetesを使用しています。後述するReconciliation Loopの考え方を採用しており、Internal Stateを読んで、常に理想状態へと近づけるように動いています。

ns-builder

ns-controllerに接続してビルドジョブを受け取り、Docker Image のビルドとビルド成果物の保存を行います。
ユーザーが指定したDockerfileを使用したビルドに加え、Dockerfile無しで自動でビルドしてくれるBuildpacksも利用できます。ns-builderとns-controller間の通信にもConnectを使用しています。

ns-ssgen

Static Site Generatorでssgenです。静的なサイトの配信を行います。DBを読んで常に最新のコンテンツを配信できるようにしています。
本番環境では実際に配信を行なっているのはCaddyで、ns-ssgenはコンテンツの更新の役割を担っています。


これらに加えて、Traefikによりユーザーアプリへのルーティングを行っています。部員の認証や、アクセスの無いアプリのスリープなども、Traefikのミドルウェアとプラグインを活用しています。
各コンポーネントは協調して動作することで、スケーラブルで障害に強いサービスになっています。

さくらのVPSの活用

現在traPではご支援をいただいているさくらのVPS上にk3sクラスターを構築し、そこでNeoShowcaseのシステムとユーザーのアプリを稼働させています。アプリ用にはメモリ4GBのノードを3台使用し、特に負荷の大きいビルド処理を担当するビルダーはメモリ8GBのノードに割り当てています。

アプリデプロイの流れ

各アプリは以下の流れでデプロイされます。

  1. ユーザーが Git Provider (GitHubなど) に変更を反映する (git push)
  2. ns-controller が変更を検知 & ビルドを enqueue
  3. ns-builder がジョブを受け取りビルドする
  4. ns-controller が 最新の状態を Container Runtimeや ns-ssgen に反映させる

ユーザーが行うのはgit pushのみなので、とても簡単にアプリの更新を行うことができます。

Reconciliation Loop の思想

NeoShowcaseではKubernetesのコンセプトであるreconciliation loopの考え方を取り入れています。これは、一定期間ごとかイベント発生時に、現在の状態と理想の状態の差分を計算し、理想に近づけるような処理をループするという考え方です。
以下はビルドでのreconciliation loopの例です。

このループを定期的に行うことにより、どこかでエラーが発生したとしても最終的には理想の状態へと近づいていきます。イベント駆動の設計と比較すると、複雑なエラー処理や再試行のためのロジックが必要ないのが特徴です。

これらは以下のようなコードで実現されています (だいぶ簡略化したもので、実際はもう少し複雑です)。

一定期間ごとに最新のコミットを取得し続けます。

func (r *service) fetchLoop(ctx context.Context) {
    ticker := time.NewTicker(time.Minute * 5)
    for {
        select {
        case <-ticker.C:
            r.fetch(ctx)
        case <-ctx.Done():
            return
        }
    }
}

func (r *service) fetch(ctx context.Context) {
    repos := r.gitRepo.ListAll()
    apps := r.appRepo.ListAll()
    // リポジトリごとに対応アプリをまとめる
    repoToApps := groupByRepository(apps)
    for repoID, apps := range repoToApps {
        r.updateApps(ctx, repo, apps)
    }
}

func (r *service) updateApps(ctx context.Context, repo *domain.Repository, apps []*domain.Application) {
    // リポジトリの最新の状態を取得
    refs := r.git.ResolveRefs(ctx, repo)
    for _, app := range apps {
        if commit, ok := refs[app.RefName]; ok {
            // 最新のコミットをDBに保存
            r.appRepo.UpdateCommit(ctx, app.ID, commit)
            // ビルドを発火
            r.cd.RegisterBuild(app.ID)
        }
    }
}

新しいコミットでビルドがされていなければ、新しくキューに入れます。

func (cd *service) registerBuild(ctx context.Context, appID string) error {
    app := cd.appRepo.GetApplication(ctx, appID)

    // 同じ commit hash, 設定 のビルドが既にあるかチェック
    existing := cd.buildRepo.GetBuilds(ctx, domain.GetBuildCondition{
        ApplicationID: optional.From(appID),
        Commit:        optional.From(app.Commit),
        ConfigHash:    optional.From(app.Config.Hash(env)),
    })
    if len(existing) > 0 {
        return nil
    }

    // 古いビルドをキャンセルし、新しくキューに追加
    cd.buildRepo.UpdateBuild(ctx, domain.GetBuildCondition{
        ApplicationID: optional.From(appID),
        Status:        optional.From(domain.BuildStatusQueued),
    }, domain.UpdateBuildArgs{
        Status: optional.From(domain.BuildStatusCanceled),
    })
    return cd.buildRepo.CreateBuild(ctx, domain.NewBuild(app, env))
}

一定時間更新がなかったビルドを失敗とみなす処理を定期的に行います。

func (cd *service) detectBuildCrash(ctx context.Context) error {
    const threshold = 60 * time.Second
    now := time.Now()

    builds := cd.buildRepo.GetBuilds(ctx, domain.GetBuildCondition{
        Status: optional.From(domain.BuildStatusBuilding),
    })

    for _, b := range builds {
        if now.Sub(b.UpdatedAt.ValueOrZero()) <= threshold {
            continue
        }
        // threshold以上更新がなければ失敗とみなす
        cd.buildRepo.UpdateBuild(ctx, domain.GetBuildCondition{
            ID:     optional.From(b.ID),
            Status: optional.From(domain.BuildStatusBuilding),
        }, domain.UpdateBuildArgs{
            Status: optional.From(domain.BuildStatusFailed),
        })
    }
    return nil
}

ShowcaseからNeoShowcaseへ

旧Showcaseの課題

NeoShowcase の Neo は昔 Showcase というサービスがあったことに由来しています。しかしこの Showcase にはいろいろと問題があり、その中でも一番の問題がスケールしないことでした。アプリの実行やビルドを含むすべての処理を単一のサーバーで行っていたため、アプリ用のサーバーを増やす、ビルダーだけ増やすといった柔軟な対応ができませんでした。ビルド並列数の制限もなかったため、ハッカソンのようなイベント時には負荷が高くなりサービスが落ちるということもありました。

課題の解決

上記の課題を解決するため、NeoShowcaseでは各コンポーネントを独立して動作させる設計に変更されました。これにより、負荷に応じてサービスをスケールさせることができます。実際に、年々規模が拡大している部内のハッカソンでも(ビルドキューが詰まることはありましたが)NeoShowcase自体が使えなくなるということはなくなりました。また、ユーザーアプリ用のサーバーも簡単に増やせるようになり、アプリの増加に合わせてサーバーの数も増やしています。
また、ns-builderからジョブを取りに行くという設計にしたことで、サーバーの構成に変更を加えることなく、外部の高性能なマシンでビルドを行うという選択肢も可能になりました。

CI/CD環境の整備

旧ShowcaseにはCI環境がありませんでした。この状況ではテストの自動実行や、コードの品質維持が困難でした。
NeoShowcaseではこの点を大幅に改善し、プルリクエストを立てるとテスト、Lintなどが自動で実行されるようになっています。また、フロントエンドのプレビュー環境も自動で構築され、実際の動作を簡単に確認できるようになりました。
CDパイプラインも同様に整備されており、NeoShowcaseのバージョンを上げると自動でtraPのマニフェストリポジトリにプルリクエストが立つようになっています。新しいバージョンをリリースしてから本番環境にデプロイされるまで、約10分で完了します。
この迅速なデプロイを活かすため、NeoShowcaseでは小さな単位で変更をリリースし、頻繁にバージョンを更新するようにしています。これにより、万が一本番環境で不具合が見つかったとしてもすぐに原因を特定し、修正できるようになっています。

実運用での課題と対応

NeoShowcase上で動いているアプリは増え続けています。その中で出会った課題点もあります。

再起動を繰り返すアプリ

NeoShowcaseではランタイムアプリが動いているコンテナに対してリソースの制限をしています。しかし、不具合などでメモリリークが発生して、OOM Killが繰り返されるようなアプリも時々あり、このようなアプリが複数あるとサーバーが高負荷な状態になってしまいます。これに対して、再起動を繰り返しているようなアプリが発生するとGrafanaでアラートが出るようにしました。アラートが出たらアプリを停止し、ユーザーに報告するようにしています。今はこの停止部分を手作業で行っているので、今後は自動で行えるようにしたいと考えています。

アプリの自動シャットダウン

ランタイムアプリの中にはテスト用や練習用で、今はほとんど使われていないようなアプリもあります。これらのアプリを動かし続けるのはリソースの無駄になってしまいます。このようなアプリのために、一定期間アクセスがないと自動でシャットダウンできる機能を導入しました。TraefikのpluginとしてSablierを使用しています。今はユーザーがこの機能を使用するか選ぶようにしているため、より多くのアプリで使ってもらえるようにしていきたいです。

アプリイメージによるストレージの圧迫

NeoShowcaseではユーザーが好きなDockerfileやbase imageも使えるようにしているため、サイズの大きいイメージが多いとレイヤー間共有も効かず、すぐにストレージがいっぱいになってしまいます。これに対してNeoShowcaseではDockerfile無しで自動で効率的なビルドをしてくれるbuildpackという選択肢を提供しています。できるだけこのbuildpackを使ってもらえるような環境を整えることが今後の課題になります。

おわりに

NeoShowcaseはこれからも、多くの人にとって使いやすく、部員の成長の支援できるようなプラットフォームを目指していきます。