ALBとLambdaを使ってS3に配置された静的ページを表示してみた

ALBとLambdaを使ってS3に配置された静的ページを表示してみた

2025.08.06

ALBを使用してメンテナンス画面を表示する際に固定レスポンスをご利用いただく方法があります。
ただし、こちらの方法はレスポンスとして返せる文字数が1024文字となっているためCSSやJavaScriptなどで装飾されているような画面だと使用するのが難しいものとなります。
https://dev.classmethod.jp/articles/alb-fixed-response/

今回は固定レスポンスの制限を回避するためALBのターゲットにLambdaを設定してS3に配置されたHTMLファイルを配信する設定を試してみました。

構成

今回作成する構成は以下のようになります。
ALB_Lambda_S3_202508011038

通常時のリクエストは全てEC2 (Webサーバー) へ送り、メンテナンスを行う際はALBのリスナールールの優先順位を変更して全てのパスへのリクエストをLambdaへ送るようにします。

設定

リソースの作成は以下のCloudFormationテンプレートを使用します。

AWSTemplateFormatVersion: "2010-09-09"
Description: ALB,EC2,Lambda,S3

Metadata:
# ------------------------------------------------------------#
# Metadata
# ------------------------------------------------------------# 
  AWS::CloudFormation::Interface:
    ParameterGroups:
      - Label: 
          default: Parameters for env Name
        Parameters:
          - env
      - Label:
          default: Parameters for Network
        Parameters:
          - VPCCIDR
          - PublicSubnet01CIDR
          - PublicSubnet02CIDR
          - PrivateSubnet01CIDR
          - PrivateSubnet02CIDR
      - Label:
          default: Parameters for EC2
        Parameters:
          - EC2VolumeSize
          - EC2VolumeIOPS
          - EC2AMI
          - EC2InstanceType

Parameters:
# ------------------------------------------------------------#
# Parameters
# ------------------------------------------------------------# 
  env:
    Type: String
    Default: dev
    AllowedValues:
      - dev

  VPCCIDR:
    Default: 192.168.0.0/16
    Type: String

  PublicSubnet01CIDR:
    Default: 192.168.0.0/24
    Type: String

  PublicSubnet02CIDR:
    Default: 192.168.1.0/24
    Type: String

  PrivateSubnet01CIDR:
    Default: 192.168.2.0/24
    Type: String

  PrivateSubnet02CIDR:
    Default: 192.168.3.0/24
    Type: String

  EC2VolumeSize:
    Default: 32
    Type: Number

  EC2VolumeIOPS:
    Default: 3000
    Type: Number

  EC2AMI:
    Default: '/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-6.1-x86_64'
    Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>

  EC2InstanceType:
    Default: t3.micro
    Type: String

Resources:
# ------------------------------------------------------------#
# IAM
# ------------------------------------------------------------# 
  EC2IAMRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument: 
        Version: "2012-10-17"
        Statement: 
          - Effect: Allow
            Principal: 
              Service: 
                - ec2.amazonaws.com
            Action: 
              - 'sts:AssumeRole'
      ManagedPolicyArns: 
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
      RoleName: !Sub iam-${env}-ec2-web-role
      Tags:
        - Key: Name
          Value: !Sub iam-${env}-ec2-web-role

  EC2IAMInstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      InstanceProfileName: !Sub iam-${env}-ec2-web-instanceprofile
      Roles: 
        - !Ref EC2IAMRole

# ------------------------------------------------------------#
# VPC
# ------------------------------------------------------------# 
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: !Ref VPCCIDR
      EnableDnsSupport: true
      EnableDnsHostnames: true
      Tags: 
        - Key: Name
          Value: !Sub vpc-${env}

# ------------------------------------------------------------#
# InternetGateway
# ------------------------------------------------------------# 
  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags: 
        - Key: Name
          Value: !Sub igw-${env}

  InternetGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      InternetGatewayId: !Ref InternetGateway
      VpcId: !Ref VPC

