今週読んだ記事 2020/12/7~2020/12/13

Ruby / Rails

github.com ActiveMailerにSESを追加するためのもの、というイメージが強かったが、いつのまにか色々な機能が増えていた。

後述する、lambdaでdockerイメージを使える機能と組み合わせた、active jobのlambdaバックエンドはうまく管理する方法を考えられれば検討に値しそう。

speakerdeck.com

techlife.cookpad.com

qiita.com

AWS

今月はre:Inventが開催中であることもあって、AWS関連の諸々が非常に多い。

aws.amazon.com

aws.amazon.com dev.classmethod.jp lambdaがdockerイメージをサポートした。 これまでlambdaのデフォルト実行環境にない共有ライブラリを使用したりする際にはlambda layerなる独自のレイヤを重ねなければならなかったことを考えると簡単になったと言える。

aws.amazon.com

aws.amazon.com

dev.classmethod.jp

dev.classmethod.jp

dev.classmethod.jp

dev.classmethod.jp

その他

qiita.com

qiita.com

joker1007.hatenablog.com

ma2k8.hateblo.jp

www.m3tech.blog

note.com

note.com

pc.watch.impress.co.jp

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さん!!

今週読んだ記事 2020/11/16~2020/11/22

Rails / Ruby

Rails 5のActive Record attributes APIについて | 日々雑記

madogiwa0124.hatenablog.com

terraform

www.hashicorp.com

frontend

www.publickey1.jp

その他

tech.preferred.jp

tech.preferred.jp

blog.cybozu.io

jp.gamesindustry.biz

ActiveModel::Validatorのインスタンス変数にオブジェクトをメモ化してはならない

タイトルの通り。

実は以前にも踏んだことがあるのだけれど、しばらく期間が開いてまた踏んでしまったので書き残しておくことにする。
ActiveModel::Validatorを継承するValidatorなんて日常的に書くわけではないのでたまに踏んでしまうのは仕方ないという話もあるが…

さて、ActiveRecordにはバリデーションを表現する方法が複数ある。

  • validatesでカラムにActiveModel::EachValidatorのサブクラスを対応付ける方法
  • validate_withでクラスにActiveModel::Validatorのサブクラスを対応付ける方法
  • validateでクラスに定義されたprivatre methodを呼び出させる方法

ある日、仕事でActiveModel::Validatorを継承したValidatorを作っていた。
そのValidatorではとある深遠な理由でバリデーション対象のレコ―ドから辿るのが少々面倒なレコードを複数回参照していたので、思わずValidator内で

def hoge_record
  @hoge_record ||= record.fuga.piyo.hoge
end

というようにインスタンス変数にメモ化してしまった。
すると、request specで一つのテストケースのみを指定した場合はテストがpassするが、2つ以上のテストケースをまとめて実行するとfailするという事象に見舞われる。

これはActiveModel::Validationsが実行すべきValidatorをModelに登録する仕組みから来るもの。
具体的に言うと、Validatorがインスタンス化されるのはvalidate_withによってValidatorがModelに対応付けられるタイミングであって、そのインスタンスが何度も使いまわされるから。

そのため||=を使ってインスタンス変数にメモ化してしまうと、初回のvalidateでメモ化したオブジェクトが入りっぱなしになってしまうため意図した動作にならない。

validate_withによってValidatorがModelに対応付けられるタイミングでValidatorがインスタンス化される、当該のコードはここ

def validates_with(*args, &block)
  options = args.extract_options!
  options[:class] = self

  args.each do |klass|
    validator = klass.new(options, &block)

    if validator.respond_to?(:attributes) && !validator.attributes.empty?
      validator.attributes.each do |attribute|
        _validators[attribute.to_sym] << validator
      end
    else
      _validators[nil] << validator
    end

    validate(validator, options)
  end
end

バリデーション対象のレコードから辿れるのであれば、バリデーション対象のレコード側でメモ化してしまうのも手だが、バリデーション対象のレコードのパブリックメソッドにてメモ化してしまうのがドメインモデル間の関係性として本当に適切かなのかは疑問が残るので少し悩ましい。