ESM 삽질기

09 Apr 2022

저희는 주기적으로 Node.js 모듈을 최신 버전으로 업데이트하고 있습니다. Node.js를 10년째 사용 중인데, CoffeeScript → TypeScript, 콜백 → Async.js → Promise(& async, await) 전환 하면서 몇 번 혼란의 시기가 있었습니다. 하지만 모듈 시스템은 쭉 이어져왔습니다. 그런데 최근에 이 모듈 시스템에 큰 변화가 생겼고 기존 변화와 다르게 양립이 잘 안 되서 모듈 업데이트를 제대로 못 하고 있습니다. 이 문제를 일으킨 ESM이 뭐고 어떤 작업이 필요한지 알아보려고 합니다. (개인적으로 만족하는 깔끔한 해결책이 나오지 못했습니다)

발생하는 문제

chalk 란 모듈이 5.0으로 가면서 Pure ESM 모듈이 됐습니다. 이를 가져다 쓰면 다음과 같은 에러가 발생합니다.

import chalk from 'chalk';
console.log(chalk.yellow('Hello'));
Error [ERR_REQUIRE_ESM]: require() of ES Module /a/node_modules/chalk/source/index.js from /a/a.ts not supported.
Instead change the require of index.js in /a/a.ts to a dynamic import() which is available in all CommonJS modules.

ESM이란 무엇인가?

초기 JavaScript는 모듈 시스템이 없었습니다. 클라이언트쪽에서는 Require.js란 것이 많이 쓰였습니다. 한편 서버(Node.js)에서는 CommonJS가 적용되어 따로 발전을 했습니다. 라이브러리가 클라이언트, 서버를 모두 지원하기 위한 패턴들이 개발되어 적용됐던 기억이 나네요. 그후 Browserify, webpack 같은 번들 시스템이 나오면서 CommonJS 쪽으로 통일되었습니다.

대부분의 경우 잘 동작하지만 뭔가 문제가 있으시까 새로운 시스템이 나왔겠죠? 상세한 히스토리는 잘 모르지만 제가 아는 CommonJS의 가장 큰 문제는 런타임에 모듈을 읽는 다는 것입니다. 다음 예를 보겠습니다.

// a.js
console.log('a1');
console.log(require('./b').b);
console.log('a2');
exports.a = 1;

// b.js
console.log('b1');
console.log(require('./a').a);
console.log('b2');
exports.b = 2;
$ node a.js 
a1
b1
undefined
b2
2
a2

위의 예에서 보다시피 require는 그 줄에 다다를 때 실행됩니다. 순환 참조가 발생하는 경우(require('./a')) 모듈을 다시 읽지는 않습니다. 이때문에 주의를 기울이지 않으면 위 예처럼 모듈이 내보낸 값이 얻어지지 않는 문제가 발생할 수 있습니다.

TypeScript의 import문은 사실 require로 변환되는 코드이기 때문에 기존 컴파일 언어에 익숙하신 분이 보면 당황할만한 부분이 좀 있습니다.

// a.ts
console.log('a1');
import { b } from './b';
console.log(b);
console.log('a2');
export const a = 1;

// b.ts
console.log('b1');
import { a } from './a';
console.log(a);
console.log('b2');
export const b = 2;
$ ts-node a.ts 
a1
b1
undefined
b2
2
a2

아마 이런 저런 이유 때문에 ESM이란 것이 나왔으리라 생각합니다. (몇년전에 .cjs, .mjs 확장자 얘기가 나오는 글들 보면서 무슨 얘기가 진행되는 거야 라고 넘어간 기억이 있습니다.)

async/await 문법이 나온 이후에 가장 아쉬운 점 중 하나가 최상위 단계에서 await가 불가능하다는 것입니다.

$ cat a.js                                                                                                                                                                         1 ↵
await Promise.resolve(1);
$ node a.js 
/a/a.js:1
await Promise.resolve(1);
^^^^^

SyntaxError: await is only valid in async functions and the top level bodies of modules

따라서 함수를 만들거나 IIFE를 사용해야 했습니다. 이것은 CommonJS의 한계로 인한 것이고 ESM에서는 가능해졌습니다. 이런 이유도 있어서 CommonJS에서 ESM 모듈을 require 하는 것이 불가능합니다. 반대로 ESM 모듈에서 CommonJS 모듈을 읽는 것은 가능하지만 신경써야 할 것들이 있습니다.

이렇게 ESM이 만들어진 후에 Sindre Sorhus란 분이 ESM 전환을 선언했습니다. 그리고 이 분이 만드는 많은 모듈들(예 file-type. npm 개인 홈에 들어가보면 1165개의 모듈에 관여하고 있다고 나오네요)이 Pure ESM 모듈로 전환됐습니다. Pure ESM은 모듈이 CommonJS/ESM 양쪽을 지원하도록 구성할 수도 있지만, 굳이 ESM만 제공한다는 뜻입니다.

문제는 CommonJS 프로젝트에서는 ESM 모듈을 불러오는 것이 불가능하다는 것에 있습니다. 따라서 프로젝트가 ESM으로 전환해야지만 Pure ESM 모듈을 사용할 수 있습니다.