# ------------------------------------------------------------#
# Subnet
# ------------------------------------------------------------# 
  PublicSubnet01:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: ap-northeast-1a
      CidrBlock: !Ref PublicSubnet01CIDR
      MapPublicIpOnLaunch: true
      Tags: 
        - Key: Name
          Value: !Sub subnet-${env}-pub1
      VpcId: !Ref VPC

  PublicSubnet02:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: ap-northeast-1c
      CidrBlock: !Ref PublicSubnet02CIDR
      MapPublicIpOnLaunch: true
      Tags: 
        - Key: Name
          Value: !Sub subnet-${env}-pub2
      VpcId: !Ref VPC

  PrivateSubnet01:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: ap-northeast-1a
      CidrBlock: !Ref PrivateSubnet01CIDR
      MapPublicIpOnLaunch: true
      Tags: 
        - Key: Name
          Value: !Sub subnet-${env}-prv1
      VpcId: !Ref VPC

  PrivateSubnet02:
    Type: AWS::EC2::Subnet
    Properties:
      AvailabilityZone: ap-northeast-1c
      CidrBlock: !Ref PrivateSubnet02CIDR
      MapPublicIpOnLaunch: true
      Tags: 
        - Key: Name
          Value: !Sub subnet-${env}-prv2
      VpcId: !Ref VPC

  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: rtb-${env}-pub

  PublicRouteTableRoute:
    Type: AWS::EC2::Route
    Properties:
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway
      RouteTableId: !Ref PublicRouteTable

  PublicRtAssociation1:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PublicRouteTable
      SubnetId: !Ref PublicSubnet01

  PublicRtAssociation2:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PublicRouteTable
      SubnetId: !Ref PublicSubnet02

  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: rtb-${env}-prv

  PrivateRtAssociation1:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      SubnetId: !Ref PrivateSubnet01

  PrivateRtAssociation2:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      SubnetId: !Ref PrivateSubnet02

# ------------------------------------------------------------#
# SecurityGroup
# ------------------------------------------------------------# 
  ALBSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: for alb
      GroupName: !Sub securitygroup-${env}-alb
      SecurityGroupEgress: 
        - CidrIp: 0.0.0.0/0
          FromPort: -1
          IpProtocol: -1
          ToPort: -1
      SecurityGroupIngress:
        - FromPort: 80
          IpProtocol: tcp
          CidrIp: 0.0.0.0/0
          ToPort: 80
      Tags: 
        - Key: Name
          Value: !Sub securitygroup-${env}-alb
      VpcId: !Ref VPC

  EC2SG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: for ec2
      GroupName: !Sub securitygroup-${env}-ec2
      SecurityGroupEgress: 
        - CidrIp: 0.0.0.0/0
          FromPort: -1
          IpProtocol: -1
          ToPort: -1
      SecurityGroupIngress:
        - FromPort: 80
          IpProtocol: tcp
          SourceSecurityGroupId: !Ref ALBSG
          ToPort: 80
      Tags: 
        - Key: Name
          Value: !Sub securitygroup-${env}-ec2
      VpcId: !Ref VPC

  VPCEndpointSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: for vpc endpoint
      GroupName: !Sub securitygroup-${env}-vpc-endpoint
      SecurityGroupEgress: 
        - CidrIp: 0.0.0.0/0
          FromPort: -1
          IpProtocol: -1
          ToPort: -1
      SecurityGroupIngress:
        - FromPort: 443
          IpProtocol: tcp
          SourceSecurityGroupId: !Ref EC2SG
          ToPort: 443
      Tags: 
        - Key: Name
          Value: !Sub securitygroup-${env}-vpc-endpoint
      VpcId: !Ref VPC

# ------------------------------------------------------------#
# VPC Endpoint
# ------------------------------------------------------------# 
  S3Endpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties: 
      RouteTableIds: 
        - !Ref PrivateRouteTable
      ServiceName: !Sub com.amazonaws.${AWS::Region}.s3
      VpcEndpointType: Gateway
      VpcId: !Ref VPC

  SystemsManagerEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      ServiceName: !Sub com.amazonaws.${AWS::Region}.ssm
      VpcId: !Ref VPC
      SubnetIds:
        - !Ref PrivateSubnet01
      SecurityGroupIds:
        - !Ref VPCEndpointSG

  SystemsManagerMessageEndpoint:
    Type: AWS::EC2::VPCEndpoint
    Properties:
      VpcEndpointType: Interface
      PrivateDnsEnabled: true
      ServiceName: !Sub com.amazonaws.${AWS::Region}.ssmmessages
      VpcId: !Ref VPC
      SubnetIds:
        - !Ref PrivateSubnet01
      SecurityGroupIds:
        - !Ref VPCEndpointSG

# ------------------------------------------------------------#
# EC2
# ------------------------------------------------------------# 
  EC2:
    Type: AWS::EC2::Instance
    Properties:
      BlockDeviceMappings: 
        - DeviceName: /dev/xvda
          Ebs:
            DeleteOnTermination: true
            Encrypted: true
            Iops: !Ref EC2VolumeIOPS
            VolumeSize: !Ref EC2VolumeSize
            VolumeType: gp3
      DisableApiTermination: false
      IamInstanceProfile: !Ref EC2IAMInstanceProfile
      ImageId: !Ref EC2AMI
      InstanceType: !Ref EC2InstanceType
      NetworkInterfaces: 
        - AssociatePublicIpAddress: false
          DeleteOnTermination: true
          DeviceIndex: 0
          GroupSet: 
            - !Ref EC2SG
          SubnetId: !Ref PrivateSubnet01
      Tags:
        - Key: Name
          Value: !Sub ec2-${env}-web
      UserData: !Base64 |
        #!/bin/bash
        dnf install httpd -y
        echo "Web Test" > /var/www/html/index.html
        systemctl start httpd
        systemctl enable httpd

