OpenPolicyAgentでS3のバケットを作成するときのポリシーチェックをしてみた

今回はS3のバケットへのアクセス制限の設定についてポリシーチェックをしていこうと思います。

環境

  • OS: CentOS 7
  • Terraform : 0.12.26
  • opa: Version: 0.21.0

S3のポリシーチェックで必要なもの

最終的に出来上がるものです。

f:id:bakotako:20200628202542p:plain

EC2からVPC Endpointを通り、Access PointからS3へアクセスするようにします。 S3へのアクセス制限をする際に設定する内容は下記のものを想定しています。

  • EC2に割り当てるIAMロールのIAMポリシーにはS3のアクセスのみ許可
  • S3のAccess PointはVPCのみ許可
  • S3のオブジェクトへはAccess Point 経由でのみアクセスを許可

ポリシーという単語をOpen Policy Agent のRegoで設定するポリシーとIAMポリシー、バケットポリシーいろんな意味で出てきてしまうため、単に「ポリシー」とあるものはOpen Policy Agentでチェックするポリシーとします。

また、今回はTerraformのtfファイルからjsonファイルへ返還する方法は特に記述せずに進めていきます。変換方法や返還後のjsonフォーマットが気になる方は下記を参考にしてみてください。

kobatako.hatenablog.com

www.terraform.io

では、それぞれのポリシーの実装についてみていきましょう。

EC2に割り当てるIAM Role

EC2に割り当てられているIAMロールのIAMポリシーがS3へのアクセスを許可しているかチェックをしていきます。 まず、TerraformでEC2にIAMプロファイルを指定します。

resource "aws_instance" "sample" {
・・・
  iam_instance_profile = aws_iam_instance_profile.sample_profile.name
・・・
}

そしてIAMプロファイルとIAMロール、IAMポリシーを作成します。

resource "aws_iam_instance_profile" "sample_profile" {
  name = "SampleEC2Role"
  role = aws_iam_role.sample_role.name
}


resource "aws_iam_role" "sample_role" {
  name = "SampleEC2Role"

  assume_role_policy = data.aws_iam_policy_document.ec2_role.json
}

resource "aws_iam_role_policy" "ec2_role_policy" {
  name = "SampleEC2RolePolicy"
  role = aws_iam_role.sample_role.id

  policy = data.aws_iam_policy_document.ec2_role_policy.json
}

data "aws_iam_policy_document" "ec2_role_policy" {
  statement {
    effect = "Allow"
    actions = [
      "s3:*"
    ]
    resources = [
      "*"
    ]
  }
}

IAMロールに割り当てるIAMポリシーはすべてのS3に対して、すべてのアクションを許可するようにしています。 IAMポリシーの設定方法はjsonファイルとして記述することもできますが、Open Policy Agentでチェックする場合はjsonではなくHCLで記述したほうが都合がいいためHCLで記述します。

では、このIAMポリシーの設定をチェックするポリシーを見ていきましょう。

valid_ec2_iam_role {
  resources := ec2_role_policy_data
  count({resource| 
    resources[resource]; ec2_iam_role_policy_s3_filter(resource)
  }) == count(resources)
}

ec2_iam_role_policy_s3_filter(resource) {
  statement := resource.expressions.statement[_]
  value := statement.actions.constant_value[_]
  re_match("s3:.*", value)
  statement.effect.constant_value == "Allow"
}

valid_ec2_iam_role のRuleでEC2に割り当てるIAMロールのIAMポリシーをチェックをしています。

まず、 ec2_role_policy_data でEC2に割り当てられているIAMロールのIAMポリシーを取得しています。そして ec2_iam_role_policy_s3_filter 関数でS3へのアクセスが許可されているIAMポリシーをフィルターしています。

statement := resource.expressions.statement[_] でIAMポリシーのステートメントをとりだし value := statement.actions.constant_value[_] でactions の値を取得しています。 re_match("s3:.*", value) では正規表現で "s3:.*" の正規表現に当てはまっているのかをチェックしてし、 statement.effect.constant_value == "Allow"ステートメントが許可する設定となっているかをチェックしています。

フィルターし、得られたIAMポリシーの数とEC2に割り当てられているIAMロールのIAMポリシーの数が等しい場合、すべてのIAMポリシーが条件に当てはまっている状態となります。

ec2_role_policy_data の実装方法は下記のようになっています。

ec2_iam_policy_data[resource] {
  policy := ec2_iam_role_policies[_]
  policy_data := policy.expressions.policy.references[_]
  resource := fetch_address_resource[policy_data]
}

ec2_iam_role_policies[resource] {
  role_address := ec2_role_addresses
  resource := data.configuration.root_module.resources[_]
  resource.expressions.role.references[_] == role_address[_]
  resource.type == "aws_iam_role_policy"
}

ec2_role_addresses[resource] {
  profiles := instances[_].expressions.iam_instance_profile.references[_]
  profile_resource := fetch_address_resource[profiles]
  resource := profile_resource.expressions.role.references[_]
}

fetch_address_resource[address] = resource {
  resource := data.configuration.root_module.resources[_]
  address := resource.address
}

instances[resource] {
  resource := data.configuration.root_module.resources[_]
  resource.type == "aws_instance"
}

長くなってしまうため、細かい説明は省略します。 処理としては、EC2インスタンスの一覧を取得し、EC2に割り当てられているIAMプロファイルを取得。IAMプロファイルからIAMロールを取得し、IAMポリシーを取得する流れとなっています。

作成するS3のAccess Point

それでは作成するS3のAccess Pointに対するポリシーチェックをしていきます。 作成するS3とAccess Pointは下記のものとなります。

resource "aws_s3_bucket" "sample" {
  bucket = "sample-bucket"
  acl = "private"
}

resource "aws_s3_access_point" "sample-access-point" {
  bucket = aws_s3_bucket.sample.id
  name = "sample-access-point"

  vpc_configuration {
    vpc_id = aws_vpc.main.id
  }
}

sample-bucket バケットAccess Pointを作成するTerraformになります。Access Point には vpc_configuration を指定し vpc_idVPCに対する作成を行います。

では、実際にS3のAccess PointがすべてVPCのみとなっているかチェックをしていきます。

ポリシーチェックの実装は下記のようになっています。

valid_s3_vpc_access_point {
  s3_access_points := s3_access_point
  count({resource|
    s3_access_points[resource]; count(resource.expressions.vpc_configuration) > 0
  }) == count(s3_access_points) 
}

s3_access_point[resource] {
  s3_address := create_s3_addresses[_]
  resource := reference_s3_resources[s3_address]

  resource.type == "aws_s3_access_point"
}

create_s3_addresses[address] {
  some i
  data.resource_changes[i].type == "aws_s3_bucket"
  data.resource_changes[i].change.actions[_] == "create"
  address := data.resource_changes[i].address
}

reference_s3_resources[s3_address] = resource {
  some i
  s3_address := data.configuration.root_module.resources[i].expressions.bucket.references[_]
  resource := data.configuration.root_module.resources[i]
}

順番に見ていきましょう。

valid_s3_vpc_access_point のRuleでS3のAccess Pointのチェックをしています。チェックするAccess Pointは s3_access_point で取得しています。

s3_access_point内では create_s3_addresses でTerraformで作成するS3のaddressを取得します。 取得したS3のaddressをもとに reference_s3_resources で S3のaddressが関連付けられたリソースを取得します。

そして、S3に関連付けられたリソースで aws_s3_access_point のものを返すようにすることでS3のAccess Pointを取得することができます。

取得したS3のAccess PointですべてのAccess Pointがvpc_configurationを所有しているかどうかをチェックすることですべてのAccess PointがVPCに制限されたものかどうかを判定しています。

S3へのアクセス制限

最後に、S3へのアクセスをAccess Pointのみ許可するようにします。

まず、Terraformで指定するバケットポリシーの内容を見ていきましょう。

resource "aws_s3_bucket" "sample" {
  bucket = "sample-bucket"
  acl = "private"
}

resource "aws_s3_access_point" "sample-access-point" {
  bucket = aws_s3_bucket.sample.id
  name = "main-access-point"

  vpc_configuration {
    vpc_id = aws_vpc.main.id
  }
}

