kawabatas技術ブログ

試してみたことを書いていきます

空のUPDATEによるDEADLOCK

概要

負荷検証中に deadlock のログを見つけて、その対処をしたのでメモです。

クエリ特定

SHOW ENGINE INNODB STATUS; コマンドを実行すると、

LATEST DETECTED DEADLOCK でどのクエリで deadlock が発生したのかわかります。

今回は

INSERT INTO tokens (id, token) VALUES(1, 'token');

INSERT INTO tokens (id, token) VALUES(2, 'token2');

のようなクエリで deadlock になっていることがわかりました。

原因&対処

INSERT の直前で、UPDATE tokens SET delete_time=NOW() WHERE id=1; のような UPDATE をしており、 これが空の場合、ネクスキーロックがかかってしまうためとわかりました。

参考

試す。 ターミナル1

mysql> BEGIN;
Query OK, 0 rows affected (0.01 sec)

mysql> UPDATE tokens SET delete_time=NOW() WHERE id=1;
Query OK, 0 rows affected (0.03 sec)
Rows matched: 0  Changed: 0  Warnings: 0

mysql> INSERT INTO tokens (id, token) VALUES(1, 'token');
Query OK, 1 row affected (7.07 sec)

ターミナル2

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> UPDATE tokens SET delete_time=NOW() WHERE id=2;
Query OK, 0 rows affected (0.01 sec)
Rows matched: 0  Changed: 0  Warnings: 0

mysql> INSERT INTO tokens (id, token) VALUES(2, 'token2');
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction

Deadlock!!!

対処。

アプリケーションのロジックで、まず、SELECT で存在をチェックして、存在すれば UPDATE する形にしました。

// ロックを避けるために存在チェック
isExist, err := // SELECT * FROM tokens WHERE id=?;
if err != nil {
    return nil, err
}
if isExist {
    // UPDATE
}

// INSERT

XXXロックとかの理解を深めないと。。。

あとで読もう

MySQL デッドロック回避パターン - あっさりと

MySQLのデッドロック対処 おまけでギャップロック│システムガーディアン株式会社

Golang で iOS/Android アプリ内課金の実装

概要

アプリ内課金の実装に関わったので、メモ。

※アプリ内課金はプラットフォーム側の影響を受ける。記載内容が古くなっている可能性があるので注意。

テーブル設計

f:id:kawabatas:20190519171953p:plain

  • products テーブル

    • App Store, Google Play に登録するアプリ内課金アイテムの情報。product_id には "com.example.100gold" などの文字列が入る。
  • receipts テーブル

    • クライアントから送られたレシートをそのまま保存する
  • purchases テーブル

    • tranasction_id カラムは App Store なら Transaction ID を入れ、Google Play なら Order ID を入れる。
  • paid_golds テーブル

    • 有償ゴールドの数
  • gold_histories テーブル

    • 有償ゴールドの購入、使用の履歴

レシートの検証

こちらを使わせていただいた。

github.com

AWA で使用されているとのことで、信頼できるし、ありがたい...

自動購読課金について【iOS編】自動購読課金について【Android編】で課金処理の流れも非常に参考になった。ありがたい...

実装したコード抜粋

// クライアントから送られたレシートの検証後のデータを扱うモデル
package model

type PurchaseOrder struct {
    Payload    string
    Store      StoreType // AppStore, GooglePlay
    OrderLines []*OrderLine
}

type OrderLine struct {
    TransactionID         string
    OriginalTransactionID string
    Quantity              int
    ProductID             string
}

AppStoreValidator

package validator

import (
    "context"
    "github.com/awa/go-iap/appstore"
)

type AppStoreValidator struct {
    Client *appstore.Client
}

// NewAppStoreValidator generates a validator.
func NewAppStoreValidator() *AppStoreValidator {
    return &AppStoreValidator{
        Client: appstore.New(),
    }
}

// Verify sends receipts and gets order lines.
func (v *AppStoreValidator) Verify(ctx context.Context, payload string) ([]*model.OrderLine, error) {
    req := appstore.IAPRequest{
        ReceiptData: payload,
    }
    resp := &appstore.IAPResponse{}
    err := v.Client.Verify(ctx, req, resp)
    if err != nil {
        return nil, err
    }

    if err := handleError(resp.Status); err != nil {
        return nil, err
    }

    var orderLines []*model.OrderLine
    for _, iap := range resp.Receipt.InApp {
        // 略
        orderLines = append(orderLines, order)
    }

    return orderLines, nil
}

