0=>1フェーズの技術的負債を解消する第一歩@バックエンド編

こんにちは、株式会社カミナシのエンジニア @imu です。

はじめに

我々のサービス『カミナシ』をローンチしてから、今年の夏で丸2年を迎えます。

私は『カミナシ』のプロダクト開発初期メンバーの1人でした。当時会社の残りランウェイ(残資金で運営できる期間)が極端に短いという厳しい時間的制約のなかで高速にプロダクトを作り上げたことを誇りに思う一方、その代償としての技術的負債は今も解消しきれずに残っています。

継続的に寄せられるお客様からの機能開発要望や、ユーザー数が増えた結果発生しはじめた不具合への対処に時間をとられ、それら技術的負債の根本的な解消には、これまで会社として注力することができていませんでした。今年に入ってから既存ユーザーにも影響が出てしまうような問題の発生が徐々に目立ちはじめています。

この状況をカイゼンすべく、私たちはバックエンドの技術的負債を解消する第一歩を踏み出しました。

今回は具体的に何をどうやって解消したのか、お話出来ればと思います!

開発環境

要約

  • パフォーマンス劣化しているエンドポイントの全体を見直しました
    • 処理フロー
      • for文で毎回Insert処理していないかの再確認
      • 処理順番が適切かどうかの再確認(機能追加でサッと追加してしまっていないか、まとめて処理できないかなど)
  • Gorm(ORM)の見直し
    • Preload
      • 便利だがすべて一つずつSQLを発行するため、一つが早くても全体が遅くなってしまう傾向にある
      • Preloadをせずに並行処理(goroutineを利用)で実行することでカイゼンさせた
  • データ構造

原因調査

1. 仮設を立てる
  • ビジネスロジックは殆どない箇所だったので、SQL文に問題があると思いました。
    • 無駄なロジックになっている(処理フローにカイゼンが見込めそう)
    • Preloadが多くて遅くなっている
2. 再現性のある環境を構築
  • ここで注意が必要なのはローカル環境ではなく、本番と同じ環境を用意することです。
  • 本番のデータ量でしか再現しない可能性もあるため、スナップショットで復元して別環境を立てて劣化している確認しました。
3. ユーザー目線からの違和感を見つける
  • ユーザーが遅いと言う時間は3秒以上掛かっているという話でした。が、これはユーザーがあるボタンをタップしてから、画面にデータが表示されるまでの体感時間です。そのため、ユーザーと同じ動作をしてボトルネックが他にないか確認しました。
  • 結果、APIのリクエスト時に認証処理をしており、認証自体に数百ミリ秒掛かっていることが分かりました。これはユーザー目線から考えることが出来たので見つかった点です。
4. ORMのPreloadは便利だけど
  • GormはPreloadを使うと関連テーブルまで検索してくれて、指定したstructに入れてくれる!とっても便利!
  • だけど実際のコードを見るとやりすぎ感が…。
    • 各Preloadで呼ばれるSQL自体は数十ミリ秒でしたが、50ms x 40 = 2000ms となり 2秒 掛かってしまいます。
// 記録した結果を取得するためにPreloadが40個書かれていました…。
db.
Preload("Hoge1").
Preload("Hoge2").
Preload("Hoge3").
...
Preload("Hoge40").
First(context, fuga, "id = ?", fuga.ID)
  • 上記のPreloadを分解して goroutine を使い並行処理にすればカイゼンされると思い対応しました!

Preloadを並行処理化する

Preloadは単に順番通りに実行されるものなので、待たなくて良いケースがありますよね。 例えばユーザー情報を取得するケースを例に実際のコードを書いてみます。

ctx := context.Background()
u := &orm.User{}

db.
Preload("Department"). // 部署
Preload("Authority"). // 権限
Preload("Members"). // チームメンバー
Preload("Members.Department"). // チームメンバーの部署
First(ctx, u, "id = ?", userID)

これは DepartmentAuthorityMembers / Members.Department は関係性がないため、分けて取得しても問題ないところです。まずは単純に分解してみましょう。

ctx := context.Background()

u := &orm.User{}
db.First(ctx, u, "id = ?", userID)

// ユーザー情報を取得して、ユーザーIDから検索する
d := &orm.Department{}
db.First(ctx, d, "user_id = ?", u.ID)

a := &orm.Authority{}
db.First(ctx, a, "user_id = ?", u.ID)

m := &orm.Members{}
db.Preload("Department").First(ctx, m, "user_id = ?", u.ID)

// 最後にUserオブジェクトにセットする
u.Department = d
u.Authority = a
u.Members = m

あとは goroutine を使って並行処理にしていきましょう。ここで大事なのは最初に実装していたPreloadの順番にしなくても良いということです。処理に時間が掛かる場合は最初に実行して、少しでも短くするように心がけました。

ctx := context.Background()

// 先にユーザー情報を取得する
u := &orm.User{}
db.First(ctx, u, "id = ?", userID)

// goroutine
eg, egctx := errgroup.WithContext(ctx)
defer egctx.Done()

// ここは Members / Departmentの2つSQLが発行されるため、最初に実行して少しでも処理時間を短くする
m := &orm.Members{}
eg.Go(func() error {
    return db.Preload("Department").First(egctx, m, "user_id = ?", u.ID)
})

d := &orm.Department{}
eg.Go(func() error {
    return db.First(egctx, d, "user_id = ?", u.ID).Error
})

a := &orm.Authority{}
eg.Go(func() error {
    return db.First(egctx, a, "user_id = ?", u.ID).Error
})

if err := eg.Wait(); err != nil {
    return err
}

u.Department = d
u.Authority = a
u.Members = m

return nil

以上になります!

実際に修正した箇所はテストコードがなかったので、cmp.Diffを使ってカイゼン前とカイゼン後に差分がないか検証し、テストコードを追加しました。0=>1フェーズでテストコードがなく、初期から実装していないとこういったリファクタリングが容易に出来ないのは辛いですね。次0=>1の経験が出来るときは初期からテストコードは実装しておきたいと思いました。

最後にApache Benchで性能テストを行い完了です!

カイゼン結果

  • リリース前
    • p90: 9.47s
    • p99: 26.76s

  • リリース後
    • p90: 179ms
    • p99: 1.26s

  • max値はネットワーク問題で処理が開始されるまでの時間も入っていて、大幅に掛かるケースがあるようです。

