nownab.log

nownabe's daily posts

RailsアプリとかをAWSのレガシーシステムからGCPのイケイケシステムに移行した話

Posted on May 21, 2019

はじめに

Railsアプリケーションを中心とするシステムをAWSからGCPに移行しました。本記事ではその過程をできるだけ赤裸々に公開します。

本プロジェクトではインフラ移行と同時にアーキテクチャも刷新しました。AWSがレガシーでGCPがイケイケという意味ではなく、移行対象システムのアーキテクチャがレガシーからイケイケになったという意味です。

技術的な内容については詳細は省いて概要の説明にとどめています。AWS、GCP、Docker、Kubernetesあたりの知識があるとスッと読めると思います。

書きたいこと書いたので長い記事になってますがぜひお付き合いください。

レガシーシステムとイケイケシステム

まず、移行前のレガシーシステムと移行後のイケイケシステムについて軽く説明します。

タイトルをキャッチーにするためこうしましたが、特別レガシーでもイケイケでもないのでご了承ください。ちょっと前と今の普通のアーキテクチャという感じです。

ざっくり全体像

移行前のシステムのざっくりとした全体像はこんな感じです。

  • 基本はモノリシックなRailsアプリ
  • クライアントとしてAndroidアプリ、iOSアプリがあり、それらはRailsのAPIを叩いている
  • WebはRailsでHTMLを出力している
  • 管理画面も同じRailsアプリで実装している
  • モノリシックなRailsアプリ以外にも周辺にいくつかアプリケーションが存在する
  • 多くの外部サービスに依存している

レガシーシステム

モノリシックなRailsアプリケーションを中心としてAWS上に構築されたシステムです。6年間開発されていてそれなりに負債もたまっています。

EC2-ClassicのVMインスタンスにOpsWorksのChefでプロビジョニングを行い、OpsWorksでデプロイしていました。データベースやストレージはRDS(MySQL)、ElastiCache(Redis、Memcached)、DynamoDB、S3、CloudSearchなどを使用していました。

Railsアプリ以外にも、EC2-VPCにデプロイされたGoのアプリケーション、LambdaやAWS Batchで動作するアプリケーションなどが存在しました。

イケイケシステム

同じくモノリシックなRailsアプリケーションを中心としてGCP上に構築されたシステムです。

各アプリケーションはコンテナ化され、GKEのKubernetes上で動作しています。データベースやストレージも一部を除きGCPのサービスを使用しています。

DynamoDBやCloudSearchなど引き続き使用しているAWSのサービスもあります。

なぜ移行したのか

本プロジェクトではアーキテクチャ刷新とインフラ移行を同時に行いました。本記事のアーキテクチャという言葉はシステムのインフラ構成ぐらいの意味で使っています。

アーキテクチャ刷新の目的としては4つありました。

  • 運用コスト削減
  • セキュリティ向上
  • 開発効率向上
  • 今後のビジネス展開の準備

インフラ移行の目的としては3つありました。

  • 新アーキテクチャの構築
  • セキュリティ向上
  • インフラコスト削減

それぞれについて説明します。

アーキテクチャ刷新の目的

運用コスト削減

旧システムは急ごしらえで構築され、現在ではインフラ担当者もおらずほぼ放置で長年運用されていたためかなりガタがきていました。運用のコストも馬鹿にならなかったのでそれを改善する目的がありました。

旧システムのガタとしてはこんな感じでした。

  • アラート頻発
  • デプロイに30分から1時間かかる
  • デプロイするたびに障害発生
  • Chefのコードは不要なコードだらけのコピペ祭りだし一部はエラーで実行不可能
  • RubyのバージョンアップはVMにSSHでログインして頑張る
  • インフラ構築した人はすでにおらずInfra as Codeもされていないので構築意図がまったくわからず何か起こるたびに困る
  • などなど

高頻度で様々な障害対応が発生するけど誰にも聞けずに辛みが深いし、インフラを改善しようにもほぼコード化されてないし部分的にコード化されてるChefもリファクタリングが必要とかいうレベルではない上にそもそもエラーで実行できない箇所があるという状況でした。

こういった状況を抜本的に改善するために、アーキテクチャを刷新するという選択をしました。

セキュリティ向上

今までのインフラはセキュリティ意識が低く構築されていました。

  • OpsWorksとChefコードの制約からめっちゃ古いOSを使い続けている
    • Kernelもミドルウェアも古い
    • 新しい脆弱性に対するセキュリティパッチがない
  • SSHの鍵はDropboxで広く共有されている
  • MySQLのrootパスワードが誰かの名前
  • IAMの権限がめっちゃ強い
  • などなど

という具合です。脆弱性など致命的な部分は都度対応しているものの、それしかできていない状態でした。

セキュリティに関しても抜本的に作り直したほうが早く改善できるという判断でした。

開発効率向上

次のような施策によって開発効率の向上を目指しました。

  • インフラまわりの単純化
  • 徹底的なInfra as a Code
  • コンテナ化
  • Kubernetes化

新システムではインフラまわりを単純化することで理解しやすくして開発効率向上を目指しました。旧システムは歴史的経緯なのかそもそもの設計が悪いのかわかりませんが無駄な複雑さが多くありました。そういった複雑な依存や機能をひとつひとつ紐解きシンプルに構築しなおすことで理解しやすくしました。

新システムではほぼすべてをTerraformで構築しました。Terraformでカバーできない範囲もコード化しCI/CDするようにしました。旧システムでは誰が何を意図して作ったかもよくわからないインスタンスや設定が多々あるし、Chefのレシピがあったとしても実はエラーで実行されなかったり、実際の設定は手動で変更されてたりするので期待される正しい状態がわからないという状況でした。そういったことがないように構成管理はTerraformに一任し、コードはGitでバージョン管理するようにしました。

コンテナ化によってアプリケーションの実行環境に対してアプリ開発者が責任を持てるようにしました。新しいライブラリが必要になったりRubyのバージョンアップしたくなったりしてもDockerfileを修正するだけで済みます。

Kubernetesを採用することでインフラの単純化、インフラのコード化、実行環境に対する権限の移譲をシンプルに実現しました。Kubernetes自体がシンプルかどうかは様々な観点で議論があると思いますが、アプリ開発者がアプリケーションを継続的に運用するという点では一からAWSで同じものを構築するより簡単に実現できます1。Kubernetesはとてもよくインフラを抽象化していて、理解すれば様々なことを標準機能2で実現できます。標準機能でできるということが大切で、Kubernetes採用にあたっては標準機能でできないことはしないということに気をつけました。

