
AWS環境でChronyを使ってみた
歴史シミュレーションゲーム好きのくろすけです!
オンプレミス環境から 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 フォワーダーのような役割として使用しています。
構成
作成した構成は下記の通りです。
検証用のためシングル AZ 構成にしていますが、本番運用を考える場合はマルチ AZ 構成が推奨されます。
ECS 単体ではプライベート IP を固定できないため、NLB と組み合わせてクライアントからは NLB のプライベート固定IPアドレスを使って NTP サーバーにアクセスする形にしています。

やってみた
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が指定されています。

EC2 のNTPサーバーとして NLB のプライベート固定IPアドレス(172.16.4.10)を指定します。
/etc/chrony.confの下記の行をコメントアウト
...
# Use NTP servers from DHCP.
# sourcedir /run/chrony.d <- この行
# Include configuration found in /etc/chrony.d/*.conf
confdir /etc/chrony.d
...
/etc/chrony.d/ntp-server.confを作成し、下記の行を追加
server 172.16.4.10 prefer iburst
- 下記のコマンドで
chronyを再起動
sudo systemctl restart chronyd
作成した NTP サーバーが使用されていることが確認できました。

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