무엇이 문제인가?

TypeScript에서 원인 찾기

위 예제에서 다시 시작하겠습니다

import chalk from 'chalk';
console.log(chalk.yellow('Hello'));

ts-node로 실행해보면 다음과 같은 에러가 발생합니다.

$ ts-node a.ts 
Error [ERR_REQUIRE_ESM]: require() of ES Module /a/node_modules/chalk/source/index.js from /a/a.ts not supported.
Instead change the require of index.js in /a/a.ts to a dynamic import() which is available in all CommonJS modules.

컴파일된 JavaScript 결과물을 보면 원인을 알 수 있습니다.

"use strict";
exports.__esModule = true;
var chalk_1 = require("chalk");
console.log(chalk_1["default"].yellow('Hello'));

ESM 모듈은 require가 아니라 import를 해야 합니다.

JavaScript에서 문제 해결

TypeScript가 아니라 JavaScript로 import 구문을 사용해 작성해 봅니다. 이번에는 다른 에러가 납니다.

$ cat a.js 
import chalk from 'chalk';
console.log(chalk.yellow('Hello'));
$ node a.js
(node:72179) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
/a/a.js:1
import chalk from 'chalk';
^^^^^^

SyntaxError: Cannot use import statement outside a module

파일명을 .mjs로 바꾸면 잘 실행됩니다.

$ node a.mjs 
Hello

파일명을 바꾸는 대신 전체 프로젝트를 ESM을 사용하는 것으로 선언할 수 있습니다. package.json에 "type": "module"을 추가합니다.

$ cat a.js 
import chalk from 'chalk';
console.log(chalk.yellow('Hello'));
$ cat package.json 
{
  "type": "module",
  "dependencies": {
    "chalk": "^5.0.1"
  }
}
$ node a.js 
Hello

또는 TypeScript 실행시 보여준 에러 처럼 dynamic import를 사용할 수 있습니다.

$ cat a.js 
(async () => {
  const chalk = await import('chalk');
  console.log(chalk.default.yellow('Hello'));
})();
$ node a.js 
Hello

정리하면 JavaScript에서 Pure ESM 모듈을 읽기 위해서는 다음 세가지 방법이 있습니다.

  • .mjs 확장자 사용
  • 전체 프로젝트를 ESM으로 전환
  • dynamic import 사용

TypeScript로 되돌아 가서

.mts 란 확장자도 인식하고, tsc로 컴파일 해보면 .mjs로 나오긴 하지만 적절한 변환은 안 됩니다.

dynamic import 구문을 사용해도 여전히 실행이 안 됩니다. 컴파일된 결과물을 보면 여전히 require로 변환이 됩니다. 이를 해결하려면 모듈시스템을 ES 것을 사용한다고 선언해야 합니다. tsconfig에서 module 설정을 적절히 해줘야 합니다.

$ cat a.ts 
(async () => {
  const chalk = await import('chalk');
  console.log(chalk.default.yellow('Hello'));
})();
$ cat tsconfig.json 
{
  "compilerOptions": {
    "target": "es2017",
    "module": "es2020",
    "moduleResolution": "node"
  }
}
$ ts-node a.ts 
Hello

import를 require로 변환하지 않고 그대로 두는 건 module 설정이지만, Node.js에서 import 구문을 이해하는 것을 별개입니다. package.json에 "type": "module" 도 추가하면 일반 import 구문도 동작합니다. ts-node로 실행시에는 --esm 옵션을 줘야 동작합니다.

$ cat a.ts 
import chalk from 'chalk';
console.log(chalk.yellow('Hello'));
$ cat package.json 
{
  "type": "module",
  "dependencies": {...}
}
$ ts-node --esm a
Hello
$ tsc --module es2020 
$ cat a.js
import chalk from 'chalk';
console.log(chalk.yellow('Hello'));

실전 적용

이제 ESM의 동작 원리에 대해 약간 감이 옵니다. (저는 이 글을 쓰면서 정리하는데도 아직도 헷갈립니다) 이제 실전 적용을 해봅니다.

JavaScript에서는 require와 dynamic import를 혼합해서 쓸 수 있습니다. 다시 말해 ESM 전환("type": "module")을 하지 않아도 ESM 모듈을 쓰는 것이 가능합니다. (static import가 안 되므로 코드를 다르게 작성하는 불편함은 있습니다.)

하지만 TypeScript에서는 import를 require로 변환 또는 모드 import로 유지, 두 가지 선택지 밖에 없습니다. require로는 ESM 모듈을 쓸 수 없으므로 import 유지("module": "es2020")를 해야 합니다. 이 경우 Node.js에서 import 구문을 사용하므로 ESM 전환("type": "module")도 해야 합니다.

단순히 import 구문만 쓰면 되는게 아닙니다. 불러올 파일의 확장자를 모두 기술해줘야 합니다. TypeScript 임에도 불구하고 .js 확장자를 써줘야 합니다. 또 저는 디렉토리명으로 import 하는 것을 종종 사용하는데 모두 파일명으로 바꿔줘야 합니다. (from './services' → from '.services/index.js') 이 과정을 도와주는 도구(fix-esm-import-path)도 있습니다.

