티스토리 뷰

문제점

accept-language에 따라 다른 언어의 html을 보여주어야했습니다. accept-language header를 cloudfront whitelist에 추가하여 origin에 header를 전달하도록 구성했습니다. 하지만 accept-language header의 형태가 다양하여 cloudfront cache의 효율이 좋지 않았고 지원하지 않는 언어가 들어오는 경우 default language 설정이 필요했습니다.

 

user-agent header를 parsing하여 IE 브라우저로 접속 시 redirect 처리하고 있습니다. user-agent header 또한 cloudfront whitelist에 추가하여 origin으로 header를 전달하고 있었지만 cache 효율이 좋지 않았습니다.

해결방법

client 요청 시 header에 관련된 처리를 하는 lambda@edge를 생성하여 cache 효율을 높이고자 했습니다.

Lambda@Edge

lambda@edge 함수는 viewer request, viewer response, origin request, origin response 중 선택하여 동작할 수 있습니다. 모든 요청에 대한 cache key를 변경하려면 viewer request를 사용해야 합니다.

Lambda 함수를 트리거하는 데 사용할 CloudFront 이벤트를 결정하는 방법

Lambda@Edge 제한사항

lambda@edge는 기본 lamdba function을 사용하는 것보다 많은 제약사항이 있습니다.

  • $LATEST 또는 별칭이 아니라 번호가 매겨진 Lambda 함수 버전을 사용해야합니다.
  • Lambda 함수는 미국 동부 리전에 있아야 합니다.
  • Viewer request와 viewer response의 timetout 제한 5초, Origin request와 origin response timeout 제한 30초 입니다.
  • Viewer request와 viewer response의 package size 제한 1MB, Origin request와 origin response의 package size 제한 50MB 제한이 있습니다.
  • 환경변수를 사용할 수 없습니다.

구현

header parsing

구현 시 lambda@edge의 1MB 제한사항 때문에 module 선택 시 size를 꼭 확인해야합니다. accept-language header를 accept-language-parser npm module을 사용하여 정형화 했습니다.

import { pick } from "accept-language-parser";

export class AcceptLanguageParser {
    private supportedLanguages: string[];
    private defaultLanguage: string;
    constructor(supportedLanguages: string[], defaultLanguage: string) {
        this.supportedLanguages = supportedLanguages;
        this.defaultLanguage = defaultLanguage;
    }

    public pickLanguage(acceptLanguage: string): string {
        const lang = pick(this.supportedLanguages, acceptLanguage, {
            loose: true,
        });
        if (lang) return lang;
        return this.defaultLanguage;
    }
}
const acceptLanguageParser = new AcceptLanguageParser(["en", "ko"], "ko");
const lang1 = acceptLanguageParser.pickLanguage("en-GB,en;q=0.8"); // en
const lang2 = acceptLanguageParser.pickLanguage("fr-CA', 'fr-FR', 'fr"); // ko

정형화한 값을 cloudfront origin으로 전달할 때는 querystring으로 전달하도록 구성했습니다.

import { parse, stringify } from "querystring";
import * as ua from "useragent";

const acceptLanguageParser = new AcceptLanguageParser(["en", "ko"], "ko");

export function handler(
    event: CloudFrontRequestEvent,
    context: Context,
    callback: Callback
) {
    const request = Records[0].cf.request;
      let userAgent;
      if (
        request.headers["accept-language"] &&
        request.headers["accept-language"][0]
    ) {
        const acceptLanguage = request.headers["accept-language"][0].value;
        delete request.headers["accept-language"]; // cloudfront cache 생성 시 accept-language header에 따라 cache가 생성되는 것을 방지하기 위해 삭제
          const lang = acceptLanguageParser.pick(acceptLanguage)
        request.querystring = stringify({ lang });
      }

      if (request.headers["user-agent"] && request.headers["user-agent"][0]) {
        userAgent = request.headers["user-agent"][0].value;
        delete request.headers["user-agent"];  // cloudfront cache 생성 시 user-agent header에 따라 cache가 생성되는 것을 방지하기 위해 삭제              
    }

      if(ua.is(userAgent).ie) callback(null, {
            status: "301",
            statusDescription: "Permanently Moved",
            headers: {
                location: [
                    {
                        key: "Location",
                        value: "{{url}}",
                    },
                ],
            },
        }); // ie 브라우저로 접근 시 redirect
     else callback(null, request);
}

