Pulumi 로 AWS EKS 구성하기

TypeScript Pulumi 를 사용해 AWS EKS, AWS CodeBuild 를 CI/CD 까지 구성하는 예시를 보여줍니다. CI/CD 전체 과정을 구성을 위해 그리고 Role 설정까지 세세히 설명합니다. Policy 코드가 많습니다.


Pulumi 란?

오래 Terraform 을 썼습니다. 무척 좋은 제품이고 제게 첫 IaC 제품이었고 또 오랜 역사와 넓은 사용자층을 가졌습니다. 다만 HCL (Hashcorp Configuration Language) 라는 테라폼만의 언어를 배우기가 좀 어렵습니다. 그리고 DSL인 만큼 "조합"을 원할 때 일반 프로그래밍 언어의 함수, 클래스, 네임스페이스 단위로 조립하던 습관대로 할 수 없어 아쉽습니다. "조합"을 위해 Terraform Module 이 있지만 제게 영 쉽지 않았습니다.

HCL 은 DSL(도메인 특화 언어)로 잘 만들어진 작품입니다. 한데 if, for 역할을 하는 구문이, HCL 이 생기고 나서 꽤 나중에 도입이 되었는데 (처음에는 없었습니다) 이 부분이 제게 좀 까슬까슬했습니다. 처음 HCL 에 if, for 과 같은 문법이 도입되던 순간에 "그럴 줄 알았다" 라는 뉘앙스의 tweet 을 보던 기억이 납니다. 왜 if, for 를 약간 다르게 새로 배워야 하지 싶었습니다. 익숙하지 않아 그런 것이 분명합니다만 좀 거북합니다. 1

Pulumi 를 처음 접했을 때, 첫 눈에 들어 왔던 문구가 "일반 프로그래밍 언어로 테라폼처럼 IaC 를 할 수 있다" 였습니다. 의심스러웠지만 -- 너무 어려운 일이라 여겼습니다 -- 조사해 보니 pulumi 도 연혁이 긴 서비스었기에 믿었습니다.

Pulumi 에서는 TypeScript, Python, Java, Go 처럼 주요 언어를 지원합니다. .NET 계열 언어인 C#, F# 도 지원합니다. TypeScript 점유율이 늘어감을 점점 느끼고 있는데, 이제야 경험해보니 자동완성이 된다는 부분이 좋았습니다. Terrraform 용 HCL 을 만들면서, .tf 파일을 코딩하면서, 특정 리소스에 어떤 인자를 넣어야 할지 알아내거나 검사하기가 힘들었습니다. Terraform 설명서 사이트에 가서 열심히 읽어야 했습니다. 이 부분이 아쉬웠는데, Pulumi 를 TypeScript 로 써보니 자동완성과, 적절한 순간에 보이는 Reference 로 그런 어려움이 좀 해소되었습니다. VS Code 만세!

Pulumi Component

테라폼에서는 묶는 단위를 모듈(Module)이라 부릅니다. 예를 들면 EKS 를 제작할 때, 테라폼에서 -- HashCorp 에서 -- 공개한 Terraform EKS Module 을 사용합니다. 한데 이 Terraform EKS Module 안에서 어떤 Resource 를 생성하고 있는지 파악하기가 그렇게 쉽지 않습니다. 적어도 제가 Terraform 을 만지던 4년 쯤 전에는 모듈을 따라가서 파악하는 에디터의 기능을 보지 못 했습니다. 그래서 공개된 소스코드를 GitHub 로 궁금할 때는 열어서 보았습니다. 그렇게 많이 파악하지는 못했고요.

이 개념을 플루미에서는 Pulumi Component 라 부릅니다. Pulumi Component 는 타입스크립트 라면 class 로 만들고, IDE 의 "Go to definition" 기능을 쓰면 Compnent 안의 소스 코드로 바로 따라 갈 수 있어 간편했습니다.

직접 해보니

AWS EKS 를 Pulumi Component 를 통해 만들고, AWS CodeBuild 에서 github 에 push 가 일어나면 실행되도록 CodeBulid 도 구성하고, ECR Repository 도 만들어서 CodeBuild 에서 ECR 로 Push 하고, EKS 로 k8s Deployment 를 할 수 있도록 Pulumi 로 조합해 보았습니다. 만족스러웠고, 그 과정을 이야기해 보겠습니다. EKS, GitHub, CodeBuild 를 엮어서 동작하게 하는 셈이니 CI/CD 구성이라고 할 수 있겠습니다.

읽으시는 분들이 이 글을 통해 Pulumi 를 접하고, 더 나아가 똑같은 환경을 원하실 때면 pulumi up 실행만으로 AWS EKS 설정 + CodeBuild + CI/CD 설정이 끝나실 수 있으면 좋겠습니다.

CI/CD 에 많은 제품이 있습니다. Jenkins, GitHub Action, CircleCI, ... 많습니다. 읽으시는 분들도 적으면 2개 많으면 손가락으로 세기 힘든 숫자를 써 보셨을 겁니다. 그 중 AWS CodeBuild 로 구성해 보도록 하겠습니다. 분당 과금이고, 꽤 저렴하지 않을까 하는 기대로 하는 시도였습니다. 다른 CI 도구에 AWS 권한을 주는 것보다 쪼금 더 보안이 낫지는 않은가 상상하기도 했습니다.

그리고 앞서 말한 대로 k8s deploy update 를, Helm 대신 Pulumi 를 써 배포해보겠습니다. k8s Deployment 를 할 때 Helm 이 많이 쓰이는데, 그 대신 Pulumi 로 선택했습니다. Helm 보다 Kustomize 가 더 좋은 도구라고 여깁니다. kubectl 에 Kustomize 가 이미 내장되어 있기도 하고, JSON Patch 를 사용해서, variation 을 적용한다는 개념이 마음에 듭니다. Kustomize 를 안 써서 그 부분을 희생하더라도, TypeScript 의 type checking 지원을 선택했습니다. YAML을 잘못 타이핑 했을 때 하는 고생이 약간이나마 사라지는 듯 했습니다. 게다가 Terraform, Helm 조합을 쓸 때 보다, 전부 Pulumi 를 쓸 수 있는 일관성이 마음에 들었습니다.

차근차근 배우려면 Pulumi 의 Difference 엔진이 어떻게 동작 하는지를 알아가도 좋겠지만 이 글에서는 어느 정도 뛰어넘고, 전체적인 감을 잡는 정도로 줄이겠습니다. 물론 pulumi up 을 실행했을 때 읽으시는 분들의 환경에서도 잘 동작하기를 목표로 합니다.

EKS

Pulumi 에서 직접 제공하는 EKS Components 를 사용했습니다. 이런 TypeScript 코드입니다.

const cluster = new eks.Cluster("pulumi-eks-tutorial")

끝입니다. 간단하지요. 물론 좀 다르게 설정 하고 부분이 있겠죠. 예를 들어 NodeGroup 설정을 바꾸고 싶을 수도 있고, 어떤 VPC에 속하게 만들지 정하고 싶을 수도 있습니다. 굳이 정하지 않고 넘겨도, 기본값이 참 일리있게 정해져 있습니다. VPC 는 default 로, NodeGroup 은 t3.medium 2대로 구성합니다. 비슷하게 하는데 약간 바꿔서 EKS 용 VPC 를 생성하고 new eks.Cluster() 에 인자로 넘겨줍니다. import 는 생략했습니다. 평범한 TypeScript import 구분들입니다.

const projectName = "pulumi-eks-tutorial"

const vpc = new awsx.ec2.Vpc(projectName, {
  cidrBlock: "10.0.0.0/16",
});

const cluster = new eks.Cluster(projectName, {
  vpcId: vpc.vpcId,
  publicSubnetIds: vpc.publicSubnetIds,
  privateSubnetIds: vpc.privateSubnetIds,
})

