AWS環境でCoreDNSを使ってみた

AWS環境でCoreDNSを使ってみた

2026.02.22

歴史シミュレーションゲーム好きのくろすけです!

オンプレミス環境からAWSリソースへのDNS解決をする場合、Route 53 Resolver の Inbound Endpoint を使う方法がありますが、小規模なシステムではコストが難点になる場合があります。
今回は代替手段として ECS Fargate で CoreDNS を試してみました。

本記事では、Terraform で AWS 上の CoreDNS(ECS Fargate + NLB)を構成してみた結果をまとめます。

概要

CoreDNS とは

CoreDNS は、Go で書かれたオープンソースの DNS サーバーです。
プラグインで機能を拡張でき、フォワードやキャッシュ、ログ出力などを設定ファイル(Corefile)で記述します。

https://coredns.io/

構成

作成した構成は下記の通りです。
検証のために作成したためシングルAZ構成にしていますが、本番運用を考える場合はマルチAZ構成が推奨されますので、NLB も設けています。

また、ECS 単体ではプライベートIPアドレスを固定できませんが、NLB と組み合わせることで固定可能です。
したがってシングルAZ構成の場合であっても、基本的には NLB と組み合わせて使用することがベターと考えています。

CoreDNS.png

やってみた

1. CoreDNS

使用した CoreDNS の Config、Dockerfile を記載します。

上流のDNSサーバーに Route 53 Resolver を指定します。
Route 53 Resolver のプライベートIPアドレスは、169.254.169.253 もしくは VPC+2 (今回の場合 172.16.0.2) に設定されているようです。

https://docs.aws.amazon.com/ja_jp/vpc/latest/userguide/AmazonDNS-concepts.html

今回は、タスクの定義にて環境変数(DNS_SERVERS)を設定しています。

Config
# CoreDNS設定ファイル
# Route53 Resolver Inbound Endpointの代替として使用

# すべてのDNSクエリを処理
.:53 {
    # エラーログを有効化
    errors

    # キャッシュ設定(30秒)
    cache 30
    
    # ループ検出(無限ループを防ぐ)
    loop
    
    # クエリログを出力
    log
    
    # VPC内のRoute53 DNSにフォワード
    # VPC DNSサーバー(Route53 Resolver)のアドレス
    # 注意: この設定はAWS VPC内でのみ動作します
    forward . {$DNS_SERVERS} {
        max_concurrent 1000
        # タイムアウト設定
        expire 10s
        # ヘルスチェック
        health_check 10s
    }
}
Dockerfile
# CoreDNS Dockerfile for DNS Server on ECS Fargate
# CoreDNS公式イメージを使用
FROM coredns/coredns:1.14.1

# 設定ファイルをコピー
COPY Corefile /etc/coredns/Corefile

# DNS用のポートを公開(TCP/UDP両方)
EXPOSE 53/tcp 53/udp

# CoreDNSを実行
WORKDIR /etc/coredns
ENTRYPOINT ["/coredns"]
CMD ["-conf", "/etc/coredns/Corefile"]

2. Terraform テンプレート

今回使用した Terraform テンプレートは下記になります。
VPCではカスタムモジュールを使用していますが、本題から逸れるかつ文字数の都合で紹介が難しいため割愛させていただきます。

下記のテンプレートでは ECR と ECS のタスクの定義で使用している URI が異なっています。
これはデプロイの順番が ECR => イメージビルド・プッシュ => ECS となる必要があるため、初めはダミーのイメージで ECS を起動し、その後に正式なイメージを ECR にプッシュして ECS に反映するという方法を採用しているためです。

方法としては「ECR のみテンプレートを分ける」もしくは「イメージビルド・プッシュもテンプレートに記述する」方法もあります。
ただし、個人的にはこれらの方法はライフサイクルの観点からあまり良いとは考えていないため、今回のような方法をとっています。

とはいえ今回の検証はダミーのリポジトリに CoreDNS のイメージを事前にプッシュしておいたので、更新不要で起動できています。

main.tf
################################################################################
# Data Sources                                                                 #
################################################################################
data "aws_caller_identity" "current" {}
data "aws_region" "current" {}

################################################################################
# VPC                                                                          #
################################################################################
module "vpc" {
  source     = "../../modules/vpc"
  name       = "${var.project_name}-${var.env}-vpc"
  cidr_block = var.vpc_cidr_block

  igw_name = "${var.project_name}-${var.env}-igw"

