simplestarの技術ブログ

目的を書いて、思想と試行、結果と考察、そして具体的な手段を記録します。

AWS:golangバイナリキャッシュサーバーがS3にバックアップを保存&復帰

前提

http リクエストで int データを受け渡すバイナリキャッシュサーバーが完成しています。

前回の作業記録をなぞるだけ
simplestar-tech.hatenablog.com

大きな目的

PlayFab の CloudScripts から int or byte x 4 データを受け渡すバイナリキャッシュサーバーが定期的に s3 に全データを記録
そこからいつでも復帰できるというもの

今回は定期的に s3 に全データをバイナリで記録する機能を試します。

参考にする s3 操作はこちら
qiita.com

ローカルで s3 操作サンプルを利用できる環境づくり

EC2 インスタンスは Elastic Beanstalk によって作られたリソースです。
IAM ロールのポリシーをチェックした感じ elasticbeanstalk-* から始まるS3 バケットへの読み書きを許可しているので問題なく保存できます。
定期実行は毎分 schedule パスで動いているので保存されるかチェック

"github.com/aws/aws-sdk-go/aws"
はどうやってインストールするのか?

ローカルで試験したときはターミナルで以下のコマンドを実行

go get -u github.com/aws/aws-sdk-go

結果 users フォルダの go\src\github.com\aws\aws-sdk-go にインストールされることを確認しました。

EC2 インスタンスでも同じことするには…ssh で入って確認する必要があるのかな?→いいえ

Elastic Beanstalk で s3 操作サンプルを利用できる環境づくり

作業内容としては .zip 圧縮する一番上の階層に
Buildfile
Procfile
の二つのテキストファイルを作成し、それぞれに yaml 形式でビルドコマンドと、アプリケーションの実行ファイルを指定します。

具体的には

こちらのドキュメントの通り
Buildfile で Executable On-Server を構築する - AWS Elastic Beanstalk

awssdk: go get -u http://github.com/aws/aws-sdk-go
build: go build -o bin/application application.go

を Buildfile に書くことで解決できました。

Procfile には

web: bin/application

こちらを記述してます。(ファイル無くても、EC2 インスタンスではディフォルトでこれが作られます)

build と連携すれば起動するアプリケーションプロセスも変えられるとのこと
[Procfile] でアプリケーションプロセスを設定する - AWS Elastic Beanstalk

起動時に S3 から復元し、定期的にバックアップする golang サーバー

S3 操作ができる環境なので、次の実装を含めるだけです。

簡単に解説すると、起動時に byte 配列を S3 のダウンロード結果から作成し、
スケジュール実行用の scheduled パスのリクエストが来たら、S3 にバックアップを作成します。
(S3 にバックアップがないと起動しないという、卵か鶏かの話は生まれてますが)

実装内容は次の通り

package main

import (
	"bytes"
	"encoding/json"
	"io/ioutil"
	"log"
	"net/http"
	"os"

	"github.com/aws/aws-sdk-go/aws"
	"github.com/aws/aws-sdk-go/aws/awserr"
	"github.com/aws/aws-sdk-go/aws/session"
	"github.com/aws/aws-sdk-go/service/s3"
	"github.com/aws/aws-sdk-go/service/s3/s3manager"
)

// ----- S3 -----

var PersistentS3Store = &S3Store{}

type S3Store struct {
	Bucket     string
	Uploader   *s3manager.Uploader
	Downloader *s3manager.Downloader
}

func (s *S3Store) Init(bucket, region string) (err error) {
	s.Bucket = bucket

	sess, err := session.NewSession(&aws.Config{
		Region: aws.String(region),
	})
	if err != nil {
		return
	}

	s.Uploader = s3manager.NewUploader(sess)
	s.Downloader = s3manager.NewDownloader(sess)

	return
}

func (s *S3Store) Set(key string, body []byte) (err error) {
	params := &s3manager.UploadInput{
		Bucket: aws.String(s.Bucket),
		Key:    aws.String(key),
		Body:   bytes.NewReader(body),
	}

	_, err = s.Uploader.Upload(params)
	return
}

func (s *S3Store) Get(key string) ([]byte, error) {
	buffer := aws.NewWriteAtBuffer([]byte{})

	_, err := s.Downloader.Download(buffer, &s3.GetObjectInput{
		Bucket: aws.String(s.Bucket),
		Key:    aws.String(key),
	})
	if err != nil {
		if aerr, ok := err.(awserr.Error); ok && aerr.Code() == "NoSuchKey" {
			return nil, nil
		}
		return nil, err
	}

	return buffer.Bytes(), nil
}

// ----- S3 -----