이렇게 하면 Pulumi 컴포넌트인 awsx 를 사용해서 베스트 프랙티스를 따라 VPC 만들고, 그 안에 EKS Cluster 를 만듭니다. 좀 더 바꿔서 t3a.medium (ARM 버전의 t3 인스턴스입니다) 으로 2대를 만들고, 나중에 필요하게 될 oidcProvider 도 추가하도록 옵션을 줬습니다. 경험해본 분들은 아시겠지만 제가 마지막으로 Terraform 으로 구성한 EKS 를 보았을 때 (벌써 3~4년 전이군요) OIDC Provider 를 설정할 때 얼마나 수동으로 개입해서 해야 했는지 모릅니다. StackOverflow 를 한 참 뒤지던 기억이 있습니다. OIDC Thumbprint 를 얻기 위해 해야 하는 수동 과정이 있었습니다. 이 글을 쓰면서 다시 찾아보니 이 부분은 테라폼에서도 Terraform AWS provider 2.28.1 에서 개선되었다고 합니다. k8s 노드에 PublicIP 를 할당하지 않는 옵션도 더 넣었습니다.

여튼 다시 코드를 보면

const projectName = "pulumi-eks-tutorial";

const vpc = new awsx.ec2.Vpc(projectName, {
  cidrBlock: "10.0.0.0/16",
});

const eksClusterName = projectName;
const eksCluster = new eks.Cluster(eksClusterName, {
  vpcId: vpc.vpcId,
  publicSubnetIds: vpc.publicSubnetIds,
  privateSubnetIds: vpc.privateSubnetIds,

  instanceType: "t3a.medium",
  desiredCapacity: 2,
  minSize: 1,
  maxSize: 2,

  createOidcProvider: true,
  nodeAssociatePublicIpAddress: false,
});

위에서 생략했던 TypeScript 의 import 은 아래와 같습니다. 당장 안 쓰지만 나중에 넣을 import 도 추가해 두었어요.

import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as awsx from "@pulumi/awsx";
import * as eks from "@pulumi/eks";
import * as kubernetes from "@pulumi/kubernetes";

package.json 에도 적절한 npm package 를 넣습니다.

{
    "name": "pulumi-eks-tutorial",
    "main": "index.ts",
    "devDependencies": {
        "@types/node": "^16"
    },
    "dependencies": {
        "@pulumi/aws": "^5.0.0",
        "@pulumi/awsx": "^1.0.0",
        "@pulumi/eks": "^1.0.2",
        "@pulumi/github": "^5.14.1",
        "@pulumi/pulumi": "^3.0.0"
    }
}

ECR 설정

EKS 를 만들었으니, 다른 Dependancy 를 가지지 않는 ECR Repository 을 셋업합니다. 이 ECR Repository 로 CodeBuild 에서 빌드한 이미지를 업로드 합니다. 나중에 CodeBuild Project 도 만들고 거기에 ECR Repository 에만 업로드 할 수 있는 권한을 줄 겁니다.

코드로 이렇게 표현합니다.

const gradleAppName = "pulumi-eks-tutorial-gradle-app";
const ecrRepo = new awsx.ecr.Repository(projectName, {
  name: gradleAppName,
  imageTagMutability: "MUTABLE",
  lifecyclePolicy: {
    rules: [
      { maximumAgeLimit: 100, tagStatus: "untagged" },
      {
        maximumNumberOfImages: 1000,
        tagStatus: "tagged",
        tagPrefixList: ["rel-", "dev-"],
      },
    ],
  },
});

export const ecrRepoUrl = ecrRepo.url;

무심코 너무 많은 이미지를 올린 채로 시간이 지나버릴 수 있으니 untagged 이미지인 경우에 100개 까지 유지합니다. 그리고 rel-dev- 라는 prefix 로 태그된 경우에 1000 개의 이미지 정도를 유지합니다. 좀 더 오래 Image 가 유지되도록 하기 위해서 1000개로 설정했습니다. 근무 중에 무심결에 한 2년 정도 LifeCycle Policy 를 설정하지 않고 지내다가 image 가 점점 쌓이면서 20~30 만개 정도의 이미지를 ECR 에 유지한 적이 있었는데 한 30만원/월 가까이 낸 기억이 있습니다. 회사에서 전체 비용이 1500~2000만원/월 정도 나가고 있던 상황이라 그렇게 티가 안 났었습니다. 이런 상황이 발생하지 않도록 제한은 넉넉히 잡고 LifeCycle Policy 를 처음부터 걸면 좋을 것 같습니다. 이렇게 1000 개씩만 유지한다 해도 경우에 따라 다르겠지만 일반적인 App 들이라면 한 달에 천원 안 나올 겁니다. 조금 더 나아간다면 mutability 를 'IMMUTABLE' 로 바꾸고 싶습니다. 그 편이 나은 방향이라 믿기는 합니다.

GitHub + CodeBuild

GitHub 가 VCS 라고 가정하고 예제로 github.com/ruseel/pulumi-eks-tutorial-gradle-app-sample 라는 gradle project 를 만들어 보았습니다. Gradle 에서 Google Jib 을 이용해 Docker Image 를 ECR 로 직접 업로드 할 수 있게 코딩했습니다. 이 예제를 만들던 당시에는 Graviton Instance 를 사용하는 것을 가정하고 있었기에 ARM Image 도 가진 MultiArch Image 를 굽도록 Google Jib 을 사용했습니다. AWS CodeBuild 는 파일 buildspec.yml 에 실행할 명령을 적으니 buildspec.yml 에 ECR Push 와 kubectl 실행이 들어가게 bash 명령을 적습니다. 뒤쪽에 코드를 넣었고 GitHub 에도 올려두었습니다.

CodeBuild Proejct 구성

pulumi 의 class aws.codebuild.Project 를 생성합니다. 인자를 꽤 많이 넣어주어야 합니다.

먼저 CodeBuild Project 용 IAM Role 을 만듭니다. Role 에서 사용할 Policy 도 만들고요. Role 을 변수 codebuildRole 로, policy 를 codebuildPolicyDocument 로 선언하고 둘을 aws.iam.RolePolicy 로 잇습니다.

IAM Role 을 만들 때 사용하는 이 개념 (Role, RolePolicy, RoleAttachment, Policy, PolicyDocument) 을 이해하기가 꽤 어려웠는데, Terraform 을 사용했을 때도 완전히 같았습니다 2, 그러니 Pulumi 의 단점은 아닙니다. 지금은 Pulumi 를 쓰면서 Terraform 때 배웠던 지식을 사용해 약간은 수월하게 넘어가고 있습니다.

또 이 CodeBuild Project 에서 ECR 로 업로드를 할 수 있게, s3 를 CodeBuild 의 Cache 로 쓸 수 있게 s3 bucket 도 만들고 접근할 수 있게 권한도 줍니다. Project 생성 전에, 필요한 Role 을 아래 코드로 만듭니다.

// codebuild 에서 cache 로 사용할 s3 bucket
const codebuildCache = new aws.s3.Bucket(`codebuild-cache`);

// codebuild 가 assumeRole 을 할 수 있도록 'Trust' 를 설정한 role.
const codebuildRole = new aws.iam.Role(`codebuild-role`, {
  assumeRolePolicy: {
    Version: "2012-10-17",
    Statement: [
      {
        Effect: "Allow",
        Action: ["sts:AssumeRole"],
        Principal: { Service: ["codebuild.amazonaws.com"] },
      },
    ],
  },
});