data "aws_iam_policy_document" "s3-bucket-policy" {
  statement {
    effect = "Deny"
    actions = [
        "s3:*Object"
    ]
    resources = [
      "${aws_s3_bucket.sample.arn}/*",
      aws_s3_bucket.sample.arn
    ]
    principals {
      type = "AWS"
      identifiers = ["*"]
    }

    condition {
      test = "StringNotEquals"
      variable = "s3:DataAccessPointArn"
      values = [
        aws_s3_access_point.sample-access-point.arn
      ]
    }
  }
}

resource "aws_s3_bucket_policy" "s3-main-policy" {
  bucket = aws_s3_bucket.sample.id
  policy = data.aws_iam_policy_document.s3-bucket-policy.json
}

バケットポリシーもHCLの表記で記述していきます。 conditionの設定で sample-access-point Access Point 以外からのアクセスに対して sample-bucket バケットのオブジェクトへのアクションは拒否する設定となっています。

これで、S3のオブジェクトへのアクセスはAccess Pointを通してのアクセスのみに制限されました。

ではこの設定がされているのかをチェックするポリシーを見ていきます。

valid_s3_bucket_policy {
  policy := s3_bucket_policies[_]
  access_point := s3_access_point
  statement := policy.expressions.statement[_]
  statement.effect.constant_value == "Deny"
  re_match("s3:.*", statement.actions.constant_value[_])
  statement.condition[_].test.constant_value == "StringNotEquals"
  statement.condition[_].variable.constant_value == "s3:DataAccessPointArn"
  statement.condition[_].values.references[_] == access_point[_].address
}

s3_bucket_policies[policy] {
  s3_address := create_s3_addresses
  resource := data.configuration.root_module.resources[_]
  resource.type == "aws_s3_bucket_policy"
  resource.expressions.bucket.references[_] == s3_address[_]
  policy := fetch_address_resource[resource.expressions.policy.references[_]]
}

s3_access_pointcreate_s3_addresses は 作成するS3のAccess Point のところで出てきたものを使っています。

valid_s3_bucket_policyバケットポリシーが想定されているものかどうかをチェックしています。 対象となる、バケットポリシーは s3_bucket_policies で取得しています。

valid_s3_bucket_policy の中を細かく見ていきましょう。

まず、 s3_bucket_policiess3_access_pointバケットポリシーとAccess Pointを取得します。 そしてバケットポリシーのステートメントを取得し、条件に当てはまるかをチェックしていきます。

これで S3のバケットポリシーのチェックができました。

まとめ

EC2に割り当てられてるIAMロールからIAMポリシーのチェックとS3のバケットポリシーのチェックをしていきました。 IAMポリシーとバケットポリシーのチェックをする際に、jsonファイルとして別ファイルなどにせずにHCLの表記にすることでOpen Policy Agent でチェックすることができます。aws_iam_policy_document についても今回初めて知り使ってみましたが個人的な意見としてはjsonよりも使いやすかったので今後は aws_iam_policy_document で書いていこうかと思います。

実装内容としては今回の実装ですとS3のバケットする際にすべてのバケットに対してポリシーチェックをするため、グローバルアクセスするものがある場合は処理を分ける必要があります。用途の異なるS3のバケットを構築する際のポリシーチェックについては別の記事で書いていこうかと思います。

Access Pointへアクセスできるものを制御したいとかある場合はアクセスポイントポリシーも設定することができるのでより厳密に制御することが可能となります。気になる方はぜひやってみてください。

[OpenPolicyAgent] Terraformで構築するEC2のSecurity Groupをチェックする

EC2を構築する際にSecurity Groupを設定することがあると思いますが、その際に想定通りの設定になっているのかを気を付ける必要がありますね。

想定通りの設定になっているのか、Open Policy Agentで確認することができるのでその実装方法についてみていきたいと思います。

(まだまだ、勉強中ですので、ご意見あればコメントいただけますと幸いです!)

動作環境

  • OS: CentOS 7
  • Terraform : 0.12.26
  • opa: Version: 0.21.0

Terraformの内容

今回対象となるTerraformの内容は下記になります。

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"

  tags = {
    Env = terraform.workspace
  }
}

resource "aws_subnet" "frontend_subnet" {
  vpc_id = aws_vpc.main.id
  cidr_block = "10.0.0.0/24"

  tags = {
    Env = terraform.workspace
  }
}

resource "aws_security_group" "http" {
  name = "http"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port = 80
    to_port = 80
    protocol = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_security_group" "ssh" {
  name = "ssh"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port = 22
    to_port = 22
    protocol = "tcp"
    cidr_blocks = ["1.2.3.4/32"]
  }
}
resource "aws_instance" "web" {
  ami           = "ami-0717724173e4d9989"
  instance_type = "t3.micro"

  subnet_id = aws_subnet.frontend_subnet.id

  tags = {
    Env = terraform.workspace
    Role = "web"
  }

  vpc_security_group_ids = [
    "${aws_security_group.http.id}",
    "${aws_security_group.ssh.id}"
  ]

  root_block_device {
    volume_type = "gp2"
    volume_size = "20"
  }
}

webサーバとしてEC2を構築します。Security Group としてはhttp と ssh へのリクエストを許可し、 ssh については「1.2.3.4/32」のIPからのみ許可しています。

Terraformのファイルの準備ができましたので、下記のコマンドでjson形式に変換してみようと思います。

terraform plan --out tfplan.binary
terraform show -json tfplan.binary > tfplan.json

jsonへ返還後の内容でEC2のSecurity Groupのチェックに必要な部分を下記に抜粋しております。

{
  ・・・
  "planned_values": {
    "root_module": {
      "resources": [
        {
          "address": "aws_instance.web",
          "mode": "managed",
          "type": "aws_instance",
          "name": "web",
          "provider_name": "aws",
          "schema_version": 1,
          "values": {
            "ami": "ami-0717724173e4d9989",
            "credit_specification": [],
            "disable_api_termination": null,
            "ebs_optimized": null,
            "get_password_data": false,
            "hibernation": null,
            "iam_instance_profile": null,
            "instance_initiated_shutdown_behavior": null,
            "instance_type": "t3.micro",
            "monitoring": null,
            "root_block_device": [
              {
                "delete_on_termination": true,
                "volume_size": 20,
                "volume_type": "gp2"
              }
            ],
            "source_dest_check": true,
            "tags": {
              "Env": "stagging",
              "Role": "web"
            },
            "timeouts": null,
            "user_data": null,
            "user_data_base64": null
          }
        },
        {
          "address": "aws_security_group.http",
          "mode": "managed",
          "type": "aws_security_group",
          "name": "http",
          "provider_name": "aws",
          "schema_version": 1,
          "values": {
            "description": "Managed by Terraform",
            "ingress": [
              {
                "cidr_blocks": [
                  "0.0.0.0/0"
                ],
                "description": "",
                "from_port": 80,
                "ipv6_cidr_blocks": [],
                "prefix_list_ids": [],
                "protocol": "tcp",
                "security_groups": [],
                "self": false,
                "to_port": 80
              }
            ],
            "name": "web",
            "name_prefix": null,
            "revoke_rules_on_delete": false,
            "tags": null,
            "timeouts": null
          }
        },
        {
          "address": "aws_security_group.ssh",
          "mode": "managed",
          "type": "aws_security_group",
          "name": "ssh",
          "provider_name": "aws",
          "schema_version": 1,
          "values": {
            "description": "Managed by Terraform",
            "ingress": [
              {
                "cidr_blocks": [
                  "1.2.3.4/32"
                ],
                "description": "",
                "from_port": 22,
                "ipv6_cidr_blocks": [],
                "prefix_list_ids": [],
                "protocol": "tcp",
                "security_groups": [],
                "self": false,
                "to_port": 22
              }
            ],
            "name": "ssh",
            "name_prefix": null,
            "revoke_rules_on_delete": false,
            "tags": null,
            "timeouts": null
          }
        }
      ]
    }
  }
  ・・・
  "configuration": {
    "provider_config": {
    ・・・
    },
    "root_module": {
      "resources": [
        {
          "address": "aws_instance.web",
          "mode": "managed",
          "type": "aws_instance",
          "name": "web",
          "provider_config_key": "aws",
          "expressions": {
            ・・・
            "vpc_security_group_ids": {
              "references": [
                "aws_security_group.http",
                "aws_security_group.ssh"
              ]
            }
          },
          "schema_version": 1
        }
      ]
    }
  }
}

