クライアント認証と Path Based Routing が必要なサーバを AWS で構築(後編:App 層)
今回の内容について
メドレー開発本部の田中です。 先日、Proxy 層を Elastic Beanstalk 上の Nginx で、App 層を EC2 インスタンスで構築する機会がありました。ここだけ見るととても普通に見えますが、制約があることで苦労した点もあり(前編参照)、制約を乗り越えるための工夫も含めてお話できる限り共有させていただきます。 前編ではProxy 層の構成として、主に Nginx を使用した Path Based Routing 周りについてのお話でした。後編では App 層で使用した EC2、 Systems Manager パラメータストアあたりについて共有いたします。
App 層の構成
App 層の方針や構築の流れ等をまとめると以下の通りです。
- ゴールデンイメージとして OS 設定やサーバアプリケーションをインストールした image(AMI)を作成しておく
- 上記の AMI を元に、クライアント毎に EC2 インスタンスを作成する
- インスタンス作成時に必要な Tag の値や環境変数を設定しておく
- 環境変数はパラメータストアに登録
- EC2 インスタンス起動時に、クライアントに応じた Tag や環境変数をもとにサーバアプリケーションのセットアップを行う
- 自身の内部 IP と Tag に設定したクライアント識別 ID を元に Route53 の PrivateDNS に登録する
それでは、それぞれの詳細について説明していきたいと思います。
AMI 作成
Packerを使用して各インスタンス共通となる AMI を作成します。provisioners
で指定した構築用スクリプトで OS 設定や必要ライブラリ、またメインとなるサーバアプリケーションをインストールします。また、cloud-init を使用して初回起動時に動かすスクリプト類もコピーしておきます。
なお、cloud-init から実行するスクリプトは Git や S3 などから動的に取得する方法もありますが、さほどスクリプトの内容に変更は発生しない点と、内容的に変更ある場合は image 再作成がどちらにしても必要になりそうだったので割り切って image 内に含めることにしています。
作成した packer.json のprovisioners
部分を抜粋するとこのような感じになります(説明コメント部分は実際には記載していません)
"provisioners": [
-- type: shell として、構築用スクリプト指定。ビルド時に実行される
{
"type": "shell",
"scripts": [
"scripts/provision.sh"
]
},
-- type: file でインスタンス起動時に実行させるスクリプト群をコピー
-- これらのスクリプトは cloud-init から実行される(cloud-init の設定は別途インスタンス作成時に行っている)
{
"type": "file",
"source": "./scripts",
"destination": "/home/hoge"
},
-- 上記のスクリプトに対して実行権限付与
{
"type": "shell",
"inline": [
"chmod +x /home/hoge/scripts/*"
]
}
]
packer build
でビルドした image が AWS に今回の共通で使用する AMI として登録されます
EC2 インスタンス作成
作成した AMI を元に、クライアントごとのインスタンスを作成します。なお、インスタンス作成は Terraform
やCloudFormation
などは使わず、AWS CLI を利用したスクリプトを作成して実行しています。
インスタンス作成スクリプトはこのような流れの処理となります。
- 引数でクライアント識別 ID やその他サーバアプリケーションセットアップに必要となる環境変数を指定
- AWS CLI で EC2 インスタンス作成
- 引数で指定された環境変数を AWS CLI でパラメータストアに登録
インスタンス作成
以下のように、aws ec2 run-instances
コマンドを使用し、Tag にクライアント識別 ID を指定して作成しています。
ここで指定したクライアント識別 ID を元にパラメータストアから自分用の環境変数を登録/取得したり、Private DNS 用のドメインに使用します。
aws ec2 run-instances \
--image-id ${AMI_ID} \
--key-name ${KEY_NAME}
--region ${REGION} \
--subnet-id ${SUBNET_ID} \
--security-group-ids ${SECURITY_GROUP} \
--user-data file://${USER_DATA} \
--instance-type ${INSTANCE_TYPE} \
--tag-specifications "ResourceType=instance,Tags=[{Key=ClientId,Value=${CLIENT_ID}}]" \
--iam-instance-profile "Arn=${SERVICE_ROLE}"
user-data
には初回起動時に実行したいスクリプト(Packer でビルド時にコピーしておいたスクリプト)を指定しているだけとなります。
#!/bin/bash
/home/hoge/scripts/bootstrap.sh
パラメータストアに環境変数登録
使用する環境変数は、Key は共通ですが値がクライアントによって異なります。そのため、HOGE という Key を使用する場合、<クライアント識別 ID>.HOGE
という形式でパラメータストアに登録しています。
(注. パラメータストアに階層やタグ付けがサポートされたらしく、このあたりの構成は今後見直す予定です)
登録は aws ssm put-parameter
を実行します
aws ssm put-parameter \
--name ${KEY} \
--value ${VALUE} \
--type ${PARAMETER_TYPE} \ # String、SecureString など
--overwrite
これでクライアントごとの EC2 インスタンスが作成、起動されます。次にインスタンス起動時の流れについてです。
EC2 インスタンス起動
起動時は、初回起動と毎回起動でそれぞれ以下のような処理を行います。
- 初回: パラメータストアから自身に関連する環境変数を取得し、サーバアプリケーションのセットアップ
- 毎回: 自身の内部 IP を Route53 の Private DNS に登録/更新
内部 IP は固定しておらず起動時に割り振られるため、毎回更新するようにしています。
それではそれぞれの内容について見ていきます。
パラメータストアから環境変数取得
登録時の内容で記載しましたが、環境変数は <クライアント識別 ID>.HOGE
という形式で登録しています。そのため、まずは自身のクライアント識別 ID を判定した後に必要な環境変数を aws ssm get-parameters
で取得します。
# 自身のインスタンス ID をメタデータから取得
INSTANCE_ID=$(curl -s https://169.254.169.254/latest/meta-data/instance-id)
# クライアント識別 ID をインスタンス作成時に指定した Tag から取得
# (describe-instances の filter に自身のインスタンス ID を指定)
CLIENT_ID_TAG=$(aws ec2 describe-instances \
--region=${REGION} \
--filters "Name=instance-id,Values=${INSTANCE_ID}" \
| jq -r '.Reservations[].Instances[].Tags[] | select(.Key == "ClientId").Value'
)
# 環境変数を取得
# タイプを SecureString にしている変数もあるため、一律 --with-decryption オプションを指定している
HOGE=$(aws ssm get-parameters \
--name "${CLIENT_ID_TAG}.HOGE" \
--with-decryption --region ${REGION} \
| jq -r ".Parameters[].Value")
export HOGE=${HOGE}
内部 IP を Private DNS に登録
最後に、Proxy 層から Private DNS で名前解決できるように自身の IP を Route 53 に登録してやります。
なお、Route53 には事前に対象の Hosted Zone を Private Hosted Zone for Amazon VPC
タイプとして登録しておきます。ここでは例として Domain Name を local とします。
EC2 インスタンスから登録される RecordSet は以下の形式とします。
- Name: <クライアント識別 ID>.local
- Type: CNAME
- Value: EC2 インスタンスの内部 IP
これらを行うスクリプト例は以下となります。
# 内部 IP を取得
# (describe-instances の filter に自身のインスタンス ID を指定)
PRIVATE_IP=$(aws ec2 describe-instances \
--region=${REGION} \
--filters "Name=instance-id,Values=${INSTANCE_ID}" \
| jq -r '.Reservations[].Instances[].PrivateIpAddress'
)
# Route53 の登録先 Hosted Zone ID を取得
# SEARCH_KEY は今回の例でいうと 'local.' になります
HOSTED_ZONE_ID=$(aws route53 list-hosted-zones \
--region=${REGION} \
| jq -r ".HostedZones[] | select(.Name == \"${SEARCH_KEY}\").Id"
)
# この後の登録コマンドで指定するための定義ファイル
# 毎起動時の登録用(IP が変わるため)に、Action には 'UPSERT' を指定
RECORDSET_FILE="/tmp/create_recordset.json"
cat <<EOT > ${RECORDSET_FILE}
{
"Changes": [
{
"Action": "UPSERT",
"ResourceRecordSet": {
"Name": "<クライアント識別 ID>.local",
"Type": "CNAME",
"TTL": 300,
"ResourceRecords": [
{
"Value": "${PRIVATE_IP}"
}
]
}
}
]
}
EOT
# 作成した定義ファイルを指定し、Route53 に登録
aws route53 change-resource-record-sets \
--hosted-zone-id ${HOSTED_ZONE_ID} \
--change-batch file:///${RECORDSET_FILE}
実行するステップはやや多いですが、このような構成をとることで VPC 内ではドメイン指定でのアクセスが可能となるため、IP を意識する必要がなくなるため柔軟な構成になるかと思います。
今回のまとめ
いまさらインスタンス立てるとかめんどくさいなぁ、、、とか思いながら色々調べて構築しましたが、EC2 まわりのサービスも増えてるんだなぁ、なんて感じました(特にパラメータストアはとても便利)
パラメータストア以外にも Systems Manager には Run Command や Patch Manager など EC2 インスタンスを管理する上でとても便利な仕組みが揃っていますのでこのあたりも導入していきたいと思います。
余談ですが、Systems Manager の存在は re:Invent 2016 で発表された時から名前だけは知ってましたが、今回の対応するまでずっとオンプレ専用のサービスだと勘違いしてて記憶から消えかけていました。。。
最後に
前編を Proxy 層(Nginx)、後編を App 層(EC2)について書かせていただきましたがいかかだったでしょうか。 そもそもの要件自体がけっこう特殊だったりもするので、なんでこんな構成に?みたいなとこもあるかも知れませんが、どなたかの参考になれば幸いです。もう少し聞いてみたい、というかたは wantedly の「話を聞いてみたい」ボタンからどうぞ。
※前編をあらためて読みたい方はこちらからどうぞ https://developer.medley.jp/entry/2017/08/24/120000_01
お知らせ
メドレーでは、医師たちがつくるオンライン医療事典「MEDLEY」、オンライン診療アプリ「CLINICS」、医療介護の求人サイト「ジョブメドレー」、口コミで探せる介護施設の検索サイト「介護のほんね」などのプロダクトを提供しています。これらのサービスの拡大を受けて、その成長を支えるエンジニア・デザイナーを募集しています。
メドレーで一緒に医療体験を変えるプロダクト作りに関わりたい方のご連絡お待ちしております。
https://www.medley.jp/recruit/creative.html