ECSの機能でSSMのParameter Storeに格納した機密情報をTaskDefinitionに埋め込む

ECSの機能でSSMのParameter Storeに格納した機密情報をTaskDefinitionに埋め込む

こんにちは!今回は去年AWSで公開された、SSM (System Manager) のParameter Storeに格納した機密な情報(DBのパスワード等)を、直接ECSのTaskDefinitionに指定できる機能が発表されましたが、今回それを試してみたので紹介します。

今回題材に使うアプリケーションはRuby on Railsと致します。(と言ってもRails特有のコードは登場しませんが)

前提

前提として、今まで開発してきたPJをECSで動かす際は、以下のような運用をしていました。

  • Environmentは development と testproduction の3つ
  • development はローカル用、 test はCI用、 production は本番とステージング用
  • 本番とステージングで使うコンテナイメージは全く同じもので、TaskDefinitionの定義によって(DBの接続先などを)微調整

最後について、RailsにはもともとEnvironment毎に設定を出し分ける機能(config/database.yml等)が備わっていますが、Dockerの特性上、極力環境の差異はアプリケーション自体に定義するのではなく、動作する環境上で実行時に確定したいと言う思いがありました。(詳細は The Twelve-Factor AppのConfigを参照)
実際には、The Twelve-Factor Appにも書かれている通りコンテナの環境変数を使って環境上の差異を吸収していました。

機密な情報の取扱

例えばRailsのSecretKeyBaseや、DBのパスワード、FirebaseやFacebook AppのAPIシークレット等がこれにあたります。これについても先述の通り環境変数を使って実行時に渡すのですが、TaskDefinitionの environment ディレクティブに直接定義するのは少々気が引けます。
実際に、当プロジェクトではTaskDefinitionはアプリ側のリポジトリに管理されていたので、開発者は全員が閲覧可能でしたが、機密情報はすべての開発者に見える必要がない為、これらの情報はデプロイ時に個別に展開する必要がありました。

これまでの対応方法

これまでも、AWSの公式ブログでこの課題について触れられていて、SSMのParameter Storeを使うものや、S3を使うもの等が紹介されていました。

前者の方がよりおすすめされていましたが、当時は以下の理由でS3の方を採用しました。

  • アプリケーションで唯一使用していたAWS SDKがRubyのS3のみであった
    • SSMにアクセスするためのSDKを別途用意する必要がある
  • dotenv Gemを使えば環境変数の一覧をファイルで管理できるのでS3の方が相性が良い

以下、実際の運用フローです。

1. 環境変数ファイルを管理するS3バケットを準備

ポイントは以下となります。

  • 特定の強い権限を持つ管理者ユーザーと、アプリケーションを実行するTaskDefinitionのみが参照可能
  • サーバーサイドエンクリプションを実行するようリクエストを強制(バケットポリシーにて
  • コンテナインスタンスはVPCの中にいる為、S3用にVPCエンドポイントを用意する

2. タスクのエントリポイント用のBashスクリプトを作成

デフォルトのエントリポイントは単純に rails s なのですが、機密情報を扱うproduction環境等では以下のような起動スクリプトを用意していました:

#!/usr/bin/env bash

# The docker entrypoint for production environment.

set -eu

# Runs ruby one-liner command to retrieve secure env vars,
# NOT via Rails environment because some mandatory env vars for Rails are missing at this point.
ruby -e 'require "aws-sdk-s3"; Aws::S3::Resource.new.bucket("bucket-to-store-secure-env").object(ENV["SECURE_ENV"]).download_file(".env")'

# If any desired command is passed to arguments runs it otherwise, runs rails server.
if [[ $# -gt 0 ]]; then
  exec "$@"
else
  exec rails server -b 0.0.0.0 -p 3000

ざっくり解説すると、インストール済みのS3 SDK for Rubyを使って機密情報が書かれた環境変数ファイル(S3のオブジェクトキーは、別途 SECURE_ENV 環境変数て指定)を .env と言うファイル名でダウンロードしてから、指定のコマンド(未指定の場合は rails s)を実行、と言った流れになります。

ECSの新機能を利用した形での対応

実際に、丁寧なドキュメントがあるのでそれに従って設定を行います。

1. SSMにSecureStringなParameterを作る

実際にはDBのパスワード等を想定しています。尚、Parameter名を /test/secure1 の様に階層構造にしておくと、あとでIAMの設定が楽になります。
尚、今回はアカウントのデフォルトKMSキーを使いました。

SSM Parameterの設定画面

2. ECS TaskがParameterを取得し、復号する為のTask Roleを設定する

TaskDefinitionを実行する為に使うExecution Roleに、次のようなポリシーを設定します(他にもECRやCloudWatchへの権限もあると思いますが、その場合は追加で):

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "ssm:GetParameters",
                "kms:Decrypt"
            ],
            "Resource": [
                "arn:aws:ssm:リージョン名:AWSアカウントID:parameter/test/*",
                "arn:aws:kms:リージョン名:AWSアカウントID:alias/aws/ssm"
            ]
        }
    ]
}

