生涯未熟

生涯未熟

プログラミングをちょこちょこと。

本番DBとステージングDBのデータを同期させる

この記事はMIXI DEVELOPERS Advent Calendar 2023の18日目の記事です。

今回はやったことある人が多そうな本番DBのデータをステージングDBに同期させる仕組みを作ったお話になります。

前提のお話

何故作ったのか?ですが、データ数の問題から開発・ステージング環境では発生せず本番環境では発生するような表示上のバグが数は少ないですが存在し、そういった問題を早期に発見するために対応できませんか?と相談されたことがきっかけで同期の仕組みを作ることになりました。

また、ステージング環境ではQAチームによる自動テストが動いており、本番環境データが同期されていればより潜在的な問題の発見に役立ちそうですね。

ここから詳しい説明に入りますが、DBはCloud SQL for MySQL v5.7に準拠した内容となっております。

作る

さて、ここから作るフェーズに入っていくのですがもう少し作りたいモノのイメージを明確にしていきましょう。
「本番DBのデータをステージングDBに同期させる」、当たり前ですがこれは大前提ですね。他には「継続的に同期させる」、こちらも当たり前ですが本番環境のデータは常に変化していくものなので継続的にステージングDBに反映していく仕組みでないとダメですね。
あとは「ステージングDBに反映してはいけないデータは反映しない」、これはユーザーの個人情報などですね。Identity-Aware Proxyを有効化したり、Cloud Armorを利用したり悪意のあるアクセスはなるべく弾くようにはしていますが、開発・ステージング環境はどうしても標的にされやすいものです。リスクを減らすためにも、漏れるとまずいデータは同期させないようにします。また「ステージングDBから営業用デモデータを退避させる」という要件も存在しており、本番DBのデータをインポートする前にそのデータを退避させなければいけません。

最後に「できれば低コストで作りたい」、というお気持ちがありました。現在、SREとしてインフラのコスト面も管理しているのですが普段コスト削減などを承っている立場上、あまりコストが高くならないようにという思いからです。

では、これらを踏まえてどうやって作っていくか考えていきましょう。先程の要件をまとめるとこちらですね。

まず、「本番DBのデータをステージングDBに同期させる」ですが、これは本番DBをエクスポートしてステージングDBにインポートすれば達成できますね。次は「継続的に同期させる」Google Cloud(以下、GC)にはSchedulerなど定期的に実行する仕組みがあるのでここも問題はなさそうです。
「ステージングDBに反映してはいけないデータは反映しない」については、本番DBからステージングDBにインポートする間にデータを編集する必要性がありそうですね。Cloud SQLでエクスポートできる形式はSQL/CSVの2種類なのですが、どちらもエクスポートしたデータを編集するのは骨が折れそうなので、今回は本番DB → ステージングDBの間に本番レプリカDBを挟み込み、このDB上でデータ編集するのが良さそうです。

「ステージングDBから営業用デモデータを退避させる」は、ステージングDBの退避させる対象データを参照しながら本番レプリカDBに追加する、といった感じで出来そうです。こうやって処理した本番レプリカDBをまたエクスポートしてからステージングDBにインポートすれば目出度く同期の完了、という目論見になります。
最後に、「できれば低コストで作りたい」という要件は、必要な処理類をCloud Functionsに寄せれば安上がりにいけそうだったので今回はがっつり利用しています。あと、それぞれ作成したCloud Functionsを一つのワークフローとして管理するためにCloud Workflowsも利用します。こちらも料金体系を確認すると、GC内のリソースをステップとする場合には月5000回まで無料で実行できるとのことで、1日1回ほどの実行頻度だと十分無料分のみで回せそうでした。

再びまとめるとこうなりますね。

ではこれらを考慮に入れて作成開始していきますか。まず先に完成図がこちらになります。

この完成図を元に詳しく見ていきましょう。ちなみにですが作成に利用する言語はGoになりますのであしからず🙇‍♂

本番DBからエクスポート

エクスポートについては幸いなことにGoogleがサンプルコードを提供してくれていますので、有り難く一部使わせていただきましょう。

cloud.google.com

それでは完成したコードはこちらになります。

このコードを見ながら重要な箇所を解説していきます。

// アプリケーションデフォルトの資格情報を使用する http.Client を作成
hc, err := google.DefaultClient(ctx, sqladmin.CloudPlatformScope)
if err != nil {
  fmt.Println("Failed to create http.Client: ", err)
  http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
  return
}