샘플로 만들어본 프로젝트에서는 이걸로 동작했습니다. 하지만 실제 프로젝트로 가니 또 다른 이슈가 있었습니다. __dirname 을 쓸 수 없다고 해서 변환을 해야 했습니다. (예 loadSchemaSync(join(__dirname, './index.graphql'))

이제 컴파일도 되고 구동해봅니다. 안 됩니다. ESM 모듈에서는 require 구문을 쓰는게 아예 안 됩니다.

$ node a.js 
file:///a/a.js:1
const http = require('http');
             ^

ReferenceError: require is not defined in ES module scope, you can use import instead
This file is being treated as an ES module because it has a '.js' file extension and '/a/package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.

그런데 저희 주요 프로젝트 구조에는 TypeScript 내에서 require를 의존하는 곳이 있습니다.

// tools/server.js
process.env.TZ = 'Etc/UTC';
require('ts-node/register/transpile-only');
require('../app/server');

// config/index.ts
function loadConfig<T>(dir: string, env?: string): T {
  const base = cloneDeep(require(`${dir}/default`).default as T);
  ...
  return base;
}

다른 건 몰라도 config는 당장 고치기 어려워 보였습니다. 또 즐겨쓰고 있는 REPL도 제대로 동작을 안 하는 건 좀 심각했습니다.

그래서 해결책은?

결국 이 이상 시간을 쏟기는 어려워서 어플리케이션을 ESM 모드로 전환하는 것은 포기했습니다. 하지만 ESM 모듈은 여전히 필요했습니다.

다행히 이번에 조사할 때는 저번에 찾지 못했던 회피 방법을 찾았습니다. TypeScript가 변환하지 않도록 import 문을 감추는 것입니다.

new Function('specifier', 'return import(specifier)')

eval도 가능하다는데 테스트해보진 않았습니다.

다만 위 코드보다는 라이브러리를 활용하는 솔루션이 더 직관적인 것 같아서 이 방법을 사용했습니다. tsimportlib 라이브러리를 사용하면 됩니다.

아쉬운 대로 동작합니다만 나중에 프로젝트가 ESM으로 전환됐을 때 dynamic import를 static import로 바꿔줘야 할 것 같아 꼭 필요한 곳에만 쓰는 것으로 생각하고 있습니다.

다행히 dynamic import는 서버에서만 필요했고, 프론트엔드 코드에서는 Next.js등이 잘 처리해주는지 static import로도 잘 동작했습니다.

마무리

개인적인 느낌으로 ESM은 Python2 → Python3를 전환시의 혼란을 보는 것 같습니다. 저를 포함해 많은 사람들이 당황해하고 불만을 터트리네요

https://github.com/sindresorhus/meta/discussions/15#discussioncomment-2495719 https://github.com/sindresorhus/meta/discussions/15#discussioncomment-2495719

하지만 좋은 싫든 ESM으로의 전환은 시작된 것 같습니다. 조금 더 생태계가 안정되면 다시 전환 시도를 해야 할 것 같습니다.

Appendix

이전 글에서는 ECS / EKS에서 서비스 하는 것에 대한 개념을 풀어 써봤습니다. 이번 글에서는 잘 만들어진 모듈을 이용해 빠르게 구성해보겠습니다.

모듈을 이용하면 편하긴 하지만 내부 개념을 정확히 이해하지 못한 채 사용하면 문제가 발생했을 때 해결이 어려운 것 같습니다. 결국 기본이 중요하다고 생각합니다.

VPC 셋업

VPC 모듈을 사용하면 이전에 길게 썼던 것을 짧게 기술할 수 있습니다. 이전에 만든 VPC 구조와 같지만 NAT 게이트웨이가 AZ 별로 따로 존재합니다.

provider "aws" {
  region = "ap-northeast-2"
}

locals {
  cluster_name = "simon-test"
}

module "vpc" {
  source = "terraform-aws-modules/vpc/aws"

  name = "simon-test"
  cidr = "10.194.0.0/16"

  azs             = ["ap-northeast-2a", "ap-northeast-2c"]
  public_subnets  = ["10.194.0.0/24", "10.194.1.0/24"]
  private_subnets = ["10.194.100.0/24", "10.194.101.0/24"]

  enable_nat_gateway     = true
  one_nat_gateway_per_az = true

  enable_dns_hostnames = true

  public_subnet_tags = {
    "kubernetes.io/cluster/${local.cluster_name}" = "shared"
    "kubernetes.io/role/elb"                      = "1"
  }

  private_subnet_tags = {
    "kubernetes.io/cluster/${local.cluster_name}" = "shared"
    "kubernetes.io/role/internal-elb"             = "1"
  }
}

EKS 클러스터 생성

AWS EKS 모듈을 사용하면 EKS 클러스터도 쉽게 생성가능합니다.

module "eks" {
  source = "terraform-aws-modules/eks/aws"

  cluster_name                    = local.cluster_name
  cluster_version                 = "1.21"
  cluster_endpoint_private_access = false
  cluster_endpoint_public_access  = true

  cluster_addons = {
    coredns = {
      resolve_conflicts = "OVERWRITE"
    }
    kube-proxy = {}
    vpc-cni = {
      resolve_conflicts = "OVERWRITE"
    }
  }

  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnets

  cloudwatch_log_group_retention_in_days = 1

  fargate_profiles = {
    default = {
      name = "default"
      selectors = [
        {
          namespace = "kube-system"
        },
        {
          namespace = "default"
        }
      ]
    }
  }
}

coredns 모듈 생성에서 계속 멈춰 있는 것을 볼 수 있습니다. Fargate만 있어서인데, CoreDNS 패치를 해 주면 잠시 후 완료됩니다.

$ aws eks update-kubeconfig --region ap-northeast-2 --name simon-test --alias simon-test
$ kubectl patch deployment coredns -n kube-system --type json -p='[{"op": "remove", "path": "/spec/template/metadata/annotations/eks.amazonaws.com~1compute-type"}]'

AWS Load Balancer Controller 설치

AWS Load Balancer Controller도 Terraform으로 설치가능합니다. 관련된 모듈이 여러 개 있는데 미묘하게 다 동작을 하지 않아서 결국은 자체적으로 구현해봤습니다. 이전에 쉘에서 실행한 명령을 Terraform으로 구성했다고 보시면 됩니다.

locals {
  lb_controller_iam_role_name        = "inhouse-eks-aws-lb-ctrl"
  lb_controller_service_account_name = "aws-load-balancer-controller"
}

data "aws_eks_cluster_auth" "this" {
  name = local.cluster_name
}

provider "helm" {
  kubernetes {
    host                   = module.eks.cluster_endpoint
    token                  = data.aws_eks_cluster_auth.this.token
    cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)
  }
}

