사이드프로젝트/chan

[Infra] AWS CDK(Type Script)를 이용하여 인프라 구성하기

woong29 2022. 9. 2. 12:56
목차
1. 구조
2. VPC
3. ECS + NLB + EC2 
4. API Gateway
5. CI / CD (Aws Code Build & Code Pipeline)

전체 Source GitHub 링크

1. 구조


2. VPC

VPC는 한 개만 생성하여 모든 서비스를 넣어도 충분했을 수도 있었습니다. 하지만 Service 별로 VPC를 생성함으로써 보안적으로 리소스 간 허용을 최소화하고 네트워크를 간편하게 구성하기 위해서 서비스별로 별도로 생성을 해주었습니다.

 

Nat Gateway를 사용안함(natGateways: 0)으로 설정한 이유

Nat Gateway는 private로 선언된 인스턴스가 외부로 접근할 때 사용되지만 VPC Endpoint를 사용하게 되면 여러가지 이점이 있었습니다.

  • pivate 하게 vpc를 구성하여서 보안적으로 이점이 있습니다.
  • 외부 네트워크가 아닌 Amazon내부의 네트워크를 통해서만 접근이 가능합니다.
  • private Link의 요금을 따라가기 때문에 트래픽이 많을 때 Nat Gateway대비 비용적으로 이득을 볼 수도 있습니다.

s3 Endpoint를 설정한 이유

Docker Image를 저장하기 위해서 AWS ECR을 사용하게 되는데 ECR이 이미지 및 아티팩트를 저장하기 위해서 S3를 사용하기 때문에 Nat Gateway가 없는 상황에서는 서비스가 docker image를 가져가기 위해서 s3와 여러 서비스에 대한 endpoint를 설정할 필요가 있습니다.

 

참조 : ECS와 ECR에 대한 Private Link 설정 방법

https://aws.amazon.com/ko/blogs/korea/setting-up-aws-privatelink-for-amazon-ecs-and-amazon-ecr/