// 成功したら 20x 系、レシートの不備・不正等は 40x 系、サーバエラー時 (クライアントにレシート送信をリトライしてほしい) は 50x 系としたかったので、独自で定義
func handleError(status int) error {
    err := appstore.HandleError(status)
    if err == nil {
        return nil
    }

    switch status {
    case 21000, 21002, 21003, 21004, 21010:
        // レシートが不正
        return domain.ErrInvalidReceipt
    case 21005, 21009:
        // サーバ側のエラーなので再送信を促す
        // 21009 はたいていの場合は App Store 側の問題であり、リトライを促す旨指示がある
        // https://developer.apple.com/library/content/technotes/tn2413/_index.html#//apple_ref/doc/uid/DTS40016228-CH1-RECEIPT-_WHEN_VALIDATING_MY_RECEIPT__THE_APP_STORE_RETURNS_A_STATUS_CODE_OF_21009__
        return domain.ErrReceiptValidationFailed
    }
    if status >= 21100 && status <= 21199 {
        return domain.ErrReceiptValidationFailed
    }
    return err
}

GooglePlayValidator

package validator

import (
    "context"
    "encoding/base64"
    "encoding/json"
    "github.com/awa/go-iap/playstore"
)

type GooglePlayValidator struct {
    Key             string
    VerifySignature func(base64EncodedPublicKey string, receipt []byte, signature string) (bool, error)
}

type IABPayload struct {
    Json      string `json:"json"`
    Signature string `json:"signature"`
}

type IABReceipt struct {
    OrderID          string `json:"orderId"`
    PackageName      string `json:"packageName"`
    ProductID        string `json:"productId"`
    PurchaseTime     int64  `json:"purchaseTime"`
    PurchaseState    int    `json:"purchaseState"`
    DeveloperPayload string `json:"developerPayload"`
    PurchaseToken    string `json:"purchaseToken"`
    AutoRenewing     bool   `json:"autoRenewing"`
}

// NewGooglePlayValidator generates a validator.
func NewGooglePlayValidator(key string) *GooglePlayValidator {
    return &GooglePlayValidator{
        Key: key,
        VerifySignature: func(key string, purchaseData []byte, signature string) (bool, error) {
            return playstore.VerifySignature(key, purchaseData, signature)
        },
    }
}

// Verify verifies in app billing signature and gets order lines.
func (v *GooglePlayValidator) Verify(ctx context.Context, payload string) ([]*model.OrderLine, error) {
    var iabPayload IABPayload
    if err := json.Unmarshal([]byte(payload), &iabPayload); err != nil {
        return nil, domain.ErrInvalidReceipt
    }

    purchaseData := []byte(iabPayload.Json)
    signature := iabPayload.Signature
    isValid, err := v.VerifySignature(v.Key, purchaseData, signature)
    if err != nil {
        return nil, err
    }
    if isValid == false {
        return nil, domain.ErrInvalidReceipt
    }

    var receipt IABReceipt
    if err = json.Unmarshal(purchaseData, &receipt); err != nil {
        return nil, domain.ErrInvalidReceipt
    }

    // もし purchaseState が 0 以外(0: purchased, 1: canceled, 2: refunded) の場合はクライアント起因のエラーとする
    if receipt.PurchaseState != 0 {
        return nil, domain.ErrInvalidReceipt
    }

    // クライアント側で DeveloperPayload に ユーザID を入れてもらう
    if receipt.DeveloperPayload == userId {
        return nil, domain.ErrInvalidReceipt
    }

    var orderLines []*model.OrderLine
    // 略
    orderLines = append(orderLines, order)

    return orderLines, nil
}

GolangでGooglePlayの課金レシートの署名検証を参考。

レシート検証〜保存

クライアントから store(AppStore / GooglePlay)と payload(購入レシート)を送ってもらう。

こんな感じに。

 // レシートの検証を行い、検証後のデータを取得
    validator // AppStoreValidator or GooglePlayValidator
    orderLines, err := validator.Verify(ctx, payload)
    if err != nil {
        return err
    }

    // トランザクション

    // ユーザ毎に排他処理を行う
    // select users for update

    // クライアントから与えられたレシートを保存
    // create receipts

    for _, orderLine := range orderLines {
        // すでに purchases テーブルに TransactionID or OriginalTransactionID が存在していないかチェック
        // isExist

        // purchase 保存
        // create purchases

        // 有償ゴールド保存
        // create paid_golds

        // 履歴保存
        // create gold_histories
    }