  subnets = {
    "public_a" = {
      name              = "${var.project_name}-${var.env}-subnet-publ-a"
      cidr_block        = var.public_subnets.a.cidr_block
      availability_zone = "ap-northeast-1a"

      route_tables = ["public"]
      network_acl  = "public"
    },
    "protected_a" = {
      name              = "${var.project_name}-${var.env}-subnet-prot-a"
      cidr_block        = var.protected_subnets.a.cidr_block
      availability_zone = "ap-northeast-1a"

      route_tables = ["protected_a"]
      network_acl  = "protected"
    }
  }

  route_tables = {
    "public" = {
      name                 = "${var.project_name}-${var.env}-rtb-publ",
      use_internet_gateway = true,
    },
    "protected_a" = {
      name        = "${var.project_name}-${var.env}-rtb-prot-a",
      nat_gateway = "ngw_a"
    },
    "protected_c" = {
      name = "${var.project_name}-${var.env}-rtb-prot-c"
    }
  }

  nat_gateways = {
    "ngw_a" = {
      name      = "${var.project_name}-${var.env}-ngw-a"
      subnet_id = "public_a"
    }
  }

  network_acls = {
    "public" = {
      name = "${var.project_name}-${var.env}-nacl-publ"
      ingress = [
        {
          rule_no    = 100
          protocol   = "-1"
          action     = "allow"
          cidr_block = "0.0.0.0/0"
          from_port  = 0
          to_port    = 0
        }
      ]
      egress = [
        {
          rule_no    = 100
          protocol   = "-1"
          action     = "allow"
          cidr_block = "0.0.0.0/0"
          from_port  = 0
          to_port    = 0
        }
      ]
    },
    "protected" = {
      name = "${var.project_name}-${var.env}-nacl-prot"
      ingress = [
        {
          rule_no    = 100
          protocol   = "-1"
          action     = "allow"
          cidr_block = "0.0.0.0/0"
          from_port  = 0
          to_port    = 0
        }
      ]
      egress = [
        {
          rule_no    = 100
          protocol   = "-1"
          action     = "allow"
          cidr_block = "0.0.0.0/0"
          from_port  = 0
          to_port    = 0
        }
      ]
    }
  }

  security_groups = {
    "sg_ec2" = {
      name        = "${var.project_name}-${var.env}-sg-ec2"
      description = "Security group for EC2"

      egress = [
        {
          description = "All outbound traffic"
          from_port   = 0
          to_port     = 0
          protocol    = "-1"
          cidr_blocks = ["0.0.0.0/0"]
        }
      ]
    },
    "sg_nlb" = {
      name        = "${var.project_name}-${var.env}-sg-nlb"
      description = "Security group for NLB"

      ingress = [
        {
          description = "DNS TCP"
          from_port   = 53
          to_port     = 53
          protocol    = "tcp"
          cidr_blocks = [var.protected_subnets.a.cidr_block]
        },
        {
          description = "DNS UDP"
          from_port   = 53
          to_port     = 53
          protocol    = "udp"
          cidr_blocks = [var.protected_subnets.a.cidr_block]
        }
      ]
      egress = [
        {
          description = "All outbound traffic"
          from_port   = 0
          to_port     = 0
          protocol    = "-1"
          cidr_blocks = ["0.0.0.0/0"]
        }
      ]
    }
  }
}

################################################################################
# NLB                                                                          #
################################################################################
resource "aws_lb" "nlb" {
  name               = "${var.project_name}-${var.env}-nlb"
  internal           = true
  load_balancer_type = "network"

  security_groups = [module.vpc.security_groups["sg_nlb"].id]

  subnet_mapping {
    subnet_id            = module.vpc.subnets["protected_a"].id
    private_ipv4_address = var.nlb_private_ip_prot_a
  }

  enable_deletion_protection = false

  tags = {
    Name = "${var.project_name}-${var.env}-nlb"
  }
}

# Target Group for DNS (TCP_UDP)
resource "aws_lb_target_group" "dns" {
  name        = "${var.project_name}-${var.env}-tg-dns"
  port        = 53
  protocol    = "TCP_UDP"
  target_type = "ip"
  vpc_id      = module.vpc.vpc.id

  health_check {
    enabled             = true
    protocol            = "TCP"
    port                = 53
    interval            = 30
    healthy_threshold   = 3
    unhealthy_threshold = 3
  }

  tags = {
    Name = "${var.project_name}-${var.env}-tg-dns"
  }
}

# NLB Listener for DNS (TCP_UDP)
resource "aws_lb_listener" "dns" {
  load_balancer_arn = aws_lb.nlb.arn
  port              = 53
  protocol          = "TCP_UDP"

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.dns.arn
  }
}

