Cloud RunとLitestreamで最強コスパ運用

Cloud RunとLitestreamで最強コスパ運用

Cloud RunとLitestream、GCSを使用して、SQLiteを使用したGoアプリケーションを格安運用していこうと思います。

FirestoreなどのNoSQL DBを使っても安く運用できますが、RDB使いたいというときもあるということで。

作ったもの

GitHub - kooooohe/litestream-cloud-run
Contribute to kooooohe/litestream-cloud-run development by creating an account on GitHub.
\]

簡単な構成

Litestream

Litestream
Litestream is an open-source, real-time streaming replication tool that lets you safely run SQLite applications on a single node.
Litestream is a standalone disaster recovery tool for SQLite. It runs as a background process and safely replicates changes incrementally to another file or S3. Litestream only communicates with SQLite through the SQLite API so it will not corrupt your database.

SQLiteのデータをBackupしたりRecoveryしたりを簡単にできるツールです。引用ではS3になっていますが、今回はGCSを使っていきます。

下準備

Backup用のGCSのバケットを作成。

一旦適当なimageを使い、Cloud Runを作成。この際に最大スケール数を1とする。

2つ以上のスケールには対応していないので注意。データが壊れたりします。個人開発や小さいサービス向きという感じはしますが、Cloud Runのスペックを上げていけば結構対応できそう。

※アクセスがないときはゼロスケールします

gcloud run deploy litestream-cloud-run \
--image=us-east1-docker.pkg.dev/YOUR_PROJECT/cloud-run-source-deploy/TARGET_IMAGE_URL \
--allow-unauthenticated \
--memory=128Mi \
--max-instances=1 \
--set-env-vars='BACKUP_URL=gcs://YOUR_BUCKET_NAME' \
--region=us-central1 \
--project=YOUR_PROJECT

注意: Cloud Runのストレージはメモリに展開されるため、DBのmaxサイズはそこに依存します。

Dockerfile

Goを使うので、いつもどおりAlpineを使ってMulti-stage build を使う

FROM golang:1.20 as builder
WORKDIR /app
COPY ./src /app
#RUN CGO_ENABLED=0 go build -o main .
RUN go build -ldflags '-s -w -extldflags "-static"' -tags osusergo,netgo,sqlite_omit_load_extension -o /app/main .

ADD https://github.com/benbjohnson/litestream/releases/download/v0.3.9/litestream-v0.3.9-linux-amd64-static.tar.gz litestream.tar.gz
RUN tar -xzf litestream.tar.gz -C ./

FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=builder /app/main /app/main
COPY --from=builder /app/litestream /usr/local/bin/litestream
COPY litestream.yml /etc/litestream.yml
COPY start.sh /app/start.sh
RUN chmod +x /app/start.sh
CMD ["/app/start.sh"]

EXPOSE 8080

litestream.yml

今回はDB 1台なので、それ用に準備。/etc配下においておくとLitestream実行時に自動的に読み込んでくれる。

dbs:
  - path: /app/db
    replicas:
      - url: ${BACKUP_URL}

cloudbuild.yaml

DockerfileのBuildからArtifact Registryにpushすること、Cloud Runへのデプロイは自動化したいので、cloudbuildの設定yamlを準備する。なんだかんだ、試行錯誤してデプロイを繰り返すのでCloud Run使うときは最初に作っておくと便利。

steps:
- name: 'gcr.io/cloud-builders/docker'
  args: ['build', '-t', 'gcr.io/$PROJECT_ID/litestream-cloud-run', '.']
- name: 'gcr.io/cloud-builders/docker'
  args: ['push', 'gcr.io/$PROJECT_ID/litestream-cloud-run']
- name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
  entrypoint: gcloud
  args: ['run', 'deploy', 'litestream-cloud-run', '--image', 'gcr.io/$PROJECT_ID/litestream-cloud-run', '--region', 'us-east1']
images:
- gcr.io/$PROJECT_ID/litestream-cloud-run

下記コマンドで実行できます。Cloud Shellで実行すると非常に楽です。

gcloud beta run deploy --source .

Go

単純にSQLiteを使い、/addの際はデータをInsertし、/の際は格納してあるデータをjsonで表示するような処理を記載。litestreamを意識する必要がないのが良いですね。

package main

import (
	"context"
	"database/sql"
	"errors"
	"flag"
	"fmt"
	"log"
	"math/rand"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"encoding/json"

	_ "github.com/mattn/go-sqlite3"
)

func init() {
	rand.Seed(time.Now().UnixNano())
}

const letter = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"

func randString(n int) string {
	b := make([]byte, n)
	for i := range b {
		b[i] = letter[rand.Intn(len(letter))]
	}
	return string(b)
}

func main() {
	fmt.Println("test")
	if err := run(); err != nil {
		log.Fatalf("%v", err)
	}
}

func UsersHandler(w http.ResponseWriter, r *http.Request) {
	us, err := readUsers()
	if err != nil {
		fmt.Println(err)
		return
	}
	j, err := json.Marshal(us)
	if err != nil {
		fmt.Printf("%v", err)
	}
	w.Header().Set("Content-Type", "application/json")
	w.Write(j)
}

func AddUserHandler(w http.ResponseWriter, r *http.Request) {
	tx, err := db.Begin()
	if err != nil {
		fmt.Printf("%v", err)
		return
	}
	_, err = db.Exec("INSERT INTO users(name) VALUES(?)", randString(16))
	if err != nil {
		fmt.Printf("%v", err)
		return
	}
	tx.Commit()

	us, err := readUsers()
	j, err := json.Marshal(us)
	if err != nil {
		fmt.Printf("%v", err)
	}
	w.Header().Set("Content-Type", "application/json")
	w.Write(j)
}

var db *sql.DB

type user struct {
	Id   int
	Name string
}

func run() error {
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM)
	defer stop()

	dataPath := flag.String("dp", "", "the path for data")

	flag.Parse()
	fmt.Println(*dataPath)
	if *dataPath == "" {
		flag.Usage()
		return errors.New("data path option error")
	}
	_, err := os.Stat(*dataPath)

	db, err = sql.Open("sqlite3", *dataPath)
	if err != nil {
		return err
	}
	defer db.Close()
	cSQLstmt := "CREATE TABLE IF NOT EXISTS users(id integer not null primary key, name text);"
	_, err = db.Exec(cSQLstmt)

	if err != nil {
		return err
	}

	http.HandleFunc("/add", AddUserHandler)
	http.HandleFunc("/", UsersHandler)
	go http.ListenAndServe(":8080", nil)
	<-ctx.Done()
	return nil
}

func readUsers() ([]user, error) {
	rows, err := db.Query("SELECT id, name FROM users")
	if err != nil {
		fmt.Printf("%v", err)
		return nil, err
	}
	defer rows.Close()

	us := []user{}
	for rows.Next() {
		u := user{}
		err := rows.Scan(&u.Id, &u.Name)
		if err != nil {
			fmt.Printf("%v", err)
		}
		us = append(us, u)
	}
	return us, nil
}

start.sh

Cloud Run起動時に、GCSからDBのデータを取得する。そして、定期的にBackupを実行するようにshellを準備

#!/bin/sh
set -e

if [ ! -f /app/db ]; then
	litestream restore -if-replica-exists -o /app/db "${BACKUP_URL}"
fi

exec litestream replicate -exec "/app/main -dp /app/db"

動き

Cloud Runはリクエストがないときにゼロスケールするので、その後サイドアクセスしてもデータ追加したデータが残っています。

これで超格安でサービスを運用できるので、とりあえずのサービスはこの構成で一旦作ってしまうのも良さげかなと思っています。