AWS SSMのパラメータストアから環境変数へパス指定で秘密情報を一括展開する

この記事は第二のドワンゴ Advent Calendar 2020 12月7日の記事です。

要するに

  • dockerコンテナ化されたサーバアプリをAWS ECS上で起動する際、パラメータストアから環境変数へ秘密情報を一括展開したい
    • ECSにネイティブでパラメータストアから環境変数へ展開してくれる機能は存在するが、パラメータストア上のパラメータと環境変数との対応関係をいちいち書かなければならず絶妙に手間がかかり使いづらい
    • パラメータストアから一括展開してくれるような単機能のgolangのワンバイナリを作るとお手軽なのでgithub.com上にたくさん存在するが、たくさん存在しすぎてどれもデファクトスタンダードになれず、メンテされているか怪しいものばかり
  • そこで、AWS CLIからの出力をjqを使ってbashのexport形式に整形し、それをevalしてしまえばメンテ要らず
    • なおコンテナイメージのサイズ肥大には目をつぶるものとする

詳しく

AWSのSystem Managerにはパラメータストアという機能があります。
これはサーバサイドアプリケーションの動作に必要な各種の設定情報を、必要であれば暗号化とともに格納しておける機能です。

一方で、サーバサイドアプリケーションをサービスインフラ上で動作させる方法のガイドラインの有名なものにThe Twelve-Factor Appがあり、その中で環境ごとに異なる設定情報は環境変数から注入せよ、という方針が示されています。
これは特にサーバサイドアプリケーションをdockerコンテナに包んで動作させる際に重要です。

この2つを合わせて考えると、パラメータストアに格納されている値を環境変数へ展開した後でサーバサイドアプリケーションを起動したくなってきますよね。

ECSによるパラメータストアから環境変数への展開

実はAWSのコンテナオーケストレーションサービスであるところのECSには、その機能があります。

じゃあそれでいいんじゃないか、と思いきや、これが絶妙に手間がかかる。

{
  "containerDefinitions": [{
    "secrets": [{
      "name": "environment_variable_name",
      "valueFrom": "arn:aws:ssm:region:aws_account_id:parameter/parameter_name"
    }]
  }]
}

Systems Manager Parameter Store を使用して機密データを指定する - Amazon ECS

というように、ECSのコンテナ定義の中に展開後の環境変数名と展開元となるパラメータストアのパラメータのarnを両方書かなければならないんですよね。

これがどのように困るかというと、

  1. サーバサイドアプリのコード上で新たな設定値を環境変数から読み込むようにする
  2. パラメータストア上にパラメータを作成する
  3. コンテナ定義を更新し忘れる
  4. コンテナ定義を更新し忘れているので当然環境変数に展開されなくて動作せず、首をかしげる

となるわけです。

新たな設定値を作るのなんてそれほど多いわけではないけれどたまにある、くらいの頻度なので余計にうっかりしてしまいやすく。

golangのワンバイナリで展開

次に思いつくのが、パラメータストアから環境変数へ展開するだけの単機能の実行ファイルをgoあたりのワンバイナリで書き、使う方法でしょう。

これは本当にお手軽な方法で、お手軽がゆえに様々な人が思いついてそれぞれが作っています。 知ってるだけで6個もある。

github.com github.com github.com github.com github.com github.com

そしてお手軽すぎてそれぞれが作ってるがゆえにどれもデファクトにはなっておらず、残念ながらメンテされているのか怪しいものばかり。

AWS CLIとjqを使う

そこでこの記事の本題。

AWS CLIとjqを使って展開してしまいましょう。AWS CLIAWS謹製なので当然メンテされていますし、jqも汎用ツールです。

具体的にはこのようにします。

gist.github.com

これが何をやっているかというと、まずaws ssm get-parameters-by-pathサブコマンドの出力は下記のような形式になっています。

$ cat response
{
    "Parameters":[{
        "Name": "/service_name/dev/HOGE",
        "Value": "hoge\\nvalue"
    },{
        "Name": "/service_name/dev/FUGA",
        "Value": "fuga value"
    }]
}

これのParametersキーの内容を各オブジェクトに分解し…

$ cat response | jq -r -c '.Parameters[]'
{"Name":"/service_name/dev/HOGE","Value":"hoge\\nvalue"}
{"Name":"/service_name/dev/FUGA","Value":"fuga value"}

jqのstring interpolation記法を使ってbashのexport形式に加工しています。

$ cat response | jq -r -c '.Parameters[] | "export \(.Name|split("/")[-1])=\"\(.Value)\""'
export HOGE="hoge\nvalue"
export FUGA="fuga value"