module "lb_controller_role" {
  source = "terraform-aws-modules/iam/aws//modules/iam-assumable-role-with-oidc"

  create_role = true

  role_name        = local.lb_controller_iam_role_name
  role_path        = "/"
  role_description = "Used by AWS Load Balancer Controller for EKS"

  role_permissions_boundary_arn = ""

  provider_url = replace(module.eks.cluster_oidc_issuer_url, "https://", "")
  oidc_fully_qualified_subjects = [
    "system:serviceaccount:kube-system:${local.lb_controller_service_account_name}"
  ]
  oidc_fully_qualified_audiences = [
    "sts.amazonaws.com"
  ]
}

data "http" "iam_policy" {
  url = "https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v2.4.0/docs/install/iam_policy.json"
}

resource "aws_iam_role_policy" "controller" {
  name_prefix = "AWSLoadBalancerControllerIAMPolicy"
  policy      = data.http.iam_policy.body
  role        = module.lb_controller_role.iam_role_name
}

resource "helm_release" "release" {
  name       = "aws-load-balancer-controller"
  chart      = "aws-load-balancer-controller"
  repository = "https://aws.github.io/eks-charts"
  namespace  = "kube-system"

  dynamic "set" {
    for_each = {
      "clusterName"           = module.eks.cluster_id
      "serviceAccount.create" = "true"
      "serviceAccount.name"   = local.lb_controller_service_account_name
      "region"                = "ap-northeast-2"
      "vpcId"                 = module.vpc.vpc_id
      "image.repository"      = "602401143452.dkr.ecr.ap-northeast-2.amazonaws.com/amazon/aws-load-balancer-controller"

      "serviceAccount.annotations.eks\\.amazonaws\\.com/role-arn" = module.lb_controller_role.iam_role_arn
    }
    content {
      name  = set.key
      value = set.value
    }
  }
}

어플리케이션 구동

간단한 서버(이번에는 공개 서버 - k8s.gcr.io/echoserver -를 이용합니다)를 Terraform으로 구성하겠습니다.

provider "kubernetes" {
  host                   = module.eks.cluster_endpoint
  token                  = data.aws_eks_cluster_auth.this.token
  cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)
}

resource "kubernetes_deployment" "echo" {
  metadata {
    name = "echo"
  }
  spec {
    replicas = 1
    selector {
      match_labels = {
        "app.kubernetes.io/name" = "echo"
      }
    }
    template {
      metadata {
        labels = {
          "app.kubernetes.io/name" = "echo"
        }
      }
      spec {
        container {
          image = "k8s.gcr.io/echoserver:1.10"
          name  = "echo"
        }
      }
    }
  }
}

resource "kubernetes_service" "echo" {
  metadata {
    name = "echo"
  }
  spec {
    selector = {
      "app.kubernetes.io/name" = "echo"
    }
    port {
      port        = 8080
      target_port = 8080
    }
    type = "NodePort"
  }
}

resource "kubernetes_ingress_v1" "alb" {
  metadata {
    name = "alb"
    annotations = {
      "alb.ingress.kubernetes.io/scheme"      = "internet-facing",
      "alb.ingress.kubernetes.io/target-type" = "ip",
    }
  }
  spec {
    ingress_class_name = "alb"
    rule {
      http {
        path {
          backend {
            service {
              name = "echo"
              port {
                number = 8080
              }
            }
          }
          path = "/*"
        }
      }
    }
  }
}

이제 로드 밸런서 주소로 요청을 보내면 응답을 보내옵니다.