const codebuildPolicyDocument: PolicyDocument = {
  Version: "2012-10-17",
  Statement: [
    {
      Effect: "Allow",
      Action: ["eks:DescribeCluster"],
      Resource: ["*"],
    },
    {
      Effect: "Allow",
      Action: [
        "logs:CreateLogGroup",
        "logs:CreateLogStream",
        "logs:PutLogEvents",
      ],
      Resource: ["*"],
    },
    {
      Effect: "Allow",
      Action: [
        "ec2:CreateNetworkInterface",
        "ec2:DescribeDhcpOptions",
        "ec2:DescribeNetworkInterfaces",
        "ec2:DeleteNetworkInterface",
        "ec2:DescribeSubnets",
        "ec2:DescribeSecurityGroups",
        "ec2:DescribeVpcs",
      ],
      Resource: ["*"],
    },
    {
      Effect: "Allow",
      Action: [
        "ecr:CompleteLayerUpload",
        "ecr:InitiateLayerUpload",
        "ecr:PutImage",
        "ecr:UploadLayerPart",
      ],
      Resource: ["*"],
    },
    {
      Sid: "ECRPullPolicy",
      Effect: "Allow",
      Action: [
        "ecr:BatchCheckLayerAvailability",
        "ecr:GetDownloadUrlForLayer",
        "ecr:BatchGetImage",
      ],
      Resource: ["*"],
    },
    {
      Sid: "ECRAuthPolicy",
      Effect: "Allow",
      Action: ["ecr:GetAuthorizationToken"],
      Resource: ["*"],
    },
    {
      Effect: "Allow",
      Action: ["ec2:CreateNetworkInterfacePermission"],
      Resource: ["arn:aws:ec2:ap-northeast-2:*:network-interface/*"],
      Condition: {
        StringEquals: {
          "ec2:AuthorizedService": ["codebuild.amazonaws.com"],
        },
      },
    },
    {
      Effect: "Allow",
      Action: [
        "ssmmessages:CreateControlChannel",
        "ssmmessages:CreateDataChannel",
        "ssmmessages:OpenControlChannel",
        "ssmmessages:OpenDataChannel",
      ],
      Resource: ["*"],
    },
    {
      Effect: "Allow",
      Action: ["s3:*"],
      Resource: [
        codebuildCache.arn,
        pulumi.interpolate`${codebuildCache.arn}/*`,
      ],
    },
  ],
};

const codebuildRolePolicy = new aws.iam.RolePolicy(`codebuild-role-policy`, {
  role: codebuildRole.name,
  policy: codebuildPolicyDocument,
});

이렇게 해서 CodeBuild Project 에 부여할 IAM Role 을 생성하였습니다. 이 CodeBuild 에서 ECR 로 업로드할 수도 있고 CodeBuild debug 를 위해서 SSM 으로 실행되고 있는 CodeBuild Instance 로 직접 접속할 수 있게끔 만들고 3 EKS 로 접속하기 위해 update-kubeconfig 도 실행할 수 있도록 권한을 주었습니다. 그래서 PolicyDocument 가 아주 깁니다.

이제 저 S3 Bucket(== codebuildCache) , Role (== codebulidRole) 을 인자로 주고 codebuild Project 를 만듭니다.

const codebuildKubectlRoleArn = `arn:aws:iam::${config.get(
  "accountId"
)}:role/${projectName}-codebuild-kubectl`;

const codebuildProject = new aws.codebuild.Project(
  "pulumi-eks-tutorial-gradle-app",
  {
    buildTimeout: 5,
    serviceRole: codebuildRole.arn,
    artifacts: {
      type: "NO_ARTIFACTS",
    },
    environment: {
      computeType: "BUILD_GENERAL1_SMALL",
      type: "ARM_CONTAINER",
      image: "aws/codebuild/amazonlinux2-aarch64-standard:3.0",
      imagePullCredentialsType: "CODEBUILD",
      privilegedMode: false,
      environmentVariables: [
        { name: "SERVICE", value: projectName },
        {
          name: "KUBECTL_ROLE",
          value: codebuildKubectlRoleArn,
        },
        {
          name: "ECR_REPOSITORY",
          value: ecrRepo.url,
        },
        { name: "EKS_CLUSTER_NAME", value: eksClusterName },
        { name: "KUBECONFIG", value: "/root/kubeconfig" },
      ],
    },
    source: {
      type: "GITHUB",
      location: `https://github.com/ruseel/pulumi-eks-tutorial-gradle-app.git`,
      gitCloneDepth: 1,
      gitSubmodulesConfig: {
        fetchSubmodules: false,
      },
    },
    sourceVersion: "main",
    cache: { type: "S3", location: codebuildCache.arn },
  },
  { dependsOn: sourceCredential }
);

온갖 옵션을 적었습니다만 모두 AWS 옵션입니다. 각종 환경변수도 buildspec.yml 에서 사용할 수 있게 넣어주고, Graviton Instance 가 저렴하니 Graviton Instance 를 쓰도록 computeType 과 image 도 설정해 주었습니다. AWS 문서에서 좀 약하게 적혀 있는 부분이라고 생각하는데 AWS CodeBuild 를 IaC 로 만들 때 SourceCredential 을 미리 만들어 주고 CodeBuild Project 를 만들어야 잘 동작했습니다.

GitHub Console 에서 저 repo 를 clone 할 수 있도록 권한을 준 Personal Access Token 을 생성하고 Pulumi 의 secret 으로 넣고 나서 이렇게 AWS SourceCredential 을 만듭니다. 이 SourceCredential 을 만들고 나서 CodeBuild Project 가 생성되도록 codeBuildProjct 를 생성할 때 { dependsOn: sourceCredential } 옵션을 넣었습니다.

const config = new pulumi.Config();
const sourceCredential = new aws.codebuild.SourceCredential(projectName, {
  serverType: "GITHUB",
  authType: "PERSONAL_ACCESS_TOKEN",
  token: config.requireSecret("github_pat"),
});

pulumi 에 넣은 secret 을 config.requireSecret() 함수로 얻습니다. 이 때는 pulumi 의 secret 은 pulumi.yml 안에 저장하도록 해서 썼습니다. 기본설정이 pulumi.yml 이나 pulumi.<stack>.yml에 저장해서 쓰게되어 있습니다. 아마 pulumi 의 secret backend 로 물론 다양한 도구를 쓸 수 있기는 할 겁니다.

GitHub 에서 main, dev 브랜치로 push 될 때 마다 CodeBuild Project 를 실행하도록, WebHook 도 만듭니다. "WebHook 을 만든다"는 말은 GitHub 쪽에서 AWS CodeBuild 쪽에 만든 WebHook 을 적당한 때 호출하게 "모든 설정을 마친다" 는 뜻입니다. 전부 잘 돌아게 만들고 나서야 그런가 보다 하고 깨달았습니다.

const webhook = new aws.codebuild.Webhook(
  "codebuild-webhook",
  {
    projectName: codebuildProject.name,
    buildType: "BUILD",
    filterGroups: [
      {
        filters: [
          {
            type: "EVENT",
            pattern: "PUSH",
          },
          {
            type: "HEAD_REF",
            pattern: "^refs/heads/(main|dev)$",
          },
        ],
      },
    ],
  },
  {
    dependsOn: codebuildProject,
  }
);

이렇게 codebuild.WebHook 을 만든 후에, GitHub WebSite > ... > webhook 메뉴로 가면 새로 생긴 webhook 을 목록에서 볼 수 있습니다. AWS 쪽 Endpoint 도 생겨있습니다. 무대 뒤에서 하는 일이 많을 것이다 싶지만 여튼 제가 넣은 코드는 이것이 전부입니다.

헷갈리는 지점이었는데 Webhook 의 인자 projectName 에는 CodeBuild Project 의 이름을 넣어줍니다. GitHub URL 에 포함된 Project 이름이 아니었습니다.

CodeBuild 에서 새 Image 를 k8s 에 적용하려면, kubectl 을 실행할 수 있게 만들어야 합니다. 이 일은 뒤에서 해보도록 하겠습니다.

AWS LoadBalancer Controller, external-dns controller 배포

role 설정

이 EKS 에 k8s Deployment 리소스나 k8s Ingress 리소스를 추가했을 때 적절한 도메인으로 접근할 수 있도록 AWS LoadBalancer Controller 와 external-dns controller 도 설정해 줍니다. 최소한의 권한만 주기 위해 IAM Role for Service Account (IRSA) 를 써 보도록 하겠습니다. IRSA 를 위해서 EKS 에 OIDC Provider 가 필요한데 아까 만들어 주었습니다. 앞으로는 AWS LoadBalancer Controller 를 alb-controller 로 적겠습니다.

