High Availability NATパターン(re:Invent 2014版)を試してみた

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、眠気の向こう側の高揚感を大事にしたい、せーのです。
Private Subnetにインスタンスを立てる時にインターネットとの接続をするためにNATを立てますが、NATは単一障害点になるので、冗長化を図り、高可用性を保つようにします。
この高可用性を保つ構成が今まで色々考えられてきました。今回はre:Invent 2014にて紹介された最新のHigh Availability NATパターンをご紹介します。

今までのHA NATパターン

HA NATのキホンと言えばCDPにもなっているHigh Availability NATパターンになります。弊社のブログにも紹介されていますね。この形です。

hanat-pattern

ではメインで使っていたNATに障害が起きた時の切り替えは具体的にどうするのでしょう。一般的にはスクリプトを組んで仕込んでおきます。NAT1に向けているRoute TableとNAT2に向けているRoute Tableをあらかじめ用意しておき、障害時にSubnetにアタッチしているRoute Tableを切り替えることでNATの向き先を変えます。

#!/bin/bash

export AWS_DEFAULT_REGION=ap-northeast-1
export AWS_DEFAULT_OUTPUT=text

WATCH_CMD="/bin/ping -c 3 -w 3"
LOGFILE=/var/log/nat-failover.log

# set variables specific to the environment to run the script
source $1

#check route table on target NAT
rt_id=`aws ec2 describe-route-tables --filter "Name=association.subnet-id,Values=$APP_SUBNET_PRI" --query RouteTables[].RouteTableId --output text`

if [ "$rt_id" = "$APP_ROUTE_TABLE_2ND" ] ; then
	echo `date --rfc-3339 ns` "[WARN] $APP_SUBNET_PRI already has the 2nd Route Table." >> $LOGFILE
    :
else
	if $WATCH_CMD ${NAT_PRI[1]} > /dev/null 2>&1; then
	    echo `date --rfc-3339 ns` "[INFO] health check OK." >> $LOGFILE
	    :
	else
		#check other NAT's health
		if $WATCH_CMD ${NAT_2ND[1]} > /dev/null 2>&1; then
		
		    echo `date --rfc-3339 ns` "[WARN] Application Route tables associations failover triggered." >> $LOGFILE
		    # replace association between route tables and subnets for Application
		    for rt_assoc_id in `aws ec2 describe-route-tables --filter "Name=route-table-id,Values=$APP_ROUTE_TABLE_PRI" --query RouteTables[].Associations[*].RouteTableAssociationId --output text`
		    do
		        echo `date --rfc-3339 ns` "[WARN] Replace association $rt_assoc_id to $APP_ROUTE_TABLE_2ND." >> $LOGFILE
		        aws ec2 replace-route-table-association --association-id $rt_assoc_id --route-table-id $APP_ROUTE_TABLE_2ND > /dev/null 2>&1
		    done
		else
			echo `date --rfc-3339 ns` "[INFO] every NAT's health check NG." >> $LOGFILE
			:
		fi
		
	fi
fi

このようなスクリプトをcron登録し、NATを監視、障害時の切り替えを行います。
これで一般的な可用性は保たれますが、cronは最小で1分単位になるのでそれより短い時間での検知となると厳しくなります。

High Availability for Amazon VPC NAT Instances

そこでその検知時間を短くするような方式がAmazonの記事に載っております。基本的な構成は今までのHA NATパターンと変わりません。変わるのは検知スクリプトになります。

#!/bin/sh
# This script will monitor another NAT instance and take over its routes
# if communication with the other instance fails

# NAT instance variables
# Other instance's IP to ping and route to grab if other node goes down
NAT_ID=
NAT_RT_ID=

# My route to grab when I come back up
My_RT_ID=

# Specify the EC2 region that this will be running in (e.g. https://ec2.us-east-1.amazonaws.com)
EC2_URL=

# Health Check variables
Num_Pings=3
Ping_Timeout=1
Wait_Between_Pings=2
Wait_for_Instance_Stop=60
Wait_for_Instance_Start=300

# Run aws-apitools-common.sh to set up default environment variables and to
# leverage AWS security credentials provided by EC2 roles
. /etc/profile.d/aws-apitools-common.sh

