Medley Developer Blog

株式会社メドレーのエンジニア・デザイナーによるブログです

クライアント認証と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インスタンスを作成する
  • EC2インスタンス起動時に、クライアントに応じたTagや環境変数をもとにサーバアプリケーションのセットアップを行う
  • 自身の内部IPとTagに設定したクライアント識別IDを元にRoute53のPrivateDNSに登録する

それでは、それぞれの詳細について説明していきたいと思います。

AMI作成

Packerを使用して各インスタンス共通となるAMIを作成します。provisionersで指定した構築用スクリプトでOS設定や必要ライブラリ、またメインとなるサーバアプリケーションをインストールします。また、cloud-initを使用して初回起動時に動かすスクリプト類もコピーしておきます。

なお、cloud-initから実行するスクリプトはGitやS3などから動的に取得する方法もありますが、さほどスクリプトの内容に変更は発生しない点と、内容的に変更ある場合はimage再作成がどちらにしても必要になりそうだったので割り切ってimage内に含めることにしています。

作成したpacker.jsonprovisioners部分を抜粋するとこのような感じになります(説明コメント部分は実際には記載していません)

  "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を元に、クライアントごとのインスタンスを作成します。なお、インスタンス作成は TerraformCloudFormationなどは使わず、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 http://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の「話を聞いてみたい」ボタンからどうぞ。

※前編をあらためて読みたい方はこちらからどうぞ developer.medley.jp

お知らせ

メドレーでは、医師たちがつくるオンライン医療事典「MEDLEY」、オンライン診療アプリ「CLINICS」、医療介護の求人サイト「ジョブメドレー」、口コミで探せる介護施設の検索サイト「介護のほんね」などのプロダクトを提供しています。これらのサービスの拡大を受けて、その成長を支えるエンジニア・デザイナーを募集しています。

メドレーで一緒に医療体験を変えるプロダクト作りに関わりたい方のご連絡お待ちしております。

www.medley.jp