ALLSSU

블로그 개발 #1 - AWS CDK를 통한 Route53 + CloudFront + S3 구성

정적 사이트 배포를 위한 AWS CDK 인프라 코드 개발

2022년 8월 20일 토 15:40

블로그 아키텍처

Gitlab CI와 AWS CDK로 구성한 블로그 인프라 아키텍처

Next.js로 마크다운 파일 기반의 블로그를 직접 만들어보고 싶어서 인프라부터 개발했다. Gitlab CI/CD에서 Next.js 빌드로 정적파일 S3 배포를 하게 되고, 배포가 되면 CloudFront에서 캐시 무효화를 해서 갱신이 되는 간단한 아키텍처를 구성했다.

정적 사이트를 배포할 인프라를 개발하자 - AWS CDK를 통한 IaC(Infrastructure As Code)

인프라 개발을 위해 AWS CDK(Cloud Development Kit)로 코드를 작성했다. AWS 리소스 구성하는데 Typescript CDK가 익숙해서 사용했다. 코드로 인프라를 개발(Infrastructure as Code)하는 것은 쓰면 쓸수록 장점이 많다고 생각된다.

  • 명령어를 입력하거나 클릭하는 것이 아닌, 소스를 작성해놓는 선언형이라는 것이 매력적이다.
  • Git과 같은 버전관리를 통해 인프라의 히스토리가 기록된다.
  • 코드 자체가 현재 구성된 인프라의 문서가 되기 때문에, 업데이트되지 않는 문서나 기억력에 의존하지 않게 된다.
  • 인프라를 코드로 작성하면서, 인프라의 구조화를 가능하게 만들어 준다. 폴더 구조나 스택 구조에 대한 고민은 있지만.
  • 규모가 커질수록 인프라를 변경해서 생기는 휴면 에러를 방지해준다.

특히 AWS CDK는 익숙한 언어(Typescript, Python, Java 등)로 AWS 인프라 개발이 가능하다. 오픈소스이기도 해서 자주 사용하고 같이 사용되는 서비스들의 묶음을 추상화해서 패키지로 제공하면서 발전하고 있다.

CDK 코드로 Amazon S3, Amazon CloudFront, Amazon Route53, AWS Certificate Manager을 코드로 이어 붙였다. 먼저 정적 사이트 파일들을 저장하기 위해 S3 버킷을 CDK 코드로 개발했다.

CDK S3 Stack 개발

// lib/blog-s3-stack.ts
import { RemovalPolicy, Stack, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import { aws_s3 as s3 } from "aws-cdk-lib";

export class BlogS3Stack extends Stack {
  public readonly bucket: s3.IBucket;
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    this.bucket = new s3.Bucket(this, "bucket", {
      bucketName: "버킷이름",
      removalPolicy: RemovalPolicy.DESTROY,
      autoDeleteObjects: true,
      publicReadAccess: false,
    });
  }
}

S3에서 제공하는 정적 웹사이트 호스팅 기능은 여기서 사용되지 않는다. 버킷을 만들 때, CloudFront에서만 접속할 수 있도록 설정(OAI)할 것이기 때문에 버킷 자체에 Public Access는 되지 않도록 설정했다. 만약 CDK에서 publicReadAccess 옵션을 true로 준다면 CloudFront에서는 S3 오리진이 아닌, HTTP 서버 오리진으로 구성한다. CloudFront S3 원본 그림

S3에서 publicReadAccess 옵션을 false로 해야 CloudFront에서 S3 원본(Origin)으로 생성이 된다.

CDK CloudFront Stack 개발

cdk deploy 명령어를 통해 버킷을 배포하고, Amazon CloudFront를 아래와 같이 CDK 코드로 개발했다.