배포 스크립트 작성

배포 스크립트는 node.js code를 작성하고 작성한 code를 packaging하여 s3에 upload하는 것까지 작성했습니다. s3에 올라간 package는 terraform으로 lambda@edge, cloudfront 설정을 했습니다. 아래 코드는 s3에 upload하는 shell 스크립트입니다.

    npm install && tsc
    npm pack --json | jq '.[0].filename' | xargs -I {} mv {} "$OUTPUT_FILENAME"

    mkdir "$TMP_FOLDER"
    mv "$OUTPUT_FILENAME" "$TMP_FOLDER"/"$OUTPUT_FILENAME"

    cd $TMP_FOLDER
    tar -xvzf "$OUTPUT_FILENAME"
    cd package && zip -r "../$OUTPUT_ZIP" . && cd ..
    ENC_METADATA=`openssl dgst -sha256 -binary $OUTPUT_ZIP | openssl enc -base64`
    aws s3 cp ./$OUTPUT_ZIP s3://{{s3 upload 위치}} --metadata sha256=$ENC_METADATA

작성 시 어려웠던 점은 node.js application package하는 것과 새로운 package를 s3 upload 시에만 terraform으 변경사항을 감지하고 배포할 수 있도록 구성하는 것이었습니다.

 

1. node.js application package

 

lambda@edge의 1MB 제한사항 때문에 devdependency가 들어가지 않도록 해야합니다. 이를 위해 package.jsonbundleDependencies를 사용했습니다.

{
 ...
    "dependencies": {
        "accept-language-parser": "^1.5.0",
        "useragent": "^2.3.0"
    },
    "bundleDependencies": [
        "accept-language-parser",
        "useragent"
    ],
}

bundleDependencies를 사용하면 npm pack 실행 시 bundleDependencies에 추가되어 있는 module만 node_modules에 추가하게 됩니다. 기존에는 yarn을 사용했지만 bundleDependencies설정이 yarn pack으로는 제대로 실행되지 않는 것을 확인되어 npm pack으로 실행하는 스크립트를 작성했습니다. .npmignore를 사용하여 ts 파일과 log file등 packaging에 필요 없는 file을 제거 했습니다.

npm pack실행의 결과물은 *.tgz입니다. tgz를 그대로 s3 upload할 수 없습니다. lambda에서 사용하는 file은 zip 형식이므로 tgz를 zip으로 변환해야합니다.

 

2. 새로운 package를 s3 upload 시에만 terraform 변경사항 감지

 

terraform에서는 변경사항이 있을 때만 배포되기 때문에 metadata를 사용하여 aws_lambda_functionsource_code_hash에 맞는 방식으로 encode한 hash 값을 설정해야 했습니다. aws_lambda_functionsource_code_hash에 잘못된 값을 넣으면 terraform에서 apply 때마다 변경사항이 발생하게 됩니다.

 

source_code_hash을 사용하는 방식 이외에도 s3 object를 versioning할 수 있도록 설정하여 version id를 aws_lambda_function에 설정하는 방식도 있습니다. s3 object를 versioning하는 방식을 사용하지 않고 있어 source_code_hash를 사용하는 방식으로 구성했습니다.

data "aws_s3_bucket_object" "header_parsing_lambda_edge_artifact" {
  bucket = "${node package를 upload한 s3 bucket}"
  key    = "${node package를 upload한 s3 key}"
}