// Google Cloud SQL サービスを作成
service, err := sqladmin.New(hc)
if err != nil {
  fmt.Println("Failed to create SQL Admin Service: ", err)
  http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
  return
}

ここではCloud SQLへの接続準備をしているところで、Application Default Credentials(ADC)を使用したClientを作成してからCloud SQL serviceを作成しています。ADCを利用するということでCloud Functionsで利用するサービスアカウントはCloud SQLに関する権限を付け忘れないよう注意です。

// エクスポート操作を実行
rb := &sqladmin.InstancesExportRequest{
  ExportContext: &sqladmin.ExportContext{
    Kind:      "sql#exportContext",
    FileType:  "SQL",
    Uri:       uriPath,
    Offload:   true, // サーバーレス エクスポートを有効にする
    Databases: []string{database},
  },
}

op, err := service.Instances.Export(project, instance, rb).Context(ctx).Do()
if err != nil {
  fmt.Println("Failed to export: ", err)
  http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
  return
}

エクスポートを実行しているところですね。先程作成したCloud SQL serviceを利用してエクスポートするのですが、その前に InstancesExportRequest でどのようにエクスポートするのか?を設定しています。本番環境からエクスポートする、ということでサーバーレスエクスポートを有効化しております。これはエクスポートのために一時的にインスタンスを生成し、そこを通してエクスポートすることでパフォーマンスの確保やインスタンスへのオペレーションを阻害しないなどのメリットがあるため有効にしてあります。

cloud.google.com

// エクスポートオペレーションの状態を10s毎に取得
for {
  op, err = service.Operations.Get(project, op.Name).Do()
  if err != nil {
    fmt.Println("Operations.Get: ", err)
    http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
    return
  }
  if op.Status != "PENDING" && op.Status != "RUNNING" {
    break
  }
  time.Sleep(10 * time.Second)
}

if op.Status != "DONE" || op.Error != nil {
  fmt.Println("Failed to export operation: ", op.Error)
  http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
  return
}

最後にエクスポートの進捗を都度取得しておきます。 service.Operations.GetPENDING , RUNNING , DONE , あとこれは取得する必要性あるかな?と思うのですが SQL_OPERATION_STATUS_UNSPECIFIED というオペレーションの状態が不明の場合のレスポンスがそれぞれ取得できます。

これを実行することで本番DBからエクスポートされたSQLファイルがCloud Storageに格納されるわけですね。

本番レプリカDBにインポート

そんなエクスポートしたデータを本番レプリカDBにインポートしていきます。と、いきたいのですがその前に本番レプリカDBについてもう少し説明したいと思います。
個人情報を扱う上でのセキュリティリスクは説明しましたが、対策としてデータをマスキングしましょうというお話をさせていただきました。しかしながら、本番レプリカDBでエクスポートしてからマスキングするまでの間は低いながらもリスクが存在するため、リスクを軽減するための対策を別途行いましょう。

Cloud SQLインスタンスは通常パブリックIPアドレスが割り当てられますが、これを割り当てずにプライベートIPアドレスのみ割り当てるようにすることでセキュリティリスクを低減させることができますので、こちらをやっていきましょう。

こちらについては説明すると長くなりますので割愛いたしますが、以下のような記事など巷に解説してらっしゃる方がいますので参考にしてみてください。

qiita.com

では、そちらを踏まえながらコードの解説に移ります。

it := client.Bucket(bucketName).Objects(ctx, &storage.Query{
  Prefix: targetDir,
})

fmt.Printf("Files matching date: %s\n", date)

var objectName string
for {
  attrs, err := it.Next()
  if err == iterator.Done {
    break
  }
  if err != nil {
    fmt.Println("Failed to list objects: ", err)
    http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
    break
  }

  // 本日日付を含むファイルがあればインポート対象とする
  if strings.Contains(attrs.Name, date) {
    objectName = attrs.Name
  }
}

if objectName == "" {
  fmt.Println("export sql file not found")
  http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
  return
}

ここではCloud Storageへのアクセスを行っており、先程のエクスポートファイルを取得していますね。判定はファイル名に記述してある日付をもとにしています。
で、取得してきたファイルをインポートする前にファイル内で USE DATABASE が記述しているところを、本番DBのものからステージングDBのものへと変えておきましょう。

replacedContents := strings.ReplaceAll(string(b), "production", "staging")