# k8s-default-alb-622014ceba-1089817135.ap-northeast-2.elb.amazonaws.com 같은 주소를 가집니다
$ LB_HOST=$(kubectl get ingress/alb -ojson | jq -r ".status.loadBalancer.ingress[0].hostname")
$ curl $LB_HOST

Hostname: echo-5499565745-tk7dm

Pod Information:
	-no pod information available-

Server values:
	server_version=nginx: 1.13.3 - lua: 10008

Request Information:
	client_address=10.194.1.162
	method=GET
	real path=/
	query=
	request_version=1.1
	request_scheme=http
	request_uri=http://k8s-default-alb-622014ceba-1089817135.ap-northeast-2.elb.amazonaws.com:8080/

Request Headers:
	accept=*/*
	host=k8s-default-alb-622014ceba-1089817135.ap-northeast-2.elb.amazonaws.com
	user-agent=curl/7.77.0
	x-amzn-trace-id=Root=1-6244434a-56cbdde1124166e4168d1cf4
	x-forwarded-for=1.2.3.4
	x-forwarded-port=80
	x-forwarded-proto=http

Request Body:
	-no body in request-

위 예에서는 같은 Terraform 파일에서 정의를 했기 때문에 module.eks의 출력을 이용했습니다. 만약 별도 스택으로 정의를 할 예정이고, kubeconfig가 설정된 상태면 다음과 같이 provider를 설정할 수 있습니다.

provider "kubernetes" {
  config_path    = "~/.kube/config"
  config_context = "simon-test"
}

후기

EKS 시리즈 글을 작성하면서 클러스터 생성을 여러 번 반복했습니다. 반복할 때마다 하나씩 빼먹어서 문제점을 찾는데 한참 걸렸습니다. 그에 비해 잘 구성된 Terraform 모듈을 활용하니 확실히 편했습니다.

하지만 각 단계를 한번씩 해봐서 전체적인 이해가 된 상황(서브넷 태깅이 왜 필요한지, CoreDNS는 왜 생성되지 않는지등)이여서 모듈 활용도 가능했다고 봅니다.

이번 예에서는 예제 파일을 단순하게 하기 위해서, 쿠버네티스에서 동작하는 어플리케이션까지 Terraform으로 구성해봤습니다. Terraform으로 인프라를 통일하면 좋다고 생각하지만, 쿠버네티스는 별개의 정의 스펙이 있다보니 애매한 점이 있는 것 같습니다. 실제로 저희 실 서비스의 경우는 Terraform이 아니라 Helm Charts를 사용해 어플리케이션을 정의했습니다.

이번 글이 쿠버네티스와 EKS를 이해하는데 도움이 되었기를 바랍니다.

Appendix

ECS 아티클에 이어 이번 글에서는 같은 서비스를 EKS로 구축해보도록 하겠습니다.

간단하게 구축하는 것은 eksctl을 쓰면 되지만, 내부 이해를 위해 여기서는 기본부터 구현하도록 하겠습니다.

VPC 셋업

VPC는 이전과 마찬가지로 두 개의 AZ에 퍼블릭 서브넷과 프리이빗 서브넷이 필요합니다. 다만 로드 밸런서가 정상적으로 배치되려며 특정한 태그를 서브넷에 부여해야합니다.

Sample VPC

서비스 컨테이너만 정의하는 ECS와 달리 쿠버네티스는 클러스터 내에서 로드 밸런서, 영구 볼륨등도 정의할 수 있습니다. 이때 정의는 쿠버네티스안에서 하지만 실제 만들어지는 것은 쿠버네티스와 무관한 AWS 리소스(ALB, EBS등)입니다. 쿠버네티스 자체는 AWS 구조와 독립적이고, 태그등 다른 방법을 통해 쿠버네티스 리소스가 위치할 AWS 리소스를 찾게 됩니다.

EKS에서 애플리케이션 로드 밸런싱 문서에 따라 다음 태그를 설정해줍니다.

  • kubernetes.io/cluster/<name>: shared
  • kubernetes.io/role/internal-elb: 프라이빗 서브넷에 대해 1로 설정합니다.
  • kubernetes.io/role/elb: 퍼블릭 서브넷에 대해 1로 설정합니다.
provider "aws" {
  region = "ap-northeast-2"
}

locals {
  vpc_name        = "simon-test"
  cidr            = "10.194.0.0/16"
  public_subnets  = ["10.194.0.0/24", "10.194.1.0/24"]
  private_subnets = ["10.194.100.0/24", "10.194.101.0/24"]
  azs             = ["ap-northeast-2a", "ap-northeast-2c"]
  cluster_name    = "simon-test"
}

## VPC를 생성합니다
resource "aws_vpc" "this" {
  cidr_block = local.cidr
  tags       = { Name = local.vpc_name }
}

## VPC 생성시 기본으로 생성되는 라우트 테이블에 이름을 붙입니다
## 이걸 서브넷에 연결해 써도 되지만, 여기서는 사용하지 않습니다
resource "aws_default_route_table" "this" {
  default_route_table_id = aws_vpc.this.default_route_table_id
  tags                   = { Name = "${local.vpc_name}-default" }
}

## VPC 생성시 기본으로 생성되는 보안 그룹에 이름을 붙입니다
resource "aws_default_security_group" "this" {
  vpc_id = aws_vpc.this.id
  tags   = { Name = "${local.vpc_name}-default" }
}

## 퍼플릭 서브넷에 연결할 인터넷 게이트웨이를 정의합니다
resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id
  tags   = { Name = "${local.vpc_name}-igw" }
}

## 퍼플릭 서브넷에 적용할 라우팅 테이블
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.this.id
  tags   = { Name = "${local.vpc_name}-public" }
}

## 퍼플릭 서브넷에서 인터넷에 트래픽 요청시 앞서 정의한 인터넷 게이트웨이로 보냅니다
resource "aws_route" "public_worldwide" {
  route_table_id         = aws_route_table.public.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.this.id
}

## 퍼플릭 서브넷을 정의합니다
resource "aws_subnet" "public" {
  count = length(local.public_subnets) # 여러개를 정의합니다

  vpc_id                  = aws_vpc.this.id
  cidr_block              = local.public_subnets[count.index]
  availability_zone       = local.azs[count.index]
  map_public_ip_on_launch = true # 퍼플릭 서브넷에 배치되는 서비스는 자동으로 공개 IP를 부여합니다
  tags = {
    Name = "${local.vpc_name}-public-${count.index + 1}",
    "kubernetes.io/cluster/${local.cluster_name}" = "shared", # 다른 부분
    "kubernetes.io/role/elb"                      = "1" # 다른 부분
  }
}

## 퍼플릭 서브넷을 라우팅 테이블에 연결합니다
resource "aws_route_table_association" "public" {
  count = length(local.public_subnets)

  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

## NAT 게이트웨이는 고정 IP를 필요로 합니다
resource "aws_eip" "nat_gateway" {
  vpc  = true
  tags = { Name = "${local.vpc_name}-natgw" }
}

## 프라이빗 서브넷에서 인터넷 접속시 사용할 NAT 게이트웨이
resource "aws_nat_gateway" "this" {
  allocation_id = aws_eip.nat_gateway.id
  subnet_id     = aws_subnet.public[0].id # NAT 게이트웨이 자체는 퍼플릭 서브넷에 위치해야 합니다
  tags          = { Name = "${local.vpc_name}-natgw" }
}

## 프라이빗 서브넷에 적용할 라우팅 테이블
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.this.id
  tags   = { Name = "${local.vpc_name}-private" }
}

## 프라이빗 서브넷에서 인터넷에 트래픽 요청시 앞서 정의한 NAT 게이트웨이로 보냅니다
resource "aws_route" "private_worldwide" {
  route_table_id         = aws_route_table.private.id
  destination_cidr_block = "0.0.0.0/0"
  nat_gateway_id         = aws_nat_gateway.this.id
}

## 프라이빗 서브넷을 정의합니다
resource "aws_subnet" "private" {
  count = length(local.private_subnets) # 여러개를 정의합니다

  vpc_id            = aws_vpc.this.id
  cidr_block        = local.private_subnets[count.index]
  availability_zone = local.azs[count.index]
  tags = {
    Name = "${local.vpc_name}-private-${count.index + 1}",
    "kubernetes.io/cluster/${local.cluster_name}" = "shared", # 다른 부분
    "kubernetes.io/role/internal-elb"             = "1" # 다른 부분
  }
}

## 프라이빗 서브넷을 라우팅 테이블에 연결합니다
resource "aws_route_table_association" "private" {
  count = length(local.private_subnets)

  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private.id
}

EKS 클러스터 생성

ECS가 하나의 리소스로 끝난 것에 비해 EKS는 조금 복잡합니다.

## 클러스터가 사용할 역할을 정의합니다.
## AmazonEKSClusterPolicy와 AmazonEKSVPCResourceController를 포함합니다.
resource "aws_iam_role" "cluster" {
  name = "${local.cluster_name}-eks-cluster-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [{
      Effect = "Allow",
      Principal = {
        Service = "eks.amazonaws.com"
      },
      Action = "sts:AssumeRole"
    }]
  })
}
resource "aws_iam_role_policy_attachment" "cluster_AmazonEKSClusterPolicy" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
  role       = aws_iam_role.cluster.name
}
resource "aws_iam_role_policy_attachment" "cluster_AmazonEKSVPCResourceController" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSVPCResourceController"
  role       = aws_iam_role.cluster.name
}

## EKS 클러스터를 정의합니다
resource "aws_eks_cluster" "this" {
  name     = local.cluster_name
  role_arn = aws_iam_role.cluster.arn
  vpc_config {
    subnet_ids = aws_subnet.private[*].id
  }
  depends_on = [ # see https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/eks_cluster#example-usage
    aws_iam_role_policy_attachment.cluster_AmazonEKSClusterPolicy,
    aws_iam_role_policy_attachment.cluster_AmazonEKSVPCResourceController,
  ]
}

## Fargate에서 팟 배치시 사용하는 실행 역할을 정의합니다.
## AmazonEKSFargatePodExecutionRolePolicy를 포함합니다.
resource "aws_iam_role" "pod_execution" {
  name = "${local.cluster_name}-eks-pod-execution-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [{
      Effect = "Allow",
      Principal = {
        Service = "eks-fargate-pods.amazonaws.com"
      },
      Action = "sts:AssumeRole"
    }]
  })
}
resource "aws_iam_role_policy_attachment" "pod_execution_AmazonEKSFargatePodExecutionRolePolicy" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonEKSFargatePodExecutionRolePolicy"
  role       = aws_iam_role.pod_execution.name
}

## EKS 클러스터에 노드를 Fargate로 공급합니다.
## default/kube-system 네임스페이스를 가진 팟에 대해 적용됩니다.
resource "aws_eks_fargate_profile" "default" {
  cluster_name           = aws_eks_cluster.this.name
  fargate_profile_name   = "fp-default"
  pod_execution_role_arn = aws_iam_role.pod_execution.arn
  subnet_ids             = aws_subnet.private[*].id # 프라이빗 서브넷만 줄 수 있습니다.
  selector {
    namespace = "default"
  }
  selector {
    namespace = "kube-system"
  }
}

쿠버네티스 클러스터에 접근할 수 있도록 kubeconfig를 생성합니다.

$ aws eks update-kubeconfig --region ap-northeast-2 --name simon-test --alias simon-test

이제 쿠버네티스 클러스터의 상태를 볼 수 있습니다.

$ kubectl get svc
NAME         TYPE        CLUSTER-IP   EXTERNAL-IP   PORT(S)   AGE
kubernetes   ClusterIP   172.20.0.1   <none>        443/TCP   23m

그런데 팟 목록을 보면 기본적으로 실행되어야 할 coredns 팟이 뜨지 않는 것이 보입니다.

$ kubectl get pods -n kube-system
NAME                       READY   STATUS    RESTARTS   AGE
coredns-6dbb778559-clx8r   0/1     Pending   0          13m
coredns-6dbb778559-zpx2h   0/1     Pending   0          13m

Fargate 노드만 사용하려면 CoreDNS 내용을 수정 해줘야 합니다.

$ kubectl patch deployment coredns \
    -n kube-system \
    --type json \
    -p='[{"op": "remove", "path": "/spec/template/metadata/annotations/eks.amazonaws.com~1compute-type"}]'
deployment.apps/coredns patched
$ kubectl get pods -n kube-system                          
NAME                       READY   STATUS    RESTARTS   AGE
coredns-6f99dbb876-hp667   1/1     Running   0          4m
coredns-6f99dbb876-skrgn   1/1     Running   0          4m

컨테이너에 IAM 권한을 부여하려면 OIDC 공급자 생성이 필요합니다

$ eksctl utils associate-iam-oidc-provider --cluster simon-test --approve

서비스 구동

이제 우리가 만든 어플리케이션을 EKS 클러스터에 올릴 차례입니다.

이전과 마찬가지로 ECR 저장소를 만들고 이미지를 올려둡니다.

locals {
  app_name = "simon-sample"
}

## simon-sample 앱을 위한 저장소를 만듭니다
resource "aws_ecr_repository" "simon_sample" {
  name = local.app_name
}

이 이미지를 띄우는 것은 쿠버네티스가 처리합니다.

쿠버네티스에 띄우는 가장 작은 단위는 파드입니다. 다만 파드로 띄우면 서버를 확장하는 것이 어렵습니다. 레플리카셋이라는 리소스를 사용하면 파드를 여러개 띄울 수 있습니다. 여기에 더해 디플로이먼트를 리소스를 사용하면 어플리케이션 갱신시 레플리카셋 교체를 해줍니다.

다음과 같이 쿠버네티스 리소스를 정의합니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: simon-sample
spec:
  replicas: 1
  selector:
    matchLabels:
      app: simon-sample
  template:
    metadata:
      labels:
        app: simon-sample
    spec:
      containers:
        - name: app
          image: <aws-account-id>.dkr.ecr.ap-northeast-2.amazonaws.com/simon-sample:latest
          ports:
            - containerPort: 3000

다음 명령으로 적용합니다.

$ kubectl apply -f simon-sample.yaml
deployment.apps/simon-sample created

보통 5분 안에(빠르면 1분 안에) 다음과 같이 Running 상태로 바뀝니다.

$ kubectl get pods -o wide                 
NAME                           READY   STATUS    RESTARTS   AGE     IP              NODE                    NOMINATED NODE   READINESS GATES
simon-sample-dd88b97bc-svtd6   1/1     Running   0          3m45s   10.194.101.15   fargate-10.194.101.15   <none>           <none>

포트 포워딩으로 세팅하고, HTTP 요청을 하면 응답이 오는 것을 볼 수 있습니다.

$ kubectl port-forward simon-sample-dd88b97bc-svtd6 3000:3000
Forwarding from 127.0.0.1:3000 -> 3000
Handling connection for 3000

## from other terminal
$ curl localhost:3000 --data 'hello world'
hello world

서비스를 외부에 노출하기

ECS와 마찬가지로 실제 서비스가 되려면 외부에 노출된 로드 밸런서에 우리의 서비스를 붙여줘야 합니다. ECS와 달리 쿠버네티스에서는 로드 밸런서 정의도 쿠버네티스 리소스로 정의합니다. 하지만 실제로는 EKS가 알아서 AWS 로드 밸런서를 생성하게 됩니다.

어플리케이션 계층(L7)에서 동작하는 Application Load Balancer(ALB)를 생성하기 위한 리소스 타입은 Ingress 입니다.

우선 AWS Load Balancer Controller 추가 기능 설치가 필요합니다.

## ALB를 컨트롤 할 수 있는 정책을 생성합니다.
$ curl -o iam_policy.json https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v2.4.0/docs/install/iam_policy.json
$ aws iam create-policy \
    --policy-name AWSLoadBalancerControllerIAMPolicy-simon-test \
    --policy-document file://iam_policy.json

## 위 정책을 포함한 IAM 역할을 생성합니다.
$ ACCOUNT_ID=$(aws sts get-caller-identity | jq -r .Account)
$ OIDC_URL=$(aws eks describe-cluster --name simon-test --query "cluster.identity.oidc.issuer" --output text | sed "s|https://||")
$ cat > load-balancer-role-trust-policy.json << EOF
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "Federated": "arn:aws:iam::${ACCOUNT_ID}:oidc-provider/$OIDC_URL"
            },
            "Action": "sts:AssumeRoleWithWebIdentity",
            "Condition": {
                "StringEquals": {
                    "${OIDC_URL}:aud": "sts.amazonaws.com",
                    "${OIDC_URL}:sub": "system:serviceaccount:kube-system:aws-load-balancer-controller"
                }
            }
        }
    ]
}
EOF
$ aws iam create-role \
    --role-name AmazonEKSLoadBalancerControllerRole-simon-test \
    --assume-role-policy-document file://"load-balancer-role-trust-policy.json"
$ aws iam attach-role-policy \
    --policy-arn arn:aws:iam::${ACCOUNT_ID}:policy/AWSLoadBalancerControllerIAMPolicy-simon-test \
    --role-name AmazonEKSLoadBalancerControllerRole-simon-test

## IAM 역할에 연결된 쿠베네티스 서비스 계정을 생성합니다.
$ cat > aws-load-balancer-controller-service-account.yaml << EOF
apiVersion: v1
kind: ServiceAccount
metadata:
  labels:
    app.kubernetes.io/component: controller
    app.kubernetes.io/name: aws-load-balancer-controller
  name: aws-load-balancer-controller
  namespace: kube-system
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::${ACCOUNT_ID}:role/AmazonEKSLoadBalancerControllerRole-simon-test
EOF
$ kubectl apply -f aws-load-balancer-controller-service-account.yaml

## AWS Load Balancer Controller를 설치합니다
$ VPC_ID=$(aws ec2 describe-vpcs --filters Name=cidr,Values=10.194.0.0/16 | jq -r .Vpcs[0].VpcId)
$ helm repo add eks https://aws.github.io/eks-charts
$ helm repo update
$ helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
    -n kube-system \
    --set clusterName=simon-test \
    --set serviceAccount.create=false \
    --set serviceAccount.name=aws-load-balancer-controller \
    --set region=ap-northeast-2 \
    --set vpcId=$VPC_ID \
    --set image.repository=602401143452.dkr.ecr.ap-northeast-2.amazonaws.com/amazon/aws-load-balancer-controller
NAME: aws-load-balancer-controller
LAST DEPLOYED: Sat Mar 26 10:02:19 2022
NAMESPACE: kube-system
STATUS: deployed
REVISION: 1
TEST SUITE: None
NOTES:
AWS Load Balancer controller installed!
$ kubectl get deployment -n kube-system aws-load-balancer-controller
NAME                           READY   UP-TO-DATE   AVAILABLE   AGE
aws-load-balancer-controller   2/2     2            2           5m51s

이제 쿠버네티스를 통해 ALB를 생성하고, 아까 만든 앱을 등록합니다.

apiVersion: v1
kind: Service
metadata:
  name: simon-sample-service
spec:
  ports:
    - port: 3000
      targetPort: 3000
      protocol: TCP
  type: NodePort
  selector:
    app: simon-sample
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: simon-test-alb
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
spec:
  ingressClassName: alb
  rules:
    - http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: simon-sample-service
                port:
                  number: 3000

이제 로드 밸런서 주소로 요청을 보내면 응답을 보내옵니다.

## k8s-default-simontes-ea5ab2759b-771587743.ap-northeast-2.elb.amazonaws.com 같은 주소를 가집니다
$ LB_HOST=$(kubectl get ingress/simon-test-alb -ojson | jq -r .status.loadBalancer.ingress[0].hostname)
$ curl $LB_HOST --data 'hello world'
hello world

후기

확실히 EKS는 ECS에 비해 훨씬 복잡합니다. 그만큼 세세한 컨트롤이 가능하고, AWS에 특화된 개념이 아니라는 것은 장점이라고 봅니다.

이번 글에서는 수동으로 처리해주는 부분이 꽤 있는데 다음에는 더 자동화(Terraform) 해보도록 하겠습니다.

Appendix