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 하려고 시도하게 됩니다. 그러니 옵션 ignoreChanges
에 containers[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
-
이렇게 생각하기는 하지만 HashCorp 코파운드인 michellh 의 경험담은 참 귀 기울여 들어볼만 합니다. https://twitter.com/mitchellh/status/1016475256498761728 ↩
-
그럴 수 밖에 없는 것이 pulumi aws-classic 에서 terraform aws provider 를 사용합니다. ↩
-
https://docs.aws.amazon.com/codebuild/latest/userguide/session-manager.html ↩
-
PolicyDocument 를 class
aws.iam.PolicyDocument
를 TypeScript 로 바로 입력해 주는 방법도 있고, 함수aws.iam.getPolicyDocument
를 사용해서 얻는 방법도 있고 함수aws.iam.getPolicyDocumentOutput
를 이용해서 얻을 수 도 있습니다. **Ouput 함수가 좀 더 나중에 추가된 함수이고 TypeScript 로 바로 입력하지 않을 때는 좀 더 추천하는 함수입니다. pulumi 의 Input Output 으로 쓸 수 있어서 좀 더 자연스러운 코드가 됩니다. ↩ -
물론 ConfigFile 의 transformation 기능을 사용하지 않고 직접 Deployment 기능을 사용해도 좋습니다. ↩
-
cert-manager 없이 alb-controller 를 설치하면 alb-controller 의 pod 이 '***-webhook-tls' 라는 secret 을 mount 할 수 없다며 ContainerCreating 과정에서 더 진행하지 않습니다. ↩
-
이제 Terraform 에서도 Terraform K8S Module 이 있어 Terraform 에서 k8s 사용할 수 있고 AWS CDK 에서도 "CDK for Terraform" 과 "CDK for K8S" 이 있어서 Terraform 이나 CDK 에서도 가능하겠지만 Pulumi 로도 해볼만 했습니다. 저는 CDK 나 Terraform K8S Module 을 써보지 않았습니다. ↩
-
https://github.com/hashicorp/terraform-provider-aws/issues/28263 왜 Terraform AWS Provider 가 관련이 있는가 하면 pulumi aws-classic 에서 Terraform AWS Provider 를 쓰기 때문입니다. ↩