Github Actions で Firebase ファンクションをデプロイする際の注意点

概要

PR をマージしたときに、

Github Actions で Firebase ファンクションをデプロイするようにしたとき、

Github Actions が2回走り、その内1回のデプロイでエラーになりました。

それを解決した時のメモです。

エラー

ログの一部抜粋です。

※Firebase ファンクションに2つ関数があり、Firebase Hosting にもデプロイしています。

1つは成功し、1つはこけている。

f:id:kawabatas:20190422174839p:plain

ファンクションのデプロイ成功。

f:id:kawabatas:20190422174916p:plain

ファンクションのデプロイ失敗。

f:id:kawabatas:20190422175041p:plain

.github/main.workflow (雰囲気です)

workflow "Deploy workflow" {
  on = "push"
  resolves = ["Deploy"]
}

action "BranchCheck" {
  uses = "actions/bin/filter@master"
  args = "branch develop"
}

action "Deploy" {
  uses = "actions/npm@master"
  runs = "sh -c"
  args = ["npx firebase deploy --project xxx"]
  secrets = ["FIREBASE_TOKEN"]
  needs = ["BranchCheck"]
}

原因

ブランチ削除のとき、Github Actions の環境変数GITHUB_REF には、デフォルトのブランチ(このリポジトリの場合は develop)が指定される。

よって、PRをマージする際、ブランチを削除するにチェックしていた場合、 develop ブランチかどうかのチェックに通り、デプロイが2回実行されてしまい、その内1回がこけていた。

修正

ブランチ削除の場合のフィルタを入れる。

github.com

https://github.com/actions/bin/pull/51 でマージされているのだが、真偽が逆になっているので、やめた。

修正後(雰囲気)

workflow "Deploy workflow" {
  on = "push"
  resolves = ["Deploy"]
}

action "BranchCheck" {
  uses = "actions/bin/filter@master"
  args = "branch develop"
}

action "Filter deleted branches" {
  uses = "UltCombo/action-filter-deleted-branches@master"
  needs = ["BranchCheck"]
}

action "Deploy" {
  uses = "actions/npm@master"
  runs = "sh -c"
  args = ["npx firebase deploy --project xxx"]
  secrets = ["FIREBASE_TOKEN"]
  needs = ["Filter deleted branches"]
}

Docker コマンドメモ

概要

Dockerコマンド、毎回ググってる気がするので、自分用のメモ。

コマンド

ビルド

docker build -t [イメージ名] .

ARG を使う場合、DockerfileにARGを定義して

docker build --build-arg TOKEN=$TOKEN -t [イメージ名] .

タグ

docker tag [イメージID] [イメージ名]:[タグ名]

DockerHubにあげる

docker push [イメージ名]:[タグ名]

コンテナを起動して、中に入る

docker run -it [イメージ名]:[タグ名] /bin/sh

環境変数あり

docker run -it -e TOKEN=$TOKEN [イメージ名]:[タグ名] /bin/sh

Github Actions で Go のテストを実行する。その2

概要

kawabatas.hatenablog.com

こちらに加え、go get でプライベートリポジトリのライブラリを使いたくなった。

手順

コード

.github/main.workflow

workflow "Test workflow" {
  on = "push"
  resolves = "Test Action"
}

action "Test Action" {
  uses = "docker://golang:1.11"
  secrets = ["ACCESS_TOKEN"]
  runs = "sh -c"
  args = ["./.github/script.sh"]
}

.github/script.sh

#!/bin/sh

set -e

git config --global url."https://${ACCESS_TOKEN}:x-oauth-basic@github.com/".insteadOf "https://github.com/"

go test -v ./...

DeleGateのコンテナイメージを公開した

SOCKS プロキシサーバーの DeleGate をコンテナで立てたかったのだが、

docker hub で上位にあったイメージがエラーで使えなかった。 hub.docker.com

$ docker-compose up
Creating delegated_delegated_1 ... done
Attaching to delegated_delegated_1
delegated_1  | #### in default conf: DGROOT='/tmp'
delegated_1  | #### loading default conf: /bin/../etc/delegated.conf
delegated_1  | #### loading conf:  -> file:/bin/../etc/delegated.conf
delegated_1  | (UNIX) 02:00:33.780 [   1] ##fopen_PATHX(r) ign.dir.{/usr/lib}/usr/lib/ssl <= dl.c:274
delegated_1  | -- ERROR: can't link the SSL/Crypto library.
delegated_1  | -- Hint: use -vl option to trace the required library,
delegated_1  | --- find it (ex. libssl.so.X.Y.Z) under /usr/lib or /lib,
delegated_1  | --- then set the library version as DYLIB='+,lib*.so.X.Y.Z'