################################################################################
# DNS                                                                          #
################################################################################

# ECR Repository for CoreDNS
module "ecr_coredns" {
  source  = "terraform-aws-modules/ecr/aws"
  version = "~> 3.2"

  repository_name = "ecs/${var.project_name}-${var.env}/dns-srv"

  # イメージタグの可変性
  repository_image_tag_mutability = "IMMUTABLE"

  # プッシュ時の自動スキャン
  repository_image_scan_on_push = true

  # 暗号化設定(デフォルトのAES256を使用)
  repository_encryption_type = "AES256"

  # リポジトリ削除時に画像があっても削除可能
  repository_force_delete = true

  # ライフサイクルポリシー(古いイメージを自動削除)
  repository_lifecycle_policy = jsonencode({
    rules = [
      {
        rulePriority = 1
        description  = "Keep last 10 images"
        selection = {
          tagStatus   = "any"
          countType   = "imageCountMoreThan"
          countNumber = 10
        }
        action = {
          type = "expire"
        }
      }
    ]
  })

  tags = {
    Name = "ecs/${var.project_name}-${var.env}/dns-srv"
  }
}

################################################################################
# ECS(DNS)                                                                   #
################################################################################
module "ecs" {
  source  = "terraform-aws-modules/ecs/aws"
  version = "~> 7.3"

  cluster_name = "${var.project_name}-${var.env}-cluster"

  # Cluster設定
  cluster_configuration = {
    execute_command_configuration = {
      logging = "DEFAULT"
    }
  }

  cluster_setting = [{
    name  = "containerInsights"
    value = "enabled"
  }]

  # Task Execution IAM Role
  create_task_exec_iam_role          = true
  task_exec_iam_role_name            = "${var.project_name}-${var.env}-task-exec-role"
  task_exec_iam_role_use_name_prefix = false
  task_exec_iam_role_policies = {
    AmazonECSTaskExecutionRolePolicy = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy"
  }

  # Fargate Capacity Provider
  cluster_capacity_providers = ["FARGATE"]
  default_capacity_provider_strategy = {
    FARGATE = {
      weight = 1
    }
  }

  # VPC ID(Security Group作成用)
  vpc_id = module.vpc.vpc.id

  # ECS Service定義
  services = {
    "${var.project_name}-${var.env}-dns-srv" = {
      cpu    = 256
      memory = 512

      track_latest = true

      security_group_name            = "${var.project_name}-${var.env}-sg-dns-srv"
      security_group_use_name_prefix = false

      # Container定義
      container_definitions = {
        "${var.project_name}-${var.env}-dns-srv" = {
          cpu       = 256
          memory    = 512
          essential = true
          image     = "${data.aws_caller_identity.current.account_id}.dkr.ecr.${data.aws_region.current.id}.amazonaws.com/ecs/dummy-krsk-repo:dns-v1"

          # CoreDNS forward の上流(VPC DNS Resolver)
          environment = [
            { name = "DNS_SERVERS", value = "169.254.169.253" }
          ]

          portMappings = [
            {
              name          = "dns"
              containerPort = 53
              protocol      = "tcp"
            }
          ]

          enable_cloudwatch_logging              = true
          cloudwatch_log_group_name              = "/aws/ecs/${var.project_name}-${var.env}/dns-srv"
          cloudwatch_log_group_retention_in_days = 7
        }
      }

      # Network設定
      subnet_ids       = [module.vpc.subnets["protected_a"].id]
      assign_public_ip = false

      # Security Group設定
      security_group_ingress_rules = {
        dns_tcp = {
          description                  = "DNS TCP from NLB"
          from_port                    = 53
          to_port                      = 53
          ip_protocol                  = "tcp"
          referenced_security_group_id = module.vpc.security_groups["sg_nlb"].id
        }
        dns_udp = {
          description                  = "DNS UDP from NLB"
          from_port                    = 53
          to_port                      = 53
          ip_protocol                  = "udp"
          referenced_security_group_id = module.vpc.security_groups["sg_nlb"].id
        }
      }
      security_group_egress_rules = {
        all = {
          ip_protocol = "-1"
          cidr_ipv4   = "0.0.0.0/0"
        }
      }

      # Load Balancer設定
      load_balancer = {
        dns = {
          target_group_arn = aws_lb_target_group.dns.arn
          container_name   = "${var.project_name}-${var.env}-dns-srv"
          container_port   = 53
        }
      }

      # Deployment設定
      deployment_maximum_percent         = 100
      deployment_minimum_healthy_percent = 0
      desired_count                      = 1

      # タスク定義のtrack_latest相当の設定
      ignore_task_definition_changes = false

      # サービス固有のタグ
      service_tags = {
        Name = "${var.project_name}-${var.env}-svc-dns-srv"
      }

      # タスク定義のタグ
      task_tags = {
        Name = "${var.project_name}-${var.env}-dns-srv"
      }
    }
  }