学び

  • ORMは便利だけど、用途を理解した上で実装しないと後から大変になる。
    • 可能であればデータ数を想定した設計が予め出来ていると良かった。
  • 技術的負債は必ず起きるので、フェーズごとに早めにカイゼンしないと後で辛くなる。
    • 過去の失敗を責めず、今起きている課題に対して実直に取り組むこと。
  • 数百ミリのSQLでも何か問題になっていないか?と疑うことでカイゼンされるケースもある。
  • テストコードがない箇所の修正は大変なので、100%じゃなくて良いのであると破壊的な変更がしやすい。
  • 技術的負債を解消するのは行き詰まったりするので、出来ないときはさっさと寝る!意外と翌日すんなり解決したり閃くことが多いです(笑)
  • goroutine のアプローチ方法が増えたので他メンバーでも実装方法の共有ができた。

おわりに

現在も課題は多く、今回対応した箇所は氷山の一角といったところです。リファクタリングついでにテストコードを拡充して少しずつではありますが、堅牢なシステムにして、素早く価値のあるプロダクトを提供できるようにしていきたいです!

バックエンド編なので、フロントエンド編も解消する箇所があるので、共有出来ればいいなと思っています。

カミナシのプロダクトに興味がありましたら、気軽にTwitterDMや以下の採用ページからご連絡ください!

最後まで読んでいただき、ありがとうございました!

careers.kaminashi.jp

ECSタスクで1時間おきにRDS Auroraのスナップショットを取得する

f:id:kaminashi-developer:20211123210637j:plain

エンジニアのTakuです。

カミナシではデータ保全のため、RDSのスナップショットを1時間おきに取得し、万が一のデータ破損に備えております。
今回はそのECSタスクで定期的にバッチ処理を実行する方法について共有させていただきたいと思います。

取得理由

カミナシでは150社以上の企業様にご利用いただいており、毎日膨大なレポートが作成されております。(2022年4月現在)

ご参考)↓の14ページ speakerdeck.com

日々膨大な量のデータが増えており、中には監査などの保管が必要なデータもあり、データやDBの破損が発生するとお客様への影響が大きいです。
そのため、万が一の際の障害発生時の影響を少しでも抑えるため、1時間に1回フルバックアップを取得しそれらを最大24時間保持することとしました。

※ポイントタイムリカバリを利用した特定の時点への復元も可能ですが、そちらが破損する事態にも備え、1時間おきに取得することとしております。

取得方式の検討

結論から申し上げますと、カミナシではECSタスクで1時間に1回取得するようにしました。 取得段階では以下3案ありそれぞれ比較・検討しましたので、採用経緯踏まえお話ししたいと思います。

案1) AWS Backup
案2) AWS Lambda
案3) AWS ECSタスク

途中の経過は不要でECSタスクの設定が見たい!という方は 実装 の項目までとばしてください

案1) AWS Backup

比較するにあたり、

  1. 車輪の再開発はしたくない
  2. メンテナンスコストをなるべく抑えたい

ということから、まずはフルマネージドなサービスを利用できないかと考え、まずはAWS Backupの調査を行いました。

docs.aws.amazon.com

調査の結果、

  1. AWS BackupはRDS Auroraに対応
  2. 削除は最小で1日毎
  3. 取得間隔はcronで自由に設定できる

ことから1時間毎にスナップショットを取得するという要件を満たしておりました。

docs.aws.amazon.com

気になるコストに関しても、

  • データ量に応じたスナップショットの料金はRDSのコンソール or CLI等から取得した料金と同じ
  • スナップショットの実行回数で追加料金

のみのようでした。

aws.amazon.com

スナップショットを取得する度に必要となる Audit Manager のコストは 1バックアップ評価あたり 0.00125 USD (※2022/4/18現在) で、
1日1時間ごとに実行した場合(※)でも Lamdba or ECSタスクの実行コストよりは間違いなく低いだろうということで、こちらの実装を進めることにしました。  ※1時間ごとに毎日実行した場合、月単位で 24*31=744 と1 USD 以下となる

設定はコンソールから以下のようなバックアッププランを作成することで簡単に設定ができました。

f:id:kaminashi-developer:20211128155047p:plain

しかし、ここで開発環境で繰り回し検証をしている際に以下のエラーが出て問題が発覚しました。

Backup job XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX could not start because it is either inside or too close to the automated backup window configured in RDS cluster.

f:id:kaminashi-developer:20211128133535p:plain

AWS Backupの仕様で、データ不整合を防ぐためにRDSのシステムバックアップの前後4時間はバックアップ取得ができないようです。

これは、RDS メンテナンス期間または RDS 自動バックアップ期間が近づいているときに発生する可能性があります。AWS Backup では、RDS メンテナンス期間または RDS 自動バックアップ期間の数時間前に行う RDS バックアップは許可されません。RDS データベースのバックアッププランは、RDS メンテナンス期間と RDS 自動バックアップ期間から 4 時間以上空けてスケジュールを組んでいることを確認してください。

aws.amazon.com

Auroraの場合こちらのバックアップを停止することも不可(ポイントタイムリカバリで使うバックアップでもあり不可)であることから、1時間おきの取得と言う要件を満たさなかったため泣く泣く利用を断念しました。。

案2) AWS Lambda

次に考えたのがAWS Lambdaです。
検索するとAWS Lambdaを利用してRDSのスナップショットを1時間おき取得している例は多々あったのですが、バッチ実行にはコンソールから編集が可能なPython or Node.jsを使っているものが主でした。

しかし、弊社ではバックエンド開発にGolangを利用しており、私を含めPython or Node.jsに長けたエンジニアがあまりおりません。

過去にAWS SAMを利用した記事を書かせていただいたとおり、それらを利用してGoでも書けなくはないのですがやはり手間がかかります。

現時点でAWS SAMを本番運用している実績もないことからこの案も見送りとしました。

案3) AWS ECSタスク

最終的に検討した案がECSタスクの利用です。

  1. ECSタスク(Fargate)はプロダクトのAPIサーバー等でも利用しており利用実績がある
  2. DockerFileを用意することで利用が可能
  3. Golangスクリプトを作成できる

ことから、今後他のエンジニアがメンテナンス容易だろうということで導入を決定しました。

実装

