内部NLBを使用して、Web・アプリサーバを分離する – Apache版

内部NLBを使用してWeb・アプリサーバを分離する - Apache版

内部NLBを使用して、Web・アプリサーバを分離する

ApacheとPythonでWebサービスを作成します。

Webサーバとアプリサーバを分離し、両サーバの間に内部NLBを配置することによって、両者を連携する方法を確認します。

構築する環境

Diagram of separating Web and App Servers Using Internal NLB - Apache Ver.

パブリックサブネットにEC2インスタンスを配置します。
インスタンスは最新版のAmazon Linux 2をベースにして作成します。
このインスタンスは、Apacheをインストールすることで、Webサーバとして動作させます。

プライベートサブネットにEC2 Auto Scalingグループを配置します。
こちらのインスタンスもAmazon Linux 2とします。
これらのインスタンスの働きは、Python(uWSGI)によって動作するアプリサーバです。

パブリックサブネットにNATゲートウェイを配置します。
先述のプライベートサブネット内に設置されているインスタンスの初期化処理時に、各種パッケージをインストールするために使用します。

Webサーバとアプリサーバ間にNLBを配置します。
NLBは内部用のものとして作成します。

クライアントと各種サーバ間の通信を整理します。

  • クライアント -> Webサーバ:HTTP(80/tcp)
  • Webサーバ -> NLB -> アプリサーバ:UNIX Domain Socket(9090/tcp)

CloudFormationテンプレートファイル

上記の構成をCloudFormationで構築します。
以下のURLにCloudFormationテンプレートを配置しています。

https://github.com/awstut-an-r/awstut-fa/tree/main/092

テンプレートファイルのポイント解説

本ページは、内部NLBを使用して、Webサーバとアプリサーバを分離する方法を中心に取り上げます。

プライベートサブネット内のリソースをELB(ALB)にアタッチする方法については、以下のページをご確認ください。

https://awstut.com/2021/11/28/attach-private-ec2-to-elb

スケーリングポリシーなしのAuto Scalingについては、以下のページをご確認ください。

https://awstut.com/2022/10/08/introduction-to-ec2-auto-scaling-no-scaling-policy

NLB

Resources:
  NLB:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: !Sub "${Prefix}-NLB"
      Scheme: internal
      Subnets:
        - !Ref PrivateSubnet1
        - !Ref PrivateSubnet2
      Type: network

  NLBTargetGroup:
    Type: AWS::ElasticLoadBalancingV2::TargetGroup
    Properties:
      Name: !Sub "${Prefix}-NLBTargetGroup"
      Protocol: TCP
      Port: !Ref UWSGIPort
      TargetGroupAttributes:
        - Key: preserve_client_ip.enabled
          Value: false
      VpcId: !Ref VPC
      HealthCheckProtocol: TCP
      HealthyThresholdCount: !Ref HealthyThresholdCount
      UnhealthyThresholdCount: !Ref UnhealthyThresholdCount
      HealthCheckIntervalSeconds: !Ref HealthCheckIntervalSeconds

  NLBListener:
    Type: AWS::ElasticLoadBalancingV2::Listener
    Properties:
      DefaultActions:
        - TargetGroupArn: !Ref NLBTargetGroup
          Type: forward
      LoadBalancerArn: !Ref NLB
      Port: !Ref UWSGIPort
      Protocol: TCP
Code language: YAML (yaml)

内部NLBを作成する上でのポイントはNLB本体のSchemaプロパティです。
本プロパティに「internal」を指定します。

Webサーバとアプリサーバ間の通信は9090/tcpで行います。
ですからターゲットグループおよびリスナーにおいて、本通信をリッスンし、ターゲットであるEC2インスタンスにルーティングするように設定します。

NLBのもう1つのポイントはクライアントIPの保持機能を無効化している点です。
本設定はTargetGroupAttributesプロパティで実施します。
無効化の目的は、NLBからルーティングされるパケットの送信元アドレスを、NLBのプライベートアドレスにするためです。
この設定の狙いは、アプリサーバであるEC2インスタンス用セキュリティグループに関係があります。
つまりセキュリティグループルールにおいて、許可する通信の送信元を、NLBのプライベートアドレスに設定することができるということです。
詳細につきましては、以下のページをご確認ください。

