この記事は Medley(メドレー) Advent Calendar 2025 3日目の記事です🎄
はじめに
どうも、株式会社メドレーのSREエンジニアの森川です⚙️
先日、新規のインフラを構築しておりまして、ALB(Application Load Balancer)とターゲットグループにてヘルスチェックが通らないという状況に陥りました。調査を進める中で得られた知見を書き留めておこうと思います。
また、この機会に普段あまり意識しない「いいヘルスチェックのコマンド」についても考えてみました。
ヘルスチェック落ちてるんだが
発見の経緯
ALB・ターゲットグループ・Fargateを構築した際、ターゲットグループのヘルスチェックが unhealthy になっていることに気が付きました。
- ECSコンテナヘルスチェックは通っている
- ECS exec でコンテナにアクセスし、プロセス確認も問題なし
Fargate側では問題がなさそうです。
次に「ALB↔Fargate間の接続の問題か?」と疑い、調査を進めました。
すると、ALBのセキュリティグループでegressルールに何も定義していないことを発見しました。
なぜegressが必要なのか
ALBのヘルスチェックは、ALBからターゲットに対してHTTPリクエストを送信することで行われます。
つまり、ALBはターゲットへの「アウトバウンド通信」を行うため、egressルールでターゲットへの通信を許可する必要があります。
検証の構成
記事執筆にあたって同様の状況を作って検証を行ってみました。
構成は以下の通りです。
インターネット
│
▼
┌─────────┐
│ ALB │ ← セキュリティグループ: ALB用
└────┬────┘ - ingress: 80/tcp (0.0.0.0/0)
│ - egress: ??? (ここを検証)
▼
┌─────────┐
│ Nginx │ ← セキュリティグループ: EC2用
│ (EC2) │ - ingress: 80/tcp (ALB SGから)
└─────────┘ ※ Nginxを使用することでアクセスログが見やすい
検証環境の構築
以下の terraform コードから構築が可能です。
検証構成 terraform コード
前提
- DefaultのVPC、Subnetが存在すること
-
profile = "your-aws-profile"は自分の環境に合わせて変更してください - Nginxのログはわかりやすいようにjson形式での出力にしています
# =============================================================================
# ALB ヘルスチェック検証用 Terraform(単一ファイル版)
# 検証手順:
# 1. enable_alb_egress = false で apply → ヘルスチェック失敗を確認
# 2. enable_alb_egress = true で apply → ヘルスチェック成功を確認
# =============================================================================
terraform {
required_version = ">= 1.0.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" {
region = "ap-northeast-1"
profile = "your-aws-profile" # 自分のプロファイルに変更
}
locals {
name = "alb-hc-test"
}
variable "enable_alb_egress" {
type = bool
default = false # まず失敗を確認、その後 true にして成功を確認
}
# -----------------------------------------------------------------------------
# Data Sources(Default VPC を使用)
# -----------------------------------------------------------------------------
data "aws_vpc" "default" {
default = true
}
data "aws_subnets" "default" {
filter {
name = "vpc-id"
values = [data.aws_vpc.default.id]
}
filter {
name = "default-for-az"
values = ["true"]
}
}
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
# -----------------------------------------------------------------------------
# Security Groups
# -----------------------------------------------------------------------------
# ALB用 SG
resource "aws_security_group" "alb" {
name = "${local.name}-alb-sg"
description = "Security group for ALB"
vpc_id = data.aws_vpc.default.id
ingress {
description = "HTTP from internet"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group_rule" "alb_egress_to_ec2" {
count = var.enable_alb_egress ? 1 : 0
type = "egress"
from_port = 80
to_port = 80
protocol = "tcp"
security_group_id = aws_security_group.alb.id
source_security_group_id = aws_security_group.ec2.id
description = "Allow HTTP to EC2 instances"
}
# EC2用 SG
resource "aws_security_group" "ec2" {
name = "${local.name}-ec2-sg"
description = "Security group for EC2 instances"
vpc_id = data.aws_vpc.default.id
ingress {
description = "HTTP from ALB"
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
description = "All outbound traffic"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# -----------------------------------------------------------------------------
# IAM Role(SSM接続用)
# -----------------------------------------------------------------------------
resource "aws_iam_role" "ec2" {
name = "${local.name}-ec2-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "ec2_ssm" {
role = aws_iam_role.ec2.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "ec2" {
name = "${local.name}-ec2-profile"
role = aws_iam_role.ec2.name
}
# -----------------------------------------------------------------------------
# EC2(Nginx)
# -----------------------------------------------------------------------------
resource "aws_instance" "nginx" {
ami = data.aws_ami.al2023.id
instance_type = "t3.micro"
subnet_id = tolist(data.aws_subnets.default.ids)[0]
vpc_security_group_ids = [aws_security_group.ec2.id]
iam_instance_profile = aws_iam_instance_profile.ec2.name
associate_public_ip_address = true
user_data = <<-EOF
#!/bin/bash
dnf install -y nginx
# JSON形式のログフォーマットを追加
cat > /tmp/log_format.conf << 'LOGFMT'
log_format json escape=json '{"time": "$time_iso8601", "msec": "$msec", "remote_addr": "$remote_addr", "server_addr": "$server_addr", "server_port": "$server_port", "x_forwarded_for": "$http_x_forwarded_for", "x_forwarded_port": "$http_x_forwarded_port", "x_forwarded_proto": "$http_x_forwarded_proto", "host": "$host", "request": "$request", "request_method": "$request_method", "request_uri": "$request_uri", "status": $status, "request_length": $request_length, "body_bytes_sent": $body_bytes_sent, "bytes_sent": $bytes_sent, "request_time": $request_time, "user_agent": "$http_user_agent", "http_connection": "$http_connection", "http_accept": "$http_accept", "connection": "$connection", "connection_requests": "$connection_requests", "tcpinfo_rtt": "$tcpinfo_rtt", "tcpinfo_rttvar": "$tcpinfo_rttvar"}';
LOGFMT
sed -i '/http {/r /tmp/log_format.conf' /etc/nginx/nginx.conf
cat > /etc/nginx/conf.d/default.conf << 'CONF'
server {
listen 80;
access_log /var/log/nginx/access.log json;
location / { return 200 'OK'; }
}
CONF
systemctl enable --now nginx
EOF
tags = { Name = "${local.name}-nginx" }
}
# -----------------------------------------------------------------------------
# ALB + Target Group
# -----------------------------------------------------------------------------
resource "aws_lb" "main" {
name = "${local.name}-alb"
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = slice(tolist(data.aws_subnets.default.ids), 0, 2)
}
resource "aws_lb_target_group" "main" {
name = "${local.name}-tg"
port = 80
protocol = "HTTP"
vpc_id = data.aws_vpc.default.id
health_check {
path = "/"
matcher = "200"
interval = 10
healthy_threshold = 2
unhealthy_threshold = 2
}
}
resource "aws_lb_target_group_attachment" "main" {
target_group_arn = aws_lb_target_group.main.arn
target_id = aws_instance.nginx.id
port = 80
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.main.arn
}
}
検証の結果
ALBのSGにegressルールがない場合
❌ ヘルスチェック失敗
- ターゲットグループのステータスが
unhealthyのまま - Nginxのアクセスログにリクエストが記録されない
ALBのSGにegressルールがある場合
✅ ヘルスチェック成功
- ターゲットグループのステータスが
healthyに - Nginxのアクセスログにリクエストが記録される
ALBのヘルスチェックアクセスを調べる
ALBのヘルスチェックのアクセスログを確認してみましょう。
作成したNginxインスタンスに入り、アクセスログを取得しました。
sh-5.2$ sudo tail -f /var/log/nginx/access.log | jq .
{
"time": "2025-12-03T10:00:00+00:00",
"msec": "1764673200.000",
"remote_addr": "192.168.10.50",
"server_addr": "192.168.20.100",
"server_port": "80",
"x_forwarded_for": "",
"x_forwarded_port": "",
"x_forwarded_proto": "",
"host": "192.168.20.100",
"request": "GET / HTTP/1.1",
"request_method": "GET",
"request_uri": "/",
"status": 200,
"request_length": 128,
"body_bytes_sent": 2,
"bytes_sent": 184,
"request_time": 0.000,
"user_agent": "ELB-HealthChecker/2.0",
"http_connection": "close",
"http_accept": "",
"connection": "214",
"connection_requests": "1",
"tcpinfo_rtt": "686",
"tcpinfo_rttvar": "343"
}
このログからいくつかのことがわかりました。
ALBはターゲットに直接HTTPリクエストを送っている
"remote_addr": "192.168.10.50", // ALBのENI(プライベートIP)
"server_addr": "192.168.20.100", // ターゲットのプライベートIP
"host": "192.168.20.100", // HostヘッダーもIP直指定
ALBはターゲットのプライベートIPに対して直接HTTPリクエストを送信しています。これがegressルールが必要な理由というわけですね。
X-Forwarded-* ヘッダーが付かない
"x_forwarded_for": "",
"x_forwarded_port": "",
"x_forwarded_proto": ""
通常のリクエストでは付与される X-Forwarded-* ヘッダーですが、ヘルスチェックでは空になっていました。ALB内部から直接送信されていることがわかります。
Keep-Aliveなし
"http_connection": "close",
"connection_requests": "1"
Connection: close が指定されており、毎回TCPコネクションを切断しています。ヘルスチェックは軽量に設計されているようです。
User-Agent
"user_agent": "ELB-HealthChecker/2.0"
ELB-HealthChecker/2.0 という専用のUser-Agentが設定されています。このUser-Agentを利用することで、ログからヘルスチェックリクエストを識別・除外するような運用も可能です。
TCP RTT(往復遅延)
"tcpinfo_rtt": "686", // 約0.7ms
"tcpinfo_rttvar": "343" // ばらつき約0.3ms
同一VPC内なのでRTTは1ms未満。ネットワーク遅延はほぼ無視できるレベルです。
教訓
Terraformで作るSGはegressも忘れずに
マネジメントコンソールでセキュリティグループを作成すると、アウトバウンドルールに 0.0.0.0/0 がデフォルトで設定されます。
しかし、Terraformの aws_security_group リソースでは、egressブロックを明示的に定義しないとルールが何もない状態になります。
ingressに意識が向きがちで、egressを忘れやすいので注意。
ヘルスチェックが落ちたときの切り分け
| 確認ポイント | チェック内容 |
|---|---|
| アプリ側 | プロセスは起動してる? ポートはLISTENしてる? |
| ターゲットグループ設定 | ヘルスチェックのパス・ポートは正しい? |
| SG(ターゲット側) | ALBからのingressは許可してる? |
| SG(ALB側) |
ターゲットへのegressは許可してる? (※ 今回のハマりどころ) |
いいヘルスチェックってなんだろう
せっかくなので、ヘルスチェックについてもう少し考えてみます。
ヘルスチェックって curl localhost でいいんだっけ?
専用のエンドポイント作った方がいい?
そもそも何をもってHealthyとするのか。
考え出すと意外と沼が深かったりしますよね。
結論をつけるとしたら、「アプリケーションの特性によって最適解は変わる」 になりそうです。
これは逃げでしょうか。いいえ、真理です(強気)
ALBヘルスチェックのパターンで考える
ここでは ALB → TargetGroup → Fargate(Nginx → App) の構成を想定して、ALB・ターゲットグループのヘルスチェックという条件で考えてみます。
ヘルスチェック
│ ┌─────────────────────┐
│ │ Fargate │
┌─────┐ ↓ │ ┌───────┐ ┌─────┐ │
│ ALB │────────→│─→│ Nginx │─→│ App │ │
└─────┘ │ └───────┘ └─────┘ │
└─────────────────────┘
パターン1: シンプルな疎通確認
ルートパス(/)やポートへの疎通を確認するシンプルなパターンです。
以下のようなコマンドが挙げられます。
curl -f http://localhost:80/
wget -q --spider http://localhost:80/
nc -z localhost 80
| 項目 | 内容 |
|---|---|
| メリット | ・シンプルで分かりやすい ・アプリ側の実装不要で既存ページがそのまま使える |
| デメリット | ・DBやキャッシュの状態までは確認できない ・SSRだとレンダリング処理が毎回走るので重い ・パスだけでは一般アクセスと区別がつかずログのノイズになる |
| 用途 | ・開発環境 ・静的サイト |
パターン2: ヘルスチェック専用エンドポイント
ヘルスチェック専用のパスやAPIを用意するパターンです。
curl -f http://localhost:80/health
wget -q --spider http://localhost:80/healthcheck
| 項目 | 内容 |
|---|---|
| メリット | ・実装次第でDB・Redisなど依存サービスの状態まで確認可能 ・SSRのような重い処理を避けられる ・パスでヘルスチェックと判別できるのでログ除外しやすい |
| デメリット | ・専用パスorAPIの実装が必要 ・プロキシ側で返す場合、Appが落ちていても気付けない |
| 用途 | ・DBやキャッシュに依存するアプリ ・「本当に動いてるか」を確認したい場合 |
コンテナのヘルスチェックのパターンで考える
次にECSやDockerのコンテナヘルスチェックで使えるパターンも考えてみます。
ヘルスチェック
┌─────────│───────│─────┐
│Fargate ↓ ↓ │
┌─────┐ │ ┌───────┐ ┌─────┐ │
│ ALB │────→│───→│ Nginx │─→│ App │ │
└─────┘ │ └───────┘ └─────┘ │
└───────────────────────┘
パターン1: プロセス存在確認
プロセスが起動しているかを確認するパターンです。
よく見る ps aux | grep よりも pgrep がシンプルでおすすめです。(最近覚えた)
# よくやっちゃうやつ
ps aux | grep 'node server.js' | grep -v grep
# おすすめ
pgrep -f 'node server.js'
| 項目 | 内容 |
|---|---|
| メリット | ・HTTPサーバーが不要でも使える ・ pgrep なら終了コードで判定可能 |
| デメリット | ・プロセスがいても応答できるとは限らない ・部分一致で意図しないプロセスにマッチする可能性 |
| 用途 | ・バックグラウンドワーカー ・非HTTPサービス |
パターン2: ソケットファイルの存在確認
Nginx ↔ App 間でUnixソケット通信をしている場合に使えるパターンです。
test コマンドの -S オプションでソケットファイルかどうかを確認できます。
test -S /tmp/sockets/puma.sock
| 項目 | 内容 |
|---|---|
| メリット | ・HTTPリクエストのオーバーヘッドがない ・プロセス起動を直接確認できる |
| デメリット | ・ソケットファイルがあっても接続できるとは限らない ・ソケット通信を使う構成限定 |
| 用途 | ・Railsアプリ(Unicorn/Puma) ・Nginx + Gunicorn など |
パターン3: 複合チェック
複数の条件を組み合わせて、より確実なヘルスチェックを実現するパターンです。
# ダブルチェック(プロセス存在 & HTTP応答)
pgrep -f 'node server.js' && curl -sf http://localhost:80/health
| 項目 | 内容 |
|---|---|
| メリット | ・複数の観点で確認できる ・「プロセスはいるけど応答しない」を検知可能 |
| デメリット | ・コマンドが複雑になる ・チェック時間が長くなる |
| 用途 | ・高可用性が求められる環境 ・確実に動作確認したい場合 |
他にもヘルスチェックに相応しいコマンドはあるかと思います。
これいいよ!というのがありましたら是非教えてください 🤲
まとめ
「なんかヘルスチェック落ちてるんだが」という稀によくある状況を発端に、普段あまり意識しないヘルスチェックについて改めて考えるよい機会となりました。
今回の学びとしてまとめると以下になります。
-
Terraformで作成したセキュリティグループにはegressルールを忘れるな
- ALBはターゲットに対してHTTPリクエストを送信するため、アウトバウンド通信が必要
- マネコンと違い、Terraformではegressを明示的に定義しないとルールがない状態になる
-
ヘルスチェックは目的に応じて使い分ける
- 非HTTPサービスには
pgrepが便利 - 複合チェックで信頼性を高めるのもあり
- 非HTTPサービスには
ちょっとしたトラブルから深堀りしてみると、新しい発見があって楽しいですね。
さて、明日12/4(木)は弊社 山田(@saku1335) の記事です!お楽しみに🎅🏼
We’re hiring!
メドレーでは各種エンジニアを絶賛募集中です!
カジュアル面談いつでもWelcomeですので、どうぞお気軽にお問い合わせください🎄✨️