# ------------------------------------------------------------#
# S3
# ------------------------------------------------------------# 
  S3:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: !Sub s3-${env}-html-${AWS::AccountId}-${AWS::Region}-001
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
            BucketKeyEnabled: false
      OwnershipControls:
        Rules:
          - ObjectOwnership: BucketOwnerEnforced

# ------------------------------------------------------------#
# Lambda
# ------------------------------------------------------------# 
  LambdaIAMRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Policies:
        - PolicyName: !Sub policy-${env}-lambda
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                  - s3:GetObject
                Resource: "*"

  Lambda:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |
          import boto3
          import os

          def lambda_handler(event, context):

              # S3設定
              bucket_name = os.environ['BUCKET_NAME']
              html_key = "503.html"

              # S3からHTMLファイルを取得
              s3_client = boto3.client('s3')
              response = s3_client.get_object(Bucket=bucket_name, Key=html_key)
              html_content = response['Body'].read().decode('utf-8')

              # 503ステータスでHTMLレスポンスを返す
              return {
                  "statusCode": 503,
                  "statusDescription": "503 Service Unavailable",
                  "headers": {
                      "Content-Type": "text/html;",
                  },
                  "body": html_content,
                  "isBase64Encoded": False
              }
      Environment:
        Variables:
          BUCKET_NAME: !Ref S3
      FunctionName: !Sub lambda-${env}-s3-download
      Handler: index.lambda_handler
      Role: !GetAtt LambdaIAMRole.Arn
      Runtime: python3.13
      Timeout: 30

  LambdaPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt Lambda.Arn
      Principal: elasticloadbalancing.amazonaws.com

