はじめましての方ははじめまして。21新卒エンジニアの Javakky です。 突然ですが質問です!みなさんは本番環境で利用するDBのアクセス情報などの機密情報はどこに保存していますか?弊チームでは、社内でホスティングしているGitLabを利用しており、リポジトリの閲覧権限がチームメンバーにしか付与されていないことから長年プロジェクトの config ファイルに平文で保存されていました。 しかし、それはつまりリポジトリを clone してしまえば機密情報を全て保存できてしまうということでもあります。今回は、 Secret Manager というサービスを利用して機密情報にたいして権限管理を行えるようにしたレポートです。
Secret Manager とは?
Secret Manager は Google Cloud のプロダクトの一つで、APIキーなどの機密情報を保存するためのストレージシステムです。 64KiB 以内のデータ (文字列) に名前をつけて管理することができ、暗号鍵を自身で用意することや有効期限を指定することもできます。
Cloud Key Management
導入検討時に候補としてあがったものとしては、 Google Cloud Key Management があります。こちらは機密情報そのものを管理するサービスではなく、暗号化のための鍵を生成・管理してくれるサービスになります。 今回は、リポジトリ内に暗号化したデータを大量に持ちたくない (何のデータか目視チェックしづらい) という理由から Secret Manager を選択しました。
Secret Manager からデータを取得する
弊チームでは Scala を使っており、Google Cloud のサービスは Java 向けの SDK が用意されているのでそちらを利用しました。
libraryDependencies += "com.google.cloud" % "google-cloud-secretmanager" % "2.1.3"
Google Cloud に認証する
弊チームのシステムは本番環境 (サーバー)・検証環境 (サーバー)・開発環境 (ローカル) の3つの環境で動作しているため、サーバー上で動かす場合とローカルで動かす場合に別の認証方法が必要でした。 サーバー上で Google Cloud サービスを利用する場合、サービスアカウントという仮想マシンなどに割り当てることができる特別なアカウントを用います。 しかし、認証に利用するアカウントを全てサービスアカウントにしてしまうと開発メンバーのローカルPCにもアカウントを発行する必要が出てしまうので、開発環境では個人の Google アカウントで認証できるようにしました。
サービスアカウントを利用した認証
Google Cloud ドキュメントの サービス アカウントとして認証する の項目を元に、生成した JSON ファイルをサーバー上に設置して変数 GOOGLE_APPLICATION_CREDENTIALS
を設定しました。
ユーザーアカウントを利用した認証
Google Cloud CLI をインストールする にある通り、ローカルの環境に Google Cloud CLI をインストールして、 gcloud auth application-default login
コマンドを利用して認証しました。
下記コマンドを入力するとブラウザから Google のページが開くので許可を選択することで認証情報が作成されます。 /Users/${User}/.config/gcloud/application_default_credentials.json
にファイルが生成されていれば完了です。
gcloud auth application-default login
機密情報にアクセスする
シークレットの作成とアクセスを Scala で書き直すことでアクセスするクラスを実装しました。 accessSecretVersion
を呼び出す際に名前とバージョンを渡すだけで機密情報を取得することができます。この通信は非同期化されていないため、並列アクセスなどを行う場合には呼び出し元で実装する必要があります。
package secretmanager import com.google.cloud.secretmanager.v1.{SecretManagerServiceClient, SecretVersionName} import secretmanager.{SecretManagerApi, SecretManagerFailGetValueException} import javax.inject.Inject import scala.util.Using import scala.util.control.NonFatal /** [[SecretManagerServiceClient]] のラッパー */ class SecretManagerApiImpl @Inject()(secretConfig: SecretManagerConfig) extends SecretManagerApi { def accessSecretVersion(name: String, version: Int): String = { Using.resource(SecretManagerServiceClient.create) { client => val secretVersionName = SecretVersionName.of(secretConfig.project, name, version.toString) val response = client.accessSecretVersion(secretVersionName) // 本番環境では取得したシークレットを表示、出力等しないこと val payload = Some(response.getPayload.getData.toStringUtf8) payload.getOrElse(throw new SecretManagerFailGetValueException("Secret Manager から値の取得に失敗しました")) } } }
これによって Secret Manager に保存された機密情報を取得することができるようになりました。では、このメソッドはいつ呼び出すことにすればよいでしょうか? 例えば利用する度に呼び出す実装にすることで、メモリ上にも長時間機密情報を保存する必要がなくなりよりセキュアになると言えます。 一方で、毎回呼び出す方式では、次のような問題が考えられます。
- なんども通信を挟むので、システム全体のレスポンスが遅くなる
- アクセス回数に応じた課金 もなされるため、費用が大きくなる
そこで、サーバーサイドアプリケーションの立ち上げ時にのみアクセスを行い、そのデータを使い回すことにしました。具体的な実装としては、得られた機密情報をそれぞれの Config クラスのフィールドとしてセットして、各 Controller の立ち上げ時にDIされるようになっています。 サーバーサイドアプリケーションの立ち上げは基本的にはデプロイ時にのみ行われるため、シークレットの削除や Google Cloud の障害があった場合には立ち上げ失敗により開発者が問題に気づくことができるようになっています。
開発モードでのキャッシュ
Play Framework を利用したプロジェクトでは、ローカル開発時にホットリロードを有効にすることができる開発モードがあります。しかし、この手法ではコードの変更時にアプリケーションが毎回リロードされ、機密情報の読み込みも毎回発生することになってしまいます。これに対処するため、チームメンバーの toshi がインメモリキャッシュを利用した機密情報の使い回しを実装しました。
方針としては静的フィールドとして宣言した ConcurrentHashMap
に機密情報を保存して使い回すという単純なものです。ただ、playframework/Reloader.scala に記載がある通り、ユーザークラスを定義する reloader.currentApplicationClassLoader
はリロードする際に作成されてしまうため、ユーザークラスとしてではなく依存ライブラリとして親クラスローダーから読み込んでいます。
project.settings( // devCacheのjarを開発モードでライブラリとして読む。 play.sbt.PlayInternalKeys.playDependencyClasspath ++= Seq( // devCacheサブプロジェクトでpackageタスクを実行した時に作成できるjarのパス (devCache / Compile / _root_.sbt.Keys.`package`).value ), ) lazy val devCache = project.settings( commonSettings, name := "dev-cache" )
package devcache; import java.util.concurrent.ConcurrentHashMap; public class DevCache { private static final ConcurrentHashMap<String, String> underlying = new ConcurrentHashMap<>(); public static void put(String key, String value) { underlying.put(key, value); } public static String get(String key) { return underlying.get(key); } }
class SecretManagerCache(classLoader: ClassLoader) { private def loadCacheClass() = { for { // アプリケーションクラスローダーの親クラスローダーを取得する parent <- Option(classLoader.getParent) cls <- try { // `applicationLoader` でキャッシュデータを保存するクラスをロードする。 // devcache.DevCache は親クラスローダーが見つけられるようにソースコードとしてではなく依存ライブラリとして読み込む。 Some(parent.loadClass("devcache.DevCache")) } catch { case _: ClassNotFoundException => // 本番環境ではdevcache.DevCacheはデプロイされないのでロードできない。なのでキャッシュも効かない。 None } } yield cls } /** キャッシュデータを取得する */ def getCache(name: String, version: Int): Option[String] = { for { cls <- loadCacheClass() // リフレクションで DevCache#getを呼び出す m <- Option(cls.getMethod("get", classOf[String])) v <- Option(m.invoke(null, cacheKey(name, version)).asInstanceOf[String]) } yield v } /** キャッシュを保存する */ def putCache(name: String, version: Int, value: String): Unit = { for { cls <- loadCacheClass() // リフレクションで DevCache#putを呼び出す m <- Option(cls.getMethod("put", classOf[String], classOf[String])) } { m.invoke(null, cacheKey(name, version), value) } } private def cacheKey(name: String, version: Int): String = s"$name:$version" }
これを実装したことで、 Secret Manager の機密情報もキャッシュできるようになりました。
package secretmanager import javax.inject.Inject import scala.util.Using import com.google.cloud.secretmanager.v1.{SecretManagerServiceClient, SecretVersionName} class SecretManagerApiImpl @Inject() (secretConfig: SecretManagerConfig, classLoader: ClassLoader) extends SecretManagerApi { private val parentClassLoaderCache = new SecretManagerCache(classLoader) override def accessSecretVersion(name: String, version: Int): String = { // キャッシュが存在しなければ parentClassLoaderCache.getCache(name, version).getOrElse { Using.resource(SecretManagerServiceClient.create) { client => val secretVersionName = SecretVersionName.of(secretConfig.project, name, version.toString) val response = client.accessSecretVersion(secretVersionName) // 本番環境では取得したシークレットを表示、出力等しないこと val payload = response.getPayload.getData.toStringUtf8 // キャッシュに書き込む parentClassLoaderCache.putCache(name, version, payload) payload } } } }
まとめ
Secret Manager を利用することで平文保存されていた機密情報を権限管理できるようになりました。また、クラスローダーを利用したキャッシュによって開発モードでもアクセス回数の削減ができました。 「機密情報、平文だったりしない?」の一言にギクっとした方は費用と相談しつつ検討してみてはいかがでしょうか?