# Determine the NAT instance private IP so we can ping the other NAT instance, take over
# its route, and reboot it.  Requires EC2 DescribeInstances, ReplaceRoute, and Start/RebootInstances
# permissions.  The following example EC2 Roles policy will authorize these commands:
# {
#  "Statement": [
#    {
#      "Action": [
#        "ec2:DescribeInstances",
#        "ec2:CreateRoute",
#        "ec2:ReplaceRoute",
#        "ec2:StartInstances",
#        "ec2:StopInstances"
#      ],
#      "Effect": "Allow",
#      "Resource": "*"
#    }
#  ]
# }

# Get this instance's ID
Instance_ID=`/usr/bin/curl --silent http://169.254.169.254/latest/meta-data/instance-id`
# Get the other NAT instance's IP
NAT_IP=`/opt/aws/bin/ec2-describe-instances $NAT_ID -U $EC2_URL | grep PRIVATEIPADDRESS -m 1 | awk '{print $2;}'`

echo `date` "-- Starting NAT monitor"
echo `date` "-- Adding this instance to $My_RT_ID default route on start"
/opt/aws/bin/ec2-replace-route $My_RT_ID -r 0.0.0.0/0 -i $Instance_ID -U $EC2_URL
# If replace-route failed, then the route might not exist and may need to be created instead
if [ "$?" != "0" ]; then
   /opt/aws/bin/ec2-create-route $My_RT_ID -r 0.0.0.0/0 -i $Instance_ID -U $EC2_URL
fi

while [ . ]; do
  # Check health of other NAT instance
  pingresult=`ping -c $Num_Pings -W $Ping_Timeout $NAT_IP | grep time= | wc -l`
  # Check to see if any of the health checks succeeded, if not
  if [ "$pingresult" == "0" ]; then
    # Set HEALTHY variables to unhealthy (0)
    ROUTE_HEALTHY=0
    NAT_HEALTHY=0
    STOPPING_NAT=0
    while [ "$NAT_HEALTHY" == "0" ]; do
      # NAT instance is unhealthy, loop while we try to fix it
      if [ "$ROUTE_HEALTHY" == "0" ]; then
    	echo `date` "-- Other NAT heartbeat failed, taking over $NAT_RT_ID default route"
    	/opt/aws/bin/ec2-replace-route $NAT_RT_ID -r 0.0.0.0/0 -i $Instance_ID -U $EC2_URL
	ROUTE_HEALTHY=1
      fi
      # Check NAT state to see if we should stop it or start it again
	  # This sample script works well with EC2 API tools version 1.6.12.2 2013-10-15. If you are using a different version and your script is stuck at NAT_STATE, please modify the script to "print $5;" instead of "print $4;".
      NAT_STATE=`/opt/aws/bin/ec2-describe-instances $NAT_ID -U $EC2_URL | grep INSTANCE | awk '{print $4;}'`
      if [ "$NAT_STATE" == "stopped" ]; then
    	echo `date` "-- Other NAT instance stopped, starting it back up"
        /opt/aws/bin/ec2-start-instances $NAT_ID -U $EC2_URL
	NAT_HEALTHY=1
        sleep $Wait_for_Instance_Start
      else
	if [ "$STOPPING_NAT" == "0" ]; then
    	  echo `date` "-- Other NAT instance $NAT_STATE, attempting to stop for reboot"
	  /opt/aws/bin/ec2-stop-instances $NAT_ID -U $EC2_URL
	  STOPPING_NAT=1
	fi
        sleep $Wait_for_Instance_Stop
      fi
    done
  else
    sleep $Wait_Between_Pings
  fi
done

今までのパターンは検知、切り替えをcronに登録していましたが、それを無限ループさせることによってcronの制限から解放されます。$Wait_Between_Pingsを調整することにより1分以下の検知単位にも対応できます。また今まではRoute Tableを用意しておいて切り替える、という形を取っていたのですが、これはRoute TableのDestinationのInstance IDを書き換えることでRoutingを切り替えているのも違いですね。
ですが、上2つに共通する問題として「固定値が多い」ということがあります。ネットワークやアプリケーション層が変わるとその度にスクリプトの変数を書き換えなくてはいけなく、汎用的ではありません。そこでre:Invent 2014では新しいHA NATが提案されました。

Advanced Dynamic network Automation approaches by re:Invent 2014

