S3 + CloudFront の導入によるAWSコスト削減の話

Last Updated: 2022/01/13 12:07:58

AWS S3 + CloudFront

EC2 + RDSで構築されたとあるサービスについて、CloudFront(AWSのキャッシュサーバー)とS3を設定するだけの比較的簡単な操作で月額コスト十数万円規模のサイトで数万円のコストカットを実現できたので、その方法を紹介する。



# 概要

今回、具体的に対応した内容としては静的ファイルをS3に転送し、CloudFront経由で表示する、という単純な対策のみ。

  • EC2に保存されている画像、および静的ファイルをS3に同期し、CloudFront経由でアクセスさせるように修正。
  • EC2自体はALB経由で表示させているが、その前段にCloudFrontを設定。
  • Route53を利用しない場合、Cloud Frontにネイキッドドメインは設定できないので(CNAMEを使うため)EC2側でネイキッドドメインでのアクセスをwwwつきのURLにリダイレクトするように設定。

今回は既存サイトのコスト削減だったので、大幅な設定変更は行っていないが、もし新規にある程度以上の規模のサイトを作る場合であれば、静的サイトジェネレーターのような発想でアクセスされるページは静的に生成してS3に配置し、動的コンテンツ以外はすべてキャッシュサーバー経由とするのもよい方法ではないかと思う。


いずれにせよ、既存サイトである程度以上大きなものは静的ファイルをS3に移動するだけでも大きな効果が見込めると思われるので、試してみる価値はあると思う。


# S3バケットの作成

S3のバケット作成は管理画面からもできるが、私の場合、(S3に限ったことではなくAWS全般でそうなのだが)同じ操作を他のケースで行いたい時に、できるだけ同じ条件で操作できるように極力、専用のスクリプトを作成して行っている。


