AWS環境でChronyを使ってみた

AWS環境でChronyを使ってみた

2026.02.23

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

オンプレミス環境から AWS VPC 内のリソースと時刻を同期したい場合、VPC の Amazon Time Sync Service(169.254.169.123)はリンクローカルアドレスのため直接参照できません。
今回は代替として、ECS Fargate 上で Chrony を NTP フォワーダーとして使用し、NLB の固定 IP を経由してクライアントに提供する構成を試しました。

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

概要

Chrony とは

Chrony は、NTP の実装の一つで、Linux で広く使われる時刻同期ソフトウェアです。
インターネットやローカルの NTP サーバーと同期しつつ、自身を NTP サーバーとして時刻を提供することもできます。

今回は AWS Time Sync Service と同期して、NTP フォワーダーのような役割として使用しています。

https://chrony-project.org/

構成

作成した構成は下記の通りです。
検証用のためシングル AZ 構成にしていますが、本番運用を考える場合はマルチ AZ 構成が推奨されます。
ECS 単体ではプライベート IP を固定できないため、NLB と組み合わせてクライアントからは NLB のプライベート固定IPアドレスを使って NTP サーバーにアクセスする形にしています。

Chrony.png

やってみた

1. Chrony(Dockerfile・entrypoint)

使用した Chrony の Dockerfile とエントリポイントを記載します。

ベースイメージは dockurr/chrony を使用しています。
NTP は UDP 123 で提供しますが、NLB のヘルスチェックは TCP/HTTP/HTTPS のみに対応のため、TCP 8080 で簡易的に listen するエントリポイントを用意し、NLB のターゲットグループでは TCP 8080 をヘルスチェックに使っています。

Dockerfile
# NTP サーバー(chrony)
# https://hub.docker.com/r/dockurr/chrony
FROM dockurr/chrony:4.8

ENV TZ="Asia/Tokyo"

ENV LOG_LEVEL="0"

# ヘルスチェック用エントリポイント(TCP 8080 で NLB ヘルスチェックを受付)
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

# NTP 用ポート公開(UDP 123)
EXPOSE 123/udp
# NLB ヘルスチェック用(TCP 8080)
EXPOSE 8080/tcp

ENTRYPOINT ["/entrypoint.sh"]
entrypoint.sh
#!/bin/sh
# ヘルスチェック用 TCP 8080 をバックグラウンドで listen(NLB の TCP ヘルスチェック用)
while true; do nc -l -p 8080; done &
exec /bin/startup "$@"

2. Terraform テンプレート

今回使用した Terraform の要点を記載します。
VPC はカスタムモジュールを使用していますが、本題から逸れるため割愛します。

上流 NTP サーバーの指定には環境変数 NTP_SERVERS で、AWS Time Sync Service のリンクローカルIPアドレスを設定しています。
ECS タスク定義では、コンテナのヘルスチェックに chronyc -n tracking を利用し、chrony が正常に動作していることを確認しています。

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"
    }
  }

  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 = "NTP UDP"
          from_port   = 123
          to_port     = 123
          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 NTP (UDP)
resource "aws_lb_target_group" "ntp" {
  name        = "${var.project_name}-${var.env}-tg-ntp"
  port        = 123
  protocol    = "UDP"
  target_type = "ip"
  vpc_id      = module.vpc.vpc.id

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

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

# NLB Listener for NTP (UDP)
resource "aws_lb_listener" "ntp" {
  load_balancer_arn = aws_lb.nlb.arn
  port              = 123
  protocol          = "UDP"

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

################################################################################
# NTP                                                                          #
################################################################################

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

  repository_name = "ecs/${var.project_name}-${var.env}/ntp-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}/ntp-srv"
  }
}

################################################################################
# ECS(NTP)                                                                   #
################################################################################
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}-ntp-srv" = {
      cpu    = 256
      memory = 512

      track_latest = true

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

      # Container定義
      container_definitions = {
        "${var.project_name}-${var.env}-ntp-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:ntp-v1"

          # Chrony forward の上流(Time Sync Service)
          environment = [
            { name = "NTP_SERVERS", value = "169.254.169.123" }
          ]

          portMappings = [
            {
              name          = "ntp"
              containerPort = 123
              protocol      = "udp"
            },
            {
              name          = "health_check"
              containerPort = 8080
              protocol      = "tcp"
            }
          ]

          healthCheck = {
            command     = ["CMD-SHELL", "chronyc -n tracking || exit 1"]
            interval    = 30
            timeout     = 5
            retries     = 3
            startPeriod = 60
          }

          enable_cloudwatch_logging              = true
          cloudwatch_log_group_name              = "/aws/ecs/${var.project_name}-${var.env}/ntp-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 = {
        ntp_udp = {
          description                  = "NTP UDP from NLB"
          from_port                    = 123
          to_port                      = 123
          ip_protocol                  = "udp"
          referenced_security_group_id = module.vpc.security_groups["sg_nlb"].id
        }
        ntp_health_check = {
          description                  = "NTP Health Check from NLB"
          from_port                    = 8080
          to_port                      = 8080
          ip_protocol                  = "tcp"
          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 = {
        ntp = {
          target_group_arn = aws_lb_target_group.ntp.arn
          container_name   = "${var.project_name}-${var.env}-ntp-srv"
          container_port   = 123
        }
      }

      # 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-ntp-srv"
      }

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

  depends_on = [
    aws_lb_listener.ntp
  ]
}

################################################################################
# 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"
  }
}

3. 動作確認

EC2 にログインして、Chrony の動作を確認します。

初期設定では、下記の通り AWS Time Sync Service のリンクローカルIPが指定されています。

CleanShot20260222at23.30.02.png

EC2 のNTPサーバーとして NLB のプライベート固定IPアドレス(172.16.4.10)を指定します。

  1. /etc/chrony.conf の下記の行をコメントアウト

...

# Use NTP servers from DHCP.
# sourcedir /run/chrony.d <- この行

# Include configuration found in /etc/chrony.d/*.conf
confdir /etc/chrony.d

...

  1. /etc/chrony.d/ntp-server.conf を作成し、下記の行を追加
server 172.16.4.10 prefer iburst
  1. 下記のコマンドで chrony を再起動
sudo systemctl restart chronyd

作成した NTP サーバーが使用されていることが確認できました。

CleanShot20260222at23.38.17.png

あとがき

Chrony は設定がシンプルで、Docker イメージと Terraform の組み合わせでもすぐに試せました。
オンプレ環境との接続において、オンプレ側にNTPサーバーが存在せず、インターネットへの接続もできない場合などに、選択肢の一つとなるかと思います。

以上、くろすけでした!

この記事をシェアする

FacebookHatena blogX

関連記事