configuration > root_module > resources の項目に構築するEC2とそれに関連するSecurity Groupの address 一覧が vpc_security_group_ids 内に記載されています。 planned_values > root_module > resources の項目にはリソースの値が入っています。 ですので、EC2に関連するSecurity Groupの address を取得し、その address をもとにSecurity Groupにどのような設定がされているのかをチェックするように実装していきます。

それぞれの内容がどういうものなのか気になる方はTerraformの公式にフォーマットの情報が記載していますので、そちらをご確認ください。

www.terraform.io

ポリシーチェック

どのようにEC2に関連しているSecurity Groupのチェックをするのかを軽く述べましたので、実装部分を見ていきたいと思います。 まず、Security Groupの設定として下記の内容をチェックしていきます。

  • SSHのリクエストは「1.2.3.4/32」のアドレスからのみ許可する ※1.2.3.4のアドレスは架空のものとなります。
  • HTTP、HTTPSのリクエストを許可する
  • 上記内容以外のSecurity Groupの設定はエラーとする

EC2のSecurity Group

初めにEC2に関連付けられているSecurity Groupを取得していきます。

web_instances[instance] {
  instance := data.planned_values.root_module.resources[_]
  instance.type == "aws_instance"
  instance.values.tags.Role == "web"
}

web_instance_security_groups[resource] {
  instances := web_instances
  resource := data.planned_values.root_module.resources[_]
  data.configuration.root_module.resources[_].address == instances[_].address
  resource.address == data.configuration.root_module.resources[_].expressions.vpc_security_group_ids.references[_]
  resource.type == "aws_security_group"
}

web_instances の RuleでTerraformで構築するEC2で「Role」タグにweb と設定されているEC2を取得します。 そして下記の部分でEC2のaddressと等しい configuration 内の リソース かどうかを比較しています。同じaddress の場合、そのリソースの vpc_security_group_ids に定義されているSecurity Group の addressと等しい リソースかどうかを比較しています。

data.configuration.root_module.resources[_].address == instances[_].address
resource.address == data.configuration.root_module.resources[_].expressions.vpc_security_group_ids.references[_]

これで、EC2に関連付けられたSecurity Groupが取得することができました。 では実際にSecurity Groupのチェックを行っていきます。

Security Groupのポリシーチェック

EC2に関連するSecurity Group が取得できたので次はその設定がポリシー通りになっているのかをチェックします。 全体の実装内容は下記のようになります。

permit_ssh_ips = ["1.2.3.4/32"]

default valid_web_instance_security_group = false
valid_web_instance_security_group {
  valid_ssh_instance_security_group
  valid_http_instance_security_group
  valid_other_instance_security_group
}

valid_other_instance_security_group {
  count(web_instance_security_groups) == count(http_security_groups) + count(https_security_groups) + count(ssh_security_groups)
}

default valid_http_instance_security_group = false
valid_http_instance_security_group {
  count(http_security_groups) > 0
}

valid_http_instance_security_group {
  count(https_security_groups) > 0
}

default valid_ssh_instance_security_group = false
valid_ssh_instance_security_group {
  security_groups := ssh_security_groups
  count({security_group|
    security_groups[security_group]; security_group.values.ingress[_].cidr_blocks == permit_ssh_ips
  }) == count(security_groups)
}

ssh_security_groups[security_group] {
  security_groups := web_instance_security_groups
  security_group := security_groups[_]
  security_group.values.ingress[_].from_port == 22
  security_group.values.ingress[_].to_port == 22
}

http_security_groups[security_group] {
  security_groups := web_instance_security_groups
  security_group := security_groups[_]
  security_group.values.ingress[_].from_port == 80
  security_group.values.ingress[_].to_port == 80
}

https_security_groups[security_group] {
  security_groups := web_instance_security_groups
  security_group := security_groups[_]
  security_group.values.ingress[_].from_port == 443
  security_group.values.ingress[_].to_port == 443
}

valid_web_instance_security_group の RuleでSecurity Groupのポリシーをチェックする各Ruleを読んでいっています。 上から順にみていきたいと思います。 まず、 valid_ssh_instance_security_group のRuleを見ていきましょう。

SSHのリクエストは「1.2.3.4/32」のアドレスからのみ許可する

valid_ssh_instance_security_group の Ruleでsshに関するSecurity Groupのポリシーをチェックしています。関連する実装部分は下記になります。

permit_ssh_ips = ["1.2.3.4/32"]

default valid_ssh_instance_security_group = false
valid_ssh_instance_security_group {
  security_groups := ssh_security_groups
  count({security_group|
    security_groups[security_group]; security_group.values.ingress[_].cidr_blocks == permit_ssh_ips
  }) == count(security_groups)
}

ssh_security_groups[security_group] {
  security_groups := web_instance_security_groups
  security_group := security_groups[_]
  security_group.values.ingress[_].from_port == 22
  security_group.values.ingress[_].to_port == 22
}

valid_ssh_instance_security_group ではsshのSecurity Groupのポリシーチェックをしています。 最初にssh_security_groups の RuleでEC2に関連付けられたSecurity Groupでポートが22番(ssh)のSecurity Groupのみ取得しています。

そして、下記で接続ものとIPアドレス帯もチェック項目となります。

  count({security_group|
    security_groups[security_group]; security_group.values.ingress[_].cidr_blocks == permit_ssh_ips
  }) == count(security_groups)

permit_ssh_ips には許可するIPアドレスの配列が記載されており、別の値が入っていた場合、count(security_groups) の数より少なくなってしまうため、falseとなる仕組みになります。 これで、EC2に関連するSecurity Groupでsshに関するポリシーチェックは完了しました。

次にhttp(https)のSecurity Groupについてみていきます。

HTTP、HTTPSのリクエストを許可する

httpとhttpsでは両方設定されている場合もありますが、どちらか片方のみしか設定されていない場合もあります。ですのでhttp or httpsの実装がある、というチェックを行う必要があります。では実装を見ていきましょう。

default valid_http_instance_security_group = false
valid_http_instance_security_group {
  count(http_security_groups) > 0
}

valid_http_instance_security_group {
  count(https_security_groups) > 0
}

http_security_groups[security_group] {
  security_groups := web_instance_security_groups
  security_group := security_groups[_]
  security_group.values.ingress[_].from_port == 80
  security_group.values.ingress[_].to_port == 80
}

https_security_groups[security_group] {
  security_groups := web_instance_security_groups
  security_group := security_groups[_]
  security_group.values.ingress[_].from_port == 443
  security_group.values.ingress[_].to_port == 443
}

httpとhttpsで同名のRuleとして定義しています。これは valid_http_instance_security_group の Rule でどちらかのRule が true になったら true になります。ですので、http か https のSecurity Group がある場合、 valid_http_instance_security_group は true となります。

上記内容以外のSecurity Groupの設定はエラーとする

最後に、http(https)とssh 以外のSecurity Group が設定されていた場合、falseになるように Rule を追加します。

valid_other_instance_security_group {
  count(web_instance_security_groups) == count(http_security_groups) + count(https_security_groups) + count(ssh_security_groups)
}

count(web_instance_security_groups) で EC2に関連しているSecurity Groupの数を取得し、 count(http_security_groups) + count(https_security_groups) + count(ssh_security_groups) で http(https)とsshのSecurity Group の数を取得し同数になっているかを比較しています。同数になっている場合は別のSecurity Group が設定されていないため true となります。

これで、不要なSecurity Group が設定されていないかチェックすることができました。

まとめ

EC2に関連するSecurity Groupの設定からポリシーをチェックする方法を見てみました。リソース間の関連付けについては configuration 内のリソースに定義されており、そこから planned_values の値のポリシーをチェックしています。Regoの表記で configuration のリソース内の情報から planned_values 内のリソースへの関連付けを記述することができるので、割とサクッと実装することができました。 各 Security Groupを取得しているとことですが、愚直に http, https, sshを別々に定義していますが、ここは下記のようにまとめることができます。