https://awstut.com/2022/10/30/use-cfn-custom-resource-to-obtain-nlb-private-address-and-set-it-as-source-of-security-group

Webサーバ

EC2インスタンス

Resources:
  EIP1:
    Type: AWS::EC2::EIP
    Properties:
      Domain: vpc

  EIPAssociation1:
    Type: AWS::EC2::EIPAssociation
    Properties:
      AllocationId: !GetAtt EIP1.AllocationId
      InstanceId: !Ref Instance1

  Instance1:
    Type: AWS::EC2::Instance
    Properties:
      IamInstanceProfile: !Ref InstanceProfile
      ImageId: !Ref ImageId
      InstanceType: !Ref InstanceType
      NetworkInterfaces:
        - DeviceIndex: 0
          SubnetId: !Ref PublicSubnet1
          GroupSet:
            - !Ref WebServerSecurityGroup
      Tags:
        - Key: !Ref InstanceTagKey
          Value: !Ref InstanceTagValueWeb1
Code language: YAML (yaml)

Webサーバとして動作する上で、特別な設定は不要です。
ただElastic IPアドレスを用意し、インスタンスに関連づけることによって、グローバルアドレスを固定化します。

以下の通り、タグを設定します。

  • キー:Server
  • 値:ApacheWeb
    これは後述するインスタンスの初期化処理時に、SSMドキュメントを使用するのですが、その際にタグ情報を使用して、対象インスタンスを区別するためです。

セキュリティグループ

Resources:
  WebServerSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: !Sub "${Prefix}-WebServerSecurityGroup"
      GroupDescription: Allow HTTP.
      VpcId: !Ref VPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: !Ref HTTPPort
          ToPort: !Ref HTTPPort
          CidrIp: 0.0.0.0/0
Code language: YAML (yaml)

クライアントからWebサーバへの通信は、不特定多数のグローバルアドレスからHTTP(80/tcp)で行われます。
ですからこれを許可するようにルールを定義します。

SSM関連付け

Resources:
  RunShellScriptAssociation:
    Type: AWS::SSM::Association
    Properties:
      AssociationName: !Sub "${Prefix}-run-shellscript-association"
      Name: AWS-RunShellScript
      OutputLocation:
        S3Location:
          OutputS3BucketName: !Ref PlaybookBucket
          OutputS3KeyPrefix: !Sub "${Prefix}/shellscript-association-log"
      Parameters:
        commands:
          - yum update -y
          - yum install -y httpd
          - !Sub "echo 'ProxyPass / uwsgi://${NLBDNSName}:${UWSGIPort}/' >> /etc/httpd/conf/httpd.conf"
          - !Sub "echo 'ProxyPassReverse / uwsgi://${NLBDNSName}:${UWSGIPort}/' >> /etc/httpd/conf/httpd.conf"
          - systemctl start httpd
          - systemctl enable httpd
      Targets:
        - Key: !Sub "tag:${InstanceTagKey}"
          Values:
            - !Ref InstanceTagValueWeb1
      WaitForSuccessTimeoutSeconds: !Ref WaitForSuccessTimeoutSeconds
Code language: YAML (yaml)

インスタンスをWebサーバとして動作させるための初期化処理を定義します。
今回はSSMドキュメントAWS-RunShellScriptを使用します。
SSMドキュメントを使用したインスタンスの初期化処理については、以下のページをご確認ください。

https://awstut.com/2021/12/02/ec2-init-4ways

実行する内容は以下の通りです。

  • Apacheをインストールする。
  • 設定ファイル(httpd.conf)にNLB向けのプロキシ設定を追記する。
  • Apacheを起動・有効化する。

Targetsプロパティで、この初期化処理を実行する対象を指定します。
先述のタグが付与されているインスタンスに対して、このSSM関連付けを適用します。

アプリサーバ

Auto Scalingグループ