AWS管理画面からにせよ、スクリプト経由にせよ、Web公開用にS3のバケットを設定する場合、下記に気をつける必要がある。

  • Static website hostingを有効にしておく必要がある(管理画面の場合 Properties にある)
  • Bucket policys3:GetObjectを有効にしておく必要がある。(管理画面の場合 Permissions にある)
  • public accessを許可しておく必要がある(管理画面ではBlock all public access

Bucket Policys3:GetObjectを有効にする、と書いたが、具体的には下記のようにポリシーを設定する。

{
	"Version":"2012-10-17",
	"Statement":[
		{
			"Sid":"AddPerm",
			"Effect":"Allow",
			"Principal": "*",
			"Action":["s3:GetObject"],
			"Resource":["arn:aws:s3:::(バケット名)/*"]
		}
	]
} 
1
2
3
4
5
6
7
8
9
10
11
12

# [boto3] S3のバケットを作成する

これらの設定をpythonのboto3で設定する場合、下記のようになる。

import json
import boto3

bucket_name = 'バケット名'
aws_region = 'ap-northeast-1' # Asia/Tokyo. Modify if you need.

s3 = boto3.client(
	's3',

	# If you use this script in AWS env, you don't need to specify these AWS Keys.
	aws_access_key_id = AWS_ACCESS_KEY_ID, 
	aws_secret_access_key = AWS_SECRET_KEY, 

	# Modify your own region
	region_name = aws_region,
)

# バケット生成
s3.create_bucket(
	Bucket = bucket_name,
	ACL = 'public-read',
	CreateBucketConfiguration = {
		'LocationConstraint' : aws_region,
	}
)

# website設定
s3.put_bucket_website(
	Bucket = bucket_name,
	WebsiteConfiguration = {
		'ErrorDocument' : {
			'Key' : 'error.html', 
		},
		'IndexDocument': {
			'Suffix' : 'index.html',
		},
	}
)
# バケットポリシー
s3.put_bucket_policy(
	Bucket = bucket_name,
	Policy = json.dumps({
		"Version": "2012-10-17",
		"Statement": [
			{
				"Sid": "AddPerm",
				"Effect": "Allow",
				"Principal": "*",
				"Action": "s3:GetObject",
				"Resource": ("arn:aws:s3:::%s/*" % bucket_name),
			}
		]
	})
)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55

# CloudFrontの作成

CloudFrontの作成はS3と同じで管理画面からできるが、私の場合、やはりそれ用のスクリプトを作成して行っている。特にCloudFrontの場合、設定項目が多いので(面倒なのだが)管理画面から設定するにせよ、なんらかの方法で設定内容を残しておいたほうがよいと思う。


概要としては下記のようになる。

  • Originは先ほど作成したS3のバケットを設定する。
  • 今回の場合、HTTPはHTTPSにリダイレクトしている。
  • CNAME はwwwつきとwwwなしの2通り設定している。ただし、前述のように Route53 を使わない場合、CloudFront にネイキッドドメインを使うことはできないため、私の場合はネイキッドドメインのDNSレコードは直接、EC2のインスタンスに設定している。
  • Default Root Objectindex.htmlを設定。

# [boto3] ACMの証明書を発行する

ACMの証明書を発行する際に、ドメインの所有権の認証があり、メール認証とDNS認証の2種類がある。メール認証の場合、ドメインのメールアドレスを受信できる状態にする必要があるため、今回はDNSで認証する。

下記は設定すべきDNS設定を表示し、DNS側の設定後にENTERを押すと、ACMのステータスを確認するプログラム。先にACMの証明書を発行し、その後にCloudFrontのDistribution作成を行う必要がある。

import json
import boto3

acm_domain = '認証したいドメイン'
acm_cname = [
	domain,
	('www.%s' % domain), 
]
aws_region = 'ap-northeast-1' # Asia/Tokyo. Modify if you need.

acm = boto3.client(
	'acm',

	# If you use this script in AWS env, you don't need to specify these AWS Keys.
	aws_access_key_id = AWS_ACCESS_KEY_ID, 
	aws_secret_access_key = AWS_SECRET_KEY, 

	# Modify your own region
	region_name = aws_region,
)

def acm_get(domain):
	global acm
	
	res = acm.list_certificates(
		CertificateStatuses = [ 'PENDING_VALIDATION', 'ISSUED']
	)
	data = res["CertificateSummaryList"]

	for a in data:
		if a["DomainName"] == domain:
			return a

def acm_describe(arn):
	global acm
	res = acm.describe_certificate(
		CertificateArn = arn,
	)
	return res["Certificate"]

def acm_create(domain):
	global acm
	res = acm.request_certificate(
		DomainName = domain,
		ValidationMethod = 'DNS',
		SubjectAlternativeNames = acm_cname,
	)

def acm_wait_validation(domain, arn):
	global acm

	a = acm_describe(arn)
	
	while True:
		# 認証のために必要な設定を表示する
		o = a["DomainValidationOptions"]
		for o_ in o:
			print('- %s: %s - %s (%s)' % ( 
				o_["DomainName"],
				o_["ResourceRecord"]["Name"],
				o_["ResourceRecord"]["Value"],
				o_["ValidationStatus"],
			))
		
		# 指定された DNS設定完了後に Enterをクリックするように促し、そのまま待つ
		print("(*) You should configure CNAME Records for Validating Certificates: ")
		print("(*) Enter after setting DNS CNAME Records: ")
		ignored_ = input()

		# 認証されているかどうか確認する
		# 実際にはDNS設定してから少し時間を空ける必要がある
		a = acm_describe(arn)
		if a["Status"] == 'PENDING_VALIDATION':
			continue
		elif a["Status"] == 'ISSUED':
			return True
		else:
			raise Exception("Unknown ACM Status: %s" % (a["Status"]))


a = acm_get(acm_domain)
if not a:
	acm_create(acm_domain)
	a = acm_get(acm_domain)

acm_wait_validation(acm_domain, a["CertificateArn"])

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87

# [boto3] CloudFrontのDistributionを作成する

CloudFrontのDistributionに関しては、設定項目が多いので引数が複雑になる。


import json
import boto3

bucket_name = 'バケット名'
aws_region = 'ap-northeast-1' # Asia/Tokyo. Modify if you need.

dist_name = 'ディストリビューション名'

cf = boto3.client(
	'cloudfront',

	# If you use this script in AWS env, you don't need to specify these AWS Keys.
	aws_access_key_id = AWS_ACCESS_KEY_ID, 
	aws_secret_access_key = AWS_SECRET_KEY, 

	# Modify your own region
	region_name = aws_region,
)

s3_domain_name = '%s.s3.%s.amazonaws.com' % (bucket_name, aws_region) # S3 domain 
cname_list = [ 'ドメイン名', 'www.ドメイン名', 'static.ドメイン名' ] # change for your app

# ACMで発行した証明書のARN。
# CloudFrontのディストリビューションでCNAMEを設定する場合、あらかじめACMで証明書を発行しておく必要がある。
acm_arn = '...' 

cf.create_distribution(
	DistributionConfig = {
		'CallerReference' : dist_name, # Distributionごとにユニークならなんでもよい?
		'Enabled' : True, # 有効かどうか
		'Aliases' : {
			'Quantity' : len(cname_list), 
			'Items' : cname,
		},
		'DefaultRootObject' : 'index.html',
		'Comment' : dist_name, # なんでもよい
		'Origins' : {
			'Quantity' : 1,
			'Items' : [{
				'Id' : s3_domain,
				'DomainName' : s3_domain,
				'OriginShield' : { 'Enabled': False }, 

				# S3 の場合はこれを忘れないように。
				'S3OriginConfig' : {
					'OriginAccessIdentity': '',
				},
			}],
		},
		'DefaultCacheBehavior' : {
			# HEAD/GET以外は許可しない
			'AllowedMethods': {
				'CachedMethods': {
					'Items': ['HEAD','GET'],
					'Quantity': 2,
				},
				'Items': ['HEAD', 'GET'],
				'Quantity': 2,
			},
			'TargetOriginId' : s3_domain, 
			# 'TrustedKeyGroups': {'Enabled': False, 'Quantity': 0},
			# 'TrustedSigners': {'Enabled': False, 'Quantity': 0},
			'ViewerProtocolPolicy': 'redirect-to-https', # リダイレクトせずにHTTP/HTTPSの両方を許可する場合は allow-all
			'SmoothStreaming': False,
			'Compress': True,
			# 'FieldLevelEncryptionId': '',
			# 'FunctionAssociations': {'Quantity': 0},
			# 'LambdaFunctionAssociations': {'Quantity': 0},
			# 'CachePolicyId': '658327ea-f89d-4fab-a63d-7e88639e58f6',

			# キャッシュ時間
			'MinTTL' : 3600, 

			# 何にも設定しない場合はこのままでよい
			'ForwardedValues' : {
				'QueryString': False,
				'Cookies' : { 'Forward':'none','WhitelistedNames':{'Quantity': 0} },
				'Headers' : { 'Quantity': 0 },
				'QueryStringCacheKeys' : { 'Quantity': 0 },
			},
		},
		# 'S3OriginConfig' : { 'OriginaAccessIdentity' : s3_domain, },
		'ViewerCertificate': {
			# cname を設定する場合 ACM で発行した証明書を指定しないとディストリビューションが作成できない
			'ACMCertificateArn': acm_arn,
			'Certificate': acm_arn,
			'CertificateSource': 'acm',
			'MinimumProtocolVersion': 'TLSv1.2_2021',
			'SSLSupportMethod': 'sni-only',
		},
		'CustomErrorResponses': {
			'Items': [
				{
					'ErrorCachingMinTTL': 600,
					'ErrorCode': 404,
					'ResponseCode': '404',
					'ResponsePagePath': '/error.html',
				},
				{
					'ErrorCachingMinTTL': 600,
					'ErrorCode': 403,
					'ResponseCode': '403',
					'ResponsePagePath': '/error.html',
				},
			],
			'Quantity': 2, 
		},
	}
)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111

# CloudFront経由でS3のファイルが正常に見られるか確認

ここまで正常に設定できていれば S3 にアップロードしたファイルをCloudFront経由で閲覧できるようになっているはずなので、下記について確認する。

  • S3に適当な内容で index.html ファイルをアップロード。
  • CloudFrontのディストリビューションから発行されたURL (***.cloudfront.net) で正常に表示されるか確認。

# ドメインのCNAMEを設定する

CloudFrontのDistributionに設定したCNAMEを実際のDNSサーバーに設定する。

ドメイン管理をRoute53で行っていない場合、ネイキッドドメインにCNAMEを設定することができないため、私の場合はEC2のIPアドレスを指定し、非ネイキッドドメインへのリダイレクトを行っている。

Route53の場合はネイキッドドメインも含めてすべてのドメインをcloudfrontに設定すればよい。

example.com         IN A (EC2に関連付けしたElastic IPのアドレス)
www.example.com     IN CNAME ***.cloudfront.net
static.example.com  IN CNAME ***.cloudfront.net
1
2
3

DNS設定してから少し時間がたったら、実際に設定したドメイン経由でアクセスできるか確認しておく。


# キャッシュしたい静的ファイルをS3に転送するように設定

ここまでできたら、実際にCloudFront経由で表示させたいファイルをローカルやEC2サーバーからS3へ転送し、そちらを参照するようにする。ここはサイト全体の前段にCloudFrontを配置している場合、CloudFrontの設定で振り分けてもよいし、あるいはサイト側でS3を参照して欲しいURLのみ example.jp -> static.example.jp のように書き換えてもよいだろう。
今回、私が対応したサイトでは後者の方法をとっている。

ここについては具体的な方法は設計によって異なるが、参考までにディレクトリ以下のファイルをすべてS3に転送させるスクリプトを載せておく。


WARNING

S3へのアップロードを繰り返し行うと費用がかかるし数が多ければサーバーの負荷もかかるので、実際にアップロード済みのファイルはアップロードしない、ファイル数が多い場合は緩やかにアップロードするなどの対策を行っている。いずれにせよ下記のプログラムだと何も考えずにただ、指定ディレクトリ以下のファイルを転送しているだけとなっており、転送するファイル数によっては余計な費用がかかってしまうため、そこは各々のサイト設計に合わせて使うようにする必要がある。

import os, re, json
import boto3

bucket_name = 'バケット名'
aws_region = 'ap-northeast-1' # Asia/Tokyo. Modify if you need.

dist_dir = '転送元ディレクトリ'

s3 = boto3.client(
	's3',

	# If you use this script in AWS env, you don't need to specify these AWS Keys.
	aws_access_key_id = AWS_ACCESS_KEY_ID, 
	aws_secret_access_key = AWS_SECRET_KEY, 

	# Modify your own region
	region_name = aws_region,
)

def get_content_type_by_path(p, default_type=None):
	ext = os.path.splitext(os.path.basename(p))[1]
	ext = re.sub(r'^\.', '', ext)
	types = {
		'gif' : 'image/gif',
		'jpeg' : 'image/jepg',
		'jpg' : 'image/jpeg',
		'png' : 'image/png',		
		'webp' : 'image/webp',
		'html' : 'text/html',
		'htm' : 'text/html',
		'css' : 'text/css',
		'js' : 'text/javascript',
	}
	if ext in types:
		return types[ext]
	
	return default_type

def find_dist_files(dist_dir):
	if not os.path.isdir(dist_dir):
		raise Exception("dist dir not exists: %s" % dist_dir)

	res = []

	pos_ = len(dist_dir)
	for cur, dirs, files in os.walk(dist_dir):
		c = re.sub(r'^/',"",cur[pos_:])
		if c != "":
			res.append({
				"type": "dir" , 
				"name" : c, 
				"path" : cur,
			})

		for f in files:
			p = "%s/%s" % (cur, f)
			name = re.sub(r'^/',"",p[pos_:])

			# follow symlink
			if os.path.islink(p):
				p = os.readlink(p)

			res.append({
				"type": "file" , 
				"name" : name, 
				"path" : p,
			})
	return res

def s3_upload_file(name, path):
	global bucket_name, 


	name = re.sub(r'^/',"", name)

	bin = None
	content_type = get_content_type_by_path(name, default_type="text/plain")
	
	if content_type.find("image/") == 0:
		with open(path, 'rb') as fp:
			bin = fp.read()
	else:
		with open(path, 'r') as fp:
			bin = fp.read()

	res = s3.put_object(
		Body = bin, 
		Bucket = bucket_name,
		Key = name, 
		ContentType = content_type, 
	)

	return res

files = find_dist_files(dist_dir)
for f in files:
	if f["type"] == "file":
		s3_upload_file(name=f["name"], path=f["path"])
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98

# 結論

以上が今回行ったコスト削減のためのS3 + CloudFront導入方法となる。

個人的な肌感覚だが、AWSのサービスの中で、EC2やRDS、ALB(またはELB)に比べるとCloudFrontの使用率は低いのではないかと思う。ただ、ある程度以上の規模であれば積極的に導入すべきではないかと感じているし(今回、対応したサイトはWordPressではないが)サードパーティのプラグインによる静的ファイルの読み込み数の多いWordPress関係のサイトでは特に有効なのではないかと感じている。

Last Updated: 2022/01/13 12:07:58
Copyright © Web Ninja All Rights Reserved.