// lib/blog-cloudfront-stack.ts
import { Stack, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import {
  aws_cloudfront as cloudfront,
  aws_cloudfront_origins as origins,
} from "aws-cdk-lib";
import { IBucket } from "aws-cdk-lib/aws-s3";
import { ICertificate } from "aws-cdk-lib/aws-certificatemanager";

interface BlogCloudfrontStackProps extends StackProps {
  s3Bucket: IBucket;
  certificate: ICertificate;
}

export class BlogCloudfrontStack extends Stack {
  public readonly distribution: cloudfront.Distribution;
  constructor(scope: Construct, id: string, props?: BlogCloudfrontStackProps) {
    super(scope, id, props);

    // 요청에 index.html 경로를 붙여주는 함수
    const blogRequestFunction = new cloudfront.Function(
      this,
      "requestfunction",
      {
        code: cloudfront.FunctionCode.fromInline(
          `function handler(event) {
    var request = event.request;
    var uri = request.uri;
    if (uri.endsWith('/')) {
        request.uri += 'index.html';
    } 
    else if (!uri.includes('.')) {
        request.uri += '/index.html';
    }

    return request;
}`
        ),
      }
    );

    // 응답에 보안헤더를 붙여주는 함수
    const blogResponseFunction = new cloudfront.Function(
      this,
      "responsefunction",
      {
        code: cloudfront.FunctionCode.fromInline(
          `function handler(event) {
    var response = event.response;
    var headers = response.headers;

    headers['strict-transport-security'] = { value: 'max-age=63072000; includeSubdomains; preload'};
    headers['content-security-policy'] = { value: "default-src 'self'"};
    headers['x-content-type-options'] = { value: 'nosniff'};
    headers['x-frame-options'] = { value: 'DENY'};
    headers['x-xss-protection'] = { value: '1; mode=block'};
    headers['referrer-policy'] = { value: 'strict-origin-when-cross-origin'};

    return response;
}`
        ),
      }
    );

    // CloudFront 배포(Distribution)
    const distribution = new cloudfront.Distribution(this, "distribution", {
      defaultBehavior: {
        origin: new origins.S3Origin(props!.s3Bucket),
        viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, // http to https
        functionAssociations: [
          {
            function: blogRequestFunction,
            eventType: cloudfront.FunctionEventType.VIEWER_REQUEST,
          },
          {
            function: blogResponseFunction,
            eventType: cloudfront.FunctionEventType.VIEWER_RESPONSE,
          },
        ],
      },
      domainNames: ["www.allssu.com"],
      certificate: props!.certificate, // Certificate Manager에서 가지고 오는 인증서
      errorResponses: [
        {
          httpStatus: 404,
          responsePagePath: "/404.html",
          responseHttpStatus: 200,
        },
      ],
    });

    this.distribution = distribution;
  }
}
CDK는 StackProps를 통해서 스택 간에 리소스를 이어 붙일 수 있다. 기존에 콘솔로 만들어놓은 Certificate Manager 인증서와 S3 버킷을 Props로 받기 위해 인터페이스에 추가했다. Amazon CloudFront를 사용하면 http to https 리디렉션도 간단하게 설정할 수 있다.

Trailing Slash와 Amazon CloudFront Function

Next.js export를 사용하면서, 동적 페이지(블로그 파일)를 각각 폴더 내의 index.html로 만들어주기 위해 Next.js 설정파일에서 trailingSlash 옵션을 사용했다. 안 붙이면 각각의 블로그 파일이름마다 html이 붙는다.

Trailing Slash를 적용한 폴더구조

Trailing Slash를 사용해서 Next.js 정적 사이트를 만든 폴더 구조

trailingSlash 옵션 적용 후 CloudFront에서 페이지마다 접속하려고 하면 index.html을 붙여줘야 하는 문제가 발생했는데, CloudFront에 요청할 때 index.html를 붙여주도록 설정하면 해결이 가능하다. 이것을 해결하기 위해서나, CloudFront에서 요청하거나 응답받는 것을 조작할 때 Lambda@Edge를 많이 사용할 텐데, 이제는 그럴 필요가 없다. 2021년 05월, 요청과 응답에 가볍게 사용할 수 있는 CloudFront Functions가 출시되었다. CloudFront Functions는 CDK에서 인라인으로 함수 작성도 가능하고, 개발한 이벤트 핸들러가 별로 변경이 없겠다 싶어서 인라인 함수로 작성했다. 하는 김에 요청뿐만 아니라 응답받을 때는 Security 헤더가 적용되도록 함수를 추가로 작성했다.

리디렉션 용도의 CloudFront & S3 Bucket 만들고 연결하기

allssu.com으로 접속하면 www.allssu.com으로 연결하는 게 깔끔하겠다는 생각이 들어서, Route53에서 두 가지 도메인로 연결하는 작업을 시작했다.

위에서 작성한 코드와 같이 S3 버킷 & CloudFront 한 세트를 만들고 나서 Route53을 통해 도메인을 연결하면 www.allssu.com은 접속이 가능하지만, allssu.com(Zone Apex, 루트 레벨 도메인)은 접속이 불가능하다. allssu.com은 서브도메인이 붙지 않은 Zone Apex라서 CNAME 레코드를 사용하지 못하고 Route53의 Alias 기능을 통해서 도메인 연결이 가능하다. 이때, allssu.com에서 www.allssu.com으로 리디렉션해 줄 새로운 S3가 필요하다. Route53 Alias 레코드를 통해 S3를 직접 연결하거나, CloudFront를 연결할 수 있는데, Zone Apex의 Alias Record로 S3를 바로 연결하면 경로가 붙은 URI에 대해서 리다이렉트가 되지 않았다. 그래서 추가로 리디렉션을 해주는 용도의 CloudFront를 만들게 됐다.

S3 리디렉션하기 위한 아키텍처

추가적인 CloudFront와 S3가 필요했다

Route 53에서는 아래와 같이 CDK 코드에서 도메인별로 각각 다른 CloudFront를 바라보게 해주었다. Props로 두 개의 CloudFront Distribution(블로그앱 용도, 리디렉션 용도)을 받게 된다.

CDK Route53 Stack 개발

// lib/blog-route53-stack.ts
import { Duration, Stack, StackProps } from "aws-cdk-lib";
import { Construct } from "constructs";
import {
  aws_cloudfront as cloudfront,
  aws_cloudfront_origins as origins,
  aws_route53 as route53,
  aws_route53_targets as targets,
} from "aws-cdk-lib";

interface BlogRoute53StackProps extends StackProps {
  distributionApp: cloudfront.Distribution;
  distributionRedirect: cloudfront.Distribution;
}

export class BlogRoute53Stack extends Stack {
  public readonly distribution: cloudfront.Distribution;
  constructor(scope: Construct, id: string, props?: BlogRoute53StackProps) {
    super(scope, id, props);

    // 기존에 콘솔에서 만든 AWS 리소스를 사용하는 방법
    const allssuZone = route53.HostedZone.fromLookup(this, "hostedZone", {
      domainName: "allssu.com",
    });

    // Zone Apex의 경우 Redirect 용도의 CloudFront 연결
    new route53.ARecord(this, "apexAlias", {
      zone: allssuZone,
      recordName: "",
      target: route53.RecordTarget.fromAlias(
        new targets.CloudFrontTarget(props!.distributionRedirect)
      ),
    });

    // www의 경우 정적사이트를 호스팅하는 CloudFront에 연결
    const wwwRecord = new route53.ARecord(this, "wwwAlias", {
      zone: allssuZone,
      recordName: "www",
      target: route53.RecordTarget.fromAlias(
        new targets.CloudFrontTarget(props!.distributionApp)
      ),
    });
  }
}
allssu.com과 www.allssu.com에 Alias Record(별칭 레코드)를 만드는 CDK Route53 스택 코드. 기존에 생성한 호스트존을 메소드로 불러와서, 각 별칭 레코드마다 Amazon CloudFront를 연결했다.

Route53에서 CloudFront를 연결하는 별칭 레코드까지 만들고 나면 도메인 접속 시 S3에 배포된 정적 파일을 읽을 수 있게 된다. 이렇게 해서 allssu.com 또는 www.allssu.com을 접속하면 https 프로토콜로 블로그를 접속하는 인프라가 완성됐다.