AWS Fargate 를 이용한 nodejs 애플리케이션 실행
기존에 AWS Lambda 를 이용해 실행하던 nodejs 애플리케이션이 Lambda 실행시간제한(15분)으로 인해 정상적으로 완료되지 않는 문제가 있어 Fargate 로의 이전을 고려하게 됐다. Fargate는 Lambda와 같은 독립적인 서비스라기 보다는 컨테이너를 실행하기 위해 특화된 실행환경으로 보면 좋을 것 같다. EC2 인스턴스에 Docker 를 세팅하여 컨테이너를 실행시킬 수도 있지만 Fargate 는 별도의 인스턴스를 생성하지 않고도 필요에 따라 컨테이너를 실행시킬 수 있다.
Fargate 는 독자적으로 사용할 수는 없고 ECS 또는 EKS 를 이용해야 한다. 이번에는 ECS 를 이용해 기능을 구성했다. ECS 는 클러스터를 통해 ECR 에 등록된 이미지를 이용해 컨테이너를 실행하고 이를 관리하는 서비스이다. ECS 에서는 클러스터, 컨테이너 이미지, 작업정의 이 3가지는 필수 요소이다. 작업을 진행하기 전에 aws cli 등은 미리 설치된 것으로 가정한다.
우선 컨테이너 실행을 위한 nodejs 애플리케이션을 Docker 이미지로 구성해야 한다. Dockerfile 작성이 필요하다.
FROM node:14-alpine
WORKDIR /usr/src/app
COPY . .
RUN npm ci
ENTRYPOINT [ "node", "index.js" ]
CMD [ "download"]
이미지 빌드는 docker build -t nodejs-app .
의 명령으로 진행하면 된다. docker 관련 명령은 자세히 설명할 수도 없고 내용도 많기 때문에 다른 문서를 찾아보는 게 좋겠다. 빌드된 이미지를 실행해서 nodejs-app 이 정상적으로 실행된다면 이제 이 이미지를 ECR 에 등록해야 한다. Docker Hub 에도 등록할 수 있지만 유료 사용자가 아닌 경우 해당 이미지가 공개될 수도 있기 때문에 중요 정보를 포함하고 있다면 주의가 필요하다. aws ecr create-repository --repository-name nodejs-app --image-scanning-configuration scanOnPush=true
의 명령을 실행하면 ECR 에 nodejs-app 이라는 리포지토리가 생성된다. 결과창에서 repositoryUri
값은 따로 메모해 둔다. 그리고 push 를 위해 aws ecr get-login-password --region ap-northeast-2 | docker login --username AWS --password-stdin <ACCOUNT ID>.dkr.ecr.ap-northeast-2.amazonaws.com
명령을 실행 해 로그인 인증처리한다.
빌드된 이미지에 태그를 등록하고 ECR 리포지토리에 push 한다. docker tag nodejs-app <ACCOUNT ID>.dkr.ecr.ap-northeast-2.amazonaws.com/nodejs-app
명령을 실행해 push를 위한 Tag 를 추가해준다. 이 과정을 생략하면 push 에서 오류가 발생한다. docker push <ACCOUNT ID
>.dkr.ecr.ap-northeast-1.amazonaws.com/nodejs-app
명령을 실행해 빌드된 이미지를 ECR 에 등록한다.
이제 컨터어너 실행을 위한 권한을 설정한다. 아래의 내용으로 task-execution-assume-role.json
파일을 생성한다.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Principal": {
"Service": "ecs-tasks.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
aws iam create-role --role-name ecsTaskExecutionRole --assume-role-policy-document file://task-execution-assume-role.json
명령을 실행하고 결과 창에서 Arn
값을 따로 메모해 둔다. 그리고 aws iam attach-role-policy --role-name ecsTaskExecutionRole --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
명령도 추가로 실행한다. 아래의 내용으로 task-definition.json
파일을 생성한다. executionRoleArn
에는 위에서 메모해둔 Arn
값으로 변경해 준다. image
값은 repositoryUri
값으로 변경한다.
{
"family": "nodejs-app",
"networkMode": "awsvpc",
"executionRoleArn": "arn:aws:iam::<ACCOUNT ID>:role/ecsTaskExecutionRole",
"containerDefinitions": [
{
"name": "nodejs-app",
"image": "<ACCOUNT ID>.dkr.ecr.ap-northeast-2.amazonaws.com/nodejs-app:latest",
"essential": true,
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "/aws/ecs/nodejs-app",
"awslogs-region": "ap-northeast-2",
"awslogs-stream-prefix": "ecs"
}
}
}
],
"requiresCompatibilities": [
"FARGATE"
],
"cpu": "256",
"memory": "512"
}
위의 작업정의 설정에는 CloudWatch 에서 로그를 확인하기 위해 logConfiguration
설정이 포함되어 있다. 여기서 한 가지 주의할 것은 ECS 작업 실행권한에는 로그그룹을 생성할 수 있는 권한이 없기 때문에 실행 전에 로그그룹을 생성해 둬야 한다는 것이다. 외부에서의 접속이 필요하다면 port 관련 설정이 추가되어야 하지만 원격의 파일을 AWS S3로 업로드 하는 단순 기능이기 때문에 port 관련 설정은 제거했다. nodejs 애플리케이션은 cron 등으로 한번만 실행하면 되는 환경이기 때문에 ECS 의 서비스에는 등록하지 않았다. 웹서버와 같은 기능을 수행해야 한다면 서비스 등록이 필요할 것으로 보인다.
이제 awc cli 중 run-task 를 이용해 등록한 작업을 실행한다. 위의 과정을 좀 더 편하게 처리하기 위해사 작성한 PHP 코드는 아래와 같다.
<?php
if ($argc < 3) {
echo 'Wrong number of parameters.'.PHP_EOL;
echo "Usage: php ".basename(__FILE__)." 'AWS_ACCESS_KEY_ID' 'AWS_SECRET_ACCESS_KEY'".PHP_EOL;
exit;
}
$aws_access_key_id = $argv[1];
$aws_secrect_access_key = $argv[2];
putenv('AWS_ACCESS_KEY_ID='.$aws_access_key_id);
putenv('AWS_SECRET_ACCESS_KEY='.$aws_secrect_access_key);
putenv('AWS_DEFAULT_REGION=ap-northeast-2');
$name = 'nodejs-app';
$repo_name = 'nodejs-app';
$task = 'nodejs-app-task';
$cluster = 'nodejs-app';
$log_group = '/aws/ecs/nodejs-app';
$task_role_file = 'task-execution-assume-role.json';
$task_def_file = 'task-definition.json';
$res = shell_exec('aws ecr create-repository --repository-name '.$repo_name.' --image-scanning-configuration scanOnPush=true');
$data = json_decode($res, true);
if (!$data) {
exit($res.PHP_EOL);
}
$repository_uri = $data['repository']['repositoryUri'];
$repository_url = str_replace('/'.$data['repository']['repositoryName'], '', $repository_uri);
$res = shell_exec('aws ecr get-login-password | docker login --username AWS --password-stdin '.$repository_url);
if (stripos($res, 'Succeeded') === false) {
exit($res.PHP_EOL);
}
$res = shell_exec('docker build -t '.$name.' .');
$res = shell_exec('docker tag '.$name.' '.$repository_uri);
$res = shell_exec('docker push '.$repository_uri);
$res = shell_exec('aws logs create-log-group --log-group-name '.$log_group);
if ($res) {
exit($res.PHP_EOL);
}
$res = shell_exec('aws ecs create-cluster --cluster-name '.$cluster);
$data = json_decode($res, true);
if (!$data) {
exit($res.PHP_EOL);
}
$json = <<<'JSON'
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "",
"Effect": "Allow",
"Principal": {
"Service": "ecs-tasks.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
JSON;
file_put_contents($task_role_file, $json);
$res = shell_exec('aws iam create-role --role-name ecsTaskExecutionRole --assume-role-policy-document file://'.$task_role_file);
$data = json_decode($res, true);
if (!$data) {
exit($res.PHP_EOL);
}
$role_arn = $data['Role']['Arn'];
$res = shell_exec('aws iam attach-role-policy --role-name ecsTaskExecutionRole --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy');
if ($res) {
exit($res.PHP_EOL);
}
$json = <<<JSON
{
"family": "$task",
"networkMode": "awsvpc",
"executionRoleArn": "$role_arn",
"containerDefinitions": [
{
"name": "$name",
"image": "$repository_uri:latest",
"essential": true,
"logConfiguration": {
"logDriver": "awslogs",
"options": {
"awslogs-group": "$log_group",
"awslogs-region": "ap-northeast-2",
"awslogs-stream-prefix": "ecs"
}
}
}
],
"requiresCompatibilities": [
"FARGATE"
],
"cpu": "256",
"memory": "512"
}
JSON;
file_put_contents($task_def_file, $json);
$res = shell_exec('aws ecs register-task-definition --cli-input-json file://'.$task_def_file);
$data = json_decode($res, true);
if (!$data) {
exit($res.PHP_EOL);
}
unlink($task_role_file);
unlink($task_def_file);
echo 'Completed.'.PHP_EOL;