はじめに
「CloudFormationを使えば、一発で環境を構築できるらしい」という大雑把な話を聞いて、
まずはやってみようとおもって試行錯誤しました。
作ってた時に考えたこととか、これ便利だなって思ったところを書いてみようと思います。
ただ、新しい技術に対応してcfn使ったら便利だった、という話ではなく、「古典的なサービスのインフラを作ることになったんだけど、古典的なインフラの作り方をcfnでやったらこんな感じになったよ」的な話です。
むしろこうしたらええんやで、とかアドバイスいただけたら嬉しいです(大きい独り言)
やったこと
- CloudFormationを使って、Cloudfront+EC2&RDSを使ったサービスのインフラを立ち上げた
- VPCは2つ作成して、本番用と、ステージング・開発用にした
- EC2を作成する際にcfn-initを使って最初から必要なパッケージを導入した
- Route53 も設定できるからやってみた、cfnでスタック構築ポンで名前解決して繋がるの強くない?
- ただ、AWSに移行できていないステージング用DNSを都合上使ったので、そのためにいくつか苦心した
方針と構成
スタックを作る方針
初めてCloudformationを触るにあたって、ベストプラクティスを読めば読むほど訳が分からなくなった()ので、下記の内容を考えました。
- 既に必要だとわかっているものは、最初から全部作ることにする
- できるだけスタックを一つにまとめたいが、性質の異なるコンポーネントで分けて考える
- 一回作ったら変わらない・変えないものでまとめる
- 階層構造で積み上げる形で考えてまとめる
- 頻繁に作ったり壊したりするものでまとめる
実際のスタックの構成
いろいろやってみた結果、スタックは最終的に5分割になりました。
それぞれのおおまかな説明と、実際に何を設定しているかは以下の通り。
1, VPCとネットワーク周りを設定するスタック
- Route53のホストゾーン
- VPC,サブネット,インターネットゲートウェイ,ルートテーブルとルート
- 固定でネットワークインターフェース(本番EC2用・ステージングEC2用)
- 上記に紐づけるEIP
- 固定でセキュリティグループ(例えば社内全アクセス許可と、Web公開許可、とか)
2, 固定で用意するEC2とRDSとS3を設定するスタック
- EC2(本番用・ステージング用)
- RDS(本番用・ステージング用)
- RDSのセキュリティグループ,パラメータグループ,サブネットグループ
- S3(本番用・ステージング用)
- Cloudfront->S3のオリジンアクセスアイデンティティ、バケットポリシー
- EC2への名前解決を行うRoute53レコードセット(本番用・ステージング用)
3, バージニア北部リージョンでACM証明書を作成するスタック
- Cloudfrontで使うACM証明書(本番用・ステージング用)
4, 公開するために必要なコンポーネントを設定するスタック
- Cloudfront(本番用・ステージング用)
- Cloudfrontへの名前解決を行うRoute53レコードセット(本番用・ステージング用)
5, 開発環境を設定するスタック
- ネットワークインターフェース,EIP
- EC2
- EC2への名前解決を行うRoute53レコードセット
- その他必要なコンポーネントの設定とか
作ったスタックの説明
各スタックで共通するパラメータ
-
PjName
同様のインフラをコピーして作成するときのことを考えて、同じプロジェクトの全てのスタックで共通した名前を指定して実行するようにします。
例えば、出力のExportなどで使います。(スタック間で値を渡す際に、どのプロジェクトのものか判別できるようにするために使います) -
ServiceDomainName
本番環境のCloudfrontに設定する、公開するドメイン名を設定します。 -
StagingDomainName
ステージング環境のCloudfrontに設定するドメイン名を設定します。
これらのパラメータについて、PjName
については全スタックで入力が必要で、その他については、リージョンごとに一番最初に実施するスタックで入力したものをExportして利用します。
1, VPCとネットワーク周りを設定するスタック
AWSTemplateFormatVersion: 2010-09-09
Description: >-
Cloudformation stack for some project Infrastructure,
Creates Network Foundations
Metadata:
'AWS::CloudFormation::Interface':
ParameterGroups:
- Label:
default: Global Setting
Parameters:
- PjName
- ServiceDomainName
- StagingDomainName
- Label:
default: VPC configuration
Parameters:
- VPCCIDR
- SubnetACIDR
- SubnetCCIDR
Parameters:
PjName:
Type: String
Default: MyProject
ServiceDomainName:
Type: String
Default: example.com
Description: Service domain
StagingDomainName:
Type: String
Default: example.com
Description: Service domain
VPCCIDR:
Type: String
Default: 192.168.0.0/16
SubnetACIDR:
Type: String
Default: 192.168.0.0/18
SubnetCCIDR:
Type: String
Default: 192.168.64.0/18
Resources:
######################################################################################################
# General components
######################################################################################################
# ------------------------------------------------------------#
# Route 53 Hosted zone
# ------------------------------------------------------------#
R53HostedZone:
Type: "AWS::Route53::HostedZone"
Properties:
Name: !Ref ServiceDomainName
######################################################################################################
# Production environment
######################################################################################################
# ------------------------------------------------------------#
# VPC for prod
# ------------------------------------------------------------#
ProdVPC:
Type: 'AWS::EC2::VPC'
Properties:
CidrBlock: !Ref VPCCIDR
EnableDnsSupport: 'true'
EnableDnsHostnames: 'true'
InstanceTenancy: default
Tags:
- Key: Name
Value: !Sub '${PjName}-vpc-prod'
# ------------------------------------------------------------#
# Prod VPC IGW
# ------------------------------------------------------------#
IGWProdVPC:
Type: 'AWS::EC2::InternetGateway'
Properties:
Tags:
- Key: Name
Value: !Sub '${PjName}-igw-prod'
InternetGatewayAttachment:
Type: 'AWS::EC2::VPCGatewayAttachment'
Properties:
InternetGatewayId: !Ref IGWProdVPC
VpcId: !Ref ProdVPC
# ------------------------------------------------------------#
# Prod VPC Routetable
# ------------------------------------------------------------#
rtbProdVPC:
Type: 'AWS::EC2::RouteTable'
Properties:
Tags:
- Key: Name
Value: !Sub '${PjName}-rtb-prod'
VpcId: !Ref ProdVPC
rtProdVPC:
Type: "AWS::EC2::Route"
DependsOn: IGWProdVPC
Properties:
RouteTableId: !Ref rtbProdVPC
DestinationCidrBlock: 0.0.0.0/0
GatewayId: !Ref IGWProdVPC
# ------------------------------------------------------------#
# Subnet Prod 1a
# ------------------------------------------------------------#
SubnetAProd:
Type: 'AWS::EC2::Subnet'
Properties:
AvailabilityZone: ap-northeast-1a
CidrBlock: !Ref SubnetACIDR
VpcId: !Ref ProdVPC
Tags:
- Key: Name
Value: !Sub '${PjName}-subnet-1a-prod'
SubnetAProdRtbAssociate:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref rtbProdVPC
SubnetId: !Ref SubnetAProd
# ------------------------------------------------------------#
# Subnet Prod 1c
# ------------------------------------------------------------#
SubnetCProd:
Type: 'AWS::EC2::Subnet'
Properties:
AvailabilityZone: ap-northeast-1c
CidrBlock: !Ref SubnetCCIDR
VpcId: !Ref ProdVPC
Tags:
- Key: Name
Value: !Sub '${PjName}-subnet-1c-prod'
SubnetCProdRtbAssociate:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref rtbProdVPC
SubnetId: !Ref SubnetCProd
# ------------------------------------------------------------#
# Security Group for Prod VPC
# ------------------------------------------------------------#
InternalAccessSGProdVPC:
Type: 'AWS::EC2::SecurityGroup'
Properties:
VpcId: !Ref ProdVPC
GroupDescription: access from Inside
Tags:
- Key: Name
Value: !Sub '${PjName}-vpc-prod-internal-access'
SecurityGroupIngress:
- CidrIp: xxx.xxx.xxx.xxx/32
Description: Inside
FromPort: -1
IpProtocol: -1
ToPort: -1
PublicWebSGProdVPC:
Type: 'AWS::EC2::SecurityGroup'
Properties:
VpcId: !Ref ProdVPC
GroupDescription: Enable HTTP and HTTPS Public access
Tags:
- Key: Name
Value: !Sub '${PjName}-vpc-prod-public-access'
SecurityGroupIngress:
- CidrIp: 0.0.0.0/0
FromPort: 80
IpProtocol: tcp
ToPort: 80
- CidrIp: 0.0.0.0/0
FromPort: 443
IpProtocol: tcp
ToPort: 443
# ------------------------------------------------------------#
# Network components for Prod EC2 Instance
# ------------------------------------------------------------#
NwIfEC2Prod:
Type: 'AWS::EC2::NetworkInterface'
Properties:
Description: Network Interface for Prod EC2
GroupSet:
- !Ref InternalAccessSGProdVPC
- !Ref PublicWebSGProdVPC
InterfaceType: interface
SourceDestCheck: false
SubnetId: !Ref SubnetAProd
Tags:
- Key: Name
Value: !Sub '${PjName}-prod-1a-nwif'
EIPEC2Prod:
Type: 'AWS::EC2::EIP'
Properties:
Domain: vpc
EIPEC2ProdAssociate:
Type: 'AWS::EC2::EIPAssociation'
Properties:
AllocationId: !GetAtt
- EIPEC2Prod
- AllocationId
NetworkInterfaceId: !Ref NwIfEC2Prod
######################################################################################################
# Staging environment
######################################################################################################
#### 各コンポーネントのProd→Stgに変更する以外は本番の丸コピー
######################################################################################################
# Output section
######################################################################################################
Outputs:
R53HostedZone:
Description: Hosted Zone for Service Domain
Value: !Ref R53HostedZone
Export:
Name: !Sub "${PjName}-R53HostedZone"
VPCProd:
Description: VPC for Production environment
Value: !Ref ProdVPC
Export:
Name: !Sub "${PjName}-ProdVPC"
VPCStg:
Description: VPC for Staging and Develop environment
Value: !Ref StgVPC
Export:
Name: !Sub "${PjName}-StgVPC"
SubnetAProd:
Description: Subnet for Prod VPC 1a
Value: !Ref SubnetAProd
Export:
Name: !Sub "${PjName}-SubnetAProd"
SubnetCProd:
Description: Subnet for Prod VPC 1c
Value: !Ref SubnetCProd
Export:
Name: !Sub "${PjName}-SubnetCProd"
SubnetAStg:
Description: Subnet for Stg VPC 1a
Value: !Ref SubnetAStg
Export:
Name: !Sub "${PjName}-SubnetAStg"
SubnetCStg:
Description: Subnet for Stg VPC 1c
Value: !Ref SubnetCStg
Export:
Name: !Sub "${PjName}-SubnetCStg"
NWIFProd:
Description: Network Interface for Prod instance
Value: !Ref NwIfEC2Prod
Export:
Name: !Sub "${PjName}-NwIfEC2Prod"
NWIFStg:
Description: Network Interface for Stg instance
Value: !Ref NwIfEC2Stg
Export:
Name: !Sub "${PjName}-NwIfEC2Stg"
EIPEC2Prod:
Description: EIP for Prod instance
Value: !Ref EIPEC2
Export:
Name: !Sub "${PjName}-EIPEC2"
EIPEC2Stg:
Description: EIP for Stg instance
Value: !Ref EIPEC2Stg
Export:
Name: !Sub "${PjName}-EIPEC2Stg"
VPCCIDR:
Description: CIDR of VPC (common Prod and Stg)
Value: !Ref VPCCIDR
Export:
Name: !Sub "${PjName}-VPCCIDR"
InternalAccessSGStg:
Description: Security group for stg VPC Internal Access
Value: !Ref InternalAccessSGStgVPC
Export:
Name: !Sub "${PjName}-stg-sg-internal"
InternalAccessSGProd:
Description: Security group for prod VPC Internal Access
Value: !Ref InternalAccessSGProdVPC
Export:
Name: !Sub "${PjName}-prod-sg-internal"
PublicWebSGStgVPC:
Description: Security group for stg VPC public web Access
Value: !Ref PublicWebSGStgVPC
Export:
Name: !Sub "${PjName}-stg-sg-public-web"
PublicWebSGProdVPC:
Description: Security group for prod VPC public web Access
Value: !Ref PublicWebSGProdVPC
Export:
Name: !Sub "${PjName}-prod-sg-public-web"
ServiceDomainName:
Description: Service Domain Name
Value: !Ref ServiceDomainName
Export:
Name: !Sub "${PjName}-ServiceDomainName"
StagingDomainName:
Description: Staging Domain Name
Value: !Ref StagingDomainName
Export:
Name: !Sub "${PjName}-StagingDomainName"
-
ネットワークの構成について
本番用VPCとステージング用VPCで、サブネットの切り方を同じにする想定をしています。設定分けると面倒かなと思って。
各サブネットは1aと1cのアベイラビリティゾーンに関連づけています。3冗長構成にする場合は、1dに関連づいたサブネットを作成する形になると思いますが、今回は使わないと判断して省略。
ただし、ネットワークアドレス的には後々作成可能なように、VPCのサブネットマスクを/16
、各サブネットワークのサブネットマスクを/18
とし、サブネットを4つ作れるようにしています。 -
インターネットゲートウェイ(IGW)について
IGWは作成とVPCへのアタッチが必要なので、それぞれの記述があります。 -
ルートテーブルについて
ルートテーブルは、VPCを作るとデフォルトのルートテーブルが作成されますが、このルートテーブルのIDを知る機会がない(GetAttで取得はできる?未調査)ため、ルートテーブルと、IGWに通信を流すルートを新しく作成し、これを各サブネットにアタッチしています。 -
ネットワークインターフェースをここで作った理由
いくつかの都合で、Stg環境のドメインをRoute53以外のDNSで管理する必要がありました。
ネットワークインターフェースをEC2と同時に作成するようにスタックに記述すると、Stg環境のEC2の再構築が必要になった場合に、EIPの取得しなおしが発生し、DNSの再設定を行う必要が出てくるため、このような形になっています。
Route53でStg環境のドメインを管理するのであれば、ネットワークアダプタの作成をここで行わず、EC2と同時にネットワクアダプタとEIPとレコードセットを作るようなスタックにできるので、このほうが楽だろうなあと思います。 -
Webの公開セキュリティグループを最初から設定している理由
Let's encryptを使ってSSL証明書を取得する際、外部からアクセスできるようにしておく必要があるため。
最終的にインフラが完成したらこのセキュリティグループは手動で外します。
2, 固定で用意するEC2とRDSとS3を設定するスタック
AWSTemplateFormatVersion: 2010-09-09
Description: >-
Cloudformation stack for some project Infrastructure,
Creates EC2, RDS, S3, Route53RecordSet
Metadata:
'AWS::CloudFormation::Interface':
ParameterGroups:
- Label:
default: Global Setting
Parameters:
- PjName
- Label:
default: EC2 configuration
Parameters:
- EC2ProdHostName
- EC2ProdKeyName
- EC2StgHostName
- EC2StgKeyName
- Label:
default: RDS configuration
Parameters:
- DBEngineVersion
- DBEngineMinorVersion
- DBName
- DBMasterUserName
- DBMasterpassword
- Label:
default: S3 configuration
Parameters:
- BucketName
Parameters:
PjName:
Type: String
Default: MyProject
EC2ProdKeyName:
Type: 'AWS::EC2::KeyPair::KeyName'
Description: Prod env EC2 instance Keypair name. Keypair must be created before use.
EC2StgKeyName:
Type: 'AWS::EC2::KeyPair::KeyName'
Description: Stg env EC2 instance Keypair name. Keypair must be created before use.
EC2ProdHostName:
Type: String
Default: prod-hostname
Description: Prod env EC2 instance Domain name will set [EC2ProdHostName].[ServiceDomainName]
EC2StgHostName:
Type: String
Default : stg-hostname
Description: Staging env EC2 instance Domain name will set [EC2StgHostName].[StagingDomainName]
DBEngineVersion:
Type: String
Default: 8.0
AllowedValues: [ "8.0" ]
Description: Select major version of mysql.
DBEngineMinorVersion:
Type: String
Default: 16
Description: minor version of mysql.
DBName:
Type: String
Default: DatabaseName
Description: Database name
DBMasterUserName:
Type: String
Default: dbmaster
Description: Database master user
DBMasterpassword:
Default: "dbpassword"
NoEcho: true
Type: String
MinLength: 8
MaxLength: 41
AllowedPattern: "[a-zA-Z0-9]*"
ConstraintDescription: "must contain only alphanumeric characters."
Description: Database master user password
Resources:
######################################################################################################
# Production environment
######################################################################################################
# ------------------------------------------------------------#
# EC2 for Prod
# ------------------------------------------------------------#
EC2Prod:
Type: 'AWS::EC2::Instance'
Metadata:
'AWS::CloudFormation::Init':
configSets:
Initialize:
- Install
Install:
packages:
yum:
mariadb: []
mariadb-libs: []
httpd: []
unzip: []
git: []
certbot: []
python2-certbot-apache: []
files:
/var/www/html/index.php:
content: !Join
- ''
- - |
<?php
- |2
phpinfo();
- |2
?>
mode: '000600'
owner: apache
group: apache
/etc/cfn/cfn-hup.conf:
content: !Join
- ''
- - |
[main]
- stack=
- !Ref 'AWS::StackId'
- |+
- region=
- !Ref 'AWS::Region'
- |+
mode: '000400'
owner: root
group: root
/etc/cfn/hooks.d/cfn-auto-reloader.conf:
content: !Join
- ''
- - |
[cfn-auto-reloader-hook]
- |
triggers=post.update
- >
path=Resources.WebServerInstance.Metadata.AWS::CloudFormation::Init
- 'action=/opt/aws/bin/cfn-init -v '
- ' --stack '
- !Ref 'AWS::StackName'
- ' --resource EC2Prod '
- ' --configsets Initialize '
- ' --region '
- !Ref 'AWS::Region'
- |+
- |
runas=root
mode: '000400'
owner: root
group: root
services:
sysvinit:
httpd:
enabled: 'true'
ensureRunning: 'true'
cfn-hup:
enabled: 'true'
ensureRunning: 'true'
files:
- /etc/cfn/cfn-hup.conf
- /etc/cfn/hooks.d/cfn-auto-reloader.conf
Properties:
ImageId: 'ami-0ff21806645c5e492'
InstanceType: 't3.small'
KeyName: !Ref EC2ProdKeyName
BlockDeviceMappings:
- DeviceName: "/dev/xvda"
Ebs:
DeleteOnTermination: False
Encrypted: False
VolumeSize: 30
VolumeType: gp2
NetworkInterfaces:
- NetworkInterfaceId:
Fn::ImportValue:
!Sub "${PjName}-NwIfEC2"
DeviceIndex: 0
Tags:
- Key: Name
Value: !Ref EC2PRodHostName
UserData: !Base64
'Fn::Join':
- ''
- - |
#!/bin/bash -xe
- |
yum update -y aws-cfn-bootstrap
- |
amazon-linux-extras install ansible2 epel php7.3
- |
# Install the files and packages from the metadata
- '/opt/aws/bin/cfn-init -v '
- ' --stack '
- !Ref 'AWS::StackName'
- ' --resource EC2Prod '
- ' --configsets Initialize '
- ' --region '
- !Ref 'AWS::Region'
- |+
- |
# Signal the status from cfn-init
- '/opt/aws/bin/cfn-signal -e $? '
- ' --stack '
- !Ref 'AWS::StackName'
- ' --resource EC2Prod '
- ' --region '
- !Ref 'AWS::Region'
- |+
- |
!Sub "hostnamectl set-hostname --static ${EC2ProdHostName}"
- |+
# ------------------------------------------------------------#
# RDS components for Prod
# ------------------------------------------------------------#
RDSProdSecurityGroup:
Type: "AWS::EC2::SecurityGroup"
Properties:
VpcId: { "Fn::ImportValue": !Sub "${PjName}-ProdVPC" }
GroupName: !Sub "RDSProd-sg"
GroupDescription: "-"
Tags:
- Key: "Name"
Value: !Sub "RDSProd-sg"
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 3306
ToPort: 3306
CidrIp: { "Fn::ImportValue": !Sub "${PjName}-VPCCIDR" }
RDSProdParameterGroup:
Type: "AWS::RDS::DBParameterGroup"
Properties:
Family: !Sub "MySQL${DBEngineVersion}"
Description: !Sub "${PjName}-RDSProdParam"
RDSProdSubnetGroup:
Type: "AWS::RDS::DBSubnetGroup"
Properties:
DBSubnetGroupName: !Sub "${PjName}-prod-rds-subnetgroup"
DBSubnetGroupDescription: "-"
SubnetIds:
- { "Fn::ImportValue": !Sub "${PjName}-SubnetAProd" }
- { "Fn::ImportValue": !Sub "${PjName}-SubnetCProd" }
RDSProd:
Type: "AWS::RDS::DBInstance"
Properties:
DBInstanceIdentifier: !Sub "${PjName}-RDSProd"
Engine: MySQL
EngineVersion: !Sub "${DBEngineVersion}.${DBEngineMinorVersion}"
DBInstanceClass: db.t3.micro
AllocatedStorage: 20
StorageType: gp2
DBName: !Ref DBName
MasterUsername: !Ref DBMasterUserName
MasterUserPassword: !Ref DBMasterpassword
DBSubnetGroupName: !Ref RDSProdSubnetGroup
PubliclyAccessible: false
MultiAZ: True
PreferredBackupWindow: "18:00-18:30"
PreferredMaintenanceWindow: "sat:19:00-sat:19:30"
AutoMinorVersionUpgrade: false
DBParameterGroupName: !Ref RDSProdParameterGroup
VPCSecurityGroups:
- !Ref RDSProdSecurityGroup
CopyTagsToSnapshot: true
BackupRetentionPeriod: 7
Tags:
- Key: "Name"
Value: !Sub "${PjName}-RDSProd"
DeletionProtection: true
# ------------------------------------------------------------#
# S3 bucket for prod
# ------------------------------------------------------------#
S3Prod:
Type: 'AWS::S3::Bucket'
Properties:
BucketName: !Sub "${BucketName}-prod"
CfAccessIdentityProd:
Type: 'AWS::CloudFront::CloudFrontOriginAccessIdentity'
Properties:
CloudFrontOriginAccessIdentityConfig:
Comment: !Sub 'access-identity-${S3Prod}'
S3BucketPolicyProd:
Type: 'AWS::S3::BucketPolicy'
Properties:
Bucket: !Ref S3Prod
PolicyDocument:
Statement:
- Action: 's3:GetObject'
Effect: Allow
Resource: !Sub 'arn:aws:s3:::${S3Prod}/*'
Principal:
CanonicalUser: !GetAtt CfAccessIdentityProd.S3CanonicalUserId
# ------------------------------------------------------------#
# Route53 Recordset for prod
# ------------------------------------------------------------#
R53RSEC2Host:
Type: "AWS::Route53::RecordSet"
Properties:
HostedZoneId:
Fn::ImportValue:
!Sub "${PjName}-R53HostedZone"
Name:
- Fn::Join:
- "."
- - !Ref EC2ProdHostName
- Fn::ImportValue: !Sub "${PjName}-ServiceDomainName"
Type: A
TTL: 300
ResourceRecords:
- Fn::ImportValue:
!Sub "${PjName}-EIPEC2Prod"
######################################################################################################
# Staging environment
######################################################################################################
#### 各コンポーネントのProd→Stgに変更する以外は本番の丸コピー
######################################################################################################
# Output section
######################################################################################################
Outputs:
EC2HostNameProd:
Value: !Ref EC2HostName
Export:
Name: !Sub "${PjName}-EC2HostNameProd"
EC2StgHostName:
Value: !Ref EC2StgHostName
Export:
Name: !Sub "${PjName}-EC2StgHostName"
EC2ProdDomainName:
Value: !Sub "${EC2HostName}.${ServiceDomainName}"
Export:
Name: !Sub "${PjName}-EC2ProdDomainName"
EC2StgDomainName:
Value: !Sub "${EC2StgHostName}.${StagingDomainName}"
Export:
Name: !Sub "${PjName}-EC2StgDomainName"
CfAccessIdentityProd:
Value: !Ref CfAccessIdentityProd:
Export:
Name: !Sub "${PjName}-CfAccessIdentityProd"
CfAccessIdentityStg:
Value: !Ref CfAccessIdentityStg:
Export:
Name: !Sub "${PjName}-CfAccessIdentityStg"
S3Prod:
Value: !Ref S3Prod
S3Stg:
Value: !Ref S3Stg
RDSProdEndpoint:
Value: !GetAtt RDSProd.Endpoint.Address
RDSStgEndpoint:
Value: !GetAtt RDSStg.Endpoint.Address
RDSDevEndpoint:
Value: !GetAtt RDSDev.Endpoint.Address
DBName:
Value: !Ref DBName
DBMasterUser:
Value: !Ref DBMasterUserName
-
キーペアについて
キーペアはCloudformationから作成できないので、あらかじめ、Webコンソールから作成しておく必要があります。
というかcfnで作成したとすると、インスタンスを起動するたびに新しいキーが生成されることになる。。
Parametersで、'AWS::EC2::KeyPair::KeyName'
を指定すると、現在のリージョンに登録されているEC2のキーペアのリストがプルダウンで選択できるようになるので、こちらを使うのがよさそう。 -
可変にできるパラメータを固定している点について
例えばインスタンスサイズやAMIについて、サンプルテンプレートと同様に選択可能にしたり、他のリージョンでも動作するように記述することもできますが、あまりに長くなるのを避けたかったため、固定の値にしてあります。
ハマりどころ
- スタック間で値をやり取りするImportValueの記述
ImportValueについて、省略形式である!ImportValue
を使うと、この中で!sub
が使えません。
省略形式ではない"Fn::ImportValue":
を使う必要があります。
!ImportValue !Sub "${PjName}-ExportedParameter"
"Fn::ImportValue": !Sub "${PjName}-ExportedParameter"
EC2作成時にどんな動作をするのか
EC2を作成する際、インスタンスが出来上がってまず最初にUserData:
に記載した内容が実行されます。
今回の場合、下記の動作をします。
-
aws-cfn-bootstrap
を導入し、Cloudformation関連の操作がEC2内部で実施できるようにします。 -
amazon-linux-extras
コマンドを使って、AmazonLinux向けに用意されているパッケージから、ansible2
epel
php7.3
を導入しています。-
Ansible2
は、Let's encryptのcertbotが自動的に証明書を作るために必要なバーチャルホストの設定などを実施します。 -
epel
は、Let's encryptのcertbotをyumで導入するために導入します。 -
php7.3
は、ここで導入するのが最も簡単かと。
-
-
cfn-initコマンドを実行します。
このコマンドを実行すると、スタックに記述したEC2のMetadata:
'AWS::CloudFormation::Init':
以下に記載した内容が実行されます。この中身については、AWS::CloudFormation::Initを参照してください。
今回の内容については、AWSのLAMP環境を構築するサンプルテンプレートを参考にしています。 -
注意
cfn-init
コマンドの中で指定するスタックの名前と操作対象のEC2の名前(今回の場合EC2Prod
)を間違えるとcfn-initコマンドが実行されないので注意が必要です。これでだいぶハマった。 -
'AWS::CloudFormation::Init':
以下に記載した内容が実行される
ここで、必要なパッケージとして、git
やApache
、RDSに接続するためのmariadb
クライアント、Let's encryptのCertbot
を導入しています。
このスタックが作成されると、本番環境のEC2に対してhttp://[EC2ProdHostName].[ServiceDomainName]
でアクセスできるようになります。
3, バージニア北部リージョンでACM証明書を作成するスタック
AWSTemplateFormatVersion: 2010-09-09
Description: >-
Cloudformation stack for some project Infrastructure,
Create ACM Certificate
this stack should to execute at us-east-1
Metadata:
'AWS::CloudFormation::Interface':
ParameterGroups:
- Label:
default: Global Setting
Parameters:
- PjName
- ServiceDomainName
- StagingDomainName
Parameters:
PjName:
Type: String
Default: MyProject
ServiceDomainName:
Type: String
Default: example.com
Description: Service domain name will set ACM certificate.
StagingDomainName:
Type: String
Default: staging.example.com
Description: Staging env service domain name will set ACM certificate.
Resources:
######################################################################################################
# Production environment
######################################################################################################
# ------------------------------------------------------------#
# ACM Certificate Cloudfront for Prod
# ------------------------------------------------------------#
ACMCertCFProd:
Type: AWS::CertificateManager::Certificate
Properties:
DomainName: !Ref ServiceDomainName
DomainValidationOptions:
- DomainName: !Ref ServiceDomainName
ValidationDomain: !Ref 'ServiceDomainName'
Tags:
- Key: Name
Value: !Sub '${ServiceDomainName}-acm'
ValidationMethod: DNS
######################################################################################################
# Staging environment
######################################################################################################
#### 各コンポーネントのProd→Stgに変更する以外は本番の丸コピー
Outputs:
ACMCertCFProd:
Description: ACM Certificate for Prod environment
Value: !Ref ACMCertCFProd
ACMCertCFStg:
Description: ACM Certificate for Stg environment
Value: !Ref ACMCertCFStg
Cloudfrontからhttps通信を行う場合、Cloudfrontの仕様でバージニア北部リージョンでACM証明書を作る必要があります。
Cloudformationのスタックは、スタックを実行したリージョンで動作が完結することを前提としているようで、リージョンをまたいだ記述ができません。(できるのかもしれないけど調べた範囲では不可だった)
このため、この部分だけは独立してバージニア北部リージョンにて実行し、スタックの実行後に出力タブにACM証明書のarnを出力するように設定します。
この時に出力されるarnを、Cloudfrontを設定するスタック実行時に、パラメータとして渡します。
※出力した値をExport設定しても、リージョンをまたいで利用できません。
後処理
ACM証明書を作成する際、DNSで認証するように設定しているので、WebコンソールのACMを開き、各証明書の認証に必要なCNAMEレコードを作成します。
Route53を使っている場合は、ACMの画面にCNAMEレコードを作成するボタンが出るので、こちらを使うと楽です。
この操作を実施しないと、スタックの作成が完了しません。また、この状態で長時間(12時間?)放置するとスタックの作成がロールバックされてしまいます。
4, 公開するために必要なコンポーネントを設定するスタック
AWSTemplateFormatVersion: 2010-09-09
Description: >-
Cloudformation stack for some project Infrastructure,
Creates Route53 Recordset, Cloudfront
Metadata:
'AWS::CloudFormation::Interface':
ParameterGroups:
- Label:
default: Global Setting
Parameters:
- PjName
- Label:
default: Cloudfront ACM Certificates
Parameters:
- ACMCertCFProd
- ACMCertCFStg
Parameters:
PjName:
Type: String
Default: MyProject
ACMCertCFProd:
Type: String
Default : ARN of ACM Certificate for Prod
Description: ARN of ACM certificate for Production environment
ACMCertCFStg:
Type: String
Default : ARN of ACM Certificate for Stg
Description: ARN of ACM certificate for Staging environment
Resources:
######################################################################################################
# Production environment
######################################################################################################
# ------------------------------------------------------------#
# Route53 Recordset for prod
# ------------------------------------------------------------#
R53RSCF:
Type: "AWS::Route53::RecordSet"
DependsOn: CfProd
Properties:
Name:
Fn::ImportValue:
!Sub "${PjName}-ServiceDomainName"
Type: A
HostedZoneId:
Fn::ImportValue:
!Sub "${PjName}-R53HostedZone"
AliasTarget:
DNSName: !GetAtt CfProd.DomainName
HostedZoneId: "Z2FDTNDATAQYW2"
# ------------------------------------------------------------#
# Cloudfront for Prod
# ------------------------------------------------------------#
CfProd:
Type: 'AWS::CloudFront::Distribution'
Properties:
DistributionConfig:
PriceClass: PriceClass_All
Aliases:
- Fn::ImportValue:
!Sub "${PjName}-ServiceDomainName"
Origins:
- DomainName: !GetAtt S3Prod.DomainName
Id: !Sub 'S3origin-${BucketName}-prod'
S3OriginConfig:
OriginAccessIdentity:
- Fn::Join:
- ""
- -'origin-access-identity/cloudfront/'
- Fn::ImportValue: !Sub "${PjName}-CfAccessIdentityProd"
- DomainName:
Fn::ImportValue:
!Sub "${PjName}-EC2ProdDomainName"
Id:
Fn::ImportValue:
!Sub "${PjName}-EC2ProdDomainName"
CustomOriginConfig:
OriginProtocolPolicy: match-viewer
DefaultRootObject: index.html
DefaultCacheBehavior:
TargetOriginId: !Sub 'S3origin-${BucketName}-prod'
ViewerProtocolPolicy: redirect-to-https
AllowedMethods:
- GET
- HEAD
CachedMethods:
- GET
- HEAD
DefaultTTL: 3600
MaxTTL: 86400
MinTTL: 60
Compress: true
ForwardedValues:
Cookies:
Forward: none
QueryString: false
CacheBehaviors:
- TargetOriginId:
Fn::ImportValue:
!Sub "${PjName}-EC2ProdDomainName"
AllowedMethods:
- HEAD
- DELETE
- POST
- GET
- OPTIONS
- PUT
- PATCH
CachedMethods:
- GET
- HEAD
Compress: true
DefaultTTL: 3600
MaxTTL: 86400
MinTTL: 60
PathPattern: /ec2/*
ViewerProtocolPolicy: redirect-to-https
ForwardedValues:
Cookies:
Forward: all
QueryString: true
ViewerCertificate:
SslSupportMethod: sni-only
MinimumProtocolVersion: TLSv1.1_2016
AcmCertificateArn: !Ref ACMCertCFProd
HttpVersion: http2
Enabled: true
######################################################################################################
# Staging environment
######################################################################################################
#### 各コンポーネントのProd→Stgに変更する以外は本番の丸コピー
######################################################################################################
# Output section
######################################################################################################
Outputs:
DistributionIDProd:
Value: !Ref CfProd
DistributionIDStg:
Value: !Ref CfStg
-
Cloudfrontに対抗するRoute53レコードセットについて
上記に記載したようなAliasTarget
の設定で固定になっています。
Route53以外のDNSを使う場合はCNAMEレコードを設定しますが、Route53ではこのように設定できるらしい。 -
Cloudfrontの
CacheBehaviors
のAllowedMethods
について
柔軟に対応できそうな見た目をしているが、決め打ちで3パターンぐらいしか通るのがなく、EC2側オリジンの記述では、アクセスメソッドが全部OKみたいな形になってしまいます。
5, 開発環境を設定するスタック
内容としてはここまで書いてきたものをコピペして作ることができます。
開発環境については、
- 複数作成する可能性があること
- 作成と削除を繰り返す可能性があること
から、独立性を高めておく必要があると考えて、このようにしています。
AWSTemplateFormatVersion: 2010-09-09
Description: >-
Cloudformation stack for some project Infrastructure,
Creates NWIF, EIP, EC2, Route53RS for develop environment
Metadata:
'AWS::CloudFormation::Interface':
ParameterGroups:
- Label:
default: Global Setting
Parameters:
- PjName
- Label:
default: EC2 configuration
Parameters:
- EC2HostName
- EC2KeyName
Parameters:
PjName:
Type: String
Default: MyProject
EC2HostName:
Type: String
Default: dev-hostname
Description: Prod env EC2 instance Domain name
EC2KeyName:
Type: 'AWS::EC2::KeyPair::KeyName'
Description: Prod env EC2 instance Keypair name. Keypair must be created before use.
Resources:
NWIF:
Type: 'AWS::EC2::NetworkInterface'
Properties:
Description: Network Interface for EC2
GroupSet:
- Fn::ImportValue:
!Sub "${PjName}-stg-sg-internal"
- Fn::ImportValue:
!Sub "${PjName}-stg-sg-public-web"
InterfaceType: interface
SourceDestCheck: false
SubnetId:
Fn::ImportValue:
!Sub "${PjName}-SubnetCStg"
Tags:
- Key: Name
Value: !Sub '${PjName}-dev-1c-nwif'
EIP:
Type: 'AWS::EC2::EIP'
Properties:
Domain: vpc
EIPEC2Associate:
Type: 'AWS::EC2::EIPAssociation'
Properties:
AllocationId: !GetAtt
- EIP
- AllocationId
NetworkInterfaceId: !Ref NWIF
EC2:
Type: 'AWS::EC2::Instance'
Metadata:
'AWS::CloudFormation::Init':
configSets:
Initialize:
- Install
Install:
packages:
yum:
mariadb: []
mariadb-libs: []
httpd: []
unzip: []
git: []
certbot: []
python2-certbot-apache: []
files:
/var/www/html/index.php:
content: !Join
- ''
- - |
<?php
- |2
phpinfo();
- |2
?>
mode: '000600'
owner: apache
group: apache
/etc/cfn/cfn-hup.conf:
content: !Join
- ''
- - |
[main]
- stack=
- !Ref 'AWS::StackId'
- |+
- region=
- !Ref 'AWS::Region'
- |+
mode: '000400'
owner: root
group: root
/etc/cfn/hooks.d/cfn-auto-reloader.conf:
content: !Join
- ''
- - |
[cfn-auto-reloader-hook]
- |
triggers=post.update
- >
path=Resources.WebServerInstance.Metadata.AWS::CloudFormation::Init
- 'action=/opt/aws/bin/cfn-init -v '
- ' --stack '
- !Ref 'AWS::StackName'
- ' --resource EC2'
- ' --configsets Initialize '
- ' --region '
- !Ref 'AWS::Region'
- |+
- |
runas=root
mode: '000400'
owner: root
group: root
services:
sysvinit:
httpd:
enabled: 'true'
ensureRunning: 'true'
php-fpm:
enabled: 'true'
ensureRunning: 'true'
cfn-hup:
enabled: 'true'
ensureRunning: 'true'
files:
- /etc/cfn/cfn-hup.conf
- /etc/cfn/hooks.d/cfn-auto-reloader.conf
Properties:
ImageId: 'ami-0ff21806645c5e492'
InstanceType: 't3.micro'
KeyName: !Ref EC2KeyName
BlockDeviceMappings:
- DeviceName: "/dev/xvda"
Ebs:
DeleteOnTermination: False
Encrypted: False
VolumeSize: 16
VolumeType: gp2
NetworkInterfaces:
- NetworkInterfaceId: !Ref NWIF
DeviceIndex: 0
Tags:
- Key: Name
Value: !Ref EC2HostName
UserData: !Base64
'Fn::Join':
- ''
- - |
#!/bin/bash -xe
- |
yum update -y aws-cfn-bootstrap
- |
amazon-linux-extras install ansible2 epel php7.3
- |
# Install the files and packages from the metadata
- '/opt/aws/bin/cfn-init -v '
- ' --stack '
- !Ref 'AWS::StackName'
- ' --resource EC2 '
- ' --configsets Initialize '
- ' --region '
- !Ref 'AWS::Region'
- |+
- |
# Signal the status from cfn-init
- '/opt/aws/bin/cfn-signal -e $? '
- ' --stack '
- !Ref 'AWS::StackName'
- ' --resource EC2 '
- ' --region '
- !Ref 'AWS::Region'
- |+
- VPCについて
Dev環境用にVPCは作成していません。
Stg環境の1cのサブネットを間借りするような形にしています。
残作業
このままではCloudfront-EC2の間の通信がhttpsにならないので、SSL通信の設定をする必要があります。
Let's encryptを使ってCloudfront-EC2の間の通信をhttpsにする
のだが、Certbotの設定を行う際、先にApacheのバーチャルホストの設定がないと設定に失敗します。
-
Ansibleでバーチャルホストの設定を作る
http通信のバーチャルホスト設定を作成します。 -
改めてCertbotを実行してhttpsで通信できるようにする。
この際、Cloudfrontの設定に合わせるように、http通信をhttps通信にリダイレクトするように設定しておくといいかもしれない。 -
EC2のネットワークインターフェースに紐づいているWeb全公開のセキュリティグループを外す
Certbotの認証が通って、無事SSL証明書が発行されたら、いったん外部からの接続をできないようにします。
ApacheとPHPの基本的な設定が必要
タイムゾーンの設定を入れるとか、PHP7.3をPHP-FPMで動くようにするとかそういう
これも上記のAnsibleに入れてしまえばOKかと。
残作業に関するコメント
実際のAnsible-playbookについてはあまりに稚拙なのでちょっと乗せるのを躊躇う。。(ひどい
うまくいっているかどうかの指標として、バーチャルホストを設定し、DocumentRootを元の/var/www/html/
から移動すると、PHPinfoのページからApacheデフォルトのページに変化します。これがうまくいっていることを確認してからCertbotを動かすといいかも。
Certbotの設定がうまくいくと、Cloudfrontに設定したドメイン名でアクセスが通るようになります。
まとめ
張り切りすぎて記事が長くなりすぎた。(大風呂敷過ぎたんだ)
というのもさることながら、EC2を使ったシステムを稼働させるためには、これだけたくさんの設定を入れる必要があって、どれか一つ失敗するとシステムはうまく動かないということを再認識。やはり自動化は必要、、、という気持ちが強くなりました。
(余談)cfnを使うことになった経緯
- 新サービスやるよ、インフラコストは可能な限り安くしてね(いつものこと)
- じゃあサーバレスでやればいいよ(半ギレ)、それに向けて準備するね?
- 開発の都合は知らないけど納期はこっちで指定するしその日までにリリースできなきゃ怒るよ(いつもの開発者を怒らせる一言)
- うーんじゃあサーバレスやめてよくある構成で考え直して(上司の常識的な判断)
- わかりました(しろめ)、でも今までやってなかった要素は取り入れるよ
という話の中で、2番の段階でどう使うか調べ始めていたCloudFormationを使うことに。
これまでインスタンスの構成管理のためにAnsibleを使っていたので、これも併用する形で、運用しやすい環境を構築してやろうと考えたわけです。