writer := client.Bucket(bucketName).Object(objectName).NewWriter(ctx)
if _, err = writer.Write([]byte(replacedContents)); err != nil {
  fmt.Println("Failed write sql file")
  http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
  return
}
if err := writer.Close(); err != nil {
  fmt.Println("Failed close sql file")
  http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
  return
}

あとはエクスポートの時と同じ要領でインポートオペレーションを実行しましょう。インポートするファイルの指定はCloud StorageのURIを指定すればOKです。

// Cloud SQLへのインポートリクエスト
importRequest := &sqladmin.InstancesImportRequest{
  ImportContext: &sqladmin.ImportContext{
    FileType: "SQL",
    Uri:      fmt.Sprintf("gs://%s/%s", bucketName, objectName),
    Database: dbName,
  },
}

// SQLファイルをインポート
op, err := sqlAdminService.Instances.Import(project, instanceName, importRequest).Do()
if err != nil {
  fmt.Println("Instances.Import: ", err)
  http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
  return
}

データマスキング

やっとこさマスキングまで来ましたが、今までと違い実際のコードからかなり省略したものを使って説明させていただきます。

実際のコードではこれ以外にも細かい様々なマスク処理をしていますが、今回は簡単にメールアドレスと電話番号を * に置き換えるコードにしてあります。

var opts []cloudsqlconn.DialOption
opts = append(opts, cloudsqlconn.WithPrivateIP())
mysql.RegisterDialContext("cloudsqlconn",
  func(ctx context.Context, addr string) (net.Conn, error) {
    return d.Dial(ctx, fmt.Sprintf("%s:%s:%s", project, region, instance), opts...)
  }
)

uri := fmt.Sprintf("%s:@cloudsqlconn(localhost:3306)/%s?parseTime=true", user, database)

ここではCloud SQLのGoコネクタを使って接続しています。以下のGoogleのサンプルコードを参考に致しました。

cloud.google.com

// マスク処理を行うSQL文を生成し、実行
for table, columns := range maskTarget {
  for _, column := range columns {
    query := fmt.Sprintf("UPDATE %s SET %s = REPEAT('*', CHAR_LENGTH(%s))", table, column, column)
    _, err := db.Exec(query)
    if err != nil {
      fmt.Println("Failed to mask the database: ", err)
      http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
      return
    }
  }
}

肝心のマスク処理ですが、 UPDATE %s SET %s = REPEAT('*', CHAR_LENGTH(%s))", table, column, column を実行し、既存のデータを * に置き換えています。至極簡単ですね。ただし、重複が許容されていないカラムなどは既存データをシードとしたランダム文字列を生成するなど、もう少し考慮する必要があります。

ステージングDBの一部データ退避

ここもかなりプロダクトのドメインと紐付いていて実際のコードを使って説明することが難しいのですが、やっていることとしては

  • 本番レプリカDBとステージングDBに接続する
  • 退避させるデータをステージングDBから取得する
  • 取得したデータを本番レプリカDBに追加する

の3つになります。
テーブルによってはFOREIGN KEY制約があると思いますが、そういった場合には本番レプリカDBに追加したデータのIDを保持しておいて、ステージングDBから取得したデータに上書いてから本番レプリカDBに追加するなどの処理が必要になります。

本番レプリカDBからエクスポート/

そろそろ終わりが見えてきました。データマスキングや退避データを入れた本番レプリカDBをエクスポートして、ステージングDBにインポートします。こちらも今までやったこととほぼほぼ同じなのでコードは省略いたしますが、違う点としては

  • エクスポート後に本番レプリカDBをまっさらにする
  • インポート前にバックアップを取っておく