RDSスナップショット取得

前述のとおり、RDSスナップショットを取得するスクリプトGolangを利用しました。

CLIAWSからGolang用のものも提供されているため以下を利用

aws.amazon.com

実装する際には pkg.go.devを参照しました。

pkg.go.dev

Golangはこういった公式ドキュメントがしっかりしているのも良いですね。

スナップショットを取得〜削除するものですので、簡易なものとし、以下のようなスクリプトを作成しました。

main.go

package main

import (
    "context"
    "fmt"
    "log"
    "os"
    "strings"
    "time"

    "github.com/aws/aws-sdk-go-v2/aws"
    "github.com/aws/aws-sdk-go-v2/config"
    "github.com/aws/aws-sdk-go-v2/service/rds"
    "github.com/joho/godotenv"
)

type rdsClient struct {
    client *rds.Client
    clusterIdentifier string
    instanceIdentifier string
}

func newRDSClient() *rdsClient {
    clusterIdentifier := os.Getenv("DB_CLUSTER_IDENTIFIER")
    instanceIdentifier := os.Getenv("DB_INSTANCE_IDENTIFIER")
    reagion := os.Getenv("REAGION")

    cfg, err := config.LoadDefaultConfig(context.TODO())
    if err != nil {
        log.Printf("faild load config, %v", err)
    }
    cfg.Region = reagion

    client := rds.NewFromConfig(cfg)

    return &rdsClient{
        client,
        clusterIdentifier,
        instanceIdentifier,
    }
}

func (rc *rdsClient) createRDSSnapShot(startUpTime time.Time) {
    currentDateHour := fmt.Sprint(startUpTime.Year(), "-", int(startUpTime.Month()), "-",startUpTime.Day(), "-", startUpTime.Hour())
    p := rds.CreateDBClusterSnapshotInput{
        DBClusterIdentifier: aws.String(rc.clusterIdentifier),
        DBClusterSnapshotIdentifier: aws.String(rc.clusterIdentifier + "-" + currentDateHour),
    }
    
    result, err := rc.client.CreateDBClusterSnapshot(context.TODO(), &p)
    if err != nil && strings.Contains(err.Error(), "DBClusterSnapshotAlreadyExistsFault: Cannot create the cluster snapshot because one with the identifier") {
        log.Printf("snapshot already exist, %v", err)
    } else if err != nil {
        log.Fatalf("faild describe snapshot, %v", err)
    }
    log.Printf("createRdsSnapShot result, %v", result)
}

func (rc *rdsClient) describeRDSSnapShot() {
    p := rds.DescribeDBClusterSnapshotsInput{
        DBClusterIdentifier: aws.String(rc.clusterIdentifier),
        SnapshotType: aws.String("manual"),
    }
    
    result, err := rc.client.DescribeDBClusterSnapshots(context.TODO(), &p)
    if err != nil {
        log.Fatalf("faild describe snapshot, %v", err)
    }
    for _, snapshot := range result.DBClusterSnapshots{
        log.Printf("describeRDSSnapShot DBClusterSnapshotIdentifier, %v", *snapshot.DBClusterSnapshotIdentifier)
        log.Printf("describeRDSSnapShot SnapshotCreateTime, %v", *snapshot.SnapshotCreateTime)
    }
}

func (rc *rdsClient) deleteRDSSnapShot(startUpTime time.Time) {
    yestadayCurrentDate := startUpTime.AddDate(0, 0, -1)
    yestadayCurrentDateHour := fmt.Sprint(yestadayCurrentDate.Year(), "-", int(yestadayCurrentDate.Month()), "-",yestadayCurrentDate.Day(), "-", yestadayCurrentDate.Hour())
    p := rds.DeleteDBClusterSnapshotInput{
        DBClusterSnapshotIdentifier: aws.String(rc.clusterIdentifier + "-" + yestadayCurrentDateHour),
    }

    result, err := rc.client.DeleteDBClusterSnapshot(context.TODO(), &p)
    if err != nil && strings.Contains(err.Error(), "DBClusterSnapshotNotFoundFault: DBClusterSnapshot not found") {
        log.Printf("snapshot not found, %v", err)
    } else if err != nil {
        log.Fatalf("faild delete snapshot, %v", err)
    } else {
        log.Printf("deleteRDSSnapShot result, %v", result)
    }
}

func main() {
    if os.Getenv("APP_ENV") == "local" {
        err := godotenv.Load(fmt.Sprintf("./%s.env", os.Getenv("GO_ENV")))
        if err != nil {
            log.Printf("faild read env file, %v", err)
        }
    }

    client := newRDSClient()

    locale, _ := time.LoadLocation("Asia/Tokyo")
    startUpTime := time.Now().In(locale)

    client.describeRDSSnapShot()
    client.createRDSSnapShot(startUpTime)
    client.deleteRDSSnapShot(startUpTime)
}

利用している関数は以下のとおりです。

他にもCreateDBSnapshotDeleteDBSnapshotという関数もあったのですが、クラスター化してあるDBに対しては使えなかったのがちょっとしたハマりポイントでした。

api error InvalidParameterValue: The specified instance is a member of a cluster and a snapshot cannot be created directly. Please use the CreateDBClusterSnapshot API instead.

ECSタスクの設定

スクリプトが作成できましたら、こちらをAWSコンソールからECSへ設定していきます。

ECSにはサービスとタスクという概念があるのですが、スケジュールを作成し、バッチ的に実行する場合はサービスの作成は不要です。
クラスターとタスク定義の設定でECSタスクの実行ができます。

サービスとタスクという概念はこちらの記事が参考になりました。 qiita.com

手順は以下のとおりです。

  1. ECRへDockerイメージのアップロード
  2. クラスターの作成
  3. タスク定義の作成
  4. タスクのスケジューリング

1. ECRへDocker Imageのアップロード

まずは作成したDockerのImageをECRへアップロードします

可能であればGIthub等と連携したCI/CDを利用したいですが、もしなくてもECRのコンソールに記載のコマンドを実行することでBuild&Uploadすることができます。

f:id:kaminashi-developer:20220416174947p:plain
ECRへのプッシュコマンドの場所

2. クラスターの作成

コンソールからクラスターの作成を行います。

こちらはECSタスク(Fargate)を利用するのであれば「ネットワーキングのみ」でよく、名前を作成したいものに即したものを設定すれば問題ございません。