  depends_on = [
    aws_lb_listener.dns
  ]
}

################################################################################
# EC2                                                                          #
################################################################################

# SSM エージェント入りの標準 Amazon Linux 2023 AMI を Parameter Store から取得
data "aws_ssm_parameter" "amazon_linux_2023_ami" {
  name = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"
}

# EC2用のIAMロール(モジュール使用)
module "ec2_iam_role" {
  source  = "terraform-aws-modules/iam/aws//modules/iam-role"
  version = "~> 6.4"

  name            = "${var.project_name}-${var.env}-role-ec2"
  use_name_prefix = false

  create_instance_profile = true

  # Trust policy(EC2が引き受けるロール)
  trust_policy_permissions = {
    EC2AssumeRole = {
      actions = ["sts:AssumeRole"]
      principals = [
        {
          type        = "Service"
          identifiers = ["ec2.amazonaws.com"]
        }
      ]
    }
  }

  # マネージドポリシー(SSM接続用)
  policies = {
    AmazonSSMManagedInstanceCore = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
  }

  tags = {
    Name = "${var.project_name}-${var.env}-role-ec2"
  }
}

# EC2インスタンス
module "ec2" {
  source  = "terraform-aws-modules/ec2-instance/aws"
  version = "~> 6.2"

  name = "${var.project_name}-${var.env}-ec2"

  ami                    = data.aws_ssm_parameter.amazon_linux_2023_ami.value
  instance_type          = "t3.micro"
  subnet_id              = module.vpc.subnets["protected_a"].id
  vpc_security_group_ids = [module.vpc.security_groups["sg_ec2"].id]
  create_security_group  = false

  iam_instance_profile = module.ec2_iam_role.instance_profile_name

  # EBSボリューム設定
  root_block_device = {
    volume_type = "gp3"
    volume_size = 8
    encrypted   = true
  }

  tags = {
    Name = "${var.project_name}-${var.env}-ec2"
  }
}
variables.tf
variable "env" {
  description = "Environment name"
  type        = string
  default     = "dev"
}

variable "project_name" {
  description = "Project name"
  type        = string
}

variable "vpc_cidr_block" {
  description = "VPC CIDR block"
  type        = string
}

variable "public_subnets" {
  description = "Public subnets"
  type = map(object({
    cidr_block = string
  }))
}

variable "protected_subnets" {
  description = "Protected subnets"
  type = map(object({
    cidr_block = string
  }))
}

variable "private_subnets" {
  description = "Private subnets"
  type = map(object({
    cidr_block = string
  }))
}

variable "nlb_private_ip_prot_a" {
  description = "Private IP for NLB node (must be in protected subnet CIDR)"
  type        = string
}

3. 動作確認

デプロイが完了後に EC2 にログインして、下記の通り動作を確認しました。
NLB のプライベートIPアドレスを指定して、NLB のDNS解決ができるか確認しています。

CleanShot20260222at16.51.27.png

今回はターゲットグループおよびセキュリティグループで tcp のポートも開放しているので、tcp でもDNS解決可能です。

CleanShot20260222at16.51.55.png

課題

最後に検証を通して見えてきた課題もありました。
NLB のヘルスチェックは TCP,HTTP,HTTPS のみ対応しているため、UDP のみ使用する場合は実際に使用する udp:53 が生きているのか正確にチェックできません。

コンテナ側でヘルスチェックを実施することが良さそうと思いましたが、CoreDNS の公式 Docker イメージは scratch ベースのため、シェルやパッケージマネージャが含まれておらず、コンテナ内で dignslookup などを後からインストールすることもできません。
コンテナ側のヘルスチェックで対応する場合は、Alpine などパッケージが使えるベースイメージで自前イメージを作成する必要がありそうです。

あとがき

CoreDNS は設定もわかりやすく、簡単に作成することができました。
もちろん Route53 Resolver Inbound Endpoint に比べて保守の手間がありますが、コストの問題がある場合には選択肢の一つとして検討してもいいかもしれません。

以上、くろすけでした!

この記事をシェアする

FacebookHatena blogX

関連記事