# ------------------------------------------------------------#
# ALB
# ------------------------------------------------------------# 
  ALB:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      IpAddressType: ipv4
      LoadBalancerAttributes:
        - Key: deletion_protection.enabled
          Value: false
      Name: !Sub alb-${env}-ec2
      Scheme: internet-facing
      SecurityGroups:
        - !Ref ALBSG
      Subnets: 
        - !Ref PublicSubnet01
        - !Ref PublicSubnet02
      Tags: 
        - Key: Name
          Value: !Sub alb-${env}-ec2
      Type: application

  TargetGroupEC2:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      HealthCheckEnabled: true
      HealthCheckIntervalSeconds: 30
      HealthCheckPath: /
      HealthCheckPort: traffic-port
      HealthCheckProtocol: HTTP
      HealthCheckTimeoutSeconds: 5
      HealthyThresholdCount: 5
      IpAddressType: ipv4
      Matcher:
        HttpCode: 200
      Name: !Sub tg-${env}-ec2
      Port: 80
      Protocol: HTTP
      ProtocolVersion: HTTP1
      Tags: 
        - Key: Name
          Value: !Sub tg-${env}-ec2
      Targets: 
        - Id: !Ref EC2
          Port: 80
      TargetType: instance
      UnhealthyThresholdCount: 2
      VpcId: !Ref VPC

  TargetGroupLambda:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Matcher:
        HttpCode: 200
      Name: !Sub tg-${env}-lambda
      Tags: 
        - Key: Name
          Value: !Sub tg-${env}-lambda
      Targets: 
        - Id: !GetAtt Lambda.Arn
      TargetType: lambda

  ALBHTTPListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      DefaultActions:
        - FixedResponseConfig:
            ContentType: text/plain
            MessageBody: "Service Unavailable"
            StatusCode: "503"
          Type: fixed-response
      LoadBalancerArn: !Ref ALB
      Port: 80
      Protocol: HTTP

  ALBHTTPEC2ListenerRule:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
      Actions:
        - TargetGroupArn: !Ref TargetGroupEC2
          Type: forward
      Conditions:
        - Field: path-pattern
          Values:
            - /*
      ListenerArn: !Ref ALBHTTPListener
      Priority: 1

  ALBHTTPLambdaListenerRule:
    Type: AWS::ElasticLoadBalancingV2::ListenerRule
    Properties:
      Actions:
        - TargetGroupArn: !Ref TargetGroupLambda
          Type: forward
      Conditions:
        - Field: path-pattern
          Values:
            - /*
      ListenerArn: !Ref ALBHTTPListener
      Priority: 2

上記のCloudFormationテンプレートではIAMロール、S3、ALB、EC2、Lambdaが作成されます。

以下のAWS CLIコマンドでデプロイを行います。

aws cloudformation create-stack --stack-name スタック名 --template-body file://CloudFormationテンプレートファイル名 --capabilities CAPABILITY_NAMED_IAM

リソースの作成ができたらS3バケット (s3-dev-html-アカウントID-ap-northeast-1-001) に503.htmlというファイル名でHTMLファイルをアップロードしてください。
今回はClaudeにサンプルのHTMLファイルを作成してもらいました。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>メンテナンス中</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            color: #333;
        }

        .maintenance-container {
            background: white;
            padding: 3rem 2rem;
            border-radius: 12px;
            box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
            text-align: center;
            max-width: 500px;
            width: 90%;
        }

        .maintenance-icon {
            font-size: 4rem;
            margin-bottom: 1.5rem;
            animation: bounce 2s infinite;
        }

        @keyframes bounce {
            0%, 20%, 50%, 80%, 100% {
                transform: translateY(0);
            }
            40% {
                transform: translateY(-10px);
            }
            60% {
                transform: translateY(-5px);
            }
        }

        h1 {
            font-size: 2.5rem;
            font-weight: 700;
            margin-bottom: 1rem;
            color: #2d3748;
        }

        .message {
            font-size: 1.1rem;
            line-height: 1.6;
            color: #4a5568;
            margin-bottom: 2rem;
        }

        .date-info {
            font-size: 1rem;
            color: #4a5568;
            margin-bottom: 1.5rem;
            font-weight: 500;
        }

        .contact-info {
            font-size: 0.9rem;
            color: #666;
            margin-top: 1rem;
        }

        @media (max-width: 480px) {
            .maintenance-container {
                padding: 2rem 1.5rem;
            }

            h1 {
                font-size: 2rem;
            }

            .maintenance-icon {
                font-size: 3rem;
            }
        }
    </style>
</head>
<body>
    <div class="maintenance-container">
        <div class="maintenance-icon">🔧</div>
        <h1>メンテナンス中</h1>
        <p class="message">
            現在システムのメンテナンスを実施しております。
        </p>

        <div class="date-info" id="current-date"></div>

    </div>

    <script>
        function formatCurrentDate() {
            const now = new Date();
            const year = now.getFullYear();
            const month = String(now.getMonth() + 1).padStart(2, '0');
            const day = String(now.getDate()).padStart(2, '0');

            return `${year}${month}${day}`;
        }

        document.addEventListener('DOMContentLoaded', function() {
            document.getElementById('current-date').textContent = formatCurrentDate();
        });
    </script>
</body>
</html>

Lambdaの説明

今回作成したLambdaのコードは以下になります。
ALBからLambdaへのアクセスが発生したら503.htmlをS3バケットからget_objectで取得してきます。
その後、HTTPステータスコード503でHTMLファイルをレスポンスとして返すようにしています。

import boto3
import os

def lambda_handler(event, context):

    # S3設定
    bucket_name = os.environ['BUCKET_NAME']
    html_key = "503.html"

    # S3からHTMLファイルを取得
    s3_client = boto3.client('s3')
    response = s3_client.get_object(Bucket=bucket_name, Key=html_key)
    html_content = response['Body'].read().decode('utf-8')

    # 503ステータスでHTMLレスポンスを返す
    return {
        "statusCode": 503,
        "statusDescription": "503 Service Unavailable",
        "headers": {
            "Content-Type": "text/html;",
        },
        "body": html_content,
        "isBase64Encoded": False
    }

動作確認

まずは、ALBのリスナールールを何も変更していない状態でブラウザからアクセスします。
何も変更をしていなければEC2へアクセスするため以下のように「Web Test」というシンプルなレスポンスが確認できます。
スクリーンショット 2025-08-06 134041

次にリスナールールを編集してLambdaがターゲットになっているルールの優先順位を上げてからアクセスしてみます。

ロードバランサーの一覧画面にアクセスして「alb-dev-ec2」を選択後、リスナーとルールのタブをクリックします。
クリック後、リスナールールから「HTTP:80」をクリックします。

スクリーンショット 2025-08-06 134443

「HTTP:80」のリスナールールを開いたらアクションからルールの優先順位を再設定をクリックします。
スクリーンショット 2025-08-06 134635

クリックしたら、tg-dev-lambdaが転送先になっているリスナールールの優先順位を1に変更してください。
変更したら変更内容の保存をクリックします。
スクリーンショット 2025-08-06 134757

変更が完了したら再度ALBのDNS名にブラウザからアクセスすると作成したメンテナンスページが表示されます。
スクリーンショット 2025-08-06 135038

さいごに

今回はALBからメンテナンスページを表示する方法を試してみました。
ALBの固定レスポンスの制限を超えるような装飾されたページを配信したい時はLambdaを使用する方法もあるというのを頭の片隅に入れておいてもよいと思いました。

この記事をシェアする

facebookのロゴhatenaのロゴtwitterのロゴ

© Classmethod, Inc. All rights reserved.