data "aws_iam_policy_document" "assume_role_policy_doc" {
  statement {
    sid    = "AllowAwsToAssumeRole"
    effect = "Allow"

    actions = ["sts:AssumeRole"]

    principals {
      type = "Service"

      identifiers = [
        "edgelambda.amazonaws.com",
        "lambda.amazonaws.com",
      ]
    }
  }
}

resource "aws_iam_role" "lambda_at_edge" {
  name               = "lambda-edge-role"
  assume_role_policy = data.aws_iam_policy_document.assume_role_policy_doc.json
}

data "aws_iam_policy_document" "lambda_logs_policy_doc" {
  statement {
    effect    = "Allow"
    resources = ["*"]
    actions = [
      "logs:CreateLogStream",
      "logs:PutLogEvents",
      "logs:CreateLogGroup",
    ]
  }
}

resource "aws_iam_role_policy" "logs_role_policy" {
  name   = "log-policy"
  role   = aws_iam_role.lambda_at_edge.id
  policy = data.aws_iam_policy_document.lambda_logs_policy_doc.json
}

resource "aws_lambda_function" "header_parsing_lambda_edge" {
  function_name = "lambda-edge"

  # Find the file from S3
  s3_bucket         = "${node package를 upload한 s3 bucket}"
  s3_key            = "${node package를 upload한 s3 key}"
  source_code_hash  = chomp(data.aws_s3_bucket_object.header_parsing_lambda_edge_artifact.metadata["Sha256"])
  provider          = aws.aws_cloudfront

  publish = true
  handler = "dist/app.handler"
  runtime = "nodejs14.x"
  role    = aws_iam_role.lambda_at_edge.arn
}

resource "aws_cloudfront_distribution" "static_distribution" {
    ...

    ordered_cache_behavior {
        lambda_function_association {
            event_type = "viewer-request"
            include_body = false
            lambda_arn = aws_lambda_function.header_parsing_lambda_edge.qualified_arn
        }
    }
}

결과

cloudfront를 적극적으로 사용하고 있습니다. 하지만 accept-language, user-agent와 같이 형태가 다양한 header를 cloudfront origin으로 전달할 경우 cache hit 효율이 떨어집니다. lambda@edge를 사용하는 방법이 이러한 문제를 해결할 수 있는 방법이 될 수 있습니다. 하지만 lambda@edge 도입에 장벽으로 느껴지는 부분이 있었습니다.

 

첫번째 장벽은 lambda@edge의 제약사항이 많다는 것이었습니다. lambda packaging 시 size 제한으로 code 개발 시 항상 사용하는 module을 전부 제거해야만 했습니다. lambda timeout 제한이 있어 외부 요청이나 db 연결 시 timeout이 발생하지 않도록 주의해야 합니다. 이러한 제한 사항으로 설계시 어려움이 많이 느껴졌습니다. 두번째 장벽은 기존에 사용하는 lambda 배포 pipeline을 같이 사용할 수 없다는 것이었습니다. 기존에 serverless framework로 lambda를 관리하고 lambda 이외의 infra는 terraform으로 관리하고 있었습니다. cloudfront를 terraform으로 관리하고 있었고 serverless framework로 배포한 lambda@edge를 terraform에 연결하는 작업이 어려움이 있다고 판단되었습니다. 기존에 사용하는 serverless framework배포 방식을 선택하지 않고 terraform으로만 관리하는 방법으로 새롭게 구성해야했습니다.

 

lambda@edge을 도입하면서 cloudfront origin으로 설정한 application에서 header를 처리하는 로직을 제거할 수 있었습니다. header를 처리하는 로직을 분리하여 application에는 core 로직에만 중집할 수 있게 되었습니다.

Reference

'develop' 카테고리의 다른 글

Elasticsearch 비용 절약하기  (0) 2024.09.19
Github Actions 관리하기(Organization secrets, Reusable workflows)  (0) 2022.06.29
python 절대경로 / 상대경로  (0) 2022.01.04
DAS / NAS / SAN  (0) 2021.10.29
3 계층 architecture  (0) 2021.10.29
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/04   »
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
글 보관함