f:id:kaminashi-developer:20211128140807p:plain

f:id:kaminashi-developer:20211128140910p:plain ※CloudWatch Container Insightsについては必要に応じて有効にしていただければ良いですが、ここでは割愛します。

3. タスク定義の作成

続いて、1でアップしたImageを利用して、タスクの定義を作成します。
設定は以下のとおりです。

f:id:kaminashi-developer:20211128142804p:plain

ポイントになってくるのは「タスクロール」と「コンテナの定義」です。

ECSタスクからRDSのスナップショットを取得するには、必要な権限を持つIAMロールを付与してあげる必要があります。

AWSServiceRoleForRDSのようなフルでDBのアクセス権限を持つIAMを付与することで実行はできると思いますが、今回はSnapShotへの取得・作成・削除権限があれば良いため、それらの権限だけを持つポリシー→ロールを作成して「タスクロール」として付与すると良いかと思います。

今回は以下のような権限を持つポリシーを作成しました。

  • DescribeDBClusterSnapshots
  • CreateDBClusterSnapshot
  • DeleteDBClusterSnapshot

あとは「コンテナの定義」ですが、1でアップしたImageを選択し、環境変数をコンテナに渡してあげることができるため、取得対象のクラスター名などはこちらで定義します。

f:id:kaminashi-developer:20211128143414p:plain f:id:kaminashi-developer:20211128143531p:plain

4. タスクのスケジューリング

ここまで来るとあとはスケジューリングのみです。

スケジューリングタブは Amazon EventBridge から設定を行います。 docs.aws.amazon.com

Amazon EventBridge昨年末にECSのコンソールが新しくなりましたので、旧コンソールの場合は「タスクのスケジューリング」タブから作成することも可能です。

必要になってくるのは、3で作成したタスク定義と、スケジュールルール(スケジュール式) です。

f:id:kaminashi-developer:20211128151902p:plain f:id:kaminashi-developer:20211128151915p:plain

※キャプチャを取り直したため「ルールを編集」となっておりますが、記載項目は新規作成と同じです

スケジュールルールは1時間おきなど定型的なものであれば「固定された間隔で実行」で設定しても良いですし、Cron式の利用も可能です。

検証中、DBの利用状況にもよるのか、以下のエラーが発生してSnapShotの取得バッチが失敗することがありました。

20XX/MM/DD XX:XX:XX faild describe snapshot, operation error RDS: DescribeDBClusterSnapshots, exceeded maximum number of attempts, 3, https response error StatusCode: 0, RequestID: , request send failed, Post "https://rds.ap-northeast-1.amazonaws.com/": dial tcp 54.239.96.231:443: i/o timeout

そのため、カミナシでは10分おき(開発は15分間隔)実行しており、Cron式で以下のように書いております。

cron(0/10 * * * ? *)

タイミングによっては均等な1時間ごとにならない場合があるのですが、RDSのコンソールを見てスナップショットが定期的に取得できていることが確認できればOKです。

f:id:kaminashi-developer:20211128152755p:plain

終わりに

前置きが少し長くなってしまいましたが、ECSにて定期的にRDSのスナップショットを取得する例でした。

今回はRDSのスナップショットでしたが、ECSタスクを利用することで定期的なバッチ処置を行うことができます。
カミナシでも他の様々なバッチ処理に利用しているため、今回の木々が定期的なバッチ処理を行う際の参考になれば幸いです。

また、カミナシでは新たなサービスの追加に加え、増え続けるユーザー様へ安定した価値を提供するために今回のようなサービス安定化からユーザー体験の向上に取り組んでおります。

  • 「ノンデスクワーカー向けのシステム開発ってどんな感じ?」
  • 「成長を続けるサービスに早いフェーズから携わりたい」

など、カミナシの業務に少しでも興味ある方がいらっしゃいましたら、以下採用ページからお気軽にご応募いただけると幸いです。 careers.kaminashi.jp

最後までご覧いただきありがとうございました。

インフラ未経験エンジニアが構築したGCPがたった数ヶ月でAWSに移行された話

こんにちは、株式会社カミナシのエンジニア @imu です。

はじめに

2021年11月に以下の開発者ブログを公開しました。

kaminashi-developer.hatenablog.jp

α版として構築したインフラですが、2022年1月にインフラ移行の話が突如検討されました。 なぜ最終的にAWSへ移行になったのかと私が何を感じたのかをお話します!

補足
  • 完全移行したのはAPIサーバーです。クライアントアプリはCloudBuild + Firebase Hostingの環境が残った状態です。
  • 移行後のAWS構成図はここでは共有していません。また別の機会に。
  • GCPを否定する記事ではありません。

本記事で様々なコメントを頂いておりますが、追加で補足をさせてください。

記事内の「私がGCPを選択した理由」で、私個人が勝手な意思決定をしてGCPを選択したように受け止められてしまう書き方でした。

インフラを構築した当時はMySQL 8.x系が利用できるのがGCPで、社内の分析はBigQueryを利用していたため、予めGCP側に寄せておくほうが後々メリットがあるのでは?とチーム内で検討していました。

また、α版からβ版へフェーズが変わる際にはインフラ環境を見直す話も事前にありました。

その上で私がインフラ構築をお願いされたとき、GCPに詳しいメンバーもいない中、新しいことにチャレンジしたいという気持ちを後押ししてくれて構築しています!

私個人としては、GCPにチャレンジ出来たことはポジティブにとらえています!

移行の話は突然に

新年早々「新規プロダクトのインフラについてGCPAWSにすべきか、利用ユーザーが少ない2022年2月までに決めておきたいという話」がslackで流れた。GCP環境を徐々に整備していた私は「???」状態になった。事前に何も聞いてないし、何が起きているかさっぱりだった。

通知されてから数日後、MTGが開催され議論されたことは以下の通りです。

  • GCPを使うメリット、デメリットの洗い出し
    • GCPのインフラエンジニアを採用できるのか
    • 数年先を見据えたときに、GCPに投資する価値があるか
  • 将来プロダクト間で共有の情報を扱う可能性がある
    • マルチクラウドにした場合のGCP <-> AWS間のデータの持ち方や参照方法など
  • GCP, AWSのナレッジ共有