Resources:
  LaunchTemplate:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateData:
        IamInstanceProfile:
          Arn: !Ref InstanceProfileArn
        ImageId: !Ref ImageId
        InstanceType: !Ref InstanceType
        SecurityGroupIds:
          - !Ref AppServerSecurityGroup
        TagSpecifications:
          - ResourceType: instance
            Tags:
              - Key: !Ref InstanceTagKey
                Value: !Ref InstanceTagValueApp
      LaunchTemplateName: !Sub "${Prefix}-LaunchTemplate"

  AutoScalingGroup:
    Type: AWS::AutoScaling::AutoScalingGroup
    Properties:
      AutoScalingGroupName: !Sub "${Prefix}-AutoScalingGroup"
      DesiredCapacity: !Ref DesiredCapacity
      LaunchTemplate:
        LaunchTemplateId: !Ref LaunchTemplate
        Version: !GetAtt LaunchTemplate.LatestVersionNumber
      MaxSize: !Ref MaxSize
      MinSize: !Ref MinSize
      VPCZoneIdentifier:
        - !Ref PrivateSubnet1
        - !Ref PrivateSubnet2
      TargetGroupARNs:
        - !Ref NLBTargetGroup
Code language: YAML (yaml)

アプリサーバとして、Auto Scalingグループを構築します。
特別な設定は不要です。

以下の通り、タグを設定します。

  • キー:Server
  • 値:App

こちらもインスタンスの初期化処理において、SSMドキュメントを使用しますが、その際に、このタグ情報を使用します。

セキュリティグループ

Resources:
  AppServerSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupName: !Sub "${Prefix}-AppServerSecurityGroup2"
      GroupDescription: Allow uWSGI from Web Server.
      VpcId: !Ref VPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: !Ref UWSGIPort
          ToPort: !Ref UWSGIPort
          CidrIp: !Sub "${NLBPrivateAddress1}/32"
        - IpProtocol: tcp
          FromPort: !Ref UWSGIPort
          ToPort: !Ref UWSGIPort
          CidrIp: !Sub "${NLBPrivateAddress2}/32"
Code language: YAML (yaml)

Webサーバからアプリサーバへの通信は、NLBを経由し、UNIX Domain Socket(9090/tcp)で行われます。
ですからこれを許可するようにルールを定義します。

今回は2つのルールを定義します。
これはNLBに割り当てられたプライベートアドレスが2つあるためです。
NLBが持つプライベートアドレスの数は、NLBに関連付いているサブネット数によって定まります。
今回の構成では、NLBに2つのサブネットに関連付けていますから、2つのアドレスを持つということになります。

NLBの項目で確認した通り、今回の構成では、クライアントIPの保持機能は無効化しています。
そのためNLBを経由後、パケットの送信元アドレスは、NLBのプライベートアドレスに置き換わります。

以上をまとめますと、セキュリティグループルールで許可するべき送信元アドレスは、NLBの2つのプライベートアドレスとなります。

SSM関連付け

Resources:
  ApplyAnsiblePlaybooksAssociation:
    Type: AWS::SSM::Association
    Properties:
      AssociationName: !Sub "${Prefix}-apply-ansible-playbook-association"
      Name: AWS-ApplyAnsiblePlaybooks
      OutputLocation:
        S3Location:
          OutputS3BucketName: !Ref PlaybookBucket
          OutputS3KeyPrefix: !Sub "${Prefix}/playbook-association-log"
      Parameters:
        Check:
          - "False"
        ExtraVariables:
          - SSM=True
        InstallDependencies:
          - "True"
        PlaybookFile:
          - !Ref PlaybookFileName
        SourceInfo:
          - !Sub '{"path": "https://${PlaybookBucket}.s3.${AWS::Region}.amazonaws.com/${Prefix}/${PlaybookPackageName}"}'
        SourceType:
          - S3
        Verbose:
          - -v
      Targets:
        - Key: !Sub "tag:${InstanceTagKey}"
          Values:
            - !Ref InstanceTagValueApp
      WaitForSuccessTimeoutSeconds: !Ref WaitForSuccessTimeoutSeconds
Code language: YAML (yaml)

インスタンスをアプリサーバとして動作させるための初期化処理を定義します。
今回はSSMドキュメントAWS-ApplyAnsiblePlaybooksを使用します。
Ansible Playbookを実行することで、初期化を実行します。

playbook.yml