どうやら OpenSSL のバージョンと合ってないよう...

仕方がないので、debiancentos に変え、自作し、公開した。

hub.docker.com

AWSのコンテナ関連サービスを調べる

概要

AWSのコンテナ関連サービスを調べた。

AWSのコンテナ関連サービス

AWSのコンテナ関連サービスの選定例の紹介

f:id:kawabatas:20190308133223p:plain

ECS/EKS/Fargate/EC2

コントロールプレーン

コンテナの管理:クラスターで Docker コンテナを簡単に実行、停止、管理できる

ECS

Cluster > Service > Task difinition > Container definition という概念がある。

Kubernetes の Cluster(Node) > Ingress(Service) > Deployment > Pod とよく似た概念と思われる。

EKS

Kubernetes を使用する場合はこれ。

標準的な Kubernetes 環境で実行されるアプリケーションは EKS と完全な互換性がある。

データプレーン

EC2(Fargate) を使用して ECSを使う、EC2を使用して EKS を使うという形になる。

Fargate

サーバーやクラスターの管理の必要なしにコンテナを実行できる。

-> コンテナを実行するために仮想マシンクラスターをプロビジョニング、設定、スケールする必要がない。

-> 従来のEC2の場合は、「これ以上コンテナを増やしたらメモリが不足しそうだから、EC2のインスタンスタイプを変えようかな」がある。

2019年2月現時点ではEKSには対応していない。

EC2

高スペックのEC2を事前に選んでおけば、Fargate より速度が向上できることもある。

Elastic Beanstalk

Elastic Beanstalk Multi-container Docker

Elastic Beanstalk は Amazon Elastic Container Service (Amazon ECS) を使用して、マルチコンテナ Docker 環境へのコンテナのデプロイを調整します。Amazon ECS には、Docker コンテナを実行するインスタンスクラスターを管理するためのツールが用意されています。Elastic Beanstalk は、クラスターの作成やタスクの定義と実行などの Amazon ECS タスクを処理します。

Elastic Beanstalk でも ECS を使っている

EC2でDocker

EC2 インスタンスに Docker をインストールして、Docker コンテナを実行することも可能。

GCE で Docker コンテナを実行できるのと同様。

所感

AWS でコンテナを実行する(k8sでない)なら ECS + Fargate が良さそう。

インフラを意識せず済み、冗長化・スケールなども問題なさそう。

で、Beanstalk Multi-container Docker がどうなのか...

ECS+Fargate vs ElasticBeanstalk

ネットより

http://labs.septeni.co.jp/entry/2016/04/05/105249

何がどう違うの?というところが解らなかったので、AWSのソリューションアーキテクトの方に弊社へ来ていただいてサービスのご紹介を頂きました。 Beanstalkの中でECSが動いているので大きな違いはありませんが、Beanstalkの方は元々PaaSなので、色々元々組み込まれて便利に使え、.ebextensionsというYAMLで管理できるようになっていいます。 ECSはdockerに特化したもので、最新機能はECSの方が組み込まれるのが早いそうです。 .ebextensionsを使っていくと何でもできちゃうので運用めっちゃ大変そうだなーと思って、シンプルにECSを試しました。

https://qiita.com/naomichi-y/items/d933867127f27524686a

Beanstalk Multi-container Dockerは、ECSを抽象化してRDSやログ管理の機能を合わせて提供してくれる。ボタンを何度か押すだけでRubyやNode.jsのアプリケーションが起動してしまう。 一見楽に見えるが、ブラックボックスな部分もありトラブルシュートでハマりやすいので、素直にECSを使った方が良いと思う

https://www.wantedly.com/companies/pring/post_articles/124469 f:id:kawabatas:20190308133517p:plain

両方試した

kawabatas.hatenablog.com

kawabatas.hatenablog.com

両方試してみて、最初、

  • ElasticBeanstalk の方が環境構築、デプロイが楽。
  • ElasticBeanstalk のコンソール画面で一括で確認できるのも良さそう。

と思ったが、、、

ecs-deployを使ってみたところ、ElasticBeanstalk のデプロイ面での優位はなくなった...

環境構築の手間もそんなに変わらないし、

ElasticBeanstalk のコンソール画面で一括で確認できる点、コード量が若干少ない点もどうだろう...

ECS Fargate を使うのが良さそうと思った。