func main() {
	f, _ := os.Create("/var/app/current/golang-server.log")
	defer f.Close()
	log.SetOutput(f)

	err := PersistentS3Store.Init(s3bucket, "ap-northeast-1")
	if err != nil {
		log.Fatal("s3 Init error.", err)
		return
	}
	cubedata, err := PersistentS3Store.Get(cubedataObjectKey)
	if err != nil {
		log.Fatal("s3 Get error.", err)
		return
	}
	port := os.Getenv("PORT")
	if port == "" {
		port = "5000"
	}

	http.HandleFunc("/request", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != "POST" {
			http.Error(w, "Not Found.", http.StatusNotFound)
			return
		}
		user, pass, ok := r.BasicAuth()
		if ok == false || user != basicAuthUser || pass != basicAuthPassword {
			http.Error(w, "Not Found.", http.StatusNotFound)
			return
		}
		requestJson := new(RequestJson)
		if buf, err := ioutil.ReadAll(r.Body); err == nil {
			if err := json.Unmarshal(buf, requestJson); err != nil {
				http.Error(w, "Bad Request.", http.StatusBadRequest)
				return
			}
		}
		if 0 > requestJson.Index || len(cubedata) <= requestJson.Index*4 {
			http.Error(w, "Bad Request.", http.StatusBadRequest)
			return
		}
		offsetIndex := requestJson.Index * 4
		if 1 == requestJson.Action {
			cubedata[offsetIndex] = requestJson.Category
			cubedata[offsetIndex+1] = requestJson.Rotation
			cubedata[offsetIndex+2] = requestJson.SideA
			cubedata[offsetIndex+3] = requestJson.SideB
		}
		responseJson := ResponseJson{http.StatusOK, cubedata[offsetIndex], cubedata[offsetIndex+1], cubedata[offsetIndex+2], cubedata[offsetIndex+3]}

		res, err := json.Marshal(responseJson)

		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}

		w.Header().Set("Content-Type", "application/json")
		w.Write(res)
	})

	http.HandleFunc("/s3dump", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != "POST" {
			http.Error(w, "Not Found.", http.StatusNotFound)
			return
		}
		user, pass, ok := r.BasicAuth()
		if ok == false || user != basicAuthUser || pass != basicAuthPassword {
			http.Error(w, "Not Found.", http.StatusNotFound)
			return
		}
		err = PersistentS3Store.Set(cubedataObjectKey, cubedata)
		if err != nil {
			log.Fatal("s3 Set error.", err)
		}
	})

	http.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
		if r.Method != "GET" {
			http.Error(w, "Not Found.", http.StatusNotFound)
		}
	})

	log.Printf("Listening on port %s\n\n", port)
	http.ListenAndServe(":"+port, nil)
}

type RequestJson struct {
	Action   int  `json:"action"`
	Index    int  `json:"index"`
	Category byte `json:"category"`
	Rotation byte `json:"rotation"`
	SideA    byte `json:"sideA"`
	SideB    byte `json:"sideB"`
}

type ResponseJson struct {
	Status   int  `json:"status"`
	Category byte `json:"category"`
	Rotation byte `json:"rotation"`
	SideA    byte `json:"sideA"`
	SideB    byte `json:"sideB"`
}

const (
	basicAuthUser     = "user"
	basicAuthPassword = "password"
	s3bucket          = "elasticbeanstalk-ap-northeast-1-xxxxxxxxxxxxxxxxx"
	cubedataObjectKey = "data.bin"
)

毎時スケジュール実行(非推奨)

同フォルダに cron.yaml を設置

version: 1
cron: 
  - name: "backup"
    url: "/scheduled"
    schedule: "20 * * * *"

期待では毎時 20 分になるとバックアップを S3 にアップロードする予定なのですがどうでしょう

ログも流れないですね
これはどういうことか?

こちらの開発者ガイドに書かれている模様
https://docs.aws.amazon.com/ja_jp/elasticbeanstalk/latest/dg/awseb-dg.pdf

定期的なタスク
ソースバンドルで cron.yaml という名前のファイルに定期的なタスクを定義し、定期的な間隔でワー
カー環境のキューにジョブを自動的に追加できます。
たとえば、次の cron.yaml ファイルは 2 つの定期的なタスクを作成します。1 つは 12 時間ごとに実行さ
れ、もう 1 つは毎日午後 11 時 (UTC) に実行されます。
Example cron.yaml
version: 1
cron:
- name: "backup-job"
url: "/backup"
schedule: "0 */12 * * *"
- name: "audit"
url: "/audit"
schedule: "0 23 * * *"
name は、各タスクに対して一意である必要があります。URL は、ジョブをトリガーするために POST リ
エストを送信するパスです。スケジュールは、タスクをいつ実行するかを決定する CRON 式です。
タスクを実行すると、デーモンは実行する必要があるジョブを示すヘッダーとともに環境の SQS キュー
にメッセージをポストします。環境の任意のインスタンスはメッセージを取得し、ジョブを処理できま
す。

うーん、自分の行いはすべて正しい
だけど POST 来てないよ?(当時は Basic 認証つけてないから、本来なら通知が来るはずだった)

あ、もしかして環境の設定?

docs.aws.amazon.com

ちょっと項目を見てみます。
ないなぁ

SQS のキーワードを得たけど、なんだっけこれ
やめやめ 動かない

PlayFab のスケジュールタスクで定期実行

S3 への保存とか認証データつけないと怖くて公開できないじゃないですか
スケジュールを待ち受ける公開されたエンドポイントとか作っちゃダメ
ということで PlayFab の定期実行タスクから、認証情報付きでリクエストしてもらうことにします。

PlayFab には cron の形式でスケジュールタスクを設定できます。
具体的な手順は公式ドキュメントが詳しい
api.playfab.com

要約すると、CloudScript 実行します、この CloudScript 関数です を指定
今回は引数に json を指定して、対象サーバを切り分けるのに使いました。
毎時 25 分に実行するようにしたところ、ここ数日問題なくスケジュールが実行されることを確認

CloudScript は次のようにサンプルをベースに作りました。

// s3dump
handlers.s3dump = function (args, context) {
    var headers = {
        "Authorization": "Basic dXNlcjpwYXNzd29yZA=="
    };
    
    var body = {
    };

    var url = "https://xxx" + args.index + ".your.domainname.net/s3dump";
    var content = JSON.stringify(body);
    var httpMethod = "post";
    var contentType = "application/json";

    var response = http.request(url, httpMethod, content, contentType, headers);
    return { responseContent: response };
};

※ここに書かれている認証情報は user:password のBasic 認証文字列なので本番とは別物です

実際 S3 に 64MB のバイナリファイルが毎時 25 分 20 秒から 2秒ほどで更新され続けていることを確認できました。

証拠

f:id:simplestar_tech:20191109183706p:plain
S3 ファイルが保存されている様子