今回は /test/ 以下の階層にあるParameter全てを取得可能に、また復号のために(先程設定した)アカウントデフォルトのキーの使用権を与えます。

3. TaskDefinitionのsecretsとexecutionRoleArnを設定する

TaskDefinitionのJSON定義の executionRoleArn に先程作ったExecution RoleのARNを、containerDefinitions[*].secrets (※ .environment ではないので注意) にも設定を、それぞれ以下の様に行います:

{
  ...,
  "executionRoleArn": "さっき指定したTaskRoleのARN",
  "containerDefinitions": [
    {
      ...,
      "secrets": [
        {
          "name": "SECURE1",
          "valueFrom": "/test/secure1"
        }
      ],
      "environment": [
        {
          "name": "NON_SECURE",
          "value": "hogehoge"
        }
      ]
      ...,
    }
  ],
  ...
}

尚、SSM ParameterとECSのリージョンが異なる場合は、 valueFrom にはParameterのFullArn (arn:aws:ssm:リージョン名:AWSアカウントID:parameter/test/secure1 の様な) を指定する必要があるので注意が必要です。

また、検証の為普通の環境変数 NON_SECURE も設定を行いました。

4. 実際に実行してみる

先程更新したTaskDefinitionを使って実際にタスクを作ります。因みに先程検証に使ったTaskDefinitionのプライマリコンテナは、単に busybox イメージで tail -f /dev/null (バックグラウンドで何もしないプロセスを継続するだけ)するだけの内容となっています。

※実際のRunTaskの手順は省略します。

タスクが起動しました。コンソール上ではこの様に表示されています:

ECS Taskの詳細画面

secretsの内容は表示されていませんね(当然ですが)。CLIでも同様でした:

$ aws ecs describe-tasks --tasks タスクID
{
    ...,
    "tasks": [
        {
            ...,
            "containers": [
                {
                    "containerArn": "arn:aws:ecs:us-west-2:xxx:container/9698822b-4c8f-4a07-9969-d0a188dc646b",
                    "taskArn": "arn:aws:ecs:us-west-2:xxx:task/タスクID",
                    "name": "test",
                    "networkBindings": [],
                    "lastStatus": "RUNNING",
                    "healthStatus": "UNKNOWN",
                    "networkInterfaces": []
                }
            ]
        }
    ]
}

それでは実際にコンテナインスタンスにSSHして、コンテナの中身を見てみましょう。以下は実行中のコンテナで env コマンドを実行し、環境変数の一覧を取得しています:

[ec2-user@ip-n-n-n-n ~]$ docker exec コンテナの名前 env
...
NON_SECURE=hogehoge
SECURE1=foobar1
...

(通常の)NON_SECURE と (機密な)SECURE1 環境変数が無事渡っている事が分かります。

どのように実現されている?

ECS Agentのコードをざっくり見た感じでは、Task Execution Roleを使ってSSM Parameterの取得と復号を行い、普通の環境変数とマージしてコンテナを実行している様です。

実際に、 docker inspect してみるとその様な動きをしているのが分かります:

[ec2-user@ip-n-n-n-n ~]$ docker inspect コンテナの名前

[
    {
        ...,
        "Config": {
            ...,
            "Env": [
                ...,
                "NON_SECURE=hogehoge",
                "SECURE1=foobar1",
                ...
            ],
            ...
        }
    }    
]

先に紹介した S3での対応方法 が言及する、普通に環境変数をセットした場合にインスタンス上からこれらの機密な情報が見えてしまうと言う問題が、この方法では解決できていない様に見えます(従来どおりコンテナの中で環境変数を展開すればinspectでは見えないので)。
しかし、どの道Docker APIにアクセスできる権限があるのであれば、先程のように env コマンドのように任意のコマンドを exec されたら結果は同じなので、そもそもインスタンスへのログイン権限とTaskDefinitionの閲覧権限は分けるのが安全です。

まとめ

以上の検証により、以前は少し面倒だったSSM ParameterのECS Taskの環境変数への埋め込みが格段に楽になりました!これは使わない手はないです。

we are hiring

優秀な技術者と一緒に、好きな場所で働きませんか

株式会社もばらぶでは、優秀で意欲に溢れる方を常に求めています。働く場所は自由、働く時間も柔軟に選択可能です。

現在、以下の職種を募集中です。ご興味のある方は、リンク先をご参照下さい。