먼저 alb-controller 설정을 하겠습니다. PolicyDocument 를 만들고, Role 을 생성하고 그 Role 에 PolicyDocument 를 잇습니다. 어떻게 보면 다음 AWS 문서를 보고 문법을 Pulumi 로 바꾸는 일입니다.

Installing the AWS Load Balancer Controller add-on

이 문서를 보면 Helm 을 쓰거나 k8s Manifest 쓰는 방법을 두 가지를 같이 설명합니다. Pulumi 에서도 Helm 을 사용할 수 있지만, 여기서는 k8s Manifest 를 바로 사용 하도록 하겠습니다. Indirection 을 약간 줄이고, 더 명확한 방법이라고 생각합니다. Helm 을 안 좋아합니다.

alb-controller 가 사용할 권한을 PolicyDocument, Policy 로 만들어 줍니다.

const albControllerPolicyDocument = aws.iam.getPolicyDocument({
  version: "2012-10-17",
  statements: [
    {
      effect: "Allow",
      actions: ["iam:CreateServiceLinkedRole"],
      resources: ["*"],
      conditions: [
        {
          test: "StringEquals",
          variable: "iam:AWSServiceName",
          values: ["elasticloadbalancing.amazonaws.com"],
        },
      ],
    },
    {
      effect: "Allow",
      actions: [
        "ec2:DescribeAccountAttributes",
        "ec2:DescribeAddresses",
        "ec2:DescribeAvailabilityZones",
        "ec2:DescribeInternetGateways",
        "ec2:DescribeVpcs",
        "ec2:DescribeVpcPeeringConnections",
        "ec2:DescribeSubnets",
        "ec2:DescribeSecurityGroups",
        "ec2:DescribeInstances",
        "ec2:DescribeNetworkInterfaces",
        "ec2:DescribeTags",
        "ec2:GetCoipPoolUsage",
        "ec2:DescribeCoipPools",
        "elasticloadbalancing:DescribeLoadBalancers",
        "elasticloadbalancing:DescribeLoadBalancerAttributes",
        "elasticloadbalancing:DescribeListeners",
        "elasticloadbalancing:DescribeListenerCertificates",
        "elasticloadbalancing:DescribeSSLPolicies",
        "elasticloadbalancing:DescribeRules",
        "elasticloadbalancing:DescribeTargetGroups",
        "elasticloadbalancing:DescribeTargetGroupAttributes",
        "elasticloadbalancing:DescribeTargetHealth",
        "elasticloadbalancing:DescribeTags",
      ],
      resources: ["*"],
    },
    {
      effect: "Allow",
      actions: [
        "cognito-idp:DescribeUserPoolClient",
        "acm:ListCertificates",
        "acm:DescribeCertificate",
        "iam:ListServerCertificates",
        "iam:GetServerCertificate",
        "waf-regional:GetWebACL",
        "waf-regional:GetWebACLForResource",
        "waf-regional:AssociateWebACL",
        "waf-regional:DisassociateWebACL",
        "wafv2:GetWebACL",
        "wafv2:GetWebACLForResource",
        "wafv2:AssociateWebACL",
        "wafv2:DisassociateWebACL",
        "shield:GetSubscriptionState",
        "shield:DescribeProtection",
        "shield:CreateProtection",
        "shield:DeleteProtection",
      ],
      resources: ["*"],
    },
    {
      effect: "Allow",
      actions: [
        "ec2:AuthorizeSecurityGroupIngress",
        "ec2:RevokeSecurityGroupIngress",
      ],
      resources: ["*"],
    },
    {
      effect: "Allow",
      actions: ["ec2:CreateSecurityGroup"],
      resources: ["*"],
    },
    {
      effect: "Allow",
      actions: ["ec2:CreateTags"],
      resources: ["arn:aws:ec2:*:*:security-group/*"],
      conditions: [
        {
          test: "StringEquals",
          variable: "ec2:CreateAction",
          values: ["CreateSecurityGroup"],
        },
        {
          test: "Null",
          variable: "aws:RequestTag/elbv2.k8s.aws/cluster",
          values: ["false"],
        },
      ],
    },
    {
      effect: "Allow",
      actions: ["ec2:CreateTags", "ec2:DeleteTags"],
      resources: ["arn:aws:ec2:*:*:security-group/*"],
      conditions: [
        {
          test: "Null",
          variable: "aws:RequestTag/elbv2.k8s.aws/cluster",
          values: ["true"],
        },
        {
          test: "Null",
          variable: "aws:ResourceTag/elbv2.k8s.aws/cluster",
          values: ["false"],
        },
      ],
    },
    {
      effect: "Allow",
      actions: [
        "ec2:AuthorizeSecurityGroupIngress",
        "ec2:RevokeSecurityGroupIngress",
        "ec2:DeleteSecurityGroup",
      ],
      resources: ["*"],
      conditions: [
        {
          test: "Null",
          variable: "aws:ResourceTag/elbv2.k8s.aws/cluster",
          values: ["false"],
        },
      ],
    },
    {
      effect: "Allow",
      actions: [
        "elasticloadbalancing:CreateLoadBalancer",
        "elasticloadbalancing:CreateTargetGroup",
      ],
      resources: ["*"],
      conditions: [
        {
          test: "Null",
          variable: "aws:ResourceTag/elbv2.k8s.aws/cluster",
          values: ["false"],
        },
      ],
    },
    {
      effect: "Allow",
      actions: [
        "elasticloadbalancing:CreateListener",
        "elasticloadbalancing:DeleteListener",
        "elasticloadbalancing:CreateRule",
        "elasticloadbalancing:DeleteRule",
      ],
      resources: ["*"],
    },
    {
      effect: "Allow",
      actions: [
        "elasticloadbalancing:AddTags",
        "elasticloadbalancing:RemoveTags",
      ],
      resources: [
        "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*",
        "arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*",
        "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*",
      ],
      conditions: [
        {
          test: "Null",
          variable: "aws:RequestTag/elbv2.k8s.aws/cluster",
          values: ["true"],
        },
        {
          test: "Null",
          variable: "aws:ResourceTag/elbv2.k8s.aws/cluster",
          values: ["false"],
        },
      ],
    },
    {
      effect: "Allow",
      actions: [
        "elasticloadbalancing:AddTags",
        "elasticloadbalancing:RemoveTags",
      ],
      resources: [
        "arn:aws:elasticloadbalancing:*:*:listener/net/*/*/*",
        "arn:aws:elasticloadbalancing:*:*:listener/app/*/*/*",
        "arn:aws:elasticloadbalancing:*:*:listener-rule/net/*/*/*",
        "arn:aws:elasticloadbalancing:*:*:listener-rule/app/*/*/*",
      ],
    },
    {
      effect: "Allow",
      actions: ["elasticloadbalancing:AddTags"],
      resources: [
        "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*",
        "arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*",
        "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*",
      ],
      conditions: [
        {
          test: "StringEquals",
          variable: "elasticloadbalancing:CreateAction",
          values: ["CreateTargetGroup", "CreateLoadBalancer"],
        },
        {
          test: "Null",
          variable: "aws:RequestTag/elbv2.k8s.aws/cluster",
          values: ["false"],
        },
      ],
    },
    {
      effect: "Allow",
      actions: [
        "elasticloadbalancing:ModifyLoadBalancerAttributes",
        "elasticloadbalancing:SetIpAddressType",
        "elasticloadbalancing:SetSecurityGroups",
        "elasticloadbalancing:SetSubnets",
        "elasticloadbalancing:DeleteLoadBalancer",
        "elasticloadbalancing:ModifyTargetGroup",
        "elasticloadbalancing:ModifyTargetGroupAttributes",
        "elasticloadbalancing:DeleteTargetGroup",
      ],
      resources: ["*"],
      conditions: [
        {
          test: "Null",
          variable: "aws:RequestTag/elbv2.k8s.aws/cluster",
          values: ["false"],
        },
      ],
    },
    {
      effect: "Allow",
      actions: [
        "elasticloadbalancing:RegisterTargets",
        "elasticloadbalancing:DeregisterTargets",
      ],
      resources: ["arn:aws:elasticloadbalancing:*:*:targetgroup/*/*"],
    },
    {
      effect: "Allow",
      actions: [
        "elasticloadbalancing:SetWebAcl",
        "elasticloadbalancing:ModifyListener",
        "elasticloadbalancing:AddListenerCertificates",
        "elasticloadbalancing:RemoveListenerCertificates",
        "elasticloadbalancing:ModifyRule",
      ],
      resources: ["*"],
    },
  ],
});