今後のビジネス展開の準備

今後のビジネス展開として新しいサービスを開発していくための準備という目的がありました。「サービスを新規開発していくからマイクロサービスができるようによろしくやっといてくれ」みたいなことを言われていました。サービスの新規開発とMicroservicesとはまったく別の話ですが、新しいアプリケーションを構築する際にも統一的なインフラ基盤があったほうが開発・運用の効率がいいことは間違いないのでそれに備えるという目的がありました。

インフラ移行の目的

なぜAWSでアーキテクチャを刷新せずにGCPに移行したか、という話です。これは簡単で、Kubernetesを使うためです。

技術選定をしたときにマネージドKubernetesを使おうと思ったらGKE一択だったのでGCP以外に選択肢は考えていませんでした。また、BigQueryを使うためにデータ分析基盤がGCPに構築されており、今後のデータ活用を考えるとGCPに統一した方が転送量等のコストも抑えられるし開発運用がやりやすいというのも理由です。

他にも値段あたりのVM性能が良かったり、セキュリティへの安心感3があったりという理由もありました。

プロジェクトについて

プロジェクトについて、社内やチームの状況、全体の流れなどを説明します。

状況

なかなか特殊な状況だったので、まずそれを説明します。状況が異なればプロジェクトの進め方等も異なってくると思います。

自分について

会社には数ヶ月の業務委託を経て入社しました。業務委託期間を含めて移行プロジェクトを始めるまではCTOの傭兵のような立ち位置で次のようなことを行っていました。

  • データ・機械学習系
    • ログ分析基盤構築
    • 類似画像検索エンジン開発
    • 画像置換システム開発
    • 記事カテゴリ分類API開発
    • 機械学習チーム立ち上げ
  • インフラ系
    • 障害対応
    • パフォーマンスチューニング
    • セキュリティ対応
    • 調査とか掃除とか
  • Railsのパフォーマンスチューニング
  • 勉強会の主催
  • などなど

機械学習寄りでいろいろやりつつ、他にできる人がいないのでインフラまわりも最低限は面倒をみていました。Railsに関しては、アプリケーションの機能開発にはまったく関わらず、使用Gemのせいでめちゃくちゃ遅くなっていた部分に関して泣く泣くRailsにパッチをあてたり、CIを高速化したりと裏方的なところをやっていました。

そんな中で、インフラやべーからなんとかしないと、という話がずっとありました。あるタイミングでCTOとバックエンドのリードエンジニアと、いつかはやらないといけないしインフラ移行やろう、という話をして自分がやることになりました。

自分はRailsアプリの機能開発は一切していなかったので、ドメインは全然詳しくないし、コードベースもほぼ触ってないし、インフラは一番詳しいかもしれないけどまだまだ闇は深い、という状況でプロジェクトが開始しました。何かあればCTOとリードエンジニアと相談しつつ進めようという感じでした。

開発チームについて

本プロジェクト開始と同時期に会社がごたついて全社的な退職のビッグウェーブが来てしまい、開発チームもCTO含めRailsエンジニアが全員退職しました。以前は業務委託等でもっと多かったみたいですが、本プロジェクト開始とほぼ同時にサービスのRails開発者がゼロになりました。

CTOは事業責任者も兼任していて、サービスのProduct Management、Project Management、技術チームのリードなどなどかなり広範囲のことをやっていたし、その後の会社の対応もよくなくて社内はまあ荒れました。詳しくは大人の事情で割愛します。

その後、クローズが決まった他サービスを開発していたRailsエンジニアが開発チームにジョインしましたが、もちろんドメインには詳しくないしコードベースには触っていないというところからでした。

そんな感じで、誰も何も知らないし何も決められないという状況でプロジェクトを進めることになりました。

プロジェクトチーム

本プロジェクトのチームについて説明します。

といっても自分一人でした。前述のような状況だったので、プロジェクトマネジメントや実作業を1人でやっていました。本プロジェクト以外にも通常の運用業務やRails含むバックエンドの技術的なケア、その他の割り込み開発、機械学習チームのリードをやりつつ、という感じでした。

後半はいろいろあって機械学習チームが自然消滅した4のでメンバーの1人には週2で移行プロジェクトを手伝ってもらいました。移行当日の深夜作業も手伝ってもらったり、彼なしでは途中で心が折れてプロジェクトを完遂できなかったと思います。圧倒的感謝です 🙏 🙏 🙏

また、他サービスからジョインしたRailsエンジニアにもコードレビューしてもらったり、確認のタスクをやってもらったりしました。🙏

最後のQAテストではPMやiOS、Androidのエンジニアにも手伝ってもらい、不具合を修正することができました 🙏

また、後半のメンテナンス等の調整はPMにやっていただきました 🙏

謝辞みたいになってしまいましたがそんな感じでした。基本的には1人で、他にケツ持つ人もおらず、相談相手もいないという状況でした。

プロジェクト全体の流れ

プロジェクトの流れはこんな感じでした。単純にひとつずつこなしていったというわけでもないので、多少の前後はあります。また、以降で説明するものに絞って列挙しています。

  • 技術調査
  • 根回しとか稟議的なアレ
  • Terraform整備
  • Kubernetesクラスタの設定
  • Railsアプリの準備
  • Docker化
  • Kubernetes化
  • CI整備
  • 動画変換機能のGCP対応
  • メンテナンスサーバ構築
  • 移行手順書作成
  • 移行リハーサル
  • 負荷試験
  • QAテスト
  • 移行作業

技術調査

最初に新しいシステムをどういう技術スタックで構成するかを決定するために調査・検討しました。