security_groups[port] = security_group {
  some port
  ports[port]
  security_groups := web_instance_security_groups
  security_group := security_groups[_]
  security_group.values.ingress[_].from_port == port
  security_group.values.ingress[_].to_port == port
}

> security_groups[80]

こうすることで、 security_groups の Ruleに渡したポート番号のSecurity Groupの一覧を取得することができます。 Regoの表記もまだまだ触ったことがないものなどありますので、別の機会に見ていきたいと思います。 また、今回はwebサーバに直接リクエストが行くようになっていますが、ALBを用いた場合のポリシーチェックなどをもしていきたいと思います。

[OpenPolicyAgent] TerraformでEC2を構築する際のポリシーをチェックする

Open Policy Agentを使うことでTerraformでEC2を構築する際にインスタンス設定が想定されているものなのかチェックができるため実装してみました。

やってみた内容について簡単にまとめていきたいと思います。

実装方法などはまだまだ勉強中ですのであくまでも参考程度に見ていただければと思います。(よい実装方法などあれば教えてくれると嬉しいです!)

動作環境

OS: CentOS 7
Terraform : 0.12.26
opa: Version: 0.21.0

Open Policy AgentでTerraformのポリシーチェック

まずはOpen Policy AgentでTerraformのポリシーをチェックする方法を見ていきます。

Terraformは .tf という拡張子でHCLで記述していきます。このままですとOpen Policy Agentでポリシーチェックができませんので、json形式に変換する必要があります。

変換方法としては下記のようにすることでjsonファイルに変換することができます。

terraform plan --out tfplan.binary
terraform show -json tfplan.binary > tfplan.json

terraform plan --out ftplan.binary でTerraformのプラン結果をファイルに出力することができます。 そして terraform show -json tfplan.binary でプランで出力した内容をjson形式としてファイルに出力することができます。

これでOpen Policy Agentでポリシーチェックをする対象の準備ができました。

ポリシーチェックするTerraformのファイル

では、早速ポリシーチェックをするTerraformを見ていこうと思います。

resource "aws_instance" "web" {
  ami           = "ami-0717724173e4d9989"
  instance_type = "t3.micro"

  tags = {
    Env = terraform.workspace
  }

  root_block_device {
    volume_type = "gp2"
    volume_size = "20"
  }
}

今回は上記のtfファイルの内容が下記のポリシー通りになっているのかを見ていきたいと思います。

  • タグに「Env」が存在しており、stagging or production のどちらかが設定されているか
  • ステージング環境の場合、インスタンスタイプが t3.micro か
  • ルートボリュームでサイズが20以上、100以下になっているか、typeがgp2になっているか

前提として Env タグに設定されている値は環境を表しており、staggingの場合はステージング環境、productionの場合はプロダクション環境となります。 また、Env タグに workspaceの値を入れるようにしているため、EC2を複数構築ときでもステージング環境とプロダクション環境の二つの環境に同時にデプロイすることはできない想定です。

このtfファイルをjson形式に変換した時の内容は下記のようになります。