const albControllerPolicy = new aws.iam.Policy("albControllerPolicy", {
  name: "albControllerPolicy",
  policy: albControllerPolicyDocument.then((_) => _.json),
});

직접 해보니 필요한 권한이 더 있었기에 문서에서 적힌 Policy 외에 더 추가 해 준 권한이 있습니다. 뭐 였는지 잘 기억이 나지 않네요. codeBuildRole 에 권한을 줄 때와 다르게 PolicyDocument 를 직접 TypeScript 의 class aws.iam.PolicyDocument 로 표현하지 않고 문서에 있는 json 을 그대로 복사해서 aws.iam.getPolicyDocumentOutput 함수를 사용해 json 으로 넣어주었습니다. 이렇게 복사해서 쓰려고 할 때 getPolicyDocument 함수를 사용하면 편할 때가 있습니다. 2가지 방법을 동시에 보려드리기 위해 서로 다른 방법을 썼습니다. 4

albController 가 assumeRole 을 사용해서 권한을 얻을 수 있도록 policy 를 작성해 줍니다. 이런 방식을 IRSA(IAM Role for ServiceAccount) 라고 부릅니다.

const albControllerAssumeRolePolicy = aws.iam.getPolicyDocumentOutput({
  version: "2012-10-17",
  statements: [
    {
      effect: "Allow",
      principals: [
        {
          type: "Federated",
          identifiers: [eksCluster.core.oidcProvider?.arn!],
        },
      ],
      actions: ["sts:AssumeRoleWithWebIdentity"],
      conditions: [
        {
          test: "StringEquals",
          variable: pulumi.interpolate`${eksCluster.core.oidcProvider
            ?.url!}:aud`,
          values: ["sts.amazonaws.com"],
        },
        {
          test: "StringEquals",
          variable: pulumi.interpolate`${eksCluster.core.oidcProvider
            ?.url!}:sub`,
          values: [
            "system:serviceaccount:kube-system:aws-load-balancer-controller",
          ],
        },
      ],
    },
  ],
});

role 과 rolePolicyAttachment 도 만들어 줍니다.

const albControllerRole = new aws.iam.Role("albControllerRole", {
  name: "albControllerRole",
  assumeRolePolicy: albControllerAssumeRolePolicy.json,
});

const rolePolicyAttachment = new aws.iam.RolePolicyAttachment(
  "albControllerRoleAttachment",
  { role: albControllerRole, policyArn: albControllerPolicy.arn }
);

k8s ServiceAccount 도 pulumi 에서 직접 만들어 줄 수 있습니다. 위에서 생성한 role 을 참조해서 annotation 으로 넣었습니다. 이 때가 pulumi 를 쓰면서 제일 좋았습니다. pulumi 만세!

// aws-load-balancer-controller-service-account.yaml
const albControllerServiceAccount = new kubernetes.core.v1.ServiceAccount(
  "aws-load-balancer-controller",
  {
    metadata: {
      name: "aws-load-balancer-controller",
      namespace: "kube-system",
      labels: {
        "app.kubernetes.io/component": "controller",
        "app.kubernetes.io/name": "aws-load-balancer-controller",
      },
      annotations: {
        "eks.amazonaws.com/role-arn": albControllerRole.arn,
      },
    },
  },
  { provider: kubeProvider }
);

추가로 넣을 권한도 role 에 붙여 줍니다. AWS 문서에서 iam_policy_v1_to_v2_additional.json 를 다운로드 받아 조금 고쳐 넣었습니다. 이번에는 PolicyDocument 를 직접 만들지도, getPolicyDocumentOutput 함수를 사용하지도 않고 JSON.stringify 함수로 string 을 변환해 넣었습니다.

// AWSLoadBalancerControllerAdditionalIAMPolicy
//
// 문서에 iam_policy_v1_to_v2_additional.json 라는 이름으로
// IAMPolicy 가 있었다. 문서와 다르게 Condition 안에 resourceTag 를
// ingress.k8s.aws 에서 elbv2.k8s.aws 로
// 바꿔주어야 동작해서 고쳐 둔 버전이다.
const albControllerAdditionalIAMPolicy = new aws.iam.Policy(
  "AWSLoadBalancerControllerAdditionalIAMPolicy",
  {
    policy: JSON.stringify({
      Version: "2012-10-17",
      Statement: [
        {
          Effect: "Allow",
          Action: ["ec2:CreateTags", "ec2:DeleteTags"],
          Resource: "arn:aws:ec2:*:*:security-group/*",
          Condition: {
            Null: {
              "aws:ResourceTag/elbv2.k8s.aws/cluster": "false",
            },
          },
        },
        {
          Effect: "Allow",
          Action: [
            "elasticloadbalancing:AddTags",
            "elasticloadbalancing:RemoveTags",
            "elasticloadbalancing:DeleteTargetGroup",
            "elasticloadbalancing:SetIpAddressType",
            "elasticloadbalancing:SetSecurityGroups",
            "elasticloadbalancing:SetSubnets",
            "elasticloadbalancing:DeleteLoadBalancer",
            "elasticloadbalancing:ModifyTargetGroup",
            "elasticloadbalancing:ModifyTargetGroupAttributes",
          ],
          Resource: [
            "arn:aws:elasticloadbalancing:*:*:targetgroup/*/*",
            "arn:aws:elasticloadbalancing:*:*:loadbalancer/net/*/*",
            "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/*/*",
          ],
          Condition: {
            Null: {
              "aws:ResourceTag/elbv2.k8s.aws/cluster": "false",
            },
          },
        },
      ],
    }),
  }
);

const additionalIAMPolicyAttachment = new aws.iam.RolePolicyAttachment(
  "AWSLoadBalancerControllerAdditionalIAMPolicy",
  { role: albControllerRole, policyArn: albControllerAdditionalIAMPolicy.arn }
);

alb-controller, external-dns-controller 를 k8s Deployment 로 배포 하기

alb-controller 를 만들려면 k8s Deployment 를 생성하는 셈이고 k8s Deployment 를 pulumi 에서 설정하려면 kubeProvider 가 필요합니다.

const kubeProvider = new kubernetes.Provider(
  projectName,
  {
    kubeconfig: eksCluster.kubeconfigJson,
    enableServerSideApply: true,
  },
  {
    dependsOn: eksCluster,
  }
);

이제 문서에서 alb-controller 를 생성하는 manifest 를 다운로드 받아 -- alb-controller-v2-4-7-full.yaml, alb-controller-v2-4-7-deployment.yaml, alb-controller-v2-4-7-ingclass.yaml 로 나눠서 저장했습니다 -- 사용합니다. Pulumi class kubernetes.yaml.ConfigFile 를 씁니다. *-deployment.yaml 에서 Deployment resource 에 약간 다른 옵션을 주어야 alb-controller 가 원할하게 돌아갑니다. ConfigFile 의 transformations 기능을 이용해 pulumi 에서 aws-vpc-id, cluster-name, aws-region 옵션을 넣도록 코딩했습니다. 5 transformation 을 사용하지 않고 직접 Deployment manifest 를 만들어도 되는데, 그 방법은 gradle-app 의 k8s Deployment 에서 적용해 보겠습니다.

const certManager = new kubernetes.yaml.ConfigFile(
  "cert-manager",
  {
    file: "k8s/cert-manager.yaml",
  },
  { provider: kubeProvider, dependsOn: eksCluster }
);