上記のことを踏まえて、最終的にざっくり以下の内容になりました。 f:id:kaminashi-developer:20220314134416p:plain

MySQLのバージョンも今現在では差がなくなり、社内のナレッジや採用を考えるとAWSに移行待ったなしとなりました。

余談)私が当時GCPを選択した理由
  • MySQL 8.xを使いたい
    • RDB再帰的な構造になることが既に分かっているので、8系の関数が利用したかった
    • Aurora MySQL 8系がサポートされメリットなくなってしまった…(ここが辛い)
  • インフラ環境を一から作る機会は滅多にないので、既にIaC化されたAWSはモチベーションが個人的に上がらない
  • カミナシレポートの分析はBigQueryを利用していたので、Auroraからの移行の手間を減らしたい
  • やるからには新しいことをしたい!(←コレが一番大事だけど、メンバーからもチャレンジを認めてくれました

見てわかる通り、ほとんど私のモチベーションで決めたインフラです。なので、GCPで絶対にやっていきたい!とは強く言えないし、社内のリソースを鑑みると無理だったので、AWSに移行は必然だったと思いました。更に、GCPを今採用するのは早すぎるという意見もあったので…。 (なので、正直インフラに対してのモチベーションはガクッと落ちましたね…。もちろん正論だし納得しました。)

AWS移行計画

下記画像の通り、APIサーバー部分をAWSへ移行する計画を立てました。 f:id:kaminashi-developer:20220313163820p:plain

まずはGCPAWS間のサービスマッピングを作成して、どの機能がどれに当たるのかを整理しました。移行期間が限られていたので、後戻りが難しいものを優先的に作成していくことになりました。

f:id:kaminashi-developer:20220314093417p:plain

既にカミナシレポートはAWSをほぼIaC化しているため、必要なサービスを選択しながら構築するだけになりました。

私は実際に手を動かす機会はほとんどなく、AWSを熟知しているメンバーが移行する際のDesign Docがまとめられており、非常に分かりやすくて私は勉強させてもらいました!

移行期間は短く、2週間で対応しなければなりませんでしたが、ほぼオンスケジュールでDevelop環境も無事に移行され、あとは本番環境の移行のみとなりました。

移行当日

2022年2月上旬。

IaC化されているのでTerraform Cloudを使って、AWS環境が構築されるのを待つだけです! (環境だけではなくOrganizationも変更していたので、少し躓いたけど問題なく移行完了)

私が対応したことは

  • GCPからMySQLのデータをDumpをする
  • AWSのAurora MySQLにデータをImportをする
  • GCPで使わなくなった環境の停止や削除をする

です。短い期間だったけど、α版の運用が出来て良かったなと思います!

所感

  • GCPの経験が少しでも出来たことは私にとってスキルアップになった
  • 結果論にはなりますが、AWSになるなら最初から議論すべきだった
    • 私のモチベーションでGCPを選択したが、先を見据えてやっても良かったかなと思いました
    • モチベーションを高く持つ要素を見つければ良かったかも知れない
  • AWSも慣れていかないとな…

おわりに

まさかGCPの環境が数ヶ月で終わりを迎えるだなんて思ってもいませんでした。正直GCPでやってみたいなと思いましたが、議論した内容でGCPを絶対にやるべき!とまでは思えませんでしたね。

ですが、組織が拡大していくなかでGCPを採用できる機会があればやってきたいと思いました!GCPの経験は私自身の財産になったかなと思うので、コレはコレで良しとしておきましょう。

と、カミナシって面白いことやってるエンジニアがいるんだなと興味をもっていただければ良いです笑

実際に事業やどんなエンジニアが働いているのか等、気になることがありましたら、カジュアルにお話できればなと思いますので、TwitterのDMや下記の採用情報サイトからご連絡お待ちしております!

最後まで読んでいただき、ありがとうございました!

careers.kaminashi.jp

multipart/form-data形式のファイルが突然アップロードに失敗する問題を仮説を立てながら解決する話

こんにちは、株式会社カミナシのエンジニア @imu です。

はじめに

突然ですがファイルアップロードに失敗することってありますよね?

カミナシレポートのプロダクトはオフライン機能を実装しており、オフラインで記録したデータをアップロードしたときに失敗するケースが、特定の条件下で起きるようになりました。そのため一部のお客様のデータが端末に残り続け、なんとかしてほしいとサポート依頼が発生しておりました。

私自身、半年ぶりにカミナシレポートのプロダクトに戻ってきたばかりで、リハビリのissueとなりました(笑)

今回は問題を解決するまでの仮説や、調査から解決までお話したいと思います!

※オフライン対応をなぜしているのかは以前書いたこちらを参照いただければと思います。 kaminashi-developer.hatenablog.jp

結論

何が問題だったか結論だけ知りたい方はこちら

開発環境

  • go - v1.16
  • echo - v3.3.10+incompatible
  • aws-sdk-go - v1.42.46
  • Mobile ( React Native + Expo )

問題の確認

@dmi8a さんが予め調査をしており、以下の共有を受けました。

  • AWS S3にファイルアップロードする際に UploadWithContext メソッドを使っていて、ここでエラーを出力している。
  • S3 UploadWithContextに関する調査は行い、Partサイズを変更してみたが結果は変わらず。
  • ファイルアップロード出来ているケースもあり、ファイルサイズの問題である可能性がある。
  • MultipartのファイルがS3にアップロードする前にファイルクローズされているのでは?という仮説。

なるほど…わからん。何はともあれ事象を確認していかないと、どこからアプローチをしていくかが分かりません。

まず、Datadogにアプリケーションログが出力されていたので、そちらのログを確認しました。

※一部抜粋
Caused by: failed to upload file:ReadRequestBody: unable to initialize upload
caused by: seek /tmp/multipart-2439640442: file already closed

特定のメソッドでエラーを出力していることが分かり、@dmi8a さんが調査したところだなと再確認。

file already closed

とログが出ているので確かにファイルクローズが原因なのか?と思い調査を開始しました。

ローカル環境で再現するか試す

私自身のやり方になってしまいますが、まずはローカル環境で同じ事象が起きるのか?と確かめないと気が済まない質です。

手順としては以下を想定していました。

  1. ファイルサイズの問題であるため、アプリケーションで制限掛けているサイズのファイルを用意する
  2. ローカルからS3にアップロードして同じエラーが発生するかを確認する
  3. エラーが発生したら原因の切り分けをする
ローカルにデータを準備する

Mobileアプリケーションは現在 iOS のみ提供しているため、ローカルの動作検証はXcode Simulatorを利用しています。

ローカルDBはSQLiteを利用しているため、データベースファイルを用意しなくてはなりません。開発で利用しているオフラインデータを元に、DB Browser for SQLite などでファイルを開き、どのテーブルでも良いのでレコード数を増やせば目的のファイルサイズまで一気に増やすことが出来ます。

用意したデータは約70MBのファイルです。

Simulatorの場合、ローカルディレクトリにSQLiteのファイルがあるので用意したファイルと入れ替えれば問題なし!

SQLiteファイルがどこにあるかを調べる方法
cd Library/Developer/CoreSimulator/Devices
find ./ -name fuga

※ディレクトリはあくまで一例
.//3B9CD40F-FFAC-4F30-9F5D-87D508468E15/data/Containers/Data/Application/F2F25F44-F105-422B-B9B5-880A2DCD6F3D/Documents/ExponentExperienceData/hoge1/hoge_directory/SQLite/fuga

あとはMobileからリクエストを投げて再現するかを確認すると…

「再現したよ!」

再現性があれば解決はできるのでまずは第一歩を踏み出しました。

仮説を立てながら原因の絞り込み

問題になっているリクエスト処理はこちらです。

※実際のコードを抜粋したもの
type sqliteFileUpload struct {
    Name string `validate:"required" jaName:"ファイル名" enName:"file name"`
    // 最大100MB
    Size int64  `validate:"required,min=1,max=104857600" jaName:"ファイルサイズ" enName:"file size"`
    File io.Reader
}

func sample(c echo.Context) error {
    cc := c.(*middleware.Context)
    form, err := cc.MultipartForm()
    if err != nil {
        return err
    }
    files := form.File["files[]"]

    uploadFiles := make([]*sqliteFileUpload, 0)
    for _, file := range files {
        src, err := file.Open()
        defer src.Close()
        if err != nil {
            return err
        }
        uploadFiles = append(uploadFiles, &sqliteFileUpload{
            Name: file.Filename,
            Size: file.Size,
            File: src,
        })
    }

    // uploadFilesを元にS3へアップロード

    return c.JSON(http.StatusOK, nil)
}
仮説1. 本当にファイルクローズが問題なのか

file already closed と出力されているため、defer src.Close() がS3アップロード前にされているのがダメなのかと思い、デバッグログを仕込んで処理フローを確認しました。

  1. Multipartファイルオープン
  2. Multipartファイルクローズ
  3. S3にアップロード

が、アップロードが成功するファイルとしないファイルでは処理フローに差分はありませんでした。

仮説2. ローカルからアップロードは可能なのか

次に試したことはMobileから送られるファイルに問題があるかどうかの切り分けです。

  1. MobileからApiサーバーに送られたMultipartファイルを os.Open する
  2. ソースファイルと同じディレクトSQLiteファイルを置いてos.Openする
  3. 2.で読み込んだファイルをMobileから送られたファイルとして、S3にアップロードしてみる(同じファイルなので、ここで失敗すればS3にアップロードが出来ないという問題に絞り込める)
func sample(c echo.Context) error {
    ...
    uploadFiles := make([]*sqliteFileUpload, 0)
    for _, file := range files {
        src, _ := file.Open()
        if err != nil {
            return err
        }
        // ソースファイルと同じディレクトにSQLiteファイルを読み取る
        localFile, err := os.Open("local SQLite file path")
        if err != nil {
            return err
        }
        defer src.Close()
        defer localFile.Close()
        uploadFiles = append(uploadFiles, &sqliteFileUpload{
            Name: file.Filename,
            Size: file.Size,
            // Mobileから送られたファイルではなく、ローカルから読み取ったファイルを指定してみた
            // File: src,
            File: localFile,
        })
    }

    // uploadFilesを元にS3へアップロード

    return c.JSON(http.StatusOK, nil)
}

ダメなんだろうなと思っていましたが、このケースはアップロードが出来ました! f:id:kaminashi-developer:20220212215000p:plain

可能性として MultipartForm(),os.Openに絞り込むことが出来ました。

仮説3. Multipartファイルがアップロード出来るケースと出来ないケースの違いを探る
  • アップロード出来るときのos.Openログ(戻り値の型はmultipart.File
※一部抜粋
...
0x69, 0x63, 0x61, 0x74, 0x65, 0x64, 0x49, 0x73, 0x73, 0x75, 0x65, 0x73, 0x22, 0x3a, 0x5b, 0x5d, 0x7d},
            i:        0,
            prevRune: -1,
        },
        base:  0,
        off:   0,
        limit: 53248,
    },
}
  • アップロード出来ないときのos.Openログ(戻り値の型はmultipart.File
&os.File{
    file: &os.file{
        pfd: poll.FD{
            fdmu:          poll.fdMutex{},
            Sysfd:         99,
            pd:            poll.pollDesc{},
            iovecs:        (*[]syscall.Iovec)(nil),
            csema:         0x0,
            isBlocking:    0x1,
            IsStream:      true,
            ZeroReadIsEOF: true,
            isFile:        true,
        },
        name:        "/tmp/multipart-1070998851",
        dirinfo:     (*os.dirInfo)(nil),
        nonblock:    false,
        stdoutOrErr: false,
        appendMode:  false,
    },
}

何かしらに失敗していることは明らかで、他に検討も付かなかった私はos.Openがどういった処理になっているのかコードを追うことにしました。 github.com

時間溶ける〜。溶けるよー。沼にハマること約1時間…。

これは解決難しいな…と思っていた矢先!

突然の閃き!!!

f:id:kaminashi-developer:20220212215032p:plain

仮説4. MultipartFormが怪しい説

echoのMultipartForm() 関数をジャンプしても定義してはこちらです。 f:id:kaminashi-developer:20220211211449p:plain 閃く前にここまでは見ていましたが、先入観で「あ、こういう関数なんだ。良しなにやってくれている」と思い込んでいました。

ただ実際何やっているのかを知る必要があると思い、検索すると…

f:id:kaminashi-developer:20220211211815p:plain f:id:kaminashi-developer:20220211211831p:plain

echo...やってくれたな…!

defaultMemory = 32MBってめっちゃ書いてあるやん…。

しかも ParseMultipartForm を見ると答えは書いてありました…お恥ずかしいかぎりです。 github.com

修正コード

const MaxSQLiteFileSize untyped int = 104857600

type sqliteFileUpload struct {
    Name string `validate:"required" jaName:"ファイル名" enName:"file name"`
    // 最大100MB
    Size int64 `validate:"required,min=1,max=104857600" jaName:"ファイルサイズ" enName:"file size"`
    File io.Reader
}

func sample(c echo.Context) error {
    //
    // defaultMemoryサイズをvalidateのサイズと同じにして読み込めるように修正
    //
    err := c.Request().ParseMultipartForm(MaxSQLiteFileSize)
    if err != nil {
        return err
    }
    form := c.Request().MultipartForm
    files := form.File["files[]"]

    uploadFiles := make([]*sqliteFileUpload, 0)
    for _, file := range files {
        src, err := file.Open()
        defer src.Close()
        if err != nil {
            return err
        }
        uploadFiles = append(uploadFiles, &sqliteFileUpload{
            Name: file.Filename,
            Size: file.Size,
            File: src,
        })
    }

    // uploadFilesを元にS3へアップロード

    return c.JSON(http.StatusOK, nil)
}

ついにアップロード出来るようになりましたとさ。めでたしめでたし。

所感

  • @dmi8a さんが予め調査をしていたので解決が早かった
  • 何気なく使っているメソッド(packageが用意している)も疑おう
  • 行き詰まったら別のアプローチを考えよう
  • 根本的にはMobileから直接S3にアップロードしても良さそう
  • 解決するとスッキリするし、仮説を立てて解決するまでは面白い

おわりに

問題を起こさないことに越したことはないけど、こういった問題が起きたときに仮説を立てながら解決していく面白さを感じています。なんとなく使っていたメソッドを深堀り出来たり、どうしたら再発しないかなど再発防止に取り組む意欲も湧いてきますね。

カミナシレポートが急成長していくなか、開発当初は考慮出来ていないことが、現在発生しており大きな課題にもなっています。

0 -> 1の開発フェーズは終わり、1 -> 10, 100といった難しさに直面しているのだと思っていますが、私自身は難しい課題や調査するのは楽しめるタイプなのでもっとプロダクトを良くしていきたいと思います。

今回は良いリハビリになりました!

最後にカミナシめっちゃ気になるなーと思ったら、TwitterのDMや下記の採用情報サイトからご連絡お待ちしております!

ここまで読んでいただき、ありがとうございました!

careers.kaminashi.jp

【登壇資料】Startup Issue Gym #3【AWS活用におけるIssue】に登壇してきました

f:id:kaminashi-developer:20211227190138j:plain

カミナシ・エンジニアリングマネージャーの @dmi8a です。

先日行われた Startup Issue Gym #3【AWS活用におけるIssue】 にて「カミナシレポートが辿り着いたメンテナンスモードのあり方」というタイトルで発表してきました。

概要

カミナシは、フロントエンド: SPA(S3 + Cloudfront + etc.)、
バックエンド: API(ECS (Fargate)+ ELB + etc.)といった技術で構成されています。

SPA以外にmobileアプリも別途存在し、mobileアプリについては24h/365日利用される前提のため、 メンテナンスモードのあり方について従来より悩んでいました。

本登壇では、その悩みの末に辿り着いた、カミナシレポートのメンテナンスモードのあり方について共有させていただきました。

speakerdeck.com

結論

メンテナンスモード中、Web(管理者向け)は 全操作不可とし、アプリ(現場向け)は オフラインモードにて利用可能 としました。

gyazo.com

メンテナンスモードの設定

カミナシのインフラ構成概要は以下の通りです。

gyazo.com

ELBのfixedレスポンス活用 + Lambda、および、MySQLinnodbオンラインDDLを併用する事でメンテナンスモードを実現しています。

gyazo.com

ELBのfixedレスポンス活用 + Lambda

カミナシでは、ELBのリスナールールを変更する事でメンテナンスモードを開始します。

具体的には、優先順位の最後にある行の「転送先」を事前にLambdaで作成したターゲットグループに変更します。

gyazo.com

Lambdaに記載された固定のHTTPレスポンス内容 + statusコードを返す事でclient側にメンテナンス中と認識させます。

gyazo.com

MySQLinnodbオンラインDDL

最も苦慮したのが、mobileアプリの「24h/365日利用可能」という要件の達成です。

こちら達成のためには、メンテナンスモード時に行う事が多い Alter table系処理 と mobileアプリのHTTPリクエストから発行されるSQLの同時実行を達成する必要があります。

transactionを貼ったSQL処理が実行された状態で Alter table系処理を流すと、容易にDBがデッドロックされてしまいサービスが止まってしまうリスクがあるため、そのリスクをどう回避するかに頭を悩ませました。

この用件をクリアするため、innodbというストレージエンジンを利用したMySQL 5.6以上にある「オンラインDDL」という機能に着目しました。

この「オンラインDDL」がある事で、Alter table系処理でテーブル定義の変更中に、SELECT/INSERT/UPDATE/DELETEに関する処理が継続できるとされていました。

gyazo.com

調べたところ、カラムデータ型変更、プライマリーキー削除、パーティション作成/追加/削除を除けば、Alter table系処理中でもmobileアプリからHTTPリクエストが来てSQLが発行された場合でも耐えられうる可能性がある事に気づきました。

gyazo.com

さらに調査を進めたところ、「Waiting for table metadata lock」が発生しないか、「innodb_online_alter_log_max_size」を超えるほど処理時間の長いものが流れないか、この2点をクリアできる場合はAlter table系処理とサービスからのリクエストの同タイミングでの実行を許容できるという結論に至りました。

結果、mobileアプリのオフラインモード + メンテナンスモード時はELBに流れてくるHTTPリクエストの総量削減 + MySQLinnodbオンラインDDLを併用、の3点を持って、mobileアプリの24h/365日利用の要件を達成しました。

gyazo.com

注意点: データが多くなって上記手法で対処できなくなった場合

データ数が多くなってくると、検証時点でAlter table系処理時間が長すぎて失敗するようになります。 その場合は、以下の通りレプリケーションを利用するしかなくなってきます。

gyazo.com

まとめ

現在はまだデータ数が少ないため、今回発表させていただいた手法を持ってメンテナンスモードが実現できました。 我々のように、まだスタートアップの初期フェーズ〜ミドルフェーズくらいまでであれば、リリースや検証コストが節約できると思いますので、そういった企業様に少しでも参考になれば幸いです。

最後に宣伝になりますが、カミナシでは、アプリエンジニア/SREと幅広く募集しております!

少しでも興味を持っていただいた方からのご応募お待ちしております!

careers.kaminashi.jp

【登壇資料】Go Conference 2021 Autumn に登壇してきました

f:id:kaminashi-developer:20211130104451j:plain

カミナシ・エンジニアの@issei です。

先日行われたGo conference にてgoldスポンサー枠にて登壇してきました。

gocon.jp

kaminashi-developer.hatenablog.jp

本日は、その際のカミナシ側の登壇資料を紹介します。

発表趣旨

カミナシではバックエンドのAPIサーバーの開発にGolangを利用しております。

カミナシからは「ノンデスクワーカー向けノーコードサービスのつらみ・うまみ」というタイトルにて、 デスクレスSaaSというあまり前例の無いサービスにおいてGoをどう利用しているのかについて、発表させていただきました。

speakerdeck.com

カミナシについて

gyazo.com

製造現場等のノンデスクワーカの分野では、まだまだ紙を主役とした人力の業務フローがメインです。 結果としてミスが多くなりがちになり、基幹業務外の仕事に時間を割かれがちになってしまいます。

カミナシでは、このような紙を主体とした業務フローをデジタル化し、ノーコードで作成できる現場管理アプリを提供しています。

木構造な内部データ

gyazo.com

ノーコードサービスゆえ、内部データが木構造で定義されており、これによりサービス内でのloop処理や条件分岐処理等をユーザが直感的に使用できるようになっています。

これにより、ユーザー自身がノーコードで独自のワークフローを作成・編集できるようになっています。

開発におけるうまみ

Goをサービスに用いることで当社が開発を通じて感じたうまみを紹介しました。

多階層テーブルと静的型付けとの相性

gyazo.com

多階層なテーブルを扱うサービスを開発する上でのGoの静的型付けの特性から受ける恩恵について紹介しました。

他言語からの敷居の低さ

gyazo.com

当社エンジニアの入社前の使用言語を例にGo未経験から実務をこなすまでの敷居の低さの恩恵を紹介しました。

開発におけるつらみ

Goをサービスに用いることで当社が開発を通じて感じたつらみを紹介しました。

カミナシでは、リレーションを多くもつデータ構造ゆえ、gormに関わるつらみを中心に紹介しました。

コード行数の増加

gyazo.com

テーブル間のリレーションが多い分、preloadを多用しがちになるほか、 (genericsがないことからの) switch文やforloopの多用、によって可読性が落ちてしまうつらみを紹介しました。

RDBでの性能低下

gyazo.com

上記で述べた通りpreloadを多用しているので、ORM側で大量のクエリを発行してしまい結果、 application⇄DB間の通信回数が増加することでRDBでの性能低下につながってしまったつらみを紹介しました。

上記への対策として、

  • application側では、ORMから一部、テーブル結合を行うような生クエリに書き換えを実施
  • DB側では、外部キーに対するindexの追加を実施

することで、両者間の通信速度が改善された事例を併せて紹介しました。

テストデータの用意

gyazo.com

多階層なデータ構造ゆえ、テストデータをJSONにて愚直に書いてしまうと用意に膨大な時間がかかるため、自動でエンティティを生成できるような外部モジュール ( factory-go )を使用した事例を紹介しました。

まとめ

現在カミナシではデスクレスSaaSというあまり前例の無いサービス開発をしており、やりがいがある一方で、今回共有した通りまだまだ多階層なテーブルデータを扱うための課題感があるのが現状です。

これらの課題に一緒に取り組んでくれる方、現場ドリブンにて現場の反応を直に感じながらのサービス開発志向のある方等、 EM/アプリエンジニア/SREと幅広く募集しておりますので、ご応募お待ちしております!

careers.kaminashi.jp

Go Conference 2021 Autumn に "Go"ld Partners として協賛・登壇します!

f:id:kaminashi-developer:20211110193200j:plain

カミナシではバックエンドのAPIサーバーの開発にGolangを利用しております。

(Goの採用に関しては過去のこちらの記事もご覧いただけると幸いです)

Go Conference に "Go"ld Partners として協賛させていただけることとなりました!

OSSの恩恵を受けプロダクト開発をさせていただいている弊社ですが、この度Go Conference 2021 Autumn に "Go"ld Partners として協賛させていただくこととなりました!

gocon.jp

gocon.jp

カミナシがGo Conferenceに協賛させていただくのは前回のSilver Partnersに引き続き2回目となります。 gocon.connpass.com

Sponsor Session で登壇させていただきます!

当日(11/13(土))は弊社エンジニアの木村が、10:30~ のSponsor Sessionで登壇させていただきます。

gocon.jp

デスクレスSaaSというあまり前例の無いサービスにおいてGoをどう利用しているのか、これまでの辛み・旨みを凝縮して共有させていただきますので、是非ご視聴いただければと思います。

開催日・参加申し込み

公開が直前となってしまい申し訳ございませんが、 Go Conferenceは今週末の2021/11/13(土) 10:00 〜 20:00の開催です!

開催はオンライン(YoutubeLive)で、チケット料金は無料。 参加申し込みは以下のconnpassページより可能ですので、ぜひお時間ある方はお申し込みください!

gocon.connpass.com

オフィスアワーにてブースも出展します

また、当日はメイン会場となるYoutubeLiveと並行して、Remoによるオフィスアワーにてブースも出展させていただきます!

ブースには弊社人事が常駐しており、当日参加するエンジニアメンバーも遊びに行きますので、少しでもご興味ある方はお気軽にお立ち寄りください!

カミナシに興味があるけど、都合が合わない場合・・

また当日都合が悪く参加できないという方も、下記採用情報サイトからご連絡いただけますとカジュアルにお話しすることも可能です。

こちらもご興味ある方はお気軽にご連絡ください!

careers.kaminashi.jp

最後になりますが、当日皆様にお越しいただけることを心よりお待ちしております!!