- hosts: all
  gather_facts: no
  become: yes

  tasks:
    - name: update yum.
      yum: name=*

    - name: install packages by yum.
      yum:
        name:
          - python3-devel
          - gcc

    - name: install packages by pip3.
      pip:
        name:
          - uwsgi
          - flask
        executable: pip3

    - name: create app directory.
      file:
        path: /home/ec2-user/myapp
        state: directory

    - name: copy flask script.
      copy:
        src: ./run.py
        dest: /home/ec2-user/myapp/run.py

    - name: copy uWSGI ini.
      copy:
        src: ./uwsgi.ini
        dest: /home/ec2-user/myapp/uwsgi.ini

    - name: copy uWSGI Service.
      copy:
        src: ./uwsgi.service
        dest: /etc/systemd/system/uwsgi.service

    - name: reload daemon.
      systemd:
        daemon_reload: yes

    - name: start and enable uWSGI.
      systemd:
        name: uwsgi
        state: started
        enabled: yes
Code language: YAML (yaml)

実行する内容は以下の通りです。

  • yumとpipでパッケージをインストールする。
  • Pythonスクリプトを配置する。
  • uWSGIファイルを配置する。
  • uWSGIサービス用ファイルを配置する。
  • uWSGIサービスを起動・有効化する。

Targetsプロパティで、この初期化処理を実行する対象を指定します。
先述のタグが付与されているインスタンスに対して、このSSM関連付けを適用します。

Pythonスクリプト

import urllib.request
from flask import Flask

app = Flask(__name__)
url = 'http://169.254.169.254/latest/meta-data/instance-id'

@app.route('/')
def main():
  request = urllib.request.Request(url)
  with urllib.request.urlopen(request) as response:
    data = response.read().decode('utf-8')
    return data

if __name__ == '__main__':
  app.run()
Code language: Python (python)

WebフレームワークFlaskで簡易的なアプリを作成します。
ルートURLにアクセスした場合、インスタンスIDを返します。

uwsgi.ini

[uwsgi]
chdir = /home/ec2-user/myapp
socket = 0.0.0.0:9090
file = /home/ec2-user/myapp/run.py
callable = app
master = True
uid = ec2-user
gid = ec2-userCode language: plaintext (plaintext)

先述のPythonスクリプトを実行する内容です。

uwsgi.service

[Unit]
Description = uWSGI
After = syslog.target

[Service]
WorkingDirectory = /home/ec2-user/myapp/
ExecStart = /usr/local/bin/uwsgi --ini /home/ec2-user/myapp/uwsgi.ini

Restart = on-failure
RestartSec = 3
KillSignal = SIGQUIT
Type = notify
StandardError = syslog
NotifyAccess = all

[Install]
WantedBy = multi-user.target
Code language: plaintext (plaintext)

uwsgi.iniを指定して、uWSGIを実行する内容です。

環境構築

CloudFormationを使用して、本環境を構築し、実際の挙動を確認します。

Ansible Playbookの準備

CloudFormationスタックを作成する前に、Ansibleの準備を行います。
具体的にはPlaybookをZip化し、S3バケットに設置します。具体的なコマンドは以下のページをご確認ください。

https://awstut.com/2021/12/02/ec2-init-4ways

CloudFormationスタックを作成し、スタック内のリソースを確認する

CloudFormationスタックを作成します。
スタックの作成および各スタックの確認方法については、以下のページをご確認ください。

https://awstut.com/2021/12/02/cloudformation-nested-stacks

各スタックのリソースを確認した結果、今回作成された主要リソースの情報は以下の通りです。

  • NLB:fa-092-NLB
  • NLBのDNS名:ec2-18-176-243-76.ap-northeast-1.compute.amazonaws.com
  • NLBのターゲットグループ:fa-092-NLBTargetGroup
  • Webサーバインスタンス:i-036a057a2caa32486

作成されたリソースをAWS Management Consoleから確認します。
NLBを確認します。

Detail of NLB 1.

NLBのDNS名等が確認できます。

ターゲットグループを確認します。

Detail of NLB 2.

ターゲットタブを見ると、2つのEC2インスタンスが登録されていることがわかります。
これらのEC2インスタンスがアプリサーバとして動作します。

WebサーバであるEC2インスタンスの詳細を確認します。

Detail of EC2 1.

NLBのDNS名がec2-18-176-243-76.ap-northeast-1.compute.amazonaws.comであることがわかります。

SSMドキュメントの実行状況を確認します。

Detail of SSM 1.

確かに2つのドキュメントが実行されていることがわかります。

まずAWS-RunShellScriptを実行し、Webサーバ用の初期化時の実行ログを確認します。