const albController = new kubernetes.yaml.ConfigFile(
  "alb-controller",
  {
    file: "k8s/alb-controller-v2-4-7-full.yaml",
  },
  { provider: kubeProvider, dependsOn: certManager }
);

const albControllerDeployment = new kubernetes.yaml.ConfigFile(
  "alb-controller-deployment",
  {
    file: "k8s/alb-controller-v2-4-7-deployment.yaml",
    transformations: [
      (obj: any, opts: pulumi.CustomResourceOptions) => {
        if (obj.kind === "Deployment" && obj.apiVersion === "apps/v1") {
          const containers = obj?.spec?.template?.spec?.containers;
          if (containers && containers[0]?.args) {
            containers[0].args = [
              "--ingress-class=alb",
              `--aws-region=${new pulumi.Config("aws").require("region")}`,
              pulumi.interpolate`--cluster-name=${eksCluster.eksCluster.name}`,
              pulumi.interpolate`--aws-vpc-id=${eksCluster.core.vpcId}`,
            ];
          }
        }
      },
    ],
  },
  { provider: kubeProvider, dependsOn: albController }
);

const albControllerIngclass = new kubernetes.yaml.ConfigFile(
  "alb-controller-ingclass",
  {
    file: "k8s/alb-controller-v2-4-7-ingclass.yaml",
  },
  {
    provider: kubeProvider,
    dependsOn: albController,
  }
);

이렇게 해서 pulumi 로 alb-controller 를 설치할 수 있습니다. alb-controller 를 설치할 때 manifest 에 넣어야 하는 정보들도 Pulumi 안에서 직접 참조해서 쓸 수 있었습니다. 딱 이렇게 설정하신다면 pulumi up 만으로 할 수 있다는 장점을, Terraform + Helm 에 대비해서 얻었습니다.

이제 extenal-dns-controller 를 설치합니다. external-dns-controller 도 IRSA 로 권한을 주고 더해서 AWS ACM 을 통해 https 요청을 TLS Certificates 를 얻게 만듭니다.

alb-controller 를 작업 할 때와 같이, Policy Document 와 Policy 를 만들고. TrustPolicy 까지도 만들고 RolePolicyAttachment 를 만들고 k8s ServiceAccount 도 생성합니다. 이 부분은 Setting up ExternalDNS for Services on AWS 문서를 pulumi 로 바꾼 것 입니다.

// external-dns
//
const externalDnsPolicyDocument = aws.iam.getPolicyDocument({
  version: "2012-10-17",
  statements: [
    {
      effect: "Allow",
      actions: ["route53:ChangeResourceRecordSets"],
      resources: ["arn:aws:route53:::hostedzone/*"],
    },
    {
      effect: "Allow",
      actions: [
        "route53:ListHostedZones",
        "route53:ListResourceRecordSets",
        "route53:ListTagsForResource",
      ],
      resources: ["*"],
    },
  ],
});

const externalDnsPolicy = new aws.iam.Policy("AllowExternalDNSUpdates", {
  name: "AllowExternalDNSUpdates",
  policy: externalDnsPolicyDocument.then((_) => _.json),
});

// trust.json
const externalDnsSaName = "external-dns";
const externalDnsIrsaRoleName = "external-dns-irsa-role";
const oidcProviderUrl = eksCluster.core.oidcProvider?.url!;

let externalDnsTrustPolicy = aws.iam.getPolicyDocumentOutput({
  version: "2012-10-17",
  statements: [
    {
      effect: "Allow",
      principals: [
        {
          type: "Federated",
          identifiers: [eksCluster.core.oidcProvider?.arn!],
        },
      ],
      actions: ["sts:AssumeRoleWithWebIdentity"],
      conditions: [
        {
          test: "StringEquals",
          variable: pulumi.interpolate`${oidcProviderUrl}:aud`,
          values: ["sts.amazonaws.com"],
        },
        {
          test: "StringEquals",
          variable: pulumi.interpolate`${oidcProviderUrl}:sub`,
          values: [`system:serviceaccount:default:${externalDnsSaName}`],
        },
      ],
    },
  ],
});

const externalDnsRole = new aws.iam.Role(externalDnsIrsaRoleName, {
  name: externalDnsIrsaRoleName,
  assumeRolePolicy: externalDnsTrustPolicy.json,
});

const externalDnsRolePolicyAttachment = new aws.iam.RolePolicyAttachment(
  externalDnsIrsaRoleName,
  {
    role: externalDnsRole,
    policyArn: externalDnsPolicy.arn,
  }
);

const externalDnsSa = new kubernetes.core.v1.ServiceAccount(
  externalDnsSaName,
  {
    metadata: {
      name: externalDnsSaName,
      namespace: "default",
      annotations: {
        "eks.amazonaws.com/role-arn": externalDnsRole.arn,
      },
    },
  },
  { provider: kubeProvider }
);

이제 external-dns-controller 자체를 위한 manifest 도 넣습니다. external-dns-with-rbac.yaml 에서 이번에는 transformation 옵션을 사용하지 않고 TypeScript Pulumi 에서 직접 k8s Deployment 리소스를 선언합니다. 대신 다운로드 받은 yaml 에서 deployment 만 제거해 둡니다.

const externalDnsWithRBACYaml = new kubernetes.yaml.ConfigFile(
  "external-dns-yaml",
  {
    file: "k8s/external-dns-with-rbac.yaml",
  },
  { provider: kubeProvider }
);

deployment 는 아래처럼 따로 만듭니다. external-dns-controller 에서 "example-corp.com" 으로 끝나는 domain 을 route53 에 등록하도록 domain-filter 옵션을 줍니다. external-dns-controller 홈페이지에서 AWS_REGION 을 설정해 주면 약간 더 좋다(?)는 글이 있었으니 AWS_REGION 도 설정했습니다.

const domainFilter = "example-corp.com";
const externalDnsDeploymentName = "external-dns";
const externalDnsAppLables = {
  "app.kubernetes.io/name": externalDnsDeploymentName,
};
const externalDnsDeployment = new kubernetes.apps.v1.Deployment(
  externalDnsDeploymentName,
  {
    metadata: {
      name: externalDnsDeploymentName,
      labels: externalDnsAppLables,
    },
    spec: {
      strategy: {
        type: "Recreate",
      },
      selector: {
        matchLabels: externalDnsAppLables,
      },
      template: {
        metadata: {
          labels: externalDnsAppLables,
        },
        spec: {
          serviceAccountName: externalDnsSaName,
          containers: [
            {
              name: "external-dns",
              image: "registry.k8s.io/external-dns/external-dns:v0.13.5",
              args: [
                "--source=service",
                "--source=ingress",
                // will make ExternalDNS see only the hosted zones matching provided domain,
                // omit to process all available hosted zones
                `--domain-filter=${domainFilter}`,
                "--provider=aws",
                // would prevent ExternalDNS from deleting any records, omit to enable full synchronization
                "--policy=upsert-only",
                // only look at public hosted zones (valid values are public, private or no value for both)
                "--aws-zone-type=public",
                "--registry=txt",
                "--txt-owner-id=external-dns",
              ],
              env: [
                {
                  name: "AWS_DEFAULT_REGION",
                  value: new pulumi.Config("aws").require("region"),
                },
              ],
            },
          ],
        },
      },
    },
  },
  {
    provider: kubeProvider,
    dependsOn: [externalDnsSa, externalDnsWithRBACYaml],
  }
);

이제 AWS CodeBuild 를 통해서 GitHub Repository 에 변경이 일어나면 그리고 명시한 Branch 에 변경이 있었으면 GitHub 가 AWS CodeBuild 로 웹훅 요청을 통해 그 사실을 알려주고, CodeBuild 에서 빌드를 시작합니다. 그리고 CodeBuild 용 buildspec.yml 파일에서 ECR Repository 로 업로드를 하고 k8s 에 배포까지 하도록 구성해 두었다면 자연스럽게 CI 를 하는 환경이 되었습니다.

cert-manager