例として次のような判断がありました。補足として選定理由やコメントも付け加えています。

  • Dockerでいこう
    • コンテナで動かしてまずいワークロードはなかった
  • Kubernetes/GKEでいこう
    • マネージドで考えるとECSもあったがKubernetes on AWSの噂もありわざわざプロプライエタリなECSを学習したくなかった
    • Kubernetesの経験があったし好きだった
  • Cloud SQLでいこう
    • メンテナンスは許容できる
    • RDSを使っているが、Cloud SQLでも性能は問題なさそう
  • RedisはHA構成でKubernetesクラスタにデプロイしよう
    • 選定当時Memorystoreがなかった
    • セキュリティめんどくさくなるしパフォーマンスの観点からElastiCacheは使いたくなかった
    • HA構成Redisの構築・運用経験があった
    • → 選定後に東京リージョンにMemorystoreが追加されたので最終的にはそっちを使った
  • MemcachedはKubernetesクラスタにデプロイしよう
    • キャッシュだし
  • DynamoDBは使い続けよう
    • DynamoDBと密結合してる部分があった
    • レイテンシは問題なかった
  • Kubernetesのクラスタは1つでいこう
    • 本番環境、ステージング環境を同じクラスタに同居させる
    • 1人で複数クラスタの面倒をみつつ移行作業するのは負担がでかいと判断した
    • → 移行後、本番環境専用のクラスタとそれ以外の開発クラスタに分割した
  • Spinnakerはやめておこう
    • 検証はしたが必要なかった
    • 運用つらそう、ルール作りつらそう、コードで管理できない
    • デプロイするためにKubernetesに加えてSpinnakerの知識が必要となってしまう。開発者の学習コストを抑えたかった
  • CronJobでいこう
    • Jobを高可用、スケーラブルにできる
    • それまではwhenever gemを使って1つのVMで定期バッチをすべて実行していたが問題が多かった
  • 証明書はcert-managerで取得しよう
    • ACMで取得していた証明書の代替が可能
    • 多少バグがあったりしたが問題なかった

根回しとか稟議的なアレ

根回しというか、技術的な部分以外でプロジェクトを始めるまでにやったことと理由やコメントです。

  • GCPの営業チームとミーティング
    • プロジェクト初期は定期的にやっていた
    • 社内へのGoogleさんと一緒にやってますよというアピールの意味合いも強かった
    • 今後リリースされるサービスのクローズドな情報を教えてもらえてよかった
    • GCPを使う上での注意点など教えてもらえてよかった
    • 社内でインフラの技術的な話ができる人はいなかったので、GCPのエンジニアと同じレベル感で話せてコメントがもらえるのがよかった
    • Googleオフィスに行くのは楽しかった ☺️
    • GCPのエンジニアに弊社にきてもらって技術ハンズオンしてもらった
  • 弊社とGCPの偉い人同士のミーティング
    • GCPの偉い人にきてもらって、弊社の偉い人に話をしてもらった
  • 新しい事業責任者にプロジェクトを説明
    • 社内の話
    • プロジェクトのゴーサインを責任者にもらうため
    • 何をするのか、なぜ必要なのかを説明
    • ダウンタイムが発生するということも説明
  • その他
    • CTO/事業責任者がいなくなってたので、ある程度偉い人にちょいちょい移行しますよ、よろしく。という話をしたりしてた

最初のGoogleチームが丁寧に対応してくれていなかったらプロジェクトが開始できていなかったかもしれないので感謝しています 🙏

Terraform整備

新しいインフラの構築にはTerraformを使いました。GCPだけでなく新システムに必要なAWSやCDNのリソースもTerraform化しました。

移行時のTerraform運用は単純で、Pull Requestを作るとterraform planの結果がコメントされ、masterブランチにマージされるとterraform applyされるというものでした。通知やコメントにはmercari/tfnotifyを使っています。

コード構成も単純で、アプリケーションごとにmoduleとしてディレクトリを分割していました。ここでいうアプリケーションはRailsアプリケーション、Goの広告配信アプリケーション、機械学習による記事カテゴリ分類API、といった粒度です。TerraformのWorkspaceは使わず本番環境やステージング環境のコードが重複して存在していました。

最初期はアプリケーションごとにterraform applyするように実装しましたが、まだ必要無いと判断してスピードを出せるようにこのような構成にしました。Workspaceを使わなかったのも同じ理由です。

移行後は一段落したので安全に運用できるようにTerraformのコードと運用を構築し直しました。アプリケーションごとにplan/applyできるようにして影響範囲を抑えplan結果を見やすくして高速化しました。また、Workspaceも導入して本番環境とそれ以外の環境を分離しました。

GCPのProject構成

GCPでは上述のアプリケーションごとにプロジェクトを作るようにしています。そうすることで、IAMでの権限管理がしやすくなります。

Kubernetesクラスタの設定

KubernetesクラスタはTerraformでデプロイしましたが、その他のクラスタに対する設定は専用のリポジトリでマニフェストYAMLをkubectl applyでデプロイするようにしています。Terraformで一元して管理したかったのですが当時はKubernetesプロバイダがまだ充実していませんでした。

次のようなものをYAMLで管理しています。

  • ClusterRole
  • ClusterRoleBinding
  • StorageClass
  • PodSecurityPolicy
  • DaemonSetとそれに関わるNamespaceやRole、Secretなど
  • Helm関係

このリポジトリもTerraformと同様に、Pull Requestでdry runしてmasterブランチにマージするとデプロイされるようにしました。

Railsアプリの移行準備

当初はアプリケーションコードにはあまり変更を加えずにインフラ移行・アーキテクチャ刷新をするという方針でしたが、結果的にはそれなりに手を加えることになりました。

以下の点について大きく修正しました。

  • コンフィグ整理
  • バグ潰し
  • リファクタリング
  • SMTPの廃止
  • fluentdの廃止
  • オブジェクトストレージ整理

それぞれについて説明します。

コンフィグ整理

まずはじめにコンフィグの整理をしました。これには次の2つの目的がありました。

  • アプリケーションを知る
    • どのような環境依存動作があるのか
    • どのような外部依存があるのか
    • コンフィグ周辺の機能やドメイン、コードの把握
  • 移行中に必要となる様々な環境で動作するようにする
    • AWSのproduction/staging環境
    • GCPのproduction/staging環境
    • AWS用で今までどおり開発している開発者のローカル環境
    • GCPの移行準備をしている開発者のローカル環境

コンフィグと言っているのは主に環境ごとに異なる次のような定数のことです。また、Rails.envをみて動作を変えるような分岐もここでのコンフィグに含みます。

  • 各種APIキーやパスワードなどの認証情報
  • データベースや外部サービスの接続先
  • オブジェクトストレージのバケットやパス
  • データベースなどのprefixや名前空間
  • ホスト名やポート番号
  • HTTP or HTTPS
  • などなど