Loaded plugins: extras_suggestions, langpacks, priorities, update-motd
...

================================================================================
 Package                Arch       Version                  Repository     Size
================================================================================
Installing:
 httpd                  aarch64    2.4.54-1.amzn2           amzn2-core    1.4 M

...

Installed:
  httpd.aarch64 0:2.4.54-1.amzn2

...

Complete!
Code language: plaintext (plaintext)

確かにApacheがインストールされていることがわかります。

続いてAWS-ApplyAnsiblePlaybooksを実行し、アプリサーバ用の初期化時の実行ログを確認します。

Amazon Linux release 2 (Karoo)

...

PLAY [all] *********************************************************************

TASK [update yum.] *************************************************************
ok: [localhost] => {"ansible_facts": {"discovered_interpreter_python": "/usr/bin/python"}, "changed": false, "msg": "", "rc": 0, "results": ["libjpeg-turbo-2.0.90-2.amzn2.0.1.aarch64 providing * is already installed"]}

TASK [install packages by yum.] ************************************************
changed: [localhost] => {"ansible_facts": {"pkg_mgr": "yum"}, "changed": true, "changes": {"installed": ["python3-devel", "gcc"]}, "msg": "", "rc": 0, ...}

TASK [install packages by pip3.] ***********************************************
changed: [localhost] => {"changed": true, "cmd": ["/usr/bin/pip3", "install", "uwsgi", "flask"], "name": ["uwsgi", "flask"], ...}

TASK [create app directory.] ***************************************************
changed: [localhost] => {"changed": true, "gid": 0, "group": "root", "mode": "0755", "owner": "root", "path": "/home/ec2-user/myapp", "size": 6, "state": "directory", "uid": 0}

TASK [copy flask script.] ******************************************************
changed: [localhost] => {"changed": true, "checksum": "392c86110eaf72f4ba78c5462ebfe5ea4bddc962", "dest": "/home/ec2-user/myapp/run.py", "gid": 0, "group": "root", "md5sum": "c7db1fa4405b84afe32795fbad73abc6", "mode": "0644", "owner": "root", "size": 352, "src": "/root/.ansible/tmp/ansible-tmp-1666527040.07-114179067830016/source", "state": "file", "uid": 0}

TASK [copy uWSGI ini.] *********************************************************
changed: [localhost] => {"changed": true, "checksum": "dc25dc7fbba4d1dc3f78f765cf65cf5e6af9672b", "dest": "/home/ec2-user/myapp/uwsgi.ini", "gid": 0, "group": "root", "md5sum": "e20220d2bcd7012cc2136339322f54b4", "mode": "0644", "owner": "root", "size": 171, "src": "/root/.ansible/tmp/ansible-tmp-1666527040.66-271438457070822/source", "state": "file", "uid": 0}

TASK [copy uWSGI Service.] *****************************************************
changed: [localhost] => {"changed": true, "checksum": "5f1a6f6e4469de744b10fe8de37af7847e33c644", "dest": "/etc/systemd/system/uwsgi.service", "gid": 0, "group": "root", "md5sum": "c520ed8cb1af45762b3d2d51c08eae8f", "mode": "0644", "owner": "root", "size": 406, "src": "/root/.ansible/tmp/ansible-tmp-1666527040.9-17771148645236/source", "state": "file", "uid": 0}

TASK [reload daemon.] **********************************************************
ok: [localhost] => {"changed": false, "name": null, "status": {}}

TASK [start and enable uWSGI.] *************************************************
changed: [localhost] => {"changed": true, "enabled": true, "name": "uwsgi", "state": "started", ...}}

PLAY RECAP *********************************************************************
localhost                  : ok=9    changed=7    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
Code language: plaintext (plaintext)

確かに各種パッケージがインストールされ、uWSGIが動作していることがわかります。

動作確認

準備が整いましたので、WebサーバであるEC2インスタンスにアクセスします。

Detail of EC2 2.
Detail of EC2 3.

Webサーバにアクセスすることで、NLBを経由し、Auto Scalingグループ内の2つのEC2インスタンスにアクセスできました。
以上のことから、内部NLBを配置することで、Webサーバとアプリサーバを分離することができました。

まとめ

Webサーバとアプリサーバを分離し、両サーバの間に内部NLBを配置して、両者を連携させる方法を確認しました。