"cert-manager 가 있어야 https 로 certificates 를 발급받아서 쓸 수 있지 않을까?" 하는 생각했고, 설정해 주기도 했는데, AWS ACM 에서 발급해 놓은 Certification 을 적용할 수 있는 경우에는 CertManager 가 Let's Encrypt 나 다른 발급기관에 인증서를 요청해서 발급받는 cert-manager 의 훌륭한 기능이 필요 없습니다. 다만 alb-controller 가 cert-manager 에 의존하고 있어서 HTTPS Certificates 를 발급받는 용도로 사용하지 않더라도 설치해야 alb-controller 가 잘 돌아갑니다. 6

HTTPS 인증서를 alb-controller 에 쓰는 방법을 검색을 하다보면 alb.ingress.kubernetes.io/certificate-arn 를 설정하면 된다 라는 말을 보게 될 가능성이 높은데, 더 오래 찾다보면 alb-contoller 에 Certificate Discovery 기능이 있고 이 기능이 AWS ACM 에서 적절한 Certificates 가 존재하는지 찾아준다는 사실을 알 수 있었습니다. 직접 해봐도 잘 돌아갑니다. 그러니 ARN 값을 Pulumi 안에, 혹은 k8s 안에 annotation 으로 넣지 않는 쪽이 유연하겠습니다.

AWS ACM 설정

AWS ACM 에 Certification 생성을 요청합니다. Validation 과정은 선택해서 따로 진행해야 합니다. 여기서는 "DNS"를 선택해보았습니다. 이렇게 CertificateRequest 를 만들고, Validation 은 AWS Management Console 에서 진행합니다.

// ACM Certificate
//
// certificate 을 validation 해주는 것은 Management Console 을 통해서 손으로 해줘야 한다.
// wildcard domain 으로 생성해 주자.
//
const certificate = new aws.acm.Certificate("acm-certificates", {
  domainName: `*.example-corp.com`,
  validationMethod: "DNS",
});

꼭 와일드카드 Certificates 를 만들어야 하는 것은 아닙니다. 다만 인터넷에서 와일드카드 Certificates 예를 보기가 좀 힘들었고 하다 보니 제가 헷갈했습니다. "*" 표시를 넣는 것인지 아닌지 시행착오를 거쳤습니다. 게다가 마지막에 작업했던 것과 같이 사내용 개발 domain (dev.mycompany.com) 에 3차 도메인으로 a.dev.mycompany.com b.dev.mycompany.com 처럼 추가해 주고 싶을 때는 와일드카드 Certificates 를 만드는 쪽이 한 번으로 일이 끝나니 -- 새 domain, 새 projectName 마다 다시 certificates 를 안 만들어줘도 됩니다 -- 겸사겸사 이렇게 해봤습니다.

Subnet 에 tag 달기

또 alb-controller 에서 Subnet 을 찾도록 만들어 주기 위해서 tag 를 달아주어야 합니다.

vpc.publicSubnetIds.apply((ids) => {
  ids.map((id) => {
    return new aws.ec2.Tag(`public-subnet-${id}`, {
      resourceId: id,
      key: "kubernetes.io/role/elb",
      value: "1",
    });
  });
});

vpc.privateSubnetIds.apply((ids) => {
  ids.map((id) => {
    return new aws.ec2.Tag(`private-subnet-${id}`, {
      resourceId: id,
      key: "kubernetes.io/role/internal-elb",
      value: "1",
    });
  });
});

k8s app 용 Deployment, Ingress 리소스 만들기

k8s Deployment 리소스 만들기

labels 에 쓰는 객체는 selector 에도 쓰고, template.metadata 에도 쓰도록 appLabels 변수로 만들어 같이 사용합니다. ecrRepoUrl 도 다른 TypeScript var 를 참조해서 쓸 수 있습니다.

const appName = "pulumi-eks-tutorial-gradle-app";
const appLabels = { app: appName };
const appDeployment = new kubernetes.apps.v1.Deployment(
  appName,
  {
    metadata: { labels: appLabels, name: appName },
    spec: {
      selector: { matchLabels: appLabels },
      strategy: { type: "RollingUpdate" },
      replicas: 1,
      template: {
        metadata: { labels: appLabels },
        spec: {
          containers: [
            {
              name: appName,
              image: ecrRepo.url,
              imagePullPolicy: "Always",
              ports: [{ name: "http", containerPort: 80 }],
            },
          ],
        },
      },
    },
  },
  {
    provider: kubeProvider,
    dependsOn: [albController, externalDnsDeployment],
    ignoreChanges: ["spec.template.spec.containers[0].image"],
    customTimeouts: {
      create: "3m",
      update: "3m",
    },
  }
);

이런 YAML 을 코딩할 때 참 걱정이 많아져서 실수를 줄이고자 CopyPaste 후 일부분씩 고칠 때가 있었는데 TypeScript 의 도움으로 별 걱정없이 할 수 있었습니다. TypeScript 에러메세지 해석에 익숙해 지기까지 조금 시간이 걸렸지만 할 만했습니다.

이 k8s Deployment 의 image 값를 업데이트해 배포를 유발합니다. 그렇게 하면 pulumi 에서 변경된 리소스라고 파악하고 다시 overwrite 하려고 시도하게 됩니다. 그러니 옵션 ignoreChangescontainers[0].image 를 넣어 image 가 변경되더라도 그대로 있는 변화로 여기게 만듭니다. 그리고 보통 k8s Resource Create, Update 요청은 금새 끝나고, 오래걸리는 경우는 다른 문제가 있을 때가 많으니 customTimeouts 도 좀 짧게 3분으로 넣어줬습니다.

k8s Service 리소스, Ingress 리소스 만들기

k8s Service 를 만들고 그 Service 를 backend 로 쓰도록 k8s Ingress 도 만들어 줍니다. ALB 에서 외부로 HTTPS 서비스를 열어주게 끔 alb-controller 용 annotation 을 많이 달아 주었습니다. Annotation 의 기능을 보면 "HTTP와 HTTPS 포트를 사용한다, HTTP 요청이 오면 HTTPS 로 redirect 한다, 공중 인터넷으로 서비스할 수 있게 한다, HeathCheck 할 때 HTTP 를 사용한다, HealthCheck용 URL 은 /swagger-ui/index.html을 쓴다" 가 됩니다.

const appService = new kubernetes.core.v1.Service(
  appName,
  {
    metadata: { labels: appDeployment.spec.template.metadata.labels },
    spec: {
      type: "NodePort",
      ports: [{ name: "http", port: 80, targetPort: 80, protocol: "TCP" }],
      selector: appLabels,
    },
  },
  { provider: kubeProvider, dependsOn: appDeployment }
);

const appIngress = new kubernetes.networking.v1.Ingress(
  appName,
  {
    metadata: {
      name: appName,
      annotations: {
        "alb.ingress.kubernetes.io/listen-ports":
          '[{"HTTP": 80}, {"HTTPS": 443}]',
        "alb.ingress.kubernetes.io/ssl-redirect": "443",
        "alb.ingress.kubernetes.io/scheme": "internet-facing",
        "alb.ingress.kubernetes.io/healthcheck-protocol": "HTTP",
        "alb.ingress.kubernetes.io/unhealthy-threshold-count": "5",
        "alb.ingress.kubernetes.io/healthcheck-path": "/swagger-ui/index.html",
        "alb.ingress.kubernetes.io/target-type": "ip",
      },
    },
    spec: {
      ingressClassName: "alb",
      rules: [
        {
          host: `${appName}.${domainFilter}`,
          http: {
            paths: [
              {
                path: "/",
                pathType: "Prefix",
                backend: {
                  service: {
                    name: appService.metadata.name,
                    port: { name: "http" },
                  },
                },
              },
            ],
          },
        },
      ],
    },
  },
  {
    provider: kubeProvider,
    dependsOn: [albController, externalDnsDeployment],
  }
);

여기까지 EKS 셋업, GitHub 와 CodeBuild 연동, k8s App 용 Deployment, Service, Ingress 리소스를 생성을 했습니다.

CodeBuild 에서, 빌드하고 ECR Push 하고 kubectl 로 배포하기