それまで各種コンフィグは様々な場所に散らばっていました。

  • config/application.rb
  • config/database.yml
  • config/environments/*.rb
  • config/initializers/*.rb
  • config/secrets.yml
  • app/lib/の中の定数やクラス変数

つまりあらゆる場所にありました。これらを次のように整理しました。

  • コンフィグは環境変数で設定する
    • The Twelve-Factor App
    • Kubernetes環境で簡単に設定可能
    • 本番環境、ステージング環境のコンフィグはKubernetesのConfigMapまたはSecretで管理する
  • 環境変数はconfig/my_app.rbで一元管理する
    • config/my_app.rbを見ればすべてのコンフィグを確認できる
    • コンフィグを抽象化するため
  • アプリケーション側ではENV['NAME']のように直接環境変数を見ずにMyApp.config.keyのようにアクセスする
    • アプリケーション側が直接環境変数の面倒をみなくてよくする
    • Boolean、Hash、Arrayなどを扱える
  • APIキーやパスワードなどの秘匿情報は暗号化してコミットする
    • 今までは平文でコミットされていた
    • 本番環境、ステージング環境の秘匿情報はKubernetesのSecretのYAMLを暗号化してコミットしている
  • Credentialsを導入し全環境共通の秘匿情報はconfig/credentials.yml.encで管理する
    • ここでのRAILS_MASTER_KEYはSecretの暗号化の暗号キーとしても用いている

MyApp.configを実現するために、要件を満たして最もシンプルだったdry-configurableを導入しました。また、Credentialsを使うためにRailsを5.2にアップデートしました。

環境ごとに異なる動作をするようなコードは移行で必要になる様々な環境を考慮するとif文が非常に複雑になってしまうため、ENABLE_MYFUNCのような環境変数を用意して分岐するようにしました。

# 修正前
do_myfunc if Rails.env.production?

# 修正後
do_myfunc if MyApp.config.enabled_myfunc?

今まで環境変数によるコンフィグ管理はしていなかったので、ローカル開発環境用のコンフィグはdirenvで管理して、移行が終わるまでのAWSの本番環境、ステージング環境のコンフィグはdotenvを使って.env.production/.env.stagingで管理するようにしました。

このコンフィグ整理でアプリケーションについて多くのことを知れたのと、設定が楽になり移行がスムーズにできたので最初に取り組んで正解でした。

バグ潰し

バグ潰しをしました。それまでは常にSentryに数百のIssueが溜まっている状態だったので、GCP環境でエラーが出ても埋もれて気づかないといったことを避けるためです。

コンフィグ整理と同じく、バグを修正することでアプリケーションを知るという目的もありました。

ただし、こちらはあまりにも数が多く、一筋縄ではいかないようなものもあり、さらに作業中も新しいIssueがどんどん増えるのである程度減らしたところで終了しました。

リファクタリング

前述のコンフィグ整理、バグ潰しはボーイスカウトになりきって作業しました。もう誰も知らない触らない部分も多かったので良い機会だとガツガツとリファクタリングしました。気づいたそのときにリファクタリングしないとコードはどんどん魔物化していくので重要なことです。

SMTPの廃止

それまではRailsからSMTPでメールを送信していましたが、GCEでは基本的にSMTPが使えないのでSendgridのAPIでメールを送信するようにしました。これについてはAWSで動作しているときに切り替えました。

fluentdの廃止

旧システムではfluentdで様々なログを収集していましたが、新システムでは欲しいログは特になにもしなくてもStackdriver Loggingに集約されるので、設定の管理コストや運用コストをなくすためにfluentdを廃止することにしました。

Railsアプリにもfluent-loggerで送信しているログがあったので、これをStackdriver Loggingに直接送信するように修正しました。こんな感じです。

# TODO(GCP): Remove fluentd
if MyApp.config.enabled_fluentd?
  Fluent::Logger.post_with_time(table, data, timestamp)
end

if MyApp.config.enabled_stackdriver?
  StackdriverLogger.write(
    MyApp.config.stackdriver_log_name,
    data.merge(timestamp: timestamp.utc.iso8601),
  )
end

このとき、google-cloud-logging gemでStackdriver Loggingにログを送信しようとするとSegmentation faultで落ちるという問題が発生しました。結論としてはPumaのCluster Modeでpreload_app!するとgrpcがセグフォする、というバグでした5。メモリ効率は悪くなりますがCluster Modeをやめることで対応しました。

オブジェクトストレージ整理

Railsのアセットファイル、ユーザーにアップロードされたファイル、それ以外のロゴ画像などの静的ファイルはS3にアップロードして配信していました。今回のプロジェクトではせっかくダウンタイムがあるし、オブジェクトストレージも同時に移行しようということでS3からGCSに移行しました。そのとき、オブジェクトストレージまわりでこれは美しくなさすぎて見過ごせないという以下の点を見つけたので修正しました。

  • 全環境で1つのS3バケットを使っている
  • 環境のprefixがバラバラ
    • 例えばproduction環境のファイルのprefixにはs3.myapp.com/web/images/p/s3.myapp.com/assets/production/といったものがある
  • バケット内のprefixを見てもどういう種類のファイルかわからない
    • CarrierWaveでアップロードされたものなのか?誰かが直接アップロードしたものなのか?
  • 人手で直接S3にアップロードされたもの、app/assetsにあるもの、public/にあるものが明確な基準がなく混在している

これを新システムでは次の方針で整理しました。

  • 環境ごとにバケットはわける
    • s.myapp.com
    • s.staging.myapp.com
    • s.development.myapp.com
  • prefixでどういう種類のファイルかわかるようにする
    • CarrierWaveでアップロードされたもの: s.myapp.com/upload/
    • そうでないもの: s.myapp.com/static/
  • s.myapp.com/static/にアップロードするファイルはすべてGitでpublic/static/にコミットする
  • assets、packsはGCSにはアップロードしない
    • アプリケーションサーバから配信してCDNでキャッシュする

これを実現するためにはS3からGCSへのデータ転送とそれぞれのファイルのパス変更が必要になります。移行時にこの2つを一気にやろうとすると非常に時間がかかるので、移行まで日次で以下の処理を行うバッチを実行するようにしました。

  • GCPのStorage Transfer Serviceを使ってS3からGCSの中間バケットに全ファイルを転送する
  • prefixマッピングテーブルに従って中間バケットからGCSの各環境用バケットにファイルをコピーする
    • このとき各ファイルで更新時間を比較し、更新がなければコピーしない

このバッチスクリプトははじめはRubyで実装していましたが、数日経っても終了しないのでGoで実装しなおしたところ数時間でおわるようになりました。

日次で実行しても新しいprefixへのマッピングは約50M個のファイルをすべてチェックする必要があるので6時間強必要でした。S3からGCSへの1日分のファイルの転送は30分程度なので、合計7時間程度処理にかかっていました。

実はオブジェクトストレージの移行はやるかどうかかなり悩みました。というか最初はやらないつもりでした。アプリケーションサーバはGCPでもそのままS3を使うことはできたし、汚いままGCSに移行することもできたからです。移行することで工数はガツッと増えるし、移行で気にすることが増えるため、「移行」プロジェクトとして考えたときには大きいデメリットがありました。しかし、まだ続いていくサービスとしてはやったほうがいいことは明らかでした。

結局、ダウンタイムなしでこれを実現するにはアプリケーション側で頑張らないといけないけど頑張る人はいなくなったし、今やらないと今後永久にできないだろうということで、これ以上エンジニアのSAN値を削らずサービスを存続させるためにもこのプロジェクトでやることに決めました。男気のある良い決断だったと思います。

Docker化

RailsアプリについてはそれまでもDocker化しようという試みはありDockerfileは存在したのですが、CentOS 6にrvmでRubyをインストールしてNginxやらNodeやらを詰め込んでmonitを起動するというVM用のChefをそのまま移植したみたいな代物でした。さすがにそれを使うわけにはいかないので一から作り直しました。

どのアプリケーションのDockerfileも特殊なことはせず、こんな感じになっています。

  • Railsアプリ
    • Baseイメージはruby:x.x.x-slim-stretch
    • Multi-stageビルドのビルドステージで次のことをしている
      • bundle install
      • yarn install
      • rake assets:precompile
  • Goアプリ
    • Baseイメージはなし(scratch)
    • Multi-stageビルドのビルドステージでビルド

また、この2種以外にもDockerイメージはあり、すべてのDockerfileで統一したユーザを作ってそのユーザを使うようにしています。6

Kubernetes化

Dockerコンテナとして起動できるようにした後、Kubernetesで動作するようにしました。移行時は1つのクラスタに18個のNamespaceがあり、7個のアプリケーションの本番環境とステージング環境が稼働していました。アプリケーションによってKubernetesでの構成要素やデプロイ方法が多少変わりますが、ここではメインとなるRailsアプリのみ説明します。

リソース構成

Railsアプリを構成するKubernetesのリソース一覧です。

  • Namespace
    • 本番環境Railsアプリ
    • ステージング環境Railsアプリ
  • Deployment
    • Puma
    • Sidekiq
    • Memcached
    • Console
      • 移行時にログインして作業するため
  • Service
    • Puma
      • Ingressを使うためNodePort
    • Memcached
      • 外には公開しないのでClusterIP
  • Ingress
    • Puma
  • Job
    • デプロイ時のdb:migrateや初期構築時のdb:createなどのRakeタスクたち
  • CronJob
    • Wheneverで管理していた定期実行のRakeタスクたち
  • ConfigMap
    • Rails環境変数
      • 環境変数でRailsアプリを設定できるようにしたのでConfigMapにすべてまとめてenvFromで設定している
    • その他いろいろ
  • Secret
    • Rails環境変数
      • ConfigMapと同じ
    • その他いろいろ
      • Cloud SQL Proxyやcert-manager用のService AccountのCredentials JSONなど
  • HorizontalPodAutoscaler
    • Puma
    • Sidekiq
  • RoleBinding
    • 開発者
      • ステージング環境にadmin権限を与える
    • 運用者
      • 本番環境にadmin権限を与える
    • PodSecurityPolicy用
      • default ServiceAccountにPodSecurityPolicyのuse権限を与える
  • LimitRange
    • default
      • 意図しないkill等を防ぐため
  • Certificate (cert-manager)
    • Puma Ingress用
  • Issuer (cert-manager)

デプロイ方法

デプロイはCircleCIでデプロイ用のBashスクリプトを実行しています。ブランチモデルはgit-flowの簡易版で、featureブランチをdevelopブランチにマージしたらステージング環境にデプロイ、developブランチをmasterブランチにマージしたら本番環境にデプロイするという運用になっています。

スクリプトはこんな感じです。Namespaceが存在しない場合は初期構築の手順が追加されますが、ここでは省略しています。

  • gcloud等をインストール
  • gcloud、kubectlの認証
  • ブランチ名から適切なKubernetesのNamespaceを設定
    • develop -> export NAMESPACE=myapp-staging
    • master -> export NAMESPACE=myapp-production
  • Namespace用の変数を設定
    • 後述する--build-argやReplica数などConfigMapで設定できないもの
    • source k8s/namespaces/${NAMESPACE}/config.sh
  • Dockerイメージのbuild、push
    • このときWebpack(assets:precompile)用の環境変数を--build-argで設定する
  • RakeタスクでERBテンプレートからDeploymentやJobのYAMLを生成する
  • Namespaceの各種リソースをkubectl applyする
    • kubectl apply -f k8s/namespaces/${NAMESPACE}/*.yaml
  • NamespaceのSecretを復号してデプロイする
  • db:migrateのJobをapplyし、終了するまで待つ
  • CronJobをkubectl apply --pruneする
  • Sidekiq、PumaのDeploymentをkubectl applyする

マニフェストYAML生成

KubernetesのマニフェストYAMLは3種類の方法で管理しています。

  • 普通のYAML
  • 暗号化されたYAML
  • ERBテンプレート

Dockerイメージの指定が必要ないマニフェストに関しては普通にYAMLでコミットして、SecretはYAMLを暗号化してコミットしています。

CIでDockerイメージをビルドするとき、Gitのコミットハッシュをタグに使っています。そして、デプロイではDeploymentやJobのERBテンプレートにコミットハッシュを埋め込んでYAMLを生成するRakeタスクを実行して、生成されたYAMLをkubectl applyしています。ERBやRakeを採用したのはRailsとの親和性が高くRails開発者が触りやすいからです。

また、Rakeタスク実行用のJobやCronJobに関してはタスク名も埋め込めるようにしています。CronJobに関してはスケジュールとタスク名を定義するconfig/schedule.yamlというファイルから自動生成しています。config/schedule.yamlはRails開発者がKubernetesを意識せず気軽に変更できるため、CronJobのみkubectl apply --pruneで削除にも対応しています。その他のリソースは手動で削除しています。

Secret管理

前述のとおりSecretは暗号化してコミットしています。そのためにSekretという簡単なコマンドラインツールを開発しました。sekret (new|edit|show|encrypte|decrypt) foobar.yaml.encのように簡単に暗号化ファイルを扱えて、Secretのバリデーションもやってくれます。

Railsのcredentialsで使われているActiveSupport::EncryptedFilerails encrypted:editを使うことも考えましたが、以下の理由で採用を見送りました。

  • ただYAMLを修正したいだけなのにRailsなので起動が遅い
  • Railsが動くまで環境構築しないとYAMLを修正できない
  • 暗号化してコミットしてしまうので編集時にスキーマのバリデーションをしたいができない

そんなわけで、お手軽に暗号化YAMLを扱えてスキーマチェックしてくれてCIで使いやすいワンバイナリなSekretを作りました。

ぜひ使ってみてください(宣伝)。

Railsアセット配信方法の変遷

assets:precompileで生成されるアセットを配信する方法は紆余曲折ありました。

一番最初はアセットをasset_syncでGCSにアップロードしてアプリケーションとは別ドメインで配信していました。これは旧アーキテクチャがそうなっていて、プロジェクト初期段階でとりあえずGKEで動かすためにこうしていました。細かい手順としては、CIでPuma Deploymentのapply前にassets:precompile assets:syncをJobとして実行し、Puma DeploymentのInit Containerでemptyボリュームにmanifest.jsonをダウンロードする、という感じです。

asset_syncによる方法は最初から変更するつもりでした。アプリケーションサーバ(Puma)の前段にもCDNがいるのでGCSにアップロードせず直接Pumaから配信して複雑性を減らしたかったからです。

Pumaからアセットを配信するときにPumaコンテナがアセットを持っておく必要があります。その場合、いつassets:precompileして、どこに持つかということが問題になります。以下のような選択肢があります。

  • いつassets:precompileするか
    • Pumaコンテナ起動時 (bundle exec pumaの直前)
    • Puma Pod起動時 (Init Container)
    • デプロイ時
    • Dockerイメージビルド時
  • どこに持つか
    • オブジェクトストレージ
    • 外部永続ディスク
    • その他ボリューム (emptyDirhostPathなど)
    • 起動後のコンテナ
    • Dockerイメージ内

2つ目の方法は、CIでPuma Deploymentのapply前にアセット用のRegional Persistent DiskをReadWriteOnceでマウントしたJobでassets:precompileを実行し、DeploymentはReadOnlyManyでそのDiskをマウントするという方法でした。この方法は

  • Dockerイメージビルドがはやく、イメージが軽くなる
  • assets:precompileが1回で済む
  • Podの起動がはやい
  • Webpackに渡す環境変数をConfigMapやSecretで管理できる

というメリットがあり、なによりGKEっぽいイケイケな感じで本来やりたい方法でした。

ところがこの方法もあとから問題が発覚しました。ボリューム関係のエラーでPodが起動しないということが続き、調べるとRegional Persistent DiskはそもそもReadOnlyManyをサポートしておらず、レプリケーションも2つのゾーン間のみということがわかりました。完全に調査不足でした 😭

最終的に、Dockerイメージビルド時にassets:precompileするという方法に落ち着きました。Podの起動時間を犠牲にせず、最もシンプルな方法ということで採用しました。

最終的な方法ではイメージビルドが遅くなったり、イメージサイズが大きくなったり、Webpack用の環境変数がConfigMapで管理できないという課題があります。GKEにおいてそれなりのサイズのボリュームをマルチゾーンにPodで共有する方法がない7以上、アセットは各Podでそれぞれ持つしかありません。そうするとイメージに含める以外だと各PodでダウンロードするかコンパイルするかになるのでPodの起動時間が大幅に長くなってしまいます。なので、イメージサイズについては諦めています。環境変数についてはKubernetes上でkanikoなどを使ってイメージをビルドすることでConfigMapやSecretで管理できそうなのでそのうち試してみたいと考えています。

ここに関してはまだ改善できると感じているのでいい方法を模索していきたいです。

Connection refused問題

デプロイしたときや、CronJobで稀にMySQLのConnection refusedエラーが発生するという問題がありました。Rails側からMySQLへリクエストするときにSidecarのCloud SQL Proxyが起動していないことが原因でした。これはPodの起動時と停止時どちらにも発生し得るので、Rails起動前にSleepを入れることと停止時にCloud SQL ProxyのpreStopでsleepすることで対応しました。

CI整備

Kubernetesで動くようにした後はCIを整備しました。スムーズに移行できるように旧システムへのデプロイと並行して、developブランチでステージング環境用のNamespaceに、masterブランチで本番環境用のNamespaceにデプロイするようにしました。移行プロジェクト中盤までは通常の開発に影響しないようにKubernetesへのデプロイが失敗してもCIはパスするようにしていました。

動画変換機能のGCP対応

システムの中でAWSの機能に依存していてGCPでは代替が難しいものがいくつかあり、その中でも動画変換機能がAWSとGCPをうまく連携させてKubernetesの恩恵を感じられたので紹介します。

動画変換機能はユーザがS3にアップロードした動画をAWSのElastic Transcoderで変換してS3に保存するというものでした。Elastic Transcoderが変換後の動画を配信用S3バケットに保存してくれるのでAWSで完結していれば非常に単純な仕組みです。図にするとこんな感じです。

  • ユーザがS3に動画をアップロード
  • S3オブジェクト作成イベントをトリガーにLambda関数を実行し、Elastic Transcoderの動画変換ジョブを作成
  • Elastic Transcoderはアップロードされた動画を変換して配信用S3バケットに保存し、終了をSNSで通知
  • SNSから動画変換終了をHTTPSでRailsアプリに通知
  • Railsアプリは通知を受け取ったらデータベースの動画ステータスを更新

新システムでは変換後の動画をS3ではなくGCSに保存する必要があります。S3から配信することもできましたが、それまでの配信用のURLをGCPに向けるためドメインを変える必要があることや、動画だけAWSから配信されるという複雑な状況を避けるためにGCSから配信することにしました。

次の図が新システムでのアーキテクチャです。HTTPSでRailsアプリに直接通知せず、SQS経由で動画転送アプリを通してRailsアプリに通知するようにしました。

  • SNSから動画変換終了をSQSに通知
  • SQSをポーリングしている動画転送アプリが通知を受信
  • 動画転送アプリはGCPのStorage Transfer Serviceの転送ジョブを作成し動画をGCSに転送
  • 転送が終了したらHTTPSでRailsアプリにSNS互換の通知を送信
  • Railsアプリは通知を受け取ったらデータベースの動画ステータスを更新

動画転送アプリからRailsアプリへの通知はSNS互換としました。これには2つ理由があります。1つ目はRailsアプリへの変更が不要だからです。2つ目はRailsアプリを気にせず動画変換機能単体で移行ができるからです。

動画変換機能の移行作業中はこのように従来通りSNSからRailsアプリに通知を送りつつ、動画転送アプリも稼働させていました。Webhookのエンドポイントは冪等だったので、動画転送アプリがうまく動作していることが確認できたら動画転送アプリからもRailsアプリに通知を送るようにしました。そして最後にSNSからの通知を切りました。こうすることで、バツッと切替えるみたいにドキドキすることなく移行が完了しました。また、この機能はAWSで旧システムを運用している段階で移行して、本移行作業では無視できるようにしました。

動画転送アプリは独立したアプリとしてGoで実装しました。Railsアプリに依存がなく、Railsだけでやろうとするとエンドポイントを増やしたりインフラの構成要素を増やしたりSidekiqのスレッドを長く専有したりする必要があったからです。Kubernetesを採用したことで気軽にこのような選択肢を取ることができました。

メンテナンスサーバ構築

移行作業中にメンテナンスページを表示するためのメンテナンスサーバを構築しました。「もう少し待ってね」というHTMLを503で返すだけのページです。何かをトリガーにしてメンテナンスモードになるようなものではなく、あくまでも本プロジェクトの移行作業のために構築しました。

また、それまでメンテナンスモードがなかったのでモバイルアプリには新しく503が返ってきたらメンテナンス表示がでるようにしました。

移行手順書作成

移行前に手順書を作成しました。手順書はMarkdownで書きGitHubのリポジトリにコミットしました。作業当日に実行した手順をチェックできるように、手順一つ一つにチェックボックスをつけるようにしました。こんな感じです。

02 停止手順書
===========

# Webサーバインスタンス停止

* [ ] myapp-webのオートスケールスケジュールを設定する

aws --region us-east-1 opsworks describe-instances \
  --layer-id ${myapp_web_layer_id} \
  | jq -r ...

* [ ] myapp-webインスタンスを停止する

aws --region us-east-1 opsworks describe-instance \
  --layer-id ${myapp_web_layer_id} \
  | jq -r ...

GitHubのWebだとコミットされているファイルのチェックボックスにはチェックできないので、作業当日は手順書をIssueにコピーしてチェックしていきました。Issueにしておけばコメントで作業ログも残せるし、1つの手順書が完了するとクローズできてテンションあがるのでいい方法でした。

ファイルとしては次の6つを用意しました。

  • 準備手順書: 移行作業当日までの準備のTODO
  • 停止手順書: AWSの旧システムを停止する手順書
  • 転送手順書: MySQLやオブジェクトストレージのデータを転送する手順書
  • 起動手順書: GCPの新システムを起動する手順書
  • 起動後手順書: 新システムが起動したあと、周辺システムなどを新システムへ切替える手順書
  • ロールバック手順書: 作業途中で問題が発生したときにAWSの旧システムを復旧させる手順書

だいたいの手順はチェックボックス1つにつき1つのコマンドとなっていましたが、関連した一連のコマンドを実行して長時間待って終わったらSlackに通知、のような作業は1つのBashスクリプトにまとめたりもしました。

また、手順書のコマンドを実行するための作業サーバを構築しました。ローカルの意図しない影響が入らないようにするためと、ネットワーク切断や電源が落ちる等のトラブルを避けるためです。作業サーバではscreenを使って作業しました。作業サーバ構築もスクリプトで自動化して、何かあったときすぐ再構築できるようにしました。

移行リハーサル

旧システムを停止してから新システムを起動するまで迅速に作業できるように、転送手順書、起動手順書のリハーサルを行いました。これには負荷テストやQAテストのために本番環境のリアルデータを新システムにコピーするという目的もありました。

リハーサルでは各作業の時間を計測しながら実行し、手順書にミスがあれば修正してコミットしました。

負荷テスト

新システムが負荷に耐えられるか確認するため負荷テストを行いました。

今回のプロジェクトではシステムにかかる負荷が予めわかっていたので目標設定は簡単でした。

  • シナリオは高負荷時に実際のスマホアプリから発生するリクエスト群
  • 最高負荷時の倍のリクエスト (rps) をさばける
  • レイテンシは旧システムより良い値を維持できる

負荷テストツールにはLocustを使いました。選定理由は以下のとおりです。

  • 上記のように目標が明らかで詳細な分析が不要だった
  • Locust単体でWebでグラフ表示できる
  • シナリオをXMLじゃない言語で書けてGitで管理できる
  • 分散負荷テストが可能

インフラとしてはGCPにMaster 1台とSlave 5台のVMを用意しました。

最初はクライアント数が増えるとリクエストをさばけずにコンテナが再起動してしまっていたので、コンテナ内外の仮で設定した値をチューニングしました。

  • PumaのWorker数やThread数やメモリ制限
  • コンテナのCPU、メモリ
  • DeploymentのReplica数
  • Horizontal Pod Autoscaler

HPAはスパイク時にはまったく追いつかないことがわかったので、高負荷時のために予めPod数を増やしておくようにしました。

通常なら負荷テストはもっと早い段階でやっておきたいところですが、今回はシステムの改修後に実データでやりたかったのでここで実施しました。そんなに負荷がかかるサービスでもないのでまあ大丈夫だろうと予想していて、技術調査の段階でもパフォーマンスのボトルネックになりそうなCloud SQLの負荷テストは実施していました。

QAテスト

プロジェクトの最終チェックとしてQAテストを行いました。自分は普段サービスを開発していないので、実作業は主にRailsエンジニアやアプリエンジニアなど他のエンジニアにやってもらいました。

対応漏れや、GCP移行対応をしたあとにGCPを考慮せず追加された部分が見つかり修正しました。

しっかりチェックしてもらい非常に助かりました。

移行作業

土曜の夜から日曜の朝にかけて移行作業を行いました。業務委託で移行プロジェクトを手伝ってくれていたエンジニアと2人で作業しました。

その週はデプロイ禁止にして、最終的な確認・調整やAWSからGCPに切替えるPull Requestを準備しました。また、作業開始から1時間程度前に集合して手順書、作業の流れ、分担を確認しました。

23時から作業開始で最初にトラフィックをすべてメンテナンスサーバに向けるという作業があったのですが、ここでいきなりトラブルが発生しました 😱 切り替え作業をやったにもかかわらずメンテナンスサーバが表示されずにCDNのエラーメッセージが表示されていました。そのトラブルも2つの原因が重なっていて、作業開始早々いきなりの緊急事態発生でした。本プロジェクトで最もアツかった時間でした 😤 🔥 原因はメンテナンスサーバがHTTPSに対応できてなかったことと、Nginxの設定の不備だったので手分けして対応しました。ELBを作成しACMで証明書を取得してHTTPSに対応し、Nginxの設定は直接サーバで設定を書き換えました。すぐにメンテナンスページを表示するはずが表示まで50分ほどかかってしまいました。

初っ端で躓いたもののそれ以降は特にトラブルもなく旧システムを停止し、仮眠などしつつデータ転送が終わるのを待ちました。

4時半頃にMySQLのデータ転送が終了したので新システムの起動を開始しました。7時半頃にはオブジェクトストレージのデータ転送も終了し、公開作業を開始しました。

8時頃公開作業が完了し、待機してくれていた他のエンジニアが動作確認を開始しました。その動作確認でQAテストで漏れていた問題が見つかったり、HTTPからHTTPSに統一したことによる問題が見つかりましたがすぐに修正して9時前に公式にメンテナンス終了のアナウンスをしました。

その後諸々の確認や残タスクをこなし、15時頃に移行作業を完全に終えました。

移行後

移行後は大きな問題はなく運用できています。問題と言えばSidekiqのキューが詰まりスレッド数とコンテナ数を調整したぐらいです。

インフラ費用は安くなり、デプロイのたびに心の準備をしなくてよくなり、30分から1時間かかっていたデプロイは8〜15分で終わるようになり、Rubyのバージョンアップができるようになり、同じ構成のステージング環境ができて、今の所いいことばかりです。

移行前後の2ヶ月をPingdomで比較したところダウンタイムは30分から14分に減り、レスンポンスタイムは約500msはやくなりました。8

良かった点と反省点

良かった点は、なんと言っても移行後ほぼ問題なく運用できていることです。ただインフラを移行するだけでなくアーキテクチャやアプリケーションコードをガッツリ変更したにも関わらず、トラブルがなく運用できているのは素晴らしい成果だと思います。また、かなりスムーズに移行作業ができたのも非常に良かった点です。準備が長くて嫌にもなりましたが、しっかり準備をしたおかげですね。

プロジェクトの進行もうまくいったと思います。1人でスケジュール管理してタスク管理していろんなリポジトリを行ったりきたりして目が回りましたが、それほど手戻りもなくスケジュールどおりに進めることができました。

反省点ですが、そもそも1人でやるようなプロジェクトではありませんでした。移行プロジェクトにフルコミットしてくれるエンジニアを探したり、PMをお願いしたりしたほうがよかったかもしれません。時間もかかったし、やりたかったけどできてないことも多々あります。一斉退職があった時点でプロジェクトを諦めたり会社を辞めたりという選択肢もありましたが、自分にとって今までにない挑戦だしいい経験になると思いやりました。

あと、メンテナンスサーバについては反省しかないですね 😂 他と比べて簡単な部分だったので気が抜けていました。

今後

まだまだやりたいことはいろいろあります。例をざっとあげるとこんな感じです。

  • Service Mesh
    • すでにいくつかMicroservices的なものがあり、Envoyあったらな〜的なことがあるので何かしらやりたい
  • 分散トレーシング
    • すでにいくつかMicroservices的なものがあり、このエラーどこがどうなった的なことがあるのでほしい
  • One-shotなジョブを実行する仕組み
    • Rakeタスクとか。今はコンテナに入って実行する感じ
  • Horizontal Pod AutoscalerのCustom metrics利用
    • Sidekiqをキューに溜まってるジョブ数でスケールするとか
  • NetworkPolicy
    • Namespace間で通信を制限したい
  • ヘルスチェック用エンドポイント
    • 今はRackが起動してるかどうかなので、ちゃんとreadiness probeできるエンドポイントがほしい
  • 監視の充実
    • 今は最低限の監視しかないので充実させたい
  • などなど

終わりに

今までも新規開発でエンジニア1人というプロジェクトの経験はありましたが、6年続いている既存のシステムに関するプロジェクトでエンジニアどころかPMはおろか社内に相談相手すらおらず本当に1人で長期間走るのは(精神的に)大変でした。前半はごたごたのおかげでプロジェクト外のストレスも多かったですが、後半は週2日といえども頼りになるエンジニアが手伝ってくれたのと会社に行かずリモートワークとフレックスで様々なストレスをシャットアウトできたのがよかったんだと思います 😃 💭

プロジェクト自体は大成功と言っても良い結果となったので良かったです。

いろいろ知見や経験も得られたので、今後ひとつずつ何かしらの形で公開していきたいです。


  1. 簡単さの比較に関してはもちろん組み立て方次第なんですが、なんとなく雰囲気を感じ取っていただければ幸いです。参考: Kubernetes は辛いのか? - @amsy810’s Blog ↩︎

  2. GKEのようなマネージドサービスの機能も含む ↩︎

  3. GCPはかなりセキュリティに力を入れてるし、例えばコンテナまわりの脆弱性が発表されたときにGKEのContainer-Optimized OSの場合は対応不要ということも多かった。 ↩︎

  4. 語り尽くせない出来事がいろいろあったりしたのですが、本筋と関係ないので泣く泣く割愛します。 ↩︎

  5. Googleのエンジニアにも伝え、grpc/grpcにもIssueをあげたけどまだ未解決っぽい ↩︎

  6. 参考 Secure User in Docker - DEV Community 👩‍💻👨‍💻 ↩︎

  7. 正確にはないことはないが、ないと言っていいぐらい面倒な方法しかないはず。簡単な方法があったら教えてください。 ↩︎

  8. あくまでもPingdomのレスンポンスタイム ↩︎