TerraformでECS Taskに書くsecretsをうまく運用する
これは「「はじめに」の Advent Calendar 2021」17日目の記事です。
ECSのタスクは環境変数をパラメータストアやシークレットマネージャーの値を起動時に設定出来る機能があります。
具体的な手順は、いつものクラメソさんの記事を参考にしてください。
運用時の問題
シークレット系の情報の管理とアプリケーションで必要な環境変数をあわせる時に、いくつか問題が発生します。
次のようなECSサービスがあったとします。
(※色々省略しているのでこのまま動きません)
resource "aws_ecs_cluster" "example" {
name = example-api
}
resource "aws_ecs_task_definition" "example" {
family = "example"
execution_role_arn = aws_iam_role.example_execution.arn
task_role_arn = aws_iam_role.example.arn
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 1024
memory = 2048
container_definitions = jsonencode([
name = "api"
image = "example/api:latest"
environment = [
{ "name": "ENV", "value": "development" }
]
secrets = [
{
"name": "TOKEN_FROM_SECRET_MANAGER",
"valueFrom":
"arn:aws:secretsmanager:ap-northeast-1:123456789012:secret:token-json:json_key::"
},
{
"name": "TOKEN_FROM_PARAMERTER_STORE",
"valueFrom":
"arn:aws:ssm:ap-northeast-1:123456789012:parameter/foo_token"
}
])
}
data "aws_ecs_task_definition" "example" {
task_definition = aws_ecs_task_definition.example.family
}
resource "aws_ecs_service" "example" {
cluster = aws_ecs_cluster.example.id
task_definition = "${aws_ecs_task_definition.example.family}:${max(aws_ecs_task_definition.example.revision, data.aws_ecs_task_definition.example.revision)}"
desired_count = 1
}
Task定義のsecretsは、ParameterStoreやSecretManagerから値を読み込み name
でしていた環境変数に起動時に設定してくれます。
ただ、このままだと運用上の めんどくさい 問題が発生します
環境変数を追加・変更したいときの問題
以下の順序で作業する必要があります。
- ParameterStoreやSecretManagerを追加
- 追加したキーと、対応する環境変数名をTask定義に追加
- Task定義を更新
terraform apply
- 新しいDockerイメージをビルドしてデプロイ
この場合、以下のような問題点が発生します。
- ECSの起動に失敗する
- 1で作ったキーを
valueFrom
に正しく記述する必要があり、1文字でも間違えるとECSの起動に失敗します。
- 1で作ったキーを
- 変数名を書き間違える
- 2の変数名も正しく記述しないと、ECSタスクは起動するものの、アプリケーションが予期しない挙動になります。最悪、変数がないことで正しく動作しないかもしれません。
- Task定義の更新を忘れる
- 3の
apply
を忘れて4のデプロイを行っても環境変数名を間違えたときと同様です。
- 3の
- ParameterStoreやSecretManagerを随時修正する必要がある
- 環境変数を追加するたびに1のParameterStoreやSecretManager を追加する必要があります。
対応-ECS起動失敗を防ぐ
ECS起動失敗を防ぐために、ParameterStoreやSecretManagerもTerraformで管理することにします。
resource "aws_ssm_parameter" "foo_token" {
name = "foo_token"
type = "SecureString"
value = var.foo_token
}
resource "aws_secretsmanager_secret" "token_json" {
name = "token-json"
}
resource "aws_secretsmanager_secret_version" "token_json" {
secret_id = aws_secretsmanager_secret.token_json.id
secret_string = jsonencode({
json_key = var.json_key_token
})
}
terraformでのcredential管理は、 SOPSやterraform cloudなど、別の方法を利用する必要がありますが、この記事ではそういった方法は取り扱いません。参考リンクを張っておきます。
ecsは次のようになります
resource "aws_ecs_task_definition" "example" {
family = "example"
execution_role_arn = aws_iam_role.example_execution.arn
task_role_arn = aws_iam_role.example.arn
requires_compatibilities = ["FARGATE"]
network_mode = "awsvpc"
cpu = 1024
memory = 2048
container_definitions = jsonencode([
name = "api"
image = "example/api:latest"
environment = [
{ "name": "ENV", "value": "development" }
]
secrets = [
{
"name": "TOKEN_FROM_SECRET_MANAGER",
"valueFrom":
"${aws_secretsmanager_secret.token_json.arn}:json_key::"
},
{
"name": "TOKEN_FROM_PARAMERTER_STORE",
"valueFrom":
aws_ssm_parameter.foo_token.arn
}
])
}
対応2-変数名の書き間違いを防ぐ
設定したシークレットの名前を環境変数名と合わせます。
また、複数のアプリケーションで同じ変数名を利用した場合もあるので衝突を防ぐ必要があります。
ParameterStoreはPath形式で記述することで容易に解決ができます。
SecretMangerは1つのSecrets内のJSONなので、別シークレットにしておけばよいです。
JSON内のキーは locals
で変数化しておきます。
resource "aws_ssm_parameter" "foo_token" {
name = "/example/TOKEN_FROM_PARAMERTER_STORE"
type = "SecureString"
value = var.foo_token
}
resource "aws_secretsmanager_secret" "token_json" {
name = "token-json"
}
locals {
sercet_key_name = "TOKEN_FROM_SECRET_MANAGER"
}
resource "aws_secretsmanager_secret_version" "token_json" {
secret_id = aws_secretsmanager_secret.token_json.id
secret_string = jsonencode({
"${local.sercet_key_name}" = var.json_key_token
})
}
(略)
container_definitions = jsonencode([
name = "api"
image = "example/api:latest"
environment = [
{ "name": "ENV", "value": "development" }
]
secrets = [
{
"name": local.sercet_key_name
"valueFrom":
"${aws_secretsmanager_secret.token_json.arn}:${local.sercet_key_name}::"
},
{
"name": element(split("/", aws_ssm_parameter.foo_token.name), 1),
"valueFrom":
aws_ssm_parameter.foo_token.arn
}
])
(略)
対応3-Task定義とシークレット情報を同期する(多分出来る)
1つキーを追加するたびに、Task定義の修正をしていくと、コードも長くなり、リソースの参照ミスが発生するようになります。
Task定義の設定を書き直さずに、ParameterStoreやSecretManagerのリソース追加に追従させてみます。
/*
example_parameters = {
TOKEN_FROM_PARAMERTER_STORE: "secret-value",
TOKEN_FROM_PARAMERTER_STORE_SECOD: "secret-value-second",
...(略)
}
example_secrets = {
TOKEN_FROM_SECRET_MANAGER: "secret-value",
TOKEN_FROM_SECRET_MANAGER_SECOD: "secret-value-second",
...(略)
}
というmap変数を想定
*/
resource "aws_ssm_parameter" "foo_token" {
for_each = var.example_parameters
name = "/example/${each.key}"
type = "SecureString"
value = each.value
}
data "aws_ssm_parameters_by_path" "example_parametes" {
path = "/example"
depends_on = [ aws_ssm_parameter.foo_token ]
}
locals {
parameter_store_values = [
for key, arn in zipmap(
data.aws_ssm_parameters_by_path.example_parametes.names,
data.aws_ssm_parameters_by_path.example_parametes.arns) : {
name: split("/", key, 1),
valueFrom: arn
}
]
}
resource "aws_secretsmanager_secret" "token_json" {
name = "token-json"
}
resource "aws_secretsmanager_secret_version" "token_json" {
secret_id = aws_secretsmanager_secret.token_json.id
secret_string = jsonencode(local.example_secrets)
}
data "aws_secretsmanager_secret" "token_json" {
arn = aws_secretsmanager_secret.token_json.arn
depends_on = [
aws_secretsmanager_secret.token_json,
]
}
locals {
secrets_manger_values = [
for key in keys(jsondecode(nonsensitive(data.aws_secretsmanager_secret_version.api_secrets.secret_string))) : {
name = key,
valueFrom = "${data.aws_secretsmanager_secret.api_secrets.arn}:${key}::"
}
]
}
(略)
container_definitions = jsonencode([
name = "api"
image = "example/api:latest"
environment = [
{ "name": "ENV", "value": "development" }
]
secrets = concat(local.parameter_store_values, local.secrets_manger_values)
(略)
これで、ParameterStoreやSecretManagerのJSONと同期したTask定義が作成できます。
data
に depends_on
を設定するとdepends_onより前のapply
とdenpend_on以降のapply
と2回applyしたのと同じ効果が得られます(得られるはず。ダメだったら2回更新したら確実。)
これで、最初に出た問題は全て解決できたはずです。
CIツールによって、ECSサービスが利用しているTaskRevisionがずれるかもしれない問題はアドベントカレンダーの14日目のテクニックで回避しています。
他の問題として
アプリケーション担当の人が「terraformは触りたくない、触れない。けれど環境変数の追加は自分でやりたい」といった場合の解決策
前提
Task定義のDockerイメージタグを latest
固定にするか、別のParameterStoreなどに格納してTerraformから参照させておいてください。(上記リンク参照)
SecretManagerやParameterStoreのValueは ignore_changes
に追加しておいてください。
妥協案.1
- SecretManagerの値はAWSコンソールからアプリケーション担当の人に修正してもらう。
- 変更を検知して、GithubActionなどでTerraformを実行してTask定義を更新
妥協案.2
- SecretManagerの値はAWSコンソールからアプリケーション担当の人に修正してもらう。
- CIパイプラインにterraform applyを組み込んでTask定義を更新
どちらもパラメータストアはキーの追加時に入力ミスがあるので、人間が入力するのであればSecretManagerをつかたほうが良いでしょう。
Discussion