const vpc = new ec2.Vpc(this, 'vpc', { 
  vpcName: `${props.serviceName}-vpc`,
  maxAzs: props.azs?? 3, //가용 영역 갯수
  cidr: props.cidr, // 서비스별로 따로 받아오도록 변수로 설정 ex) '10.0.0.0/16'
  natGateways: 0, //nat gateway를 사용하지 않고 외부와의 연결을 api gateway를 제외하고는 없애기 위해 설정
  subnetConfiguration: [
  	//mask는 24면 2^8 - 5 = 251개씩 사용 가능
    //외부와 차단된 subnet이 private만 선언
    { name: 'isolate', cidrMask: 24, subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
    { name: 'private', cidrMask: 24, subnetType: ec2.SubnetType.PRIVATE_WITH_NAT },
  ],

  //Docker Image를 저장하는 ECR은 S3에 컨테이너 이미지 및 아티팩트를 저장하기 때문에 endpoint를 생성해주어야합니다.
  gatewayEndpoints: {
    S3: {
      service: ec2.GatewayVpcEndpointAwsService.S3,
    },
  },
});

//해당 서비스에서 사용하는 aws 서비스에 대해서 endpoint를 등록 해주어야 한다.
const serviceList = [
      {name:'ecr'       , service:ec2.InterfaceVpcEndpointAwsService.ECR           },
      {name:'ecr-docker', service:ec2.InterfaceVpcEndpointAwsService.ECR_DOCKER    },
      {name:'ecr-agent' , service:ec2.InterfaceVpcEndpointAwsService.ECS_AGENT     },
      {name:'ecs-tel'   , service:ec2.InterfaceVpcEndpointAwsService.ECS_TELEMETRY },
      {name:'ecs'       , service:ec2.InterfaceVpcEndpointAwsService.ECS           },
    ]    
       
serviceList.forEach( el => 
  vpc.addInterfaceEndpoint(el.name, {
    service: el.service
}));

3. ECS (Elastic Compute Cloud)

프로젝트를 시작할때 AWS를 사용하기로 결심한 시점부터 많은 양의 트래픽이 집중되었을 때를 고려하게 되고 여러 상황을 고려하다 Load Balancer를 공부하게 되고 컨테이너를 관리해줄 수 있는 ECS를 사용하기로 결심했습니다. AutoScaling을 통한 확장성, ELB를 통한 트래픽 분산 등의 여러 가지 장점이 있다고 생각하였습니다.

NLB vs ALB 

applicaion Load Balancer와 Network Load Balancer중 nlb를 고른 이유는 현재 계획에서는 ALB의 많은 기능이 필요하지 않고 단순 단순히 Network계층까지만 확인하는 Loab balnacer의 역할만 필요하기 때문에 NLB를 사용했습니다.

 

//생성해둔 vpc와 ecr
const vpc = props.vpc
const ecrRepo = props.ecrRepo;

const instanceSecurityGroup = new ec2.SecurityGroup(this, 'instanceSecurityGroup', { 
  securityGroupName: `${props.serviceName}-asg-instance-sg`,
  vpc,
  allowAllOutbound: true,
});

//AutoScalingGroup 생성
const autoScalingGroup = new AutoScalingGroup(this, 'asg', {
  autoScalingGroupName: `${props.serviceName}-asg`,
  vpc,
  instanceType: ec2.InstanceType.of(ec2.InstanceClass.T2, ec2.InstanceSize.MICRO),
  machineImage: ecs.EcsOptimizedImage.amazonLinux2(),
  desiredCapacity: 2,
  maxCapacity: 4,
  minCapacity: 2,
  securityGroup: instanceSecurityGroup,
  requireImdsv2: true,
});

//instance가 줄어들고 늘어나는 설정
autoScalingGroup.scaleOnCpuUtilization('CpuScaling', {
  targetUtilizationPercent: 70,
  cooldown: Duration.minutes(5),
});

//Cluster 생성
const cluster = new ecs.Cluster(this, `cluster`, { 
  clusterName: `${props.clusterName}`,
  vpc,
});

cluster.addAsgCapacityProvider(new ecs.AsgCapacityProvider(this, 'AsgCapacityProvider', {
  autoScalingGroup,
}));

//Task 정의
const taskDefinition = new ecs.TaskDefinition(this, 'TaskDef', {
  compatibility: ecs.Compatibility.EC2,
  memoryMiB: '512',
  cpu: '256',
});

const logGroup = new LogGroup(this, 'loggroup', {
  logGroupName: props.serviceName+"-prod",
  retention: RetentionDays.ONE_WEEK,
})

taskDefinition.addContainer(`container`, {
  containerName: `${props.serviceName}-container`,
  image: ecs.ContainerImage.fromEcrRepository(ecrRepo, 'latest'),
  memoryLimitMiB: 256,
  secrets: props.containerSecretEnv,
  environment: props.containerEnv,
  cpu: 256,
  logging: ecs.LogDrivers.awsLogs({
    streamPrefix: "prod",
    logGroup: logGroup,
  }),
  portMappings:[
    {hostPort:80, containerPort: props.containerPort, protocol: ecs.Protocol.TCP},
  ]
})

const service = new ecsp.NetworkLoadBalancedEc2Service(this, `${props.serviceName}`, {
  cluster: cluster,
  cpu: 256,
  loadBalancer: props.loadbalancer,
  memoryLimitMiB: 256,
  desiredCount: 2,
  minHealthyPercent: 80,
  maxHealthyPercent: 100,
  serviceName: props.serviceName,
  taskDefinition: taskDefinition,
  publicLoadBalancer: false,
});

4. API Gateway

Api gateway는 여러가지 이유중 아래 두가지 이유로 사용하게 되었습니다.

  1. 클라이언트에서 마이크로 서비스가 어떻게 구성되는지 알필요없게 하거나 내부 변경시 클라이언트에서는 변경이 발생하면 안되게 할수 있다.
  2. 권한 부여나 토근 확인시 효율을 위해서 필요. 만약 보안처리를 각 서비스마다 해주게 되면 유지보수가 힘들어 질수 있기때문에 API Gateway에서 인증처리를 한다면 위의 문제를 해결할 수 있다.

https://docs.aws.amazon.com/ko_kr/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html

 


5. CI / CD (Aws Code Build & Code Pipeline)

CI (Continuous Integration) 및 CD (Continuous Delivery)를 위하여 AWS의 Code build 및 pipeline을 이용하였습니다. 인프라 관련 CDK 소스도 동일하게 github을 통하여 관리하게 되고 업로드시 build되어 Infra에 자동으로 적용되도록 구성하였습니다.

github에 Push를 진행하면 webhook을 통하여 AWS Code build에 Triger를 주게 되고  code build는 build설정에 따라 build 및 테스트를 진행하게 되고 설정에 따라 아티팩트를 저장하게 됩니다. code deploy는 저장된 아티팩트의 경로를 받아 배포를 진행하게 됩니다.

 //Code Pipeline
    const pipeline = new Pipeline(this, 'Pipeline', {
      pipelineName: config.serviceName, 
      artifactBucket: new s3.Bucket(this, `bucket`, {
        bucketName: `${config.serviceName}-pipeline`,
        removalPolicy: RemovalPolicy.DESTROY,
        autoDeleteObjects: true,
    })});

    //Source Stage
    const sourceOutput = new Artifact();
    const sourceAction = this.getGitHubSourceAction(config.gitRepo, sourceOutput);
    pipeline.addStage({stageName: 'Source', actions: [sourceAction],})

    //Build Stage
    const buildOutput = new Artifact();
    const buildAction = this.getCodeBuildAction(sourceOutput, buildOutput);
    pipeline.addStage({stageName: 'Build', actions: [buildAction],})
    
    //Deploy Beta Stage
    const deployBetaAction = this.getEcsBetaDeployActioin(buildOutput);
    pipeline.addStage({stageName: 'Deploy-Beta', actions: [deployBetaAction],})


private getGitHubSourceAction = (repo:GitRepo, output:Artifact) : GitHubSourceAction => {
    return new GitHubSourceAction({
        actionName: 'GitHubSourceAction',
        owner: repo.owner,
        output: output,
        repo: repo.repoName,
        branch: repo.branch,
        oauthToken: SecretValue.secretsManager(repo.tokenName),
    });
  }

  private getCodeBuildAction = (input: Artifact, output: Artifact): CodeBuildAction => {
    return new CodeBuildAction({
        actionName: "BuildAction",
        input: input,
        project: this.createCodeBuildProject(),
        outputs: [output]
    });
  }
  
  private getEcsBetaDeployActioin = (buildArtifact: Artifact):EcsDeployAction => {
    return new EcsDeployAction({
        actionName: `DeployAction`,
        service: this.config.serviceBeta.service,
        input: buildArtifact,
    });
  }
                                               

  private createCodeBuildProject = (): PipelineProject => {
    const buildspec = buildSpecContent;
    
    buildspec.phases.post_build.commands.push(
      `printf \'[{"name":"${this.config.serviceName}-container","imageUri":"%s"}]\' $ECR_REPO:latest > imagedefinitions.json`
    )
    
    const codeBuildProject = new PipelineProject(this, `${this.config.serviceName}-Codebuild`, {
        projectName: `${this.config.serviceName}-Codebuild`,
        environment: {
            buildImage: LinuxBuildImage.STANDARD_5_0,
            privileged: true,
        },
        environmentVariables: this.getEnvironmentVariables(),
        buildSpec: BuildSpec.fromObject(buildspec),
        cache: Cache.local(LocalCacheMode.DOCKER_LAYER, LocalCacheMode.CUSTOM),
    });

    codeBuildProject.role?.addManagedPolicy(
        ManagedPolicy.fromAwsManagedPolicyName('AmazonEC2ContainerRegistryPowerUser')
    );

    return codeBuildProject;
  }

  private getEnvironmentVariables = () => {
    return {
        ACCOUNT_ID: {
            value: this.account
        },
        ACCOUNT_REGION: {
            value: this.region
        },
        ECR_REPO: {
            value:  this.config.ecrRepo.repositoryUri
        },
        IMAGE_NAME: {
            value: this.config.ecrRepo.repositoryName
        },
    };
}
export default {
  version: '0.2',
  phases: {
      pre_build: {
          commands: [
              'echo Login to Amazon ECR...',
              'aws --version',
              'echo $ACCOUNT_ID.dkr.ecr.$ACCOUNT_REGION.amazonaws.com',
              '(aws ecr get-login-password --region $ACCOUNT_REGION | docker login --username AWS --password-stdin $ACCOUNT_ID.dkr.ecr.$ACCOUNT_REGION.amazonaws.com)',
              'COMMIT_HASH=$(echo $CODEBUILD_RESOLVED_SOURCE_VERSION | cut -c 1-7)',
              'IMAGE_TAG=${COMMIT_HASH:=latest}'
          ]
      },
      build: {
          commands: [
              'echo Build started on `date`',
              './gradlew build',
              'echo Building the Docker image : $ECR_REPO, $IMAGE_NAME',
              'docker build -t $IMAGE_NAME:latest .',
              'docker tag $IMAGE_NAME:latest $ACCOUNT_ID.dkr.ecr.$ACCOUNT_REGION.amazonaws.com/$IMAGE_NAME:latest',
              'echo Build completed on `date`'
          ]
      },
      post_build: {
          commands: [
              'echo Build completed on `date`',
              'echo Pushing the Docker image...',
              'docker push  $ACCOUNT_ID.dkr.ecr.$ACCOUNT_REGION.amazonaws.com/$IMAGE_NAME:latest',
              'printf \'{"ImageURI":"%s"}\' $ECR_REPO:latest > imageDetail.json',
          ]
      }
  },
  artifacts: {
    files: [
        'imageDetail.json',
        'imagedefinitions.json',
    ]
  }
};