新しいHA NATが提案されたのはre:Invent 2014の「ARC401:Black-Belt Networking for the Cloud Ninja」というセッション。動画やスライドはここから[ARC401]で検索してください。
新しいパターンではNATをAuto Scalingにすることでコストダウンと可用性の確保をしています。
またRoute Table IDを決め打ちするのではなく、Route TableにTagをつけて、Tagをフィルタリングして該当するRoute Tableを見つけ出す形を取ることで汎用性が高まります。

arc401-blackbelt-networking-for-the-cloud-ninja-aws-reinvent-2014-21-638

障害の検知はAuto Scalingに任せます。障害があるとNATインスタンスはTerminateし、新しいNATが立ち上がります。その立ち上がり時のcloud-initでRoute Tableの切り替えを行います。スクリプトを見てみましょう。

#!/bin/bash 
INSTANCE_ID=`/usr/bin/curl --silent http://169.254.169.254/latest/meta-data/instance-id` 
AZ=`/usr/bin/curl --silent http://169.254.169.254/latest/meta-data/placement/availability-zone` 
REGION="${AZ%?}" 
MAC=`curl --silent http://169.254.169.254/latest/meta-data/network/interfaces/macs/` 
VPC_ID=`curl --silent http://169.254.169.254/latest/meta-data/network/interfaces/macs/$MAC/vpc-id` 
ROUTE_TABLES=`aws ec2 describe-route-tables --region $REGION --output text --filters "Name=tag:NATAZ,Values=any,$AZ" | grep ROUTETABLES | awk '{print $2}'` 

# Parse through RouteTables that need to be modified 
for MY_RT_ID in $ROUTE_TABLES; do 
  aws ec2 replace-route --route-table-id $MY_RT_ID --destination-cidr-block 0.0.0.0/0 --instance-id $INSTANCE_ID` --region $REGION 
done

スクリプトの内容もだいぶ変わりました。今まで固定値になっていたInstance ID, AZ, Region, VPC IDは全てmeta-dataから取ってくる形になっています。またroute tableは[NATAZ]タグが自身のNATの所属するAZ(またはany)になっているRoute Table IDをまとめて抽出、Route TableのDestination先を自分のInstance IDに書き換えます。固定しているIDが全くない為、どこのVPCでも内容を全く変えずに通用しますね。汎用性が高まりました。

それでは実際にやってみましょう。既にCloudFormationのテンプレートがあるので、それを入れてみます。

HA_Nat3

テンプレートを入れるとこのようなサブネットが出来上がります。10.0.0.0/24, 10.0.2.0/24というpublic subnetにNATをそれぞれ1台ずつ、10.0.1.0/24, 10.0.3.0/24のprivate subnetからそれぞれのNATに向けてRoute Tableが向けられています。
10.0.1.0/24のサブネットの中にEC2インスタンスを一台立て、ポートフォワードでSSHログインして外に向かって打ってみると

[ec2-user@ip-10-0-1-63 ~]$ curl http://www.google.com/
<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>302 Moved</TITLE></HEAD><BODY>
<H1>302 Moved</H1>
The document has moved
<A HREF="http://www.google.co.jp/?gfe_rd=cr&amp;ei=38l8VIX8BueN8QeD-oDYAg">here</A>.
</BODY></HTML>

バッチリ繋がります。
Route Tableを見てみると

HA_Nat6

NATに向けてルーティングがされています。
ここでNATをTerminateさせてみます。

HA_Nat

Auto Scalingになっているので、自動的に新しいNATが立ち上がります。

HA_Nat4

この新しいNATが立ち上がる際にcloud-initで上のスクリプトが走り、自動的にRoute Tableの切り替えが行われます。
改めてRoute Tableを見てみると

HA_Nat7

新しいNATにルーティングが向けられているのが確認できます。

まとめと改善点

いかがでしょうか、汎用的なNAT用のCloudFormationを用意しておくと構築時にとても便利ですね。
ですがこれにもまだ改善点があります。現在の構成ですと障害時に新しいNATが立ち上がる時に初めてルーティングの切り替えが行われます。NATとなるEC2インスタンスが立ち上がるのは小さいものでも2分〜5分はかかるので、その間NATには繋がらないことになります。上でせっかく検知時間を秒単位まで早めたので、ここでそれを諦めるのはもったいないですね。
アイデアとしてはLifeCycleHookを利用してTerminate前に切り替えてから落とすようにしたら、なんて考えています。実験しなきゃ。