もう少し詳しく説明しますが、まず\(~) がjqのstring interpolation記法です。

.Name|split("/")[-1]の部分はNameキーの内容を"/"でsplitし、その配列中の末尾要素を取り出しています。これが展開後の環境変数名となります。

Valueキーの内容はそのままでよいので単に.Valueです。

.Valueの前後の\"は単にエスケープしたダブルクオーテーションであって、これはjqのstring interpolationがダブルクオーテーションでなければ動作しなかったためにエスケープが必要でした。

というわけで、このbashスクリプトをこれをサーバサイドアプリケーションの起動直前にevalすれば、パラメータストアから環境変数へ秘密情報を展開した状態で起動できます。

そうすると、コンテナ定義に書くのは下記だけで良くなります。

{
  "containerDefinitions": [
    {
      "environment": [
        {
          "name": "AWS_PARAMETER_STORE_REGION",
          "value": "ap-northeast-1"
        },
        {
          "name": "AWS_PARAMETER_STORE_PREFIX",
          "value": "/service_name/dev/"
        }
      ],
    }
}

パラメータの追加時も単にパラメータストアに作成するだけで、コンテナ定義を編集する必要はありません。

ついでに、/service_name/以下を編集する権限をそのサービスの開発チームに付与すれば、設定値を変更する権限を安全に移譲できます。

実際、僕の仕事の担当サービスではマイクロサービスとまでは行かないものの複数のサブシステムが存在するため、それぞれの開発者にサブシステムの名前空間だけを編集する権限を移譲しています。

これを動作させるためにはECSコンテナにタスクロールとして与えるIAMロールにおいて下記アクションの許可が必要となります。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "ssm:GetParametersByPath",
        "kms:Decrypt"
      ],
      "Resource": [
        "arn:aws:ssm:<region>:<aws_account_id>:parameter/<parameter_name>",
        "arn:aws:kms:<region>:<aws_account_id>:key/<key_id>"
      ]
    },
    {
      "Effect": "Allow",
      "Action": [
        "ssm:DescribeParameters"
      ],
      "Resource": ["*"]
    }
  ]
}

なお、この方法には明確な弱点があって、jqとAWS CLI、そしてAWS CLIの依存であるpython3をインストールしなければならない関係で、コンテナイメージのサイズが肥大します。具体的には60MBほど。

つまり、

  • ECS公式の環境変数展開の絶妙な使いづらさ
  • 自分でgoのワンバイナリを保守する手間
  • メンテされているのか不明な自分以外の人のワンバイナリを使うリスク
  • コンテナイメージのサイズ肥大

を天秤にかけた結果、最後のコンテナイメージ肥大を許容してAWS CLIとjqを使う方法に落ち着いたわけです。

ついでにパラメータストアへのパラメータ作成もterraformで簡単にする

これで設定値の追加時にコンテナ定義は編集しなくてよくなったわけですが、パラメータストアへのパラメータ作成もterraformで簡単に.してしまいましょう。

具体的には下記のようにします。

resource "aws_ssm_parameter" "parameter" {
  for_each = var.app_environment_variables
  type     = "String"
  name     = "/${var.service_name}/${var.env}/${each.key}"
  value    = each.value
}

variable "env" {}
variable "service_name" {}
variable "app_environment_variables" {}

var.service_nameにはサービス名を、var.envには環境名を渡しましょう。今回の場合はservice_namedevですね。

そして、var.app_environment_variablesに下記のようなmapを渡してあげると、既に示した例で出てくるような、パラメータがパラメータストア上に作成されます。便利。

module "parametor_store" {
  env = "dev"
  service_name = "service_name"

  app_environment_variables = {
    HOGE = "hoge\nvalue"
    FUGA = "fuga value"
  }
}

一つ注意しなければならないのが、aws_ssm_parameter.parameterのtypeをStringにしているため、パラメータストア上に平文で保存される点です。

これをSecureStringにするとkmsの暗号鍵で暗号化して保存できますが、terraformで書くと結局平文の値がtfファイルやtfstateに書かれることになってしまうため、SecureStringにする意味が薄くなってしまいます。

そのため仕事では、SecureString型にする必要があるようなデータベースへの接続パスワードなどについてはあえてterraformで管理せず、Webコンソール上から直接手で作成しています。

何か良い方法があったら知りたい。

おわりに

というわけで、AWS SSMのパラメータストアから環境変数へパス指定で秘密情報をAWS CLIとjqを使って一括展開する方法でした。

本当はECSがパス指定からの一括展開をネイティブでやってくれるようになればこのようなことはやらなくて良いのですが!

なにとぞ、どうにかなりませんか、AWSさん!!