【ShellScript】Amazon EC2上で環境変数と秘密情報をいい感じに取得してくれるくん【SSM Parameter Store / Secrets Manager】

# 概要

久しぶりにAmazon EC2上で環境変数と秘密情報を取得する必要があったので、シェルスクリプトを書いた。あわせてCloudFormationでそれらをどう管理するか改めて整理した。

# 2022/09/14追記
* 実用しながらエラーになった箇所を修正
* 利用できない文字種類にアンド（&）を追加

# 用語の整理
* 環境変数： パブリックに、もしくは組織内であれば公開しても良さそうな環境依存の情報
    * 接続先ホスト名、機能のリリースフラグ、etc.
* 秘密情報： パブリックにはもちろん、組織内であっても公開がはばかられる情報
    * ユーザー名、パスワード、etc.

「秘密情報も、サーバ側で設定するときは環境変数（という手法を使って設定するの）では？」と思わなくもないが、便宜上こう置く。

# 全体像
* EC2インスタンスにはPHPで書かれたアプリケーションをデプロイする
* PHPのアプリケーションは、ローカルに置かれたenvファイルから色々読み出す形になっており、**「いい感じに取得してくれるくん」で環境変数と秘密情報を解決する**
    * **環境変数はSystems Manager Parameter Store（以下、SSM Parameter Store）、秘密情報はSecrets Managerで管理する**
* アプリケーションのデプロイや「いい感じに取得してくれるくん」の実行はCodeDeployが担当する
    * CodeDeployの話は本筋からズレるので本記事では割愛

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/62601/fee8d126-164e-3084-e26b-94488c320d58.png)


# 動作確認環境
Ubuntu 22.04
bash 5.1.16

# 前提条件

## bashのバージョン
bash 4以降。