{
・・・
  "resource_changes": [
    {
      "address": "aws_instance.web",
      "mode": "managed",
      "type": "aws_instance",
      "name": "web",
      "provider_name": "aws",
      "change": {
        "actions": [
          "create"
        ],
        "before": null,
        "after": {
          "ami": "ami-0717724173e4d9989",
          "credit_specification": [],
          "disable_api_termination": null,
          "ebs_optimized": null,
          "get_password_data": false,
          "hibernation": null,
          "iam_instance_profile": null,
          "instance_initiated_shutdown_behavior": null,
          "instance_type": "t3.micro",
          "monitoring": null,
          "root_block_device": [
            {
              "delete_on_termination": true,
              "volume_size": 20,
              "volume_type": "gp2"
            }
          ],
          "source_dest_check": true,
          "tags": {
            "Env": "stagging"
          },
          "timeouts": null,
          "user_data": null,
          "user_data_base64": null
        },
・・・

resource_changes に構築するEC2の情報が記述されています。構築時のポリシーチェックになるのでここの要素に対してポリシーを作成していきます。

EC2のポリシーをチェックする

ポリシーは下記のように実装しました。

package terraform.aws

import input as tfplan

stagging_instance_type = ["t3.micro"]
production_instance_type = ["m5.large"]
env_tags = ["stagging", "production"]

default allow = false
allow {
  valid_instance_tag
  valid_instance_root_block_device
  valid_environment_instance_type
}

valid_instance_root_block_device {
  instances := aws_instances
  count({instance|
    instances[instance]; valid_instance_root_block_device_filter(instance)
  }) == count(instances)
}

valid_instance_root_block_device_filter(instance) {
  instance.change.after.root_block_device[_].volume_size >= 20
  instance.change.after.root_block_device[_].volume_size <= 100
  instance.change.after.root_block_device[_].volume_type == "gp2"
}

valid_instance_tag {
  instances := aws_instances
  count({instance|
    instances[instance]; instance_tag_filter(instance)
  }) == count(instances)
}

instance_tag_filter(instance) {
  instance.change.after.tags.Env == env_tags[_]
}

default valid_environment_instance_type = false
valid_environment_instance_type = true {
  valid_stagging_instance_type 
}

valid_environment_instance_type = true {
  valid_production_instance_type 
}

aws_instances[instance] {
  instance := tfplan.resource_changes[_]
  instance.type == "aws_instance"
}

stagging_instances[instance] {
  instances := aws_instances
  instance := instances[_]
  instance.change.after.tags.Env == "stagging"
}

valid_stagging_instance_type {
  instances := stagging_instances
  count({instance|
    instances[instance]; stagging_instance_type_filter (instance)
  }) == count(instances) 
}

stagging_instance_type_filter (instance) {
  instance.change.actions[_] == "create"
  instance.change.after.instance_type == stagging_instance_type[_]
}

production_instances[instance] {
  instances := aws_instances
  instance := instances[_]
  instance.change.after.tags.Env == "production"
}

valid_production_instance_type {
  instances := production_instances
  count({instance|
    instances[instance]; production_instance_type_filter(instance)
  }) == count(instances) 
}

production_instance_type_filter(instance) {
  instance.change.actions[_] == "create"
  instance.change.after.instance_type == production_instance_type[_]
}

ここでは

  • タグに「Env」が存在しており、stagging or production のどちらかが設定されているか
  • ステージング環境の場合、インスタンスタイプが t3.micro か
  • ルートボリュームでサイズが20以上、100以下になっているか、typeがgp2になっているか

をチェックしています。 では、 どのようにしてチェックしているか を順を追ってみていきます。

allow

allowというRuleの中で複数のRuleを記述しています。allowの中に記述しているRule内で細かくポリシーのチェックを行い、すべてのRuleがtrueになった場合にallowもtrueになります。

default allow = false
allow {
  valid_instance_tag
  valid_instance_root_block_device
  valid_environment_instance
}

タグに「Env」が存在しており、stagging or production のどちらかが設定されているか

EC2に「Env」タグが存在しているか、「Env」タグが stagging か production かどうかは valid_instance_tag でチェックしています。 実装している部分は下記のところとなります。

valid_instance_tag {
  instances := aws_instances
  count({instance|
    instances[instance]; instance_tag_filter(instance)
  }) == count(instances)
}

instance_tag_filter(instance) {
  instance.change.after.tags.Env == env_tags[_]
}

instances := aws_instances ですがこちらは構築対象となるインスタンス一覧を取得しています。

aws_instances のRuleの内容ですが下記のようになっています。

aws_instances[instance] {
  instance := tfplan.resource_changes[_]
  instance.type == "aws_instance"
}

簡単にまとめると resource_changes の一覧で typeaws_instance のリソースを取得するため、構築するEC2の一覧を取得することができます。 詳しくは公式の内容を見ていただけるとわかるかと思います。

www.openpolicyagent.org

では本題の タグに「Env」が存在しており、stagging or production のどちらかが設定されているか をチェックしている Rule を見てみたいと思います。 内容を一部抜粋して下記に記載してあります。

env_tags = ["stagging", "production"]

instances := aws_instances
count({instance| instances[instance]; instance_tag_filter(instance) }) == count(instances)

instance_tag_filter(instance) {
  instance.change.after.tags.Env == env_tags[_]
}

ここで重要になるのが instance_tag_filter 関数の内容になります。 instance_tag_filter関数でtagsにEnvがあり、Envの値が env_tags 内のものと一致しているインスタンス一覧を返す処理をしています。

そして count({instance| instances[instance]; instance_tag_filter(instance) }) の処理ですが instance_tag_filter関数から取得したインスタンス一覧の数を取得し、count(instances) で構築するEC2インスタンスの数を取得しています。

つまり instance_tag_filter関数から取得したインスタンス一覧の数と構築するEC2インスタンス数が同数かをチェックしています。

こうすることで、すべてのEC2インスタンスがタグに「Env」があり、 stagging or productionの値が設定されているかをチェックすることができます。 この書き方は公式に記載がありますので、詳しく知りたい方は公式を見てみてください。

www.openpolicyagent.org

これで、EC2に「Env」タグがあり、stagging or production の値が設定されているかのチェックができました。

ステージング環境の場合、インスタンスタイプが t3.micro か

次にステージング環境の場合、インスタンスタイプが t3.micro かのチェック方法を見ていきたいと思います。 っとその前に、「Env」タグの値が「stagging」なのか「production」なのかで分岐する必要があります。ただ、Ruleとして下記のように記載してしまうと誤った挙動となります。

valid_environment_instance_type {
  valid_stagging_instance_type
  valid_production_instance_type
}

valid_stagging_instance_type が staggingの場合のインスタンスタイプチェック、valid_production_instance_type が production の場合のインスタンスタイプチェックとなります。 こうしてしまうと、両方のRuleがtrueになったときにしか valid_environment_instance_type の Ruleがtrueしません。

前提としてステージング環境とプロダクション環境を同時にデプロイすることはないので、仮にステージング環境にデプロイしようとすると Envタグに production が設定されているものがなく、valid_production_instance_type の結果が undefined となり、valid_environment_instance_type が falseになります。

理想はstaggingとproductionが同時に指定されることはないため valid_stagging_instance_type と valid_production_instance_type でどちらかがtrueになった場合、valid_environment_instance_type がtrueになるようにしたいです。 そのためには下記のように設定する必要があります。

default valid_environment_instance_type = false
# ステージング環境
valid_environment_instance_type = true {
  valid_stagging_instance_type 
}

# プロダクション環境
valid_environment_instance_type = true {
  valid_production_instance_type 
}

こうすることで、どちらかのRuleがtrueになれば valid_environment_instance_type が true になります。(ほかにいい方法があれば教えていただけると嬉しいです!)

この書き方は公式に載っていますので、詳しくはこちらを参考にしてください。

www.openpolicyagent.org

では、valid_stagging_instance_type のRuleの内容を見ていきたいと思います。

stagging_instance_type = ["t3.micro"]

valid_stagging_instance_type {
  instances := stagging_instances
  count({instance|
    instances[instance]; stagging_instance_type_filter(instance)
  }) == count(instances) 
}


stagging_instances[instance] {
  instances := aws_instances
  instance := instances[_]
  instance.change.after.tags.Env == "stagging"
}

stagging_instance_type_filter(instance) {
  instance.change.actions[_] == "create"
  instance.change.after.instance_type == stagging_instance_type[_]
}

ここでもタグのポリシーチェックのときと同様に条件に当てはまるもの(stagging_instance_type_filter 関数で取得したインスタンス一覧)が作成するEC2インスタンス数と同数になっているかをチェックしています。

これをもう少しかみ砕いて整理していきたいと思います。

まず、 stagging_instances でEnvタグにstaggingが設定されているインスタンスの一覧を取得します。 そして、stagging_instance_type_filter 関数で条件に当てはまるインスタンス一覧(ここではインスタンスタイプ)を取得します。関数の中で、 instance.change.after.instance_type == stagging_instance_type[_] とありますが、ステージング環境に構築できるインスタンスタイプかどうかを判定しています。 stagging_instance_type には t3.micro のみ設定しているため、ステージング環境では t3.micro のみ許可します(もちろん、複数指定することも可能です)。

こうすることで、環境ごとでインスタンスタイプが想定されているものなのかをチェックすることができました。

ルートボリュームでサイズが20以上、100以下になっているか、typeがgp2になっているか

最後のチェック項目として、 root_block_device でサイズが 20以上、100以下になっているか、typeがgp2になっているか をチェックしていきます。

valid_instance_root_block_device {
  instances := aws_instances
  count({instance|
    instances[instance]; valid_instance_root_block_device_filter(instance)
  }) == count(instances)
}

valid_instance_root_block_device_filter(instance) {
  instance.change.after.root_block_device[_].volume_size >= 20
  instance.change.after.root_block_device[_].volume_size <= 100
  instance.change.after.root_block_device[_].volume_type == "gp2"
}

ここまで記事を読んでいただいた方には上記内容で伝わるかと思いますが、、簡単に説明していきたいと思います。

valid_instance_root_block_device の Rule内 構築するインスタンス一覧と valid_instance_root_block_device_filter 関数から取得したインスタンス一覧が同数になっているかをチェックしています。

valid_instance_root_block_device_filter 関数では root_block_device の値で volume_size と volume_type が想定されているものかどうかをチェックし、該当するインスタンス一覧を取得します。 これで、 root_block_device 内の値がポリシー通りになっているかをチェックすることができました。

まとめ

以上がTerraformで構築するEC2インスタンスのポリシーチェックになります。ポリシーを機械的にチェックできるので設定ミスや不正なもののデプロイを防ぐことができますし、レビューの負担も減らすことができると思います。 また、今回は構築(actionsがcreate)時のみインスタンスタイプのチェックしていました。同様にアクションごとでポリシーのチェックを利用することで更新や削除についてもチェックすることができるかと思いますので、別の機会に試してみたいと思います。 S3とかのポリシーについても実装してみたいですね。

Open Policy Agentへ入門

CNCFのプロジェクトにOpen Policy Agentっというものがあります。 Open Policy Agentはポリシーエンジンとして動作し、指定したポリシーに準じているかどうかをチェックしてくれます。 そんなOpen Policy Agentについて軽く触ってみたのでどういった機能があるのか、気になった部分を中心に書いていきたいと思います。

www.openpolicyagent.org

目次

  1. Open Policy Agent
  2. Regoの特徴
  3. Regoの記述
  4. Regoのテスト
  5. 終わりに

1. Open Policy Agentについて

上記で簡単に説明しましたが、Open Policy Agentとはポリシーエンジンとして動作し、ポリシーのチェックを行います。

利用方法としてはコマンドラインで利用したり、デーモンで動かすパターンとライブラリとして利用するパターンがあります。

KubernetesやEnvoy、Kafkaと連携したり、Terraformのプラン結果をチェックできたりと、いろんな用途で扱うことができそうです。

基本的にはJSONデータであればポリシーチェックを行える感じですかね。 Terraformのポリシーチェックに関しても plan した結果を JSON形式に出力してチェックをしてますし、Kubernetesに関してもマニュフェストファイルをJSON形式に変換してチェックをしているようです。

次は、Open Policy Agentでポリシーを記述する「Rego」についてみて行きたいと思います。

2. Regoの特徴

Open Policy Agentではポリシーを定義するため、「Rego」っという言語で記述していきます。 宣言的にポリシーを記述でき、強力な表現方法を持っていきます。 特に配列に対する操作が特殊な部分があり理解するのに少々手間取ってしまいました。。。 例えば下記のような記述があったとします。

example.rego

package example

default allow = false

allow {
  has_environment["prod"]
}

has_environment [name] {
  sites = [
    {
      "name": "prod"
    },
    {
      "name": "dev"
    },
  ]
  name := sites[_].name
}

まずは詳細は理解せず雰囲気で見てもらいたいのですが、 allow から has_environment を呼び、引数として prod っという文字列を渡しています。

これをコマンドラインから実行すると下記のような結果が返ってきます。 下記のコマンドでは上記のRegoのファイルを引数として渡し、 allow を実行しています。

$ ./opa eval --data example.rego  'data.example.allow'
{
  "result": [
    {
      "expressions": [
        {
          "value": true,
          "text": "data.example.allow",
          "location": {
            "row": 1,
            "col": 1
          }
        }
      ]
    }
  ]
}

"value": true, が返ってきていますね。これはRegoの評価した結果が true になった証拠です。 ためしに、 has_environment の引数を stg にしてみます。すると結果が false になってしまいます。

これは、 sites の要素で name 属性の値を網羅的に引数の値(name)と比較して同じものがあるかどうかをチェックしてるっという感じです。

また、Regoでは先ほどの has_environmentallow はルールをまとめるルールとして定義しています。 has_environment では引数の値が sites の要素で name 属性の値と比較し評価しています。allow では has_environment の結果を評価しています。

そして、allow ではすべての評価対象が true のときのみ true を返します。

Regoではこうしてルールを定義し、評価していくことでポリシーに準じているかどうかをチェックしています。 また、こうして宣言的に記述したほうがポリシーの評価を簡単に行えます。

どちらかというとAlloyとか形式手法の言語に近いんですかね。

3. Regoの記述

基本的な記述方法

ここからREPLを使ってRegoの記述について触れていこうかと思います。 一般的な記述として以下のように値を扱えます。

x := 42
rect := {"width": 2, "height": 4}

ここでは x を42にとし、 rect{"width": 2, "height": 4} として扱えます。

> x == 42
true
> x == 43
false

等価の評価は == で行うことができます。 また、下記のように記述することでルールを作成することができます。

> t { x := 42; y := 41; x > y }
> t
true

仮にルールを評価したときに false になった時は下記のようになります。

> t2 { x := 42; y := 41; x < y }
> t2
undefined

Regoではルールの中の評価する順序について特殊な扱いをすることができ、下記のように記述することが可能となります。

> s { x > y; y = 41; x = 42 }

ここでは := ではなく = を扱っています。こうすることで記述する順番を気にすることなくルールを定義することも可能です。

次に配列を見ていきたいと思います。 上記でも記載しましたが、配列は下記のように記述することが可能です。

> sites = [{"name": "prod"}, {"name": "smoke1"}, {"name": "dev"}]
> r { sites[_].name == "prod" }
> r
true

sites[_].namesites の要素を網羅的に name 属性の値をチェックし、prod がないかチェックしています。

[_] が配列の中を網羅的に参照するので下記のようなに記述した場合、sites の要素の name 属性のみが返ってきます。

> q[name] { name := sites[_].name }
> q
[
  "prod",
  "smoke1",
  "dev"
]

ここで、 q のルールを包括している p のルールを作成して実行してみたいと思います。

> p { q["prod"] }
> p
true

true が返ってきましたね。ここで sites 要素の name 属性に存在しない値を入れてみたいと思います。

> w { q["smoke2"] }
> w
undefined 

true ではなくなりましたね。ちゃんと評価できているのが確認できます。

他にも配列ではイテレート処理に関して下記のように記述することも可能です。

> some i; sites[i]
+---+-------------------+
| i |     sites[i]      |
+---+-------------------+
| 0 | {"name":"prod"}   |
| 1 | {"name":"smoke1"} |
| 2 | {"name":"dev"}    |
+---+-------------------+

見てわかるように、ほかの言語(PHPとか)と同じように変数っぽく値を扱えたり、スカラー値や配列なども表現することができます。

違いとしては何度も言っていますが、宣言的な記述で手続きではないっというところですかね。

4. Regoのテスト

もう一つ、Open Policy Agentで気になった部分として先ほどのRegoに対してテストを書くことができます。 まずはサンプルを見てみたいと思います。

example.rego

package authz

allow {
    input.path == ["users"]
    input.method == "POST"
}

allow {
    some profile_id
    input.path = ["users", profile_id]
    input.method == "GET"
    profile_id == input.user_id
}

そして、先ほどのRegoをテストするコードを記述していきます。

example_test.rego

package authz

test_post_allowed {
    allow with input as {"path": ["users"], "method": "POST"}
}

test_get_anonymous_denied {
    not allow with input as {"path": ["users"], "method": "GET"}
}

test_get_user_allowed {
    allow with input as {"path": ["users", "bob"], "method": "GET", "user_id": "bob"}
}

test_get_another_user_denied {
    not allow with input as {"path": ["users", "bob"], "method": "GET", "user_id": "alice"}
}

ではこれを実行してみます。

$ ./opa test . -v
data.authz.test_post_allowed: PASS (785.051µs)
data.authz.test_get_anonymous_denied: PASS (443.895µs)
data.authz.test_get_user_allowed: PASS (526.708µs)
data.authz.test_get_another_user_denied: PASS (362.388µs)
--------------------------------------------------------------------------------
PASS: 4/4

おぉーテストできましたね(サンプルをコピペしただけですが)。 これは、ルールのプレフィックスtest_ で始まっているものを実行していっています。 テストは実行した結果が true になる場合のみ PASS となり、それ以外は FAIL、もしくは ERROR となります(そもそもランタイムエラーとか)。

サンプルとして下記のテストを実行してみます。

pass_fail_error_test.rego

package example

# This test will pass.
test_ok {
    true
}

# This test will fail.
test_failure {
    1 == 2
}

# This test will error.
test_error {
    1 / 0
}
$ ./opa test pass_fail_error_test.rego
data.example.test_failure: FAIL (414.31µs)
data.example.test_error: ERROR (400.987µs)
  pass_fail_error_test.rego:15: eval_builtin_error: div: divide by zero
--------------------------------------------------------------------------------
PASS: 1/3
FAIL: 1/3
ERROR: 1/3

test_failure で結果が FAIL となり、 test_error ではランタイムエラーとなり ERROR となりましたね。

テストを書けることでポリシーの品質の担保を行えるので、非常にありがたいですね。

4. 終わりに

Open Policy Agentについて軽く触ってみて、Regoの記述の仕方など興味深いものがありました。 ポリシーを評価するにあたって配列の扱いについてかなり表現の仕方が柔軟でほかの言語と書き方がだいぶ違うので戸惑うかと思いますが、慣れてくればすごく扱いやすいものになるんじゃないかと思います。

テストについてもポリシー自体の品質を担保してくれるので、とてもありがたい機能ですね。

次はOpen Policy Agentをアプリケーションの中で使ってみるか、Terraformの出力結果とかで使ってみたいと思います。

Prometheus PushgatewayにGolangからメトリクスを登録する

PrometheusではExporterに対してメトリクスを取得するため、HTTPリクエストを行いますが、これではバッチ処理などの一時的に起動するものなどのメトリクスを収集することができません。

そのため、PrometheusではPushgatewayというものが用意されています。これはバッチ処理などのメトリクスを保存し、PrometheusからPushgatewayに対して保存されているメトリクスを取得する仕組みになっています。

Pushgatewayにメトリクスを追加するためのライブラリがGolangにあったので、今回はこれを使ってメトリクスを収集したいと思います。

目次

  1. 動作環境
  2. PrometheusとPushgatewayの起動
  3. バッチ処理の作成
  4. 終わりに

1. 動作環境

  • Golang: 1.13.1
  • Prometheus: 2.15.1

2. PrometheusとPushgatewayの起動

まずは、PrometheusとPushgatewayを起動させます。PushgatewayはDocker Imageが用意されているようなので、それを使って起動させます。

$ docker pull prom/pushgateway
$ docker run -d -p 9091:9091 prom/pushgateway

これだけで完了です。 PushgatewayもExporterと同様にHTTPリクエストを受けれる口が用意されています。下記が確認用としてcurlでリクエストした結果になります。

$ curl http://localhost:9091/metrics
# HELP go_gc_duration_seconds A summary of the GC invocation durations.
# TYPE go_gc_duration_seconds summary
go_gc_duration_seconds{quantile="0"} 9.943e-06
go_gc_duration_seconds{quantile="0.25"} 3.0521e-05
go_gc_duration_seconds{quantile="0.5"} 4.2184e-05
go_gc_duration_seconds{quantile="0.75"} 0.000109096
go_gc_duration_seconds{quantile="1"} 0.000429447
go_gc_duration_seconds_sum 0.003177886
go_gc_duration_seconds_count 38
# HELP go_goroutines Number of goroutines that currently exist.
# TYPE go_goroutines gauge
go_goroutines 12
・・・

また、curlからメトリクスの追加もできます。

$ echo "some_metric 3.14" | curl --data-binary @- http://192.168.33.141:9091/metrics/job/some_job

$ curl http://localhost:9091/metrics

・・・
pushgateway_http_requests_total{code="200",handler="push",method="post"} 3
pushgateway_http_requests_total{code="200",handler="push",method="put"} 2
pushgateway_http_requests_total{code="200",handler="status",method="get"} 1
# TYPE some_metric untyped
some_metric{instance="",job="some_job"} 3.14

無事に追加できるところまで確認できましたね。

では次にPrometheusの設定をしていきます。 Prometheusでは通常の設定と同様、scrapeに設定を追加していきます。

  - job_name: 'push gateway'
    metrics_path: /metrics
    static_configs:
      - targets: ['192.168.33.141:9091']

192.168.33.141 はPushgatewayが起動してるサーバのIPになります。 これだけで準備完了となります。

3. バッチ処理の作成

次にバッチ処理Golangで書いていきたいと思います。 まずはサンプルとして Counter のメトリクスを作成し、Pushgatewayに送信したいと思います。

package main

import (
    "fmt"

    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/push"
)

func main() {
    sample := prometheus.NewCounter(prometheus.CounterOpts{
            Name: "myapp_sample_counter",
            Help: "myapp sample counter",
    })
        // 値をインクリメント
    sample.Inc()
    if err := push.New("http://192.168.33.141:9091", "my_job").
        Collector(sample).
        Push(); err != nil {
            fmt.Printf(err.Error())
        }
}

では、これを実行し、Pushgatewayでメトリクスを確認したいと思います。

$ curl http://localhost:9091/metrics
・・・
# HELP myapp_sample_counter myapp sample counter
# TYPE myapp_sample_counter counter
myapp_sample_counter{instance="",job="my_job"} 1

無事に登録されていますね。 バッチ処理のメトリクスっぽく起動時間を登録したいと思います。

サンプルなので途中で10秒停止して時間を計測したいと思います。

   execute := prometheus.NewSummary(prometheus.SummaryOpts{
            Name: "my_batch_job_execution_time",
            Help: "execution time",
    })
        // 時間の計測
    start := time.Now()
    time.Sleep(time.Second * 10)
    end := time.Now()

        // 実行時間を登録
    execute.Observe((end.Sub(start)).Seconds())
    if err := push.New("http://192.168.33.141:9091", "my_job").
        Collector(execute).
        Push(); err != nil {
            fmt.Printf(err.Error())
        }
$ curl http://localhost:9091/metrics

・・・
# HELP my_batch_job_execution_time execution time
# TYPE my_batch_job_execution_time summary
my_batch_job_execution_time_sum{instance="",job="my_job"} 10.000940275
my_batch_job_execution_time_count{instance="",job="my_job"} 1

登録できましたね。めでたし。

4. 終わりに

簡単ではありますが、Pushgatewayでバッチ処理のメトリクス収集までやってみました。

カスタムメトリクスの収集方法を覚えれば、Exporterでも役立つのでもっと使い方を調べていきたいと思います。

PrometheusでPhoenixを監視する

以前PrometheusでElixir Plugからメトリクスの収集をできるよう設定をしました。 今回はPhoenixで設定をしていきたいと思います。

ちなみに、下記のものがPlug単体のときに設定したものになります。

kobatako.hatenablog.com

目次

  1. 環境情報
  2. Phoenixのメトリクス収集の設定
  3. カスタムメトリクスに入門する
  4. 終わりに

1. 環境情報

  • Elixir: 1.9.1
  • Phoenix: 1.4.11
  • prometheus_phoenix: 1.3.0
  • prometheus_plugs: 1.1.5

2. Phoenixのメトリクス収集の設定

諸々のインストール

mix.exsprometheus_phoenixprometheus_plugs を追加します

・・・
      {:jason, "~> 1.0"},
      {:plug_cowboy, "~> 2.0"},
      {:prometheus_phoenix, "~> 1.3.0"},
      {:prometheus_plugs, "~> 1.1.5"}
    ]
  end

追加したら deps.get でインストールしていきます。

$ mix deps.get
・・・
New:
  accept 0.3.5
  prometheus 4.4.1
  prometheus_ex 3.0.5
  prometheus_phoenix 1.3.0
  prometheus_plugs 1.1.5
* Getting prometheus_phoenix (Hex package)
* Getting prometheus_plugs (Hex package)
* Getting accept (Hex package)
* Getting prometheus_ex (Hex package)
* Getting prometheus (Hex package)

必要なもののインストールが完了したので、ExporterとPhoenixのメトリクス収集のための設定をしていきたいと思います。

Exporterとメトリクス収集の設定

まずは、Exporter用のモジュールの作成をしていきます。

defmodule SamplePhoenix.PrometheusExporter do
  use Prometheus.PlugExporter
end

これはPlugでの設定と同様のものになります。

次に、Phoenixのメトリクス収集のためのモジュールを作成していきます。

defmodule SamplePhoenix.Instrumenter do
  use Prometheus.PhoenixInstrumenter
end

use Prometheus.PhoenixInstrumenter を記述するだけという、簡単なものになります。

ExporterとPhoenixのメトリクス収集用のモジュールが作成できたので application.ex にセットアップ処理を追加します。

  def start(_type, _args) do
  ・・・
    SamplePhoenix.Instrumenter.setup()
    SamplePhoenix.PrometheusExporter.setup()
    
    opts = [strategy: :one_for_one, name: SamplePhoenix.Supervisor]
    Supervisor.start_link(children, opts)

それぞれのモジュールで setup メソッドを呼べば完了となります。

次にExporter用のPlugの設定をしていきます。

Phoenixではよく router.ex にPlugの追加をすると思いますが、PrometheusのPlugの追加は endpoint.ex に追加します。

・・・
  plug SamplePhoenix.PrometheusExporter

  plug SamplePhoenixWeb.Router
end

これで一通り準備完了です。 一度、適当にアクセス(トップページとか)してから /metrics にアクセスし、メトリクスを確認してみましょう。

phoenix_controller_render_duration_microseconds_bucket{format="html",template="index.html",view="Elixir.SamplePhoenixWeb.PageView",le="10"} 0
phoenix_controller_render_duration_microseconds_bucket{format="html",template="index.html",view="Elixir.SamplePhoenixWeb.PageView",le="25"} 0
phoenix_controller_render_duration_microseconds_bucket{format="html",template="index.html",view="Elixir.SamplePhoenixWeb.PageView",le="50"} 0
phoenix_controller_render_duration_microseconds_bucket{format="html",template="index.html",view="Elixir.SamplePhoenixWeb.PageView",le="100"} 8

・・・

phoenix_channel_join_duration_microseconds_bucket{channel="Phoenix.LiveReloader.Channel",topic="phoenix:live_reload",transport="websocket",le="10000000"} 7
phoenix_channel_join_duration_microseconds_bucket{channel="Phoenix.LiveReloader.Channel",topic="phoenix:live_reload",transport="websocket",le="+Inf"} 7
phoenix_channel_join_duration_microseconds_count{channel="Phoenix.LiveReloader.Channel",topic="phoenix:live_reload",transport="websocket"} 7
phoenix_channel_join_duration_microseconds_sum{channel="Phoenix.LiveReloader.Channel",topic="phoenix:live_reload",transport="websocket"} 748.044

・・・

phoenix_controller_call_duration_microseconds_bucket{action="index",controller="SamplePhoenixWeb.PageController",le="+Inf"} 15
phoenix_controller_call_duration_microseconds_count{action="index",controller="SamplePhoenixWeb.PageController"} 15
phoenix_controller_call_duration_microseconds_sum{action="index",controller="SamplePhoenixWeb.PageController"} 7902.988

無事にPhoenixのメトリクス情報を確認することができましたね。

メトリクスですが、phoenix_controller_render_duration_microseconds_bucket では呼ばれたformat、template、viewが確認でき、phoenix_controller_call_duration_microseconds_bucket では呼ばれたactionとcontrollerが確認できます。 これだけでもメトリクスの収集としてはだいぶいい情報が集まってると思います。

Phoenixのメトリクス収集の設定ですが、今回は特にしてなかったですが設定を変更することができます。下記ドキュメントに記載されているので、もしよかったら合わせて確認してみてくださいな。

hexdocs.pm

3. カスタムメトリクスに入門する

次にカスタムメトリクスを追加してみたいと思います。 今回作成してものはトップページにリクエストが来たらカウントアップするだけという単純なものになります。

早速、カウントアップ用のモジュールを用意したいと思います。

defmodule SamplePhoenix.TopHttpRequestInstrumenter do
  use Prometheus.Metric

  def setup do
    Counter.declare([name: :top_request_count,
                   help: "http request count"])
  end

  def add() do
    Counter.inc([name: :top_request_count])
  end
end

setup メソッドでは Counter.declare を呼びます。これで top_request_count メトリクスのCounterを作成します。

add メソッドでは top_request_count メトリクスの値をインクリメントするよう、Counter.inc を呼びます。 ですのでトップページにリクエストがきたタイミングで add メソッドを呼ぶ処理を追加してあげれば top_request_count メトリクスがインクリメントされるようになります。

カスタムモジュールの準備ができたのでセットアップ処理を application.ex 追加します。

  def start(_type, _args) do
  ・・・
    # 追加分
    SamplePhoenix.TopHttpRequestInstrumenter.setup()
    SamplePhoenix.Instrumenter.setup()
    SamplePhoenix.PrometheusExporter.setup()

次にトップページにリクエストが来たときに呼ばれるコントローラに add メソッドを呼ぶよう処理を追加しましょう。

  def index(conn, _params) do
    SamplePhoenix.TopHttpRequestInstrumenter.add()
    render(conn, "index.html")
  end

これで準備が完了しました。Phoenixを起動させ、 /metrics にアクセスしてみましょう。

# TYPE top_request_count counter
# HELP top_request_count http request count
top_request_count 0

まだ一度もトップページにリクエストしてないので、カウントは0の状態ですね。

試しに、5回ほど、トップページにリクエストした後にメトリクスを確認してみます。

# TYPE top_request_count counter
# HELP top_request_count http request count
top_request_count 5

無事にカウントアップされてるのが確認できました。 以上で、カスタムメトリクスの設定は完了となります。

補足ですが、今回カスタムメトリクスで Counter を使ってみましたが、ほかにも GaugeSummaryHistogram Boolean などがあります。

それぞれの用途は下記のようにあります。

  • Counter: カウンタアップしていくもの(リクエスト数など)
  • Gauge: 瞬間的な値(メモリ利用量など)
  • Summary: 平均値(レイテンシなど)
  • Histogram: データの分布などに使う(レイテンシのパーセントごとに分布させるなど)
  • Boolean: True or Falseでのフラグ(ステータスなど)

4. 終わりに

今回はPhoenixでPrometheus用のExporterの設定とメトリクスの監視、カスタムメトリクスの追加まで行いました。 デフォルトのPhoenixのPrometheusのメトリクスでも十分な部分もありますが、よりアプリケーションよりのメトリクスを監視したい場合はカスタムメトリクスが必要になってくると思います。なので Counter Gauge Summary Histogram Boolean がどのようにメトリクスを収集するのか性質を把握しておく必要があります。

ただ、カスタムメトリクスの設定自体は簡単なものだったので他のも機会を見つけて試していきたいと思います。

PrometheusでElixir Plugを監視する(Grafara設定付き)

最近、Prometheusを触り始めました。

なので何か監視したいと思い、サンプルとしてElixirのPlugの設定をし、Erlang VMの監視をしたいと思います。

今回使ったライブラリのリポジトリは下記になるので、設定とか参考にしてみてください。

github.com

また、監視として見れるものは以下に記載されていますので、確認してみてください。

hexdocs.pm

目次

  1. 環境情報
  2. Plugの設定
  3. Prometheusの設定
  4. Grafanaで可視化
  5. 終わりに

1. 環境情報

  • Elixir: 1.9.1
  • Plug: 2.0
  • prometheus_plugs: 1.1.1
  • Prometheus: 2.15.1
  • Grafana: 6.5.2

2. Plugの設定

まずはPlugの設定をしていきます。 とりあえず、ルーティングの設定をしたものになります。

また、今回はPlugについての説明は省いていくのでご了承くださいな。

defmodule SampleWeb.Router do
  use Plug.Router

  plug SampleWeb.MetricsExporter
  plug :match
  plug :dispatch

  get "/" do
    send_resp(conn, 200, "Welcome")
  end

  match _ do
    send_resp(conn, 404, "Oops!")
  end
end

Plugが起動できて、HTTPリクエストが受けられるか確認するため / にアクセスが来たら Welcome を返すだけのものを作成しました。

ここでは plug SampleWeb.MetricsExporter を追加しました。 これは /metrics にアクセスが来たらメトリクスを返すような設定になります。

では、 SampleWeb.MetricsExporter の中を見てみましょう。

defmodule SampleWeb.MetricsExporter do
  use Prometheus.PlugExporter
end

設定としてはこれだけです。 use Prometheus.PlugExporter を記載するだけという非常にシンプルなものになります。

次に Application での設定をしていきます。

defmodule SampleWeb.Application do
  
  use Application

  def start(_type, _args) do
    children = [
      {Plug.Cowboy, scheme: :http, plug: SampleWeb.Router, options: [port: 8080]}
    ]

    SampleWeb.MetricsExporter.setup()
    
    opts = [strategy: :one_for_one, name: SampleWeb.Supervisor]
    Supervisor.start_link(children, opts)
  end
end

ここでは SampleWeb.MetricsExporter.setup() を呼び出すだけで完了になります。これでPlugの設定が完了しましたので、次にPrometheusの設定をしていきます。

3. Prometheusの設定

Prometheusでは以下の設定をしていきます。

scrape_configs:
  - job_name: 'elixir_plug'
    metrics_path: /metrics
    static_configs:
     - targets: ['192.168.33.101:8080']

これでひとまず準備完了。では実際にPrometheusの画面で見てみましょう。

f:id:bakotako:20200103004106p:plain

無事にメトリクスが見れるようになりましたね。

4. Grafanaで可視化

Prometheusで集めたメトリクス情報をGrafarで可視化したいと思います。 まずは、Grafaraにログインし、データソースを追加します。

f:id:bakotako:20200103000252p:plain

Name には Elixir Plug とし、URL 部分に起動中のPrometheusのURLを入れます。

f:id:bakotako:20200103000656p:plain

これで設定が完了しました。

では早速、可視化してみましょう。とりあえず、 erlang_vm_allocatorsinstance_no=0 のものを見てみたいと思います。

f:id:bakotako:20200103001740p:plain

とりあえず、可視化することに成功しました。やったぜ。

次に erlang_vm_memory_bytes_totalkind="processes" を見てみたいと思います。これで現在割り当てられているメモリの量が確認することができます。

f:id:bakotako:20200103002155p:plain

Erlang VMのいろんなメトリクス情報を可視化することができました。

5. 終わりに

簡単ではありましたが、PrometheusでErlang VM監視用のElixir Plugの設定とGrafanaの可視化までやってみました。 可視化については簡単にどのように使うかまでしか見てませんので、実運用になってきたらメモリ関係はしっかりと監視したいですね。

Prometheusではいろんな言語でClientライブラリがあり、その中でElixirのメトリクス監視をやってみました。Plugの設定箇所は少なかったのですが、ちょっと苦戦してしまいました。 特にElixirですとPhoenixを使いたいところですが、今回の設定をそのままやってみてもうまくいきませんでした。。。

機会があるときにPhoenixでの設定に挑戦してみたいと思います。