の2つですね。前者は説明は要らないと思いますが、後者はコードからやるのもいいですがステージング環境のインスタンス設定で定期バックアップさせておくのが楽です。もし厳密に直前のバックアップを取るといった場合には sqladminBackupRun などがありますのでこれを使って出来そうですね。(未検証

pkg.go.dev

Cloud WorkflowsでCloud Functionsを実行する

最後にCloud WorkflowsでCloud Functionsを定期実行するように設定しましょう。Cloud FunctionsやCloud Runにリクエストを飛ばす場合にはOIDCを利用することになります。

cloud.google.com

一応、エラーがあった際などどこまで処理が成功していたのか?などログから分かりやすいように、 call: sys.log を使ってログを出力しておくのをオススメします。そういったことを含めて、Workflowsのコードとしては一部を載せますが以下のような感じになります。

main:
    params: [input]
    steps:
        - sync-production-to-staging:
            try:
                steps:

                # 本番DBからSQLファイルをCloud Storageへエクスポート
                - exportProductionDBLog:
                    call: sys.log
                    args:
                        data: "=== exportProductionDB START ==="
                        severity: "INFO"
                - exportProductionDB:
                    call: http.get
                    args:
                        url: 'https://hoge.cloudfunctions.net/export-production-db'
                        auth:
                            type: OIDC
                            audience: 'https://hoge.cloudfunctions.net/export-production-db'
                    next: importProductionReplicaDBLog

                # エクスポートされた本番DBのSQLファイルを本番レプリカDBへインポート
                - importProductionReplicaDBLog:
                    call: sys.log
                    args:
                        data: "=== importProductionReplicaDB START ==="
                        severity: "INFO"
                    next: importProductionReplicaDB
                - importProductionReplicaDB:
                    call: http.get
                    args:
                        url: 'https://hoge.cloudfunctions.net/import-production-replica-db'
                        auth:
                            type: OIDC
                            audience: 'https://hoge.cloudfunctions.net/import-production-replica-db'

            except:
                as: e
                steps:
                    - known_errors:
                        switch:
                        - condition: ${not("HttpError" in e.tags)}
                          raise: "Connection problem"
                        - condition: ${e.code == 404}
                          raise: "URL wasn't found"
                        - condition: ${e.code == 403}
                          raise: "Authentication error"
                        - condition: ${e.code == 500}
                          raise: "Internal Server Error"
                    - unhandled_exception:
                        raise: ${e}

try-except でどのようなエラーで落ちたか?も出力するようにしておくと失敗時の調査に良いですね。あとは今まで作成したCloud Functionsも記載していけば完了です。

おわりに

本番DBとステージングDBの同期はよくある実装ですが、一つの例として今回取り上げさせていただきました。読んでくださった方の役に立つ情報があれば幸いです🙇‍♂

次は、@fanglangによる記事になります!

GKEの開発環境クラスタはとりあえず「使用率の最適化」やっとこう

GKE / k8s、まだまだ分からんことが多くて学び甲斐があるなぁとしみじみ思っている今日この頃。
今回はそんな中で掲題のコスト最適化のお話をします。

要約

  • 本番環境が載っていないクラスタは自動スケーリング プロファイルをoptimize-utilization(使用率の最適化)やっとくと良い
  • 特に短いスパンでのCronJobを動かしているとコスト改善の可能性大

前提

GKEを使ってサービス運用している時に気になるのはやはりコストですよね。私が関わっているサービスでもコストをもう少し下げようといった声が大きくなり、その中でも大きな割合が割かれているGKEに注目が集まりました。

こういう時、まず手を付けるのは開発環境周りですね。Develop, Staging環境などを一つのクラスタで管理しており、ここなら最悪ぶっ壊しても問題なく着手しやすいのでここからやっていきます。

やったこと

やったこととしてはめちゃくちゃ単純なのですが、

  • GKEクラスタの自動スケーリング プロファイルをbalancedからoptimize-utilization(使用率の最適化)に変更する
  • PDBを設定する

になります。

自動スケーリング プロファイルの変更

GKEクラスタの自動スケーリング プロファイルとは、クラスタ内ノードの削除に対する挙動に関する設定です。balancedとoptimize-utilizationの2種類があるのですが、ドキュメントを読むとoptimize-utilizationは

クラスタ内で余剰リソースを保持するよりも使用率の最適化を優先させます。
このプロファイルを有効にすると、クラスタ オートスケーラーはクラスタをより積極的にスケールダウンします。

とのことです。正直この説明だけでは具体的なところが分からず、しっくり来なかったので調べてみるとGoogleの方の登壇資料に以下のようなことが書かれていました。

ref: Google Kubernetes Engine (GKE) で実現する運用レスな世界

つまり、balancedは「10分毎にスケジュール、使用率50%以下のノードが削除対象」で、optimize-utilizationは「1分毎にスケジュール、使用率85%以下のノードが削除対象」となるようです。optimize-utilizationではより1ノードの凝集度が高まる、といったことがよくわかりますね。

スパイク発生時などに余剰リソースが求められる本番環境では使えませんが、開発環境では使えそうですね。

PDBを設定する

これはノードを削除するために必要な設定となります。設定しましょうで終わる話なのですが、一つハマったところとしてCronJobリソースでの扱いです。
CronJobでは成功/失敗のJobを残すことができ、 successfulJobsHistoryLimitfailedJobsHistoryLimit という設定値があります。ここに幾つのJobを残すか設定できるのですが、開発環境では成功履歴に関しては残す必要がなかったので successfulJobsHistoryLimit を0に設定しておりました。

こうした場合に、成功時のノードの様子を見てみると fluentbitgke-metrics-agent などのkube-systemが残っている状態でした。てっきり、これらがoptimize-utilizationを設定した時にノード削除してくれるものかと思ったらノードが残ったままに・・・
なんでじゃろ?と調べてみると、このkube-systemのnamespaceに配備されているものに関してもPDBを設定する必要がありました。

こちらのCloud Skills Boostにもkube-systemのPodに対してもPDBを設定しましょうと書かれており、完全に盲点でした。

www.cloudskillsboost.google

デフォルトでは、これらの Deployment のシステム Pod のほとんどは、クラスタ オートスケーラーによって完全にオフラインにされ再スケジュールされることを回避します。
このラボでは、kube-system Pod に Pod 停止予算を適用し、クラスタ オートスケーラーが Pod を別のノードに安全にリスケジュールできるようにします。こうすることで、クラスタをスケールダウンする余裕を確保できます。

ラボ内で解説されている中では以下のようなPDBを設定しています。

kubectl create poddisruptionbudget kube-dns-pdb --namespace=kube-system --selector k8s-app=kube-dns --max-unavailable 1
kubectl create poddisruptionbudget prometheus-pdb --namespace=kube-system --selector k8s-app=prometheus-to-sd --max-unavailable 1
kubectl create poddisruptionbudget kube-proxy-pdb --namespace=kube-system --selector component=kube-proxy --max-unavailable 1
kubectl create poddisruptionbudget metrics-agent-pdb --namespace=kube-system --selector k8s-app=gke-metrics-agent --max-unavailable 1
kubectl create poddisruptionbudget metrics-server-pdb --namespace=kube-system --selector k8s-app=metrics-server --max-unavailable 1
kubectl create poddisruptionbudget fluentd-pdb --namespace=kube-system --selector k8s-app=fluentd-gke --max-unavailable 1
kubectl create poddisruptionbudget backend-pdb --namespace=kube-system --selector k8s-app=glbc --max-unavailable 1
kubectl create poddisruptionbudget kube-dns-autoscaler-pdb --namespace=kube-system --selector k8s-app=kube-dns-autoscaler --max-unavailable 1
kubectl create poddisruptionbudget stackdriver-pdb --namespace=kube-system --selector app=stackdriver-metadata-agent --max-unavailable 1
kubectl create poddisruptionbudget event-pdb --namespace=kube-system --selector k8s-app=event-exporter --max-unavailable 1

これでCronJobが成功後に残ったノードも削除されるようになりました。

まとめ

単純なことを2つやっただけですが、結果コストは以下のように最適化されました。

具体的な数値は出せませんが、グラフだけを見ると約半分くらいに最適化されたことがわかりますね。

開発環境クラスタを持っている場合は、この設定をやっておくのが良いと思います✌

GitHubのorganization移行をやったお話

この記事はSRE Advent Calendar 2023の1日目の記事です。

今回は業務で「GitHubリポジトリをあるorganization(以下、org)から別のorgへ移行させる」ということをやったので、その時のお話をさせていただきます。

何をするのか?

もう少し、何を目標としていたのか?を具体的に書くと、現在リポジトリ群が所属しているorgからGitHub Enterprise Cloud(以下、GHEC)配下に作成したorgへ移行させる、ということをやろうとしたお話になります。

GHECに移行するモチベーションとしてはGitHub Copilot for Businessが利用できたり、SAML SSOを利用できたりするというメリットを受けることが出来るからですね。(開発者にとってはCopilotが理由として大きいかも)

移行前の状況

移行前は以下のような状況でした。

  • 自分が関わっていて移行対象となるGitHubリポジトリが10個
  • 共通のPAT(Personal Access Token)を払い出すbotアカウントあり
  • 外部の業務委託でお手伝いいただいている方のアカウントあり
  • orgに紐付いたGitHub Projectsが2つ、特定のリポジトリに紐付いたProjectsが1つ
  • CI/CDはGitHub Actions、CircleCI、ArgoCDなどを利用
  • その他、GitHubリポジトリと紐付いている自作のツール類やGoogle CloudのCloud Functionsなどが存在している
  • 移行先のorgはOktaを利用したSSOを設定

色々と移行に際して引っ掛かりそうなポイントが幾つかありそうですね。何となく察しがつく方もいらっしゃるかもしれませんが、後々躓きポイントも説明していきます。

作業開始、その前に

早速移行させるぞ〜〜〜・・・とはいかず、まずは影響を調べたりなどの事前調査からですね。移行先orgを作成したり、作ったorgをGHEC配下にしたりは今回は省略させていただきます。

事前調査もすべてを隈なく調べ切れるならいいのですが、それでもどこかヌケモレが発生することが考えられます。また、移行に際して締切日が設定されていたため、どこが破綻すると一番まずいか?を開発チームと話し、「サービスのデプロイ」「GitHub Projectsが使える」の2つが破綻するとまずいとのことで、そこは徹底して調査するようにしました。

調査

GHEC配下の移行先orgにリポジトリを移行すると一体何が起こるのでしょうか?ザザッと箇条書きにしてまとめてみます。

  • リポジトリURLが変わる
  • issue, PR, wikiなどの情報は移行先のリポジトリに受け継がれる
  • 移行先orgに改めてGitHub Appをインストールし直す必要あり
  • OktaでのSAML SSOが設定されているため、移行先orgに招待するユーザーはOktaアカウントを持っていることが必要
  • orgと紐づいているGitHub Projectを移行させる
  • Teamsもorgに紐づいているので新しく作成する

事前情報としてササッと調べた結果、このような感じで思った印象は「割と色々やらなきゃならんな」でした。とはいえ、設けられた締切日には十分間に合うんじゃないかな〜という感じでありました。

さて、次に移行対象となるGitHubリポジトリの洗い出し、デプロイのために使っていたCircleCIについての調査をやっていきます。リポジトリの洗い出しは特に問題はなかったのですが、CircleCIでは新しくorgを作成する必要があり、こちら新orgを作ってさえしまえば特に問題起きないだろうなと思いきや、作業後に躓きポイントがあったのでこちらも後ほど説明します。

CIといえばGitHub Actionsで、共通して利用するようなActionsは呼び出し元とは別リポジトリで管理していて、例えば以下のような感じでした。

ここの、 uses で指定している old-orgnew-org と移行先org名にしないといけません。

あとはスマートフォンアプリも開発していて、その中で利用しているBitriseも調査しました。こちらはBitrise側で管理しているリポジトリURLを変更する必要があり、詳細は以下のリンク先に書いております。

devcenter.bitrise.io

以前記事にした「開発環境の使用状況分かるくん」など、自作ツールでデプロイに紐づくところも移行することで影響が出ないか調査しました。いくつかのツールでbotアカウントが生成したPATを使っていましたので、botアカウントを移行先のorgに追加するのを忘れないようにすれば良さそうです。

syossan.hateblo.jp

ここまでがCI/CDなどデプロイに関する調査でした。次にGitHub Projectsについて調査しましょう。

リポジトリに紐づいているGitHub Projectsは特に何もせずともリポジトリ移行すればOKなのですが、orgに紐づいているGitHub Projectsはそうはいきません。
調査した結果、Project自体はorgを跨いでコピーすることが出来てビューやフィールド、ドラフトissueなどもコピーされるようです。

docs.github.com

ただし、リポジトリに作成されてProjectに紐づけているissueはコピーでは反映されず、カスタムフィールドを利用している場合はそちらもコピーされないので(弊チームではスクラムのポイントをissueに割り振るのに使用)、ここはスクリプトなりでGitHub APIを叩いて何とかする必要がありそうです。

一旦はここまでが事前に定義した必須条件であるデプロイとGitHub Projectsの調査になります。

この他にも

  • Cloud FunctionsでリポジトリURLを指定しているところの修正
  • Cloud Buildのトリガーである接続リポジトリの修正
  • ドキュメント類の修正
  • 移行後のSlack通知の再有効化
  • 外部の業務委託の方のOktaゲストアカウントの払い出し
  • botアカウントをoutside collaboratorとして追加
  • SSHキー、PATのSSO設定をする
  • 作業後のチームメンバーへの共有事項をまとめる

などの対応が必要という調査結果となりました。

一つ注意が必要なのは、今回諸々の事情からbotアカウントをoutside collaboratorで追加することになりましたが、本来であればGitHub Appを作成しそちらを使うべきであります。また、 Personal access tokens (classic) を使っている場合は特に気にすることはないのですが、 Fine-grained personal access tokens を使っている場合はoutside collaboratorではリポジトリなどに権限を持ったトークンを生成できませんので、そこはご注意ください。

github.com

作業開始

ここまでで必要な分は調査できましたので、事故が無いように誰も触らない休日に移行作業を開始しました。

リポジトリ移行

なにはともあれ、まずはリポジトリ移行を行いましょう。

docs.github.com

GitHubのDanger Zone触るのが初めてだったので、多少ビビりましたがこちらは特に問題なく完了。

botアカウントをoutside collaboratorとして追加

次に、botアカウントをoutside collaboratorとして追加しましょう。こちらは移行したリポジトリCollaborators and teams 設定からbotアカウントを追加すればOKです。

docs.github.com

移行後のSlack通知の再有効化

このあたりで忘れないようSlack通知を再有効化しておきました。Slack上で実行するコマンドとしてはこんな感じ。

/github subscribe new-org/hoge issues, pulls, commits, releases, deployments, reviews, comments

SSHキー、PATのSSO設定をする

これは個人アカウント、botアカウント、メンバー各々のアカウントのすべてで必要な作業なのですが、移行先orgがSSOを利用しているためGitHubに登録しているSSHキー、生成しているPATで Configure SSO というSSO設定が必要になります。

docs.github.com

Teamsを再作成

移行元orgで作成していたTeamsを移行先orgでも再び作成し、移行したリポジトリに追加したりしておきましょう。幸いにも弊チームでは扱うTeamsの数が数個だったので手作業で移行しましたが、Teamsの量が多い方は gh コマンドなどを駆使して自動化しましょう。

リポジトリURLに依存している箇所を修正

をガガッと移行先org名に修正しておきました。機械的にパパっと置換していきましょう。

RenovateのGitHub Appを再連携

ここで事前調査と違ったタスクが出てきました👀

これはGitHub Appを改めてインストールしていた際にRenovateだけ再連携の必要があり、やっておきました。連携後にコンソール画面からその後の動作をどうするか?を聞かれると思いますが、特に何もしない的な選択肢を選んでおくと良さそうです。

GitHub Projectsの移行

さて、やってきましたGitHub Projectsの移行の時間です。

こちらは事前調査から

  • Projectを移行先orgへコピー
  • 紐付けていたissueを再紐づけ

といった作業が必要なことは分かっていました。で、スクリプトが必要というお話でしたね。
幸いなことに、社内の他チームの方が組んでらっしゃったスクリプトを参考に、少し手直しをして作成しました。それがこちら。

やっていることは gh コマンドを使って、移行元orgのProjectから必要な情報であるissueやfield, custom fieldを移行先orgのProjectに移行しているといった感じですね。

スクリプトも用意できましたので、作業再開しましょう。まずはスクリプトのコメントにも書いてある通り、 gh コマンドで現在のProjectの状態を backup.json として保存しておきます。
その後、移行したいGitHub Projectsのメニューから Make a copy を選んで、移行先orgへコピーしましょう。

ドラフトissueもコピーしておきたいので、 Draft issues will be copied if selected は忘れず選択しておきます。

移行先のProjectが問題なくコピーされていることを確認したら、スクリプトを実行してissueの紐付けなどやっておきましょう。

で、調査段階の時に漏れていたことがProjectのIDを指定して実行する自作ツールが幾つかあり、これらのID修正もやっておきました。もし、Project IDを使ってゴニョゴニョしている方はお気をつけ下さい。

確認

最後にデプロイフローがきちんと動くかどうかの確認をして終了になります。

起こったこと

さて、ここまでやったことをザッと書いていきましたが、もちろん作業中・後に色々と問題も起こりました。ここでは起こった問題を書き残していきます。

CircleCIのorganizationに紐付いたContext variablesを移行し忘れていた

デプロイフローの確認時に発覚したのですが、デプロイ中にSlack通知を飛ばすところがあり、そこが動いておりませんでした。アレー?と思い調べてみると、CircleCIのorganizationに作成していたContext variablesに登録していたAPI Tokenを移行しなくてはいけないのをすっかり忘れておりました。うっかり。

CircleCIのProject, Pipelineなどが見れない

移行後、チームメンバーに触ってもらっている時に発覚。これについては原因自体は謎なのですが、CircleCIのProjectやPipelineが一切見れないという状況になっておりました。
云々かんぬん試してみたのですが、結論としてCircleCIの再ログインを行うと何故か再び見れるように。何故に🤔

Bitriseのworkflowが走らない

コードをコミットしたらBitriseのworkflowが走り、コードの静的解析をするのですがそこが全く動かず・・・
Bitriseのappの設定を確認すると、何故か GitHub Checks の項目がdisabledになっていたのでenabledにすることで解決。

リポジトリに紐付けたTeamの権限間違い

これは凡ミスなのですが、リポジトリに紐付けたTeamの権限が軒並みReadになっていてチームメンバーからご指摘が。申し訳ない・・・
ただ、ありがちなミスではあるのでご注意を。

まとめ

GitHub organizationの移行という中々体験出来ないことが体験でき、経験になりました。こういう作業は難度の割にトチると業務が滞るので結構神経使いますね😇
この記事が、今後organizationの移行する方の参考になれば幸いです。

次は @shu-kobさんによる「ブロックチェーンノード運用の苦労話」です!

shu-kob.hateblo.jp

開発しているサービスの名前が海外のセクシー系サービスの名前と被った時に気を付けるたった一つのこと

Xでのサービス関連ポストをSlackに安易に流すととんでもないことになるぞ

何が起こるか?

ビジネス側からの要望で「Xでうちのサービスのことつぶやいているポストがあったら適宜Slackに流してくれない?」が発生することは結構あるかと思います。
GASなり、IFTTTなりで X -> Slack に流すことになると思いますが、まずこういった形で流したSlackの投稿は第三者が消すことは出来ません。(※消せるなら教えてくだせぇ)

で、あまり何も考えずにXの検索クエリをサービス名のみでやってしまうとあら大変、削除できないセンシティブ画像の波がやってきます。

ここで「検索クエリ気を付ければ大丈夫でしょ?」と思う方もいらっしゃるかもですが、割と弾こうと思っても弾けないものでございます。
例えば、実際にあったのは同一名サービスのドメインで弾いたり、言語設定を日本語のみのポストに絞ったり色々手は尽くしたものの、日本人がサービス名称を記載したポストをしたがために流れてしまったパターンです。

海外のそういったサービスを利用する日本人の方はまぁ少ないので、逐一ユーザー毎に除外していく他ないかなとやっているんですが、何かもっと良い方法があれば誰かご教授くださいまし。

というわけで開発しているサービスの名前が海外サービスと被ってないか?とかは事前に調べておくと良いかなと思います。

BitriseのWorkflowが無限にスタックする事象に遭遇した

Flutterアプリ開発のCIにBitriseを採用しているのですが、今回タイトルのような現象に遭遇したので知見のために書き記します。

何が起こったのか?

何が起こったのかというと、前提としてBitriseでCredits-Basedなプランを契約しており「月〇〇creditsまではこのお値段。超過したら追加課金が発生します」といったような課金体系となっています。
で、creditsは常に確認して超過しないよう注意しながら開発を続けていたのですが、ある日突然とんでもないcredits量が消費されておりました。月のcredits量を余裕で突破し、追加課金がかなり発生している状態です。

状況確認

まずは状況確認ということで、実際にどのWorkflowがcreditsを消費していたのかを調べると、あるWorkflowが15時間も動いていたことに気付きました。これは異常なことで、普段は長くても10minほどのWorkflowしか動いていなかったのでこれが原因だろうとすぐに察しがつきました。

もう一つ、異常なことがBitrise側のデフォルトのタイムアウトが3.5時間なのですがこれが効いていなかったことです。

devcenter.bitrise.io

さすがにこれはこちらの問題ではなく、Bitrise側の問題であろうということでサポートに連絡することに。

その後

サポートに連絡したことで15時間分のcreditsを返却してもらえましたが、この問題自体はBitriseとしても既知のものであり、再度発生した場合にはまた連絡してくれという回答でありました。
事実、この返却後に今後は3.5時間のスタックが発生し、こちらも返却してもらいましたが頻発するようでは困る・・・

ということで、こちらで出来ることはないかと調べると社内wikiでBitriseのこういった問題事象をまとめている方がおり、同じようなスタックする事象も書かれておりました。そこに「step毎のタイムアウト時間を設定しておくのが良い」と対処法もセットで書かれていたので対応することに。

devcenter.bitrise.io

これで、設定時間中に何も出力がされない場合にはAbortするようにできます。こういった問題が発生する以上、マストでこの設定しておいた方が良さそうですね🤔

ということで、Bitriseで遭遇した問題のお話でした。