이제 CI/CD 를 위해 CodeBuild Role 이 kubectl 명령을 실행할 수 있게 구성합니다. codebuildRole 가 kubectl 을 실행할 수 있는 권한을 가진 Policy 로 AssumeRole 할 수 있게 설정합니다. 이 부분을 알아내기가 좀 어려웠는데, k8s configMap/aws-auth 에도 이 Role 을 추가합니다.

벌써 3~4번 했듯이 PolicyDocument, Policy, Role, RolePolicyAttachment 를 만듭니다.

// Role codebuild-kubectl

const codebuildKubectlAssumRolePolicyDocument: PolicyDocument = {
  Version: "2012-10-17",
  Statement: [
    {
      Effect: "Allow",
      Action: ["sts:AssumeRole"],
      Principal: {
        AWS: [codebuildRole.arn],
      },
    },
  ],
};

const codebuildKubectlRole = new aws.iam.Role(`codebuild-kubectl`, {
  name: `codebuild-kubectl`,
  assumeRolePolicy: codebuildKubectlAssumRolePolicyDocument,
});

const codebuildRoleKubectlRolePolicy = new aws.iam.RolePolicy(
  `codebuild-kubectl-role-policy`,
  {
    role: codebuildKubectlRole.name,
    policy: {
      Version: "2012-10-17",
      Statement: [
        {
          Effect: "Allow",
          Action: ["eks:DescribeCluster"],
          Resource: ["*"],
        },
      ],
    },
  }
);

k8s 에서도 configMap/aws-auth 를 통해 권한을 줍니다.

// k8s configMap/aws-auth
//
const configMapPatch = new kubernetes.core.v1.ConfigMapPatch(
  "aws-auth",
  {
    metadata: {
      annotations: {
        "pulumi.com/patchForce": "true",
      },
      name: "aws-auth",
      namespace: "kube-system",
    },
    data: {
      mapRoles: pulumi.interpolate`
- rolearn: ${eksCluster.instanceRoles[0].arn.apply((arn) => arn)}
  username: system:node:{{EC2PrivateDNSName}}
  groups:
    - system:bootstrappers
    - system:nodes
- rolearn: ${codebuildKubectlRole.arn}
  username: codebuild
  groups:
    - system:masters
`,
    },
  },
  { provider: kubeProvider }
);

gradle-app buildspec.yml 파일에서 ECR Push, kubectl 로 image tag 를 갱신하도록 명령을 추가합니다.

version: 0.2
phases:
  build:
    commands:
      - BRANCH=$(echo $CODEBUILD_WEBHOOK_HEAD_REF | awk -F'/' '{print $3}')
      - GIT_SHA1_SHORT=$(echo $CODEBUILD_SOURCE_VERSION | cut -c 1-7)
      - IMAGE_TAG=$BRANCH-$GIT_SHA1_SHORT
      - if [ -z $IMAGE_TAG ]; then echo "image tag could not be guessed"; exit 1; fi

      - |
        ./gradlew jib --console=plain -Djib.console=plain \
            -Djib.to.image=$ECR_REPOSITORY:$IMAGE_TAG

      - CREDENTIALS=$(aws sts assume-role --role-arn $KUBECTL_ROLE --role-session-name codebuild-kubectl --duration-seconds 900)
      - export AWS_ACCESS_KEY_ID=$(echo ${CREDENTIALS} | jq -r '.Credentials.AccessKeyId')
      - export AWS_SECRET_ACCESS_KEY=$(echo ${CREDENTIALS} | jq -r '.Credentials.SecretAccessKey')
      - export AWS_SESSION_TOKEN=$(echo ${CREDENTIALS} | jq -r '.Credentials.SessionToken')
      - export AWS_EXPIRATION=$(echo ${CREDENTIALS} | jq -r '.Credentials.Expiration')

      - |
        aws eks update-kubeconfig --name $EKS_CLUSTER_NAME --kubeconfig $KUBECONFIG

      - DEPLOYMENT=$(kubectl get deployments -l "app=$SERVICE" -o=jsonpath='{.items[0].metadata.name}')
      - kubectl set image "deployment/$DEPLOYMENT" "$SERVICE=$ECR_REPOSITORY:$IMAGE_TAG"

여기에서 참고하고 있는 환경변수는 모두 위에서 CodeBuide Project 에 넣었거나, CodeBulid 에서 기본 제공합니다. 전체 코드는 github.com/ruseel/pulumi-eks-tutorial 과 github.com/ruseel/pulumi-eks-tutorial-gradle-app 에서 보실 수 있습니다.

결론

어떻게 보면 많은 범위를 다루기도 했고 EKS 와 k8s 와 CodeBuild 환경을 다루면서 또 CodeBuild 에서 k8s 로 적절한 권한을 주기위해 Policy 가 길었고 제 잘못도 더해져, 글이 아주 길어졌습니다.

pulumi-eks-tutorial 7 과 pulumi-eks-tutorial-gradle-app 8 을 가지고 AWS EKS 를 셋업 CodeBuild 로 빌드를 돌리고, ECR Push, 그리고 k8s 로 배포하도록 만들어 봤습니다. 이것을 Terraform 과 Helm 으로 한다고 생각했을 때 서로의 영역으로 넘겨 주고 받는 값이 있는데 이것을 Pulumi 안에서 한 번에 처리하면서 pulumi up 만으로 배포할 수 있었습니다. 9

용감한 영혼에게 Pulumi 를 추천합니다. 10

참고자료

SSO Credential 을 이용하기가 어려워서 제 경우에는 환경변수에 CREDENTIAL 을 넣고 했습니다. 아마 아직 잘 안된다 싶습니다. 11

Learn Pulumi 의 문서들을 읽으면서 배웠습니다.

Footnotes

  1. 이렇게 생각하기는 하지만 HashCorp 코파운드인 michellh 의 경험담은 참 귀 기울여 들어볼만 합니다. https://twitter.com/mitchellh/status/1016475256498761728

  2. 그럴 수 밖에 없는 것이 pulumi aws-classic 에서 terraform aws provider 를 사용합니다.

  3. https://docs.aws.amazon.com/codebuild/latest/userguide/session-manager.html

  4. PolicyDocument 를 class aws.iam.PolicyDocument 를 TypeScript 로 바로 입력해 주는 방법도 있고, 함수 aws.iam.getPolicyDocument 를 사용해서 얻는 방법도 있고 함수 aws.iam.getPolicyDocumentOutput 를 이용해서 얻을 수 도 있습니다. **Ouput 함수가 좀 더 나중에 추가된 함수이고 TypeScript 로 바로 입력하지 않을 때는 좀 더 추천하는 함수입니다. pulumi 의 Input Output 으로 쓸 수 있어서 좀 더 자연스러운 코드가 됩니다.

  5. 물론 ConfigFile 의 transformation 기능을 사용하지 않고 직접 Deployment 기능을 사용해도 좋습니다.

  6. cert-manager 없이 alb-controller 를 설치하면 alb-controller 의 pod 이 '***-webhook-tls' 라는 secret 을 mount 할 수 없다며 ContainerCreating 과정에서 더 진행하지 않습니다.

  7. https://github.com/ruseel/pulumi-eks-tutorial

  8. https://github.com/ruseel/pulumi-eks-tutorial-gradle-app

  9. 이제 Terraform 에서도 Terraform K8S Module 이 있어 Terraform 에서 k8s 사용할 수 있고 AWS CDK 에서도 "CDK for Terraform" 과 "CDK for K8S" 이 있어서 Terraform 이나 CDK 에서도 가능하겠지만 Pulumi 로도 해볼만 했습니다. 저는 CDK 나 Terraform K8S Module 을 써보지 않았습니다.

  10. https://twitter.com/pyrasis/status/1575985667015786496

  11. https://github.com/hashicorp/terraform-provider-aws/issues/28263 왜 Terraform AWS Provider 가 관련이 있는가 하면 pulumi aws-classic 에서 Terraform AWS Provider 를 쓰기 때문입니다.