4以降で実装された記法を使っているため。
cf. [Bashで変数を大文字小文字変換(uppercase/lowercase)する - Qiita](https://qiita.com/kawaz/items/211266021515b3f033a3)

## IAM権限
EC2インスタンスが以下の権限を持っていること。

* ssm:GetParameters
* secretsmanager:GetSecretValue

:::note info
ssm:GetParameters
後述のシンプル版を使う場合は、ssm:GetParametersの代わりにssm:GetParameterが必要。
:::

## コマンド類
EC2インスタンスに以下のコマンドがインストールされていること。

* awscli v2 （v1でも動くかもしれないが未検証）
* jq

:::note info
コマンドのインストール方法
CodeDeployを使う場合はその実行の範囲でインストール可能。
後述のスクリプト内でインストールも可能。いずれも、依存解決はあまり難しくない。
:::

## envファイルの構造
* KEYが大文字のスネークケースになっていること
* 環境変数のVALUEに `<ENV>`、秘密情報のVALUEに `<SECRET>` と記載していること
```conf
# KEY=VALUE

DEBUG=false

DB_USER=<SECRET>
DB_PASSWORD=<SECRET>
DB_HOST=<ENV>
...
```

## SSM Parameter Store / Secrets Managerのパラメータ名
envファイルのKEYを小文字にして、先頭とハイフンをスラッシュで置き換えた名前になっていること。

```
DB_USER => /db/user 
DB_PASSWORD => /db/password
```

## 利用できる文字種
* 環境変数： セミコロン、アンド以外
* 秘密情報： アットマーク、アンド以外

:::note info
「取得してくれるくん」で使っているsedの都合。変更すれば好きな文字を使える
:::

# 環境変数と秘密情報をいい感じに取得してくれるくん
実行すると指定したenvファイルの`<ENV>`と`<SECRET>`の記述を置換する。
環境変数・秘密情報に対応するパラメータが未登録の場合は、exit codeが非ゼロになる。

実行前後のイメージ：
| before | after |
| --- | --- | 
| DEBUG=false | DEBUG=false |
| DB_USER=\<SECRET> | DB_USER=app | 
| DB_PASSWORD=\<SECRET> | DB_PASSWORD=hoge_fuga |
| DB_HOST=\<ENV> | DB_HOST=db.example.com |

## 効率版
スクリプト末尾の`<ENV FILE>`だけ書き換える必要あり。

```bash:環境変数と秘密情報をいい感じに取得してくれるくん（効率版）
#!/bin/bash

set -e

function env_from_aws() {

    ENV_FILE=$1
    declare -a env_keys=()
    for v in $(grep -E "^[A-Z0-9_]+=[\"\']?<ENV>[\"\']?" $ENV_FILE | grep -o -E "^[A-Z0-9_]+")
    do
        tmp=${v,,}
        env_keys+=(/${tmp//_/\/})
    done

    if [ ${#env_keys[@]} -eq 0 ];then
       return 0;
    fi

    # ex. env_keys=(/db/password /db/user)

    declare -a env_kvs=()
    if [ ${#env_keys[@]} <= 10 ]; then
        env_kvs+=($(aws ssm get-parameters --region ap-northeast-1 --names ${env_keys[@]} --query "Parameters[*].{Key: Name,Value:Value}" --output text))
    else
        for ((i=0; i<${#env_keys[@]}; i+=10)); do
            declare -a tmp=()
            for ((j=0; j<10; j+=1)); do
                tmp+=(${env_keys[i+j]})
            done
            env_kvs+=($(aws ssm get-parameters --region ap-northeast-1 --names ${tmp[@]} --query "Parameters[*].{Key: Name,Value:Value}" --output text))
        done
    fi

    declare -A envs=()
    for ((i=0; i<${#env_kvs[@]}; i+=2)); do
        envs[${env_kvs[i]}]=${env_kvs[i+1]}
    done

    # ex. envs["/db/password"]=mypassword, envs["/db/user"]=app

    for v in ${env_keys[@]}
    do
        if [ -z ${envs["$v"]} ]; then
            echo "$v is not registered in parameters store"
            exit 1
        fi

        # ex. v=/db/password

        tmp=${v:1}
        tmp=${tmp//\//_}
        uppercase=${tmp^^}

        # ex. uppercase=DB_PASSWORD

        echo "$uppercase=${envs[$v]}"
        sed -r -e "s@$uppercase=([\"\']?)<ENV>([\"\']?)@$uppercase=\1${envs[$v]}\2@" -i  $ENV_FILE
    done
}

function secrets_from_aws() {

    ENV_FILE=$1
    for v in $(grep -E "^[A-Z0-9_]+=[\"\']?<SECRET>[\"\']?" $ENV_FILE | grep -o -E "^[A-Z0-9_]+"); do
        lowercase=${v,,}
        secret=$(aws secretsmanager get-secret-value --region ap-northeast-1 --secret-id /${lowercase//_/\/} --query "SecretString" --output text | jq ".[]" -r)

        echo "$v=*****"
        sed -r -e "s@$v=([\"\']?)<SECRET>([\"\']?)@$v=\1$secret\2@" -i  $ENV_FILE
    done

}


env_from_aws <ENV FILE>
secrets_from_aws <ENV FILE>
```

:::note info
対象のenvファイルをスクリプト中に記載する理由
これは、スクリプトを（本記事では詳細を省いた）CodeDeployから実行する都合上。CodeDeployは、その実行中に任意のスクリプトを呼び出せるが、**スクリプトに引数を与えることはできない**。
* OK: "hoge.sh"
* NG: "hoge.sh parameter"

実行時に引数を与えられるツールを使う場合や手動で実行する場合なら、記載不要。
:::

## 別パターン：シンプル版
```sh:環境変数と秘密情報をいい感じに取得してくれるくん（シンプル版）
#!/bin/bash

set -e

function env_from_aws() {
    for param in $(grep -E "^[A-Z0-9_]+=<ENV>" $ENV_FILE | grep -o -E "^[A-Z0-9_]+"); do
        lowercase=${param,,}
        env=$(aws ssm get-parameters --region ap-northeast-1 --names "/${lowercase//_/\/}" --query "Parameters[0].Value" --output text)

        echo "${param}=${env}"
        sed -e "s/${param}=<ENV>/${param}=${env}/" -i  $ENV_FILE
    done
}

function secrets_from_aws() {
    # ここは同じ
}

env_from_aws <ENV FILE>
secrets_from_aws <ENV FILE>
```

## 両者の違い
両者の違いは、SSM Parameter Storeを呼び出す回数。効率版は各インスタンスで1回だけ呼び出すが、シンプル版は（インスタンス x 変数）の回数呼び出す。

SSM Parameter Storeは、WebAPIの呼び出し上限がデフォルトで40req/sec、最大でも3000req/sec。変数が多い場合やインスタンスが多い場合は、スロットリングの危険性が高まる[^quota]。
cf. [AWS Systems Manager endpoints and quotas - AWS General Reference](https://docs.aws.amazon.com/general/latest/gr/ssm.html)

そのため、少々記述が長くなるが効率版のほうが安定する。一方で、変数の数が少ない && 同時にデプロイするインスタンスが少ない場合は、シンプル版の方が見通しがいい。
お好きな方を。

[^quota]: Secrets Managerは5000req/secまで呼び出せる。cf. [AWS Secrets Manager quotas - AWS Secrets Manager](https://docs.aws.amazon.com/secretsmanager/latest/userguide/reference_limits.html)
:::

# 環境変数をSSM Parameter Store、秘密情報をSecrets Managerに保存している理由
CloudFormationで、可能な限り全てを管理するため。

CloudFormationの`AWS::SSM::Parameter`では、SecureStringを登録できない。手動で登録する必要がある。
cf. [AWS::SSM::Parameter - AWS CloudFormation](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-ssm-parameter.html)

一方、`AWS::SecretsManager::Secret`を使えば秘密情報をCloudFormationから登録できる上、**秘密情報の生成も任せることができる**。
cf. [AWS::SecretsManager::Secret - AWS CloudFormation](https://docs.aws.amazon.com/ja_jp/AWSCloudFormation/latest/UserGuide/aws-resource-secretsmanager-secret.html)

```yaml: CloudFormationでSSM Parameter StoreとSecrets Managerに登録するサンプル
AWSTemplateFormatVersion: '2010-09-09'
Parameters:
  DBAppUsername:
    Type: 'String'
    NoEcho: true
Resources:
  # Omit...

  # 秘密情報の作成を任せるとき（作成ルールを指定可能）
  # 以下の場合、{"password": "<random string>"} という形で保存される
  # 「取得してくれるくん」はkeyを無視するので、"password"の部分は何でも良い
  SecretsManagerSecretDBAppPassword:
    Type: 'AWS::SecretsManager::Secret'
    Properties:
      Description: 'managed by cloudformation'
      GenerateSecretString:
        SecretStringTemplate: '{}'
        GenerateStringKey: 'password'
        PasswordLength: 16
        ExcludeCharacters: "\"@/\\"
      Name: '/db/password'

  # 秘密情報を明示的に指定するとき
  SecretsManagerSecretDBAppUsername:
    Type: 'AWS::SecretsManager::Secret'
    Properties:
      Description: 'managed by cloudformation'
      SecretString: !Sub '{"username": "${DBAppUsername}"}'
      Name: '/db/username'

  # 環境変数
  SSMParameterDBHost:
    Type: 'AWS::SSM::Parameter'
    Properties:
      DataType: 'text'
      Name: '/db/host'
      Type: 'String'
      Value: !GetAtt RDSDBCluster.Endpoint.Address
```

## 秘密情報を明示的に指定するとき、しないとき
自分が管理する環境では、以下のときだけNoEcho属性でパラメータから指定している。

* 利用しているSaaSのアクセストークンなど、自分たちの都合で生成できないとき
* DBユーザなど、乱数を使うことで著しく使い勝手が悪くなったり理解を妨げるとき
    * 人間がそれを使ってログインすることがあるかはさておき、意味を持った名前にするべき

パラメータが増えるとスタックの作成・更新が煩雑になるため、意識的に利用を減らしている。

## 全部Secrets Managerではだめ？
仕組み上は特に問題ない。
ただし、「Secrets Managerに特別シークレットじゃない情報を保存するのか？」という違和感や、課金体系の違い[^billing]もあるため使い分けている。

[^billing]: SSM Parameter Storeは保存するだけなら無料。Secrets Managerは1シークレットあたり0.40USD/月。

2つのサービスの比較はこちらの記事が詳しい。
cf. [AWSのParameter StoreとSecrets Manager、結局どちらを使えばいいのか？比較 - Qiita](https://qiita.com/tomoya_oka/items/a3dd44879eea0d1e3ef5#2019%E5%B9%B45%E6%9C%888%E6%97%A5%E6%9B%B4%E6%96%B0)

# まとめ
* EC2上でenvファイルを相手に、環境変数と秘密情報の解決をした
* 環境変数はSSM Parameter Store、秘密情報はSecrets Managerから取得した
* Secrets Managerを使うと、CloudFormation経由で秘密情報の生成・管理ができる