{"id":1467,"date":"2020-06-26T00:14:36","date_gmt":"2020-06-25T22:14:36","guid":{"rendered":"https:\/\/blog.besharp.it\/?p=1467"},"modified":"2021-03-24T12:08:50","modified_gmt":"2021-03-24T11:08:50","slug":"hosting-a-static-site-on-aws-is-cloudfront-always-the-right-choice","status":"publish","type":"post","link":"https:\/\/blog.besharp.it\/hosting-a-static-site-on-aws-is-cloudfront-always-the-right-choice\/","title":{"rendered":"Hosting a static site on AWS: is CloudFront always the right choice?"},"content":{"rendered":"
Binomial services CloudFront<\/strong> and S3<\/strong> are nowadays part of consolidated practices for many companies that have the necessity of hosting a static website (or part of it) while keeping its costs low without sacrificing security standards<\/strong> (eg: SSL termination on proprietary domain). But is this the only way pursuable? Are there any other means by which we can achieve the same result by also solving other necessities? This article tries to give an answer to that!<\/span><\/p>\n Imagine you have to handle the following need: your company’s frontend team requests testing a freshly developed feature on a production-like infrastructure to show and verify that the result is somewhat the one expected for the release. In particular, it would like to have a different domain name for every feature developed in order to avoid path related inconsistencies.<\/span><\/p>\n Even without some calculator at hand to predict costs of managed traffic, it is crystal clear that maintaining different CloudFront distributions for every feature and every frontend project can lead to useless complications from an AWS account\u2019s management perspective. It is worth mentioning that it\u2019s currently impossible to create two different origins from the main path and link them to two distinct domains within the same CloudFront distribution without incurring in a possible redirect that would lead to path problems mentioned above.\u00a0<\/span><\/p>\n At first, we must notice that these deploys have an effimere nature. This means that a relatively short time of creation and removal for these infrastructural elements must be respected. To reach this goal it’s useful to share the same resources between more than one interlocutors to minimize overhead.\u00a0\u00a0<\/span><\/p>\n The proposed solution is made thanks to the following infrastructural elements:<\/span><\/p>\n <\/p>\n To obtain the desired result it\u2019s imperative to create the S3 Bucket with the correct configuration. Following the definition of the CloudFormation template for this task:<\/span><\/p>\n <\/p>\n It\u2019s worth noticing that \u201cwebsite configuration\u201d has been enabled to allow HTTP requests towards the bucket but at the same time a Bucket Policy denies retrieving any kind of object from it unless a request is coming from the S3\u2019s VPC endpoint, ensuring that only allowed actors passing from the account\u2019s VPC can access that very bucket.\u00a0<\/span><\/p>\n To allow final users to see the static website hosted on S3, a Load Balancer must be created (depending on the final user\u2019s nature, you can choose to keep the Load Balancer private or not). In CloudFormation this is the set of resources that must be created:<\/span><\/p>\n <\/p>\n Thanks to this template, a public Load Balancer is deployed with a listener on port 80 (HTTP) redirecting on port 443 (HTTPS) with another listener which contacts a Target Group with a specific Lambda function registered on it.<\/span><\/p>\n Proposed solution\u2019s logic is managed by a Lambda Function. Below an example code can be found (being an example it\u2019s better to review and adapt code in case of production-ready solutions):<\/span><\/p>\n <\/p>\n Despite being a little tricky to read, operations done here are quite simple: starting from the DNS name which the user exploits to reach the Load Balancer, the Lambda Function processes the request to the S3 Bucket by building the feature\u2019s appropriate subfolder to contact. To ensure the entire process works as expected, a DNS name for each feature must be created.<\/span><\/p>\n We showed how it is possible, thanks to a wise choice of configuration of a set of specific resources, to maintain a S3 Bucket private, despite having public webhosting enabled, with the goal of managing more versions of a static website: one for each defined sub-folder, reachable with a specific yet different DNS name. This approach is undoubtedly more fast and agile when the needs are to verify visible aspects of a site instead of configure its infrastructure avoiding time and management efforts of a CloudFront distribution.\u00a0<\/span><\/p>\nProblem<\/span><\/h2>\n
Solution<\/span><\/h2>\n
\n
Creation and configuration of the S3 Bucket<\/span><\/h2>\n
\r\n S3Bucket:\r\n Type: AWS::S3::Bucket\r\n Properties:\r\n BucketName: 'subdomain.mydomain.com'\r\n CorsConfiguration:\r\n CorsRules:\r\n - AllowedHeaders:\r\n - '*'\r\n AllowedMethods:\r\n - GET\r\n - HEAD\r\n - POST\r\n - PUT\r\n - DELETE\r\n AllowedOrigins:\r\n - 'https:\/\/*.mydomain.com'\r\n PublicAccessBlockConfiguration:\r\n BlockPublicAcls: true\r\n BlockPublicPolicy: true\r\n IgnorePublicAcls: true\r\n RestrictPublicBuckets: true\r\n WebsiteConfiguration:\r\n ErrorDocument: error.html\r\n IndexDocument: index.html\r\n \r\n S3BucketPolicy:\r\n Type: AWS::S3::BucketPolicy\r\n Properties:\r\n Bucket: !Ref S3Bucket\r\n PolicyDocument:\r\n Version: '2012-10-17'\r\n Statement:\r\n - Sid: VPCEndpointReadGetObject\r\n Effect: Allow\r\n Principal: \"*\"\r\n Action: s3:GetObject\r\n Resource: !Sub '${S3Bucket.Arn}\/*'\r\n Condition:\r\n StringEquals:\r\n aws:sourceVpce: !Ref S3VPCEndpointId\r\n<\/pre>\n
Creation and configuration of the Load Balancer<\/span><\/h2>\n
LoadBalancer:\r\n Type: AWS::ElasticLoadBalancingV2::LoadBalancer\r\n Properties:\r\n Name: !Sub '${ProjectName}'\r\n LoadBalancerAttributes:\r\n - Key: 'idle_timeout.timeout_seconds'\r\n Value: '60'\r\n - Key: 'routing.http2.enabled'\r\n Value: 'true'\r\n - Key: 'access_logs.s3.enabled'\r\n Value: 'true'\r\n - Key: 'access_logs.s3.prefix'\r\n Value: loadbalancers\r\n - Key: 'access_logs.s3.bucket'\r\n Value: !Ref S3LogsBucketName\r\n Scheme: internet-facing\r\n SecurityGroups:\r\n - !Ref LoadBalancerSecurityGroup\r\n Subnets:\r\n - !Ref SubnetPublicAId\r\n - !Ref SubnetPublicBId\r\n - !Ref SubnetPublicCId\r\n Type: application\r\n \r\n LoadBalancerSecurityGroup:\r\n Type: AWS::EC2::SecurityGroup\r\n Properties:\r\n GroupName: !Sub '${ProjectName}-alb'\r\n GroupDescription: !Sub '${ProjectName} Load Balancer Security Group'\r\n SecurityGroupIngress:\r\n - CidrIp: 0.0.0.0\/0\r\n Description: ALB Ingress rule from world\r\n FromPort: 80\r\n ToPort: 80\r\n IpProtocol: tcp\r\n - CidrIp: 0.0.0.0\/0\r\n Description: ALB Ingress rule from world\r\n FromPort: 443\r\n ToPort: 443\r\n IpProtocol: tcp\r\n Tags:\r\n - Key: Name\r\n Value: !Sub '${ProjectName}-alb'\r\n - Key: Environment\r\n Value: !Ref Environment\r\n VpcId: !Ref VPCId\r\n \r\n HttpListener:\r\n Type: AWS::ElasticLoadBalancingV2::Listener\r\n Properties:\r\n DefaultActions:\r\n - RedirectConfig:\r\n Port: '443'\r\n Protocol: HTTPS\r\n StatusCode: 'HTTP_301'\r\n Type: redirect\r\n LoadBalancerArn: !Ref LoadBalancer\r\n Port: 80\r\n Protocol: HTTP\r\n \r\n HttpsListener:\r\n Type: AWS::ElasticLoadBalancingV2::Listener\r\n Properties:\r\n Certificates:\r\n - CertificateArn: !Ref LoadBalancerCertificateArn\r\n DefaultActions:\r\n - Type: forward\r\n TargetGroupArn: !Ref TargetGroup\r\n LoadBalancerArn: !Ref LoadBalancer\r\n Port: 443\r\n Protocol: HTTPS\r\n \r\n TargetGroup:\r\n Type: AWS::ElasticLoadBalancingV2::TargetGroup\r\n Properties:\r\n Name: !Sub '${ProjectName}'\r\n HealthCheckEnabled: false\r\n TargetType: lambda\r\n Targets:\r\n - Id: !GetAtt Lambda.Arn\r\n DependsOn: LambdaPermission\r\n<\/pre>\n
Routing Lambda\u2019s code<\/span><\/h2>\n
import json\r\nfrom boto3 import client as boto3_client\r\nfrom os import environ as os_environ\r\nimport base64\r\nfrom urllib3 import PoolManager\r\n \r\nhttp = PoolManager()\r\ns3 = boto3_client('s3')\r\n \r\ndef handler(event, context):\r\n try:\r\n print(event)\r\n print(context)\r\n \r\n host = event['headers']['host']\r\n print(\"Host:\", host)\r\n \r\n feature = host.split('.')[0]\r\n feature = \"-\".join(feature.split('-')[1:])\r\n print(\"Feature:\", feature)\r\n \r\n path = event['path'] if event['path'] != \"\/\" else \"\/index.html\"\r\n print(\"Path:\", path)\r\n \r\n query_string_parameters = event['queryStringParameters']\r\n query_string_parameters = [f\"{key}={value}\" for key, value in event['queryStringParameters'].items()]\r\n print(\"Query String Parameters:\", query_string_parameters)\r\n \r\n http_method = event[\"httpMethod\"]\r\n url = f\"http:\/\/{os_environ['S3_BUCKET']}.s3-website-eu-west-1.amazonaws.com\/{feature}{path}{'?' if [] != query_string_parameters else ''}{'&'.join(query_string_parameters)}\"\r\n print(url)\r\n \r\n headers = event['headers']\r\n headers.pop(\"host\")\r\n print(\"Headers:\", headers)\r\n \r\n body = event['body']\r\n print(\"Body:\", body)\r\n \r\n r = http.request(http_method, url, headers=headers, body=body)\r\n print(\"Response:\", r)\r\n print(\"Response Data:\", r.data)\r\n \r\n try:\r\n decoded_response = base64.b64encode(r.data).decode('utf-8')\r\n except:\r\n decoded_response = base64.b64encode(r.data)\r\n \r\n print(\"Decoded Response:\", decoded_response)\r\n print(\"Headers Response:\", dict(r.headers))\r\n return {\r\n 'statusCode': 200,\r\n 'body': decoded_response,\r\n \"headers\": dict(r.headers),\r\n \"isBase64Encoded\": True\r\n }\r\n except Exception as e:\r\n print(e)\r\n return {\r\n 'statusCode': 400\r\n }\r\n<\/pre>\n
Conclusions<\/span><\/h2>\n