서비스를 운영하다 보면 주기적으로 실행이 필요한 작업이 생깁니다. 이런 작업을 실행하는 방법은 여러 가지가 있을 수 있습니다. 다음은 크로키에서 현재 선택해서 전환 중인 AWS Batch에 관해 설명합니다.

지그재그 서비스 초기에는 서버가 EC2 인스턴스 위에서 동작하고 있었습니다. 이때는 반복 작업은 작업 전용 EC2 인스턴스에서 실행하도록 구성했습니다. 그리고 그 일정은 리눅스 cron을 통해 관리했습니다.

이후 AWS Lambda로 서비스 서버를 옮기기로 하면서 새로운 방법이 필요해졌습니다. 그래서 CloudWatch Events에 람다에 연결해서 반복 작업을 실행했습니다. 다만 EC2와 달리 실행 시간에 제한이 있고, 동시 실행을 방지할 방법이 없었습니다.

현재는 서비스 서버를 ECS Fargate로 이전한 상황입니다. 여기에 맞는 반복 작업 실행 방법을 찾아야 하는데 처음에는 Scheduled Tasks를 고려했지만, 여러 이유로 진도가 나가지 않고 있었습니다.

더는 기존 시스템을 계속 둘 수는 없기에 빠르게 전환할 방법으로 AWS Batch를 선택하게 됐습니다. 나중에는 Apache AirflowKubernetes CronJob 같은 더 기능이 풍부한 해결책이 필요해질 수도 있지만, 현재 상황에서는 충분히 만족스러운 해결책이었습니다.

다음은 저희 상황에서 AWS Batch를 적용했을 때의 장단점입니다.

  1. 이미 실행할 프로그램이 Docker 이미지로 만들어져서 ECR에 올라가 있습니다. 명령(CMD)만 바꿔주면 서비스 프로세스 대신 작업이 수행됩니다.
  2. 실행 결과를 보기가 편했습니다. 기존 방식은 로그를 한꺼번에 보기가 어려웠는데 AWS Batch를 적용하면 작업만 모아서 볼 수 있어서 좋았습니다.
  3. 작업 실패 시 알림을 받기도 편했습니다.
  4. ECS처럼 CPU 단위를 1/4 vCPU 단위로 지정할 수 있으면 좋은데 1 vCPU 단위라서 리소스가 실제 필요보다 많이 쓰는 단점이 있습니다.

AWS Batch 환경 구축

저희는 현재 CloudFormation으로 인프라를 구성하고 있습니다. (CDK도 고려 중입니다.)

다음은 AWS Batch 인프라와 그 설명입니다.

Resources:
  # 우선 AWS Batch가 동작할 환경(EC2)을 정의합니다.
  # 주어진 VPC의 Subnet 위에 상황에 맞는 적절한 인스턴스가 생성됩니다.
  # 관리형을 선택해 자동으로 인스턴스가 늘어나고 줄어듭니다.
  # 작업이 중단되는 것을 원하지 않아 스팟 인스턴스는 적용하지 않았습니다.
  BatchComputeEnvironment:
    Type: AWS::Batch::ComputeEnvironment
    Properties:
      ComputeResources:
        InstanceRole: ecsInstanceRole
        InstanceTypes:
          - optimal
        MaxvCpus: 16
        MinvCpus: 2
        SecurityGroupIds:
          - !ImportValue VpcSecurityGroupId
        Subnets:
          - !ImportValue SubnetId1
          - !ImportValue SubnetId2
        Type: EC2
      ServiceRole: !Sub arn:aws:iam::${AWS::AccountId}:role/AWSBatchServiceRole
      Type: MANAGED

  # 다음은 작업 대기열을 설정합니다.
  # 세부적으로 나눌 필요성을 못 느껴 대부분의 작업은 default 대기열에서 실행됩니다.
  BatchQueueDefault:
    Type: AWS::Batch::JobQueue
    Properties:
      ComputeEnvironmentOrder:
        - ComputeEnvironment: !Ref BatchComputeEnvironment
          Order: 1
      JobQueueName: default
      Priority: 5
      State: ENABLED

  # 10분 이하 간격으로 실행되는 작업이 있는데
  # default 대기열에 넣으면 화면에 작업 목록이 너무 길어져서 분리했습니다.
  # 우선순위도 조금 낮게 설정했습니다.
  BatchQueueContinuously:
    Type: AWS::Batch::JobQueue
    Properties:
      ComputeEnvironmentOrder:
        - ComputeEnvironment: !Ref BatchComputeEnvironment
          Order: 1
      JobQueueName: continuously
      Priority: 3
      State: ENABLED

  # 작업을 실행할 수 있는 역할을 미리 생성해서 각 작업 정의 시 사용할 수 있도록 했습니다.
  EventsBatchSubmitJobRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - events.amazonaws.com
            Action:
              - sts:AssumeRole
      Policies:
        - PolicyName: default
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - batch:SubmitJob
                Resource:
                  - '*'

  # 작업 실패 시 실행할 람다 함수의 역할입니다.
  # 슬랙으로 메시지를 전송하는 SendToSlack이라는 함수를 호출할 수 있도록 권한을 부여했습니다.
  JobFailedAlertLambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - lambda.amazonaws.com
            Action:
              - sts:AssumeRole
      Policies:
        - PolicyName: default
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - logs:CreateLogGroup
                  - logs:CreateLogStream
                  - logs:PutLogEvents
                Resource:
                  - !Sub arn:aws:logs:ap-northeast-2:${AWS::AccountId}:*
        - PolicyName: send-to-slack
          PolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Action:
                  - lambda:InvokeFunction
                Resource:
                  - !Sub arn:aws:lambda:ap-northeast-2:${AWS::AccountId}:function:SendToSlack

  # 작업 실패 시 실행되는 람다 함수입니다.
  # 실패 메시지를 분석해 적절한 에러 메시지를 슬랙으로 보내줍니다.
  JobFailedAlertLambda:
    Type: AWS::Lambda::Function
    Properties:
      Code:
        ZipFile: |
          const AWS = require('aws-sdk');
          const slackChannel = '#cron-error';

          const lambda = new AWS.Lambda({ region: 'ap-northeast-2' });

          async function processEvent(event) {
            console.log(JSON.stringify(event));
            const slackMessage = {
              channel: slackChannel,
              username: 'AWS Batch Job Failed Alert',
              icon_emoji: ':cloud:',
            };

            let color = 'danger';
            slackMessage.attachments = [{
              color: color,
              text: `${event.detail.jobName} failed by ${event.detail.statusReason}`,
            }];
            await lambda.invoke({
              FunctionName: 'SendToSlack',
              Payload: JSON.stringify(slackMessage),
            }).promise();
          }

          exports.handler = async (event, context, callback) => {
            try {
              const response = await processEvent(event);
              callback(null, response);
            } catch (error) {
              callback(error);
            }
          };          
      Handler: index.handler
      Role: !GetAtt JobFailedAlertLambdaRole.Arn
      Runtime: nodejs10.x

  # AWS Batch에서 작업 실패를 감지하면 람다 함수를 호출하도록 구성합니다.
  JobFailedEvent:
    Type: AWS::Events::Rule
    Properties:
      EventPattern:
        detail-type:
          - Batch Job State Change
        source:
          - aws.batch
        detail:
          status:
            - FAILED
      Targets:
        - Arn: !GetAtt JobFailedAlertLambda.Arn
          Id: lambda

  # 작업 실패 이벤트가 람다 함수를 실행할 수 있도록 권한을 부여합니다.
  JobFailedEventPermission:
    Type: AWS::Lambda::Permission
    Properties:
      Action: lambda:InvokeFunction
      FunctionName: !GetAtt JobFailedAlertLambda.Arn
      Principal: events.amazonaws.com
      SourceArn: !GetAtt JobFailedEvent.Arn

# 위에서 정의한 AWS Batch 리소스를 다른 CloudFormation에서 사용할 수 있도록 내보냅니다.
Outputs:
  BatchQueueArnDefault:
    Value: !Ref BatchQueueDefault
    Export:
      Name: BatchQueueArnDefault

  BatchQueueArnContinuously:
    Value: !Ref BatchQueueContinuously
    Export:
      Name: BatchQueueArnContinuously

  EventsBatchSubmitJobRoleArn:
    Value: !GetAtt EventsBatchSubmitJobRole.Arn
    Export:
      Name: EventsBatchSubmitJobRoleArn

AWS Batch 작업 정의

다음은 위 환경 위에서 실제 동작할 작업에 대한 정의입니다.

Resources:
  # 서비스 코드가 올라간 저장소입니다.
  # 서비스 구동용 ECS 작업(task) 정의에서도 같이 사용합니다.
  Repository:
    Type: AWS::ECR::Repository
    Properties:
      RepositoryName: my-service

  # 작업을 위한 역할을 정의합니다.
  Role:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: 2012-10-17
        Statement:
          - Effect: Allow
            Principal:
              Service:
                - ecs-tasks.amazonaws.com
            Action:
              - sts:AssumeRole

  # 첫 번째 작업을 정의합니다.
  # 20분 정도 실행되는 작업으로 넉넉하게 30분의 시간제한을 뒀습니다.
  # 재시도는 하지 않습니다.
  DoSomethingJobDefinition:
    Type: AWS::Batch::JobDefinition
    Properties:
      ContainerProperties:
        Command: ['node', 'app/jobs/do-something']
        Image: !Sub ${AWS::AccountId}.dkr.ecr.ap-northeast-2.amazonaws.com/my-service:latest
        JobRoleArn: !Ref Role
        Memory: 1024
        Vcpus: 1
      RetryStrategy:
        Attempts: 1
      Timeout:
        AttemptDurationSeconds: 1800
      Type: container

  # 매일 아침 9시 0분(UTC 기준 새벽 0시 0분) 작업 실행하도록 CloudWatch Events를 생성합니다.
  DoSomethingEvent:
    Type: AWS::Events::Rule
    Properties:
      ScheduleExpression: cron(0 0 * * ? *)
      Targets:
        - Arn: !ImportValue BatchQueueArnDefault
          Id: task
          BatchParameters:
            JobDefinition: !Ref DoSomethingJobDefinition
            JobName: my-service-do-something
          RoleArn: !ImportValue EventsBatchSubmitJobRoleArn

  # 자주 실행하는 작업을 정의합니다.
  DoOftenJobDefinition:
    Type: AWS::Batch::JobDefinition
    Properties:
      ContainerProperties:
        Command: ['node', 'app/jobs/do-often']
        Image: !Sub ${AWS::AccountId}.dkr.ecr.ap-northeast-2.amazonaws.com/my-service:latest
        JobRoleArn: !Ref Role
        Memory: 1024
        Vcpus: 1
      RetryStrategy:
        Attempts: 1
      Timeout:
        AttemptDurationSeconds: 60
      Type: container

  # 10분마다 작업을 실행하도록 구성합니다.
  DoOftenEvent:
    Type: AWS::Events::Rule
    Properties:
      ScheduleExpression: cron(7/10 * * * ? *)
      Targets:
        - Arn: !ImportValue BatchQueueArnContinuously
          Id: task
          BatchParameters:
            JobDefinition: !Ref DoOftenJobDefinition
            JobName: my-service-do-often
          RoleArn: !ImportValue EventsBatchSubmitJobRoleArn

기타

짧게 실행되는 작업에 대해서 중복 실행을 막고 싶은 요구사항이 있습니다. 이는 다음과 같이 현재 구동 중인 작업을 검사하는 방식으로 해결했습니다. (batch:ListJobs 권한이 필요합니다.)

import AWS from 'aws-sdk';
import { JobStatus } from 'aws-sdk/clients/batch';

const job_name = 'do-something';

const batch = new AWS.Batch({ region: 'ap-northeast-2' });

async function checkAlreadyRunStatus(status: JobStatus) {
  const result = await batch.listJobs({
    jobQueue: process.env.AWS_BATCH_JQ_NAME,
    jobStatus: status,
  }).promise();
  const found = result.jobSummaryList.findIndex((item) => item.jobName === job_name && item.jobId !== process.env.AWS_BATCH_JOB_ID);
  if (found >= 0) {
    throw new Error(`already run ${result.jobSummaryList[found].jobId} / ${status}`);
  }
}

async function checkAlreadyRun() {
  await checkAlreadyRunStatus('SUBMITTED');
  await checkAlreadyRunStatus('PENDING');
  await checkAlreadyRunStatus('RUNNABLE');
  await checkAlreadyRunStatus('STARTING');
  await checkAlreadyRunStatus('RUNNING');
}

async function run() {
  try {
    await checkAlreadyRun();
  } catch (error) {
    process.exit(0); // 0으로 종료해야 실패로 처리되지 않습니다.
  }

  // 실제 수행할 코드
}

run();

작업에 따라서는 여러 단계로 나뉘어 순차적으로 실행돼야 할 수 있습니다. 작업 정의로는 그런 세세한 제어는 어렵지만, 이전 작업 마지막에서 submitJob을 수동으로 호출해주면 될 것으로 생각하고 있습니다.

AWS Batch는 노드를 여러 개 띄워서 동시에 실행하는 기능도 제공하지만, 저희는 아직 사용하지 않고 있습니다.

결론

AWS Batch는 기능이 많은 편은 아니지만, 간단하게 쓰기에는 충분했습니다. 물론 나중에 서비스 규모가 더 커지면 다른 도구를 도입할 가능성은 계속 열려있습니다.

현재 크로키는 API를 GraphQL로 만들고 있습니다. 아직 많은 부분에 대해서 연구 중이어서 현재 상황만 간단하게 정리해 보겠습니다.

Thrift를 1년 정도 쓴 시점부터 여러 가지 불편함을 느끼고 대안을 찾다가 GraphQL을 선택했습니다. GraphQL 생태계가 Node.js를 중심으로 발전하고 있어서 크게 망설이지 않고 정할 수 있었던 것 같습니다.

외부에 영향을 주지 않는 기능 중 N+1 문제를 가지고 있는 기능을 하나 선택해 변환을 해봤습니다. 조금 생각은 해야 하지만 충분히 기존 API에 성능이 떨어지지 않고 사용은 더 편하게 할 수 있는 것을 확인하고 조금씩 확대해나갔습니다.

1년 이상 진행하면서 정리된 API 스펙은 스타일 가이드 저장소에서 공개하고 있습니다.

스키마 정의

처음에는 GraphQLObjectType을 써서 정의했습니다.

import { GraphQLInt, GraphQLNonNull, GraphQLObjectType, GraphQLString } from 'graphql';

const type = new GraphQLObjectType({
  name: 'Shop',
  fields: {
    id: {
      type: new GraphQLNonNull(GraphQLInt),
    },
    name: {
      type: new GraphQLNonNull(GraphQLString),
    },
    url: {
      type: new GraphQLNonNull(GraphQLString),
    },
  },
});

그러다가 타입이 많아지면 보기가 힘들어서 스키마 문자열을 통해 정의하는 것으로 변경해 봤습니다.

import { makeExecutableSchema } from 'graphql-tools';

const typeDefs = `
type Shop {
  id: ID!
  name: String!
  url: String!
}
`;

const resolvers = {
  Query: {
    shop: ShopService.shop,
  },
};

const schema = makeExecutableSchema({
  typeDefs: [typeDefs],
  resolvers: [resolvers],
});

현재는 resolver 구현에도 타입 체킹이 되고, DB 모델과 통일시키기 위해 type-graphql을 적용했습니다. 다만 현재 퍼포먼스 이슈 때문에 수정한 버전을 사용하고 있습니다.

import { Field, ID, ObjectType } from 'type-graphql';

@ObjectType()
export class Shop {
  @Field((type) => ID, { nullable: false })
  id: string;

  @Field((type) => String, { nullable: false })
  name: string;

  @Field((type) => String, { nullable: false })
  url: string;
}

클라이언트 호출 코드

클라어인트에서는 apollo 툴을 사용해 코드를 생성해 호출하고 있습니다.

안드로이드

LoginInput input = LoginInput.builder().email(email).password(password).build();
apolloClient.mutate(
    LoginMutation.builder().input(input).build()
).enqueue(new ApolloCall.Callback<LoginMutation.Data>() {
    ...
});

iOS

let input = LoginInput(email: email, password: password)
apolloClient.perform(mutation: LoginMutation(input: input)) { (result, error) in
    ...
}

// api.ts (apollo-tooling 위에 자체 스크립트로 생성한 파일)
import * as types from './types';

export async function login(variable: types.LoginVariables, options?: GqlRequestOptions) {
  const query = 'mutation Login($input: LoginInput!) { login(input: $input) }';
  return await request<types.Login, types.LoginVariables>(query, variable, options);
}

// LoginView.ts
async login() {
  await api.login({
    input: { email, password },
  });
}

마무리하며

전환을 시작한 이후로 많은 GraphQL API를 만들었지만, 기존 API는 손대지 못하고 거의 그대로 사용하고 있습니다. 그걸 보면서 진작에 GraphQL로 전환했으면 두 번 작업하는 일이 줄어들었을 터라는 생각이 듭니다. 하지만 초반에 적용했으면 아직 안정화되지 않은 생태계로 인해 고생했을 것 같습니다.

웹 기술을 TypeScript로, GraphQL로, React로 전환할 때마다, 이 방향이 맞는지, 이 시기가 적당한지 고민이 됩니다. 서비스를 이어가는 와중에 기술 부채를 털어낼 적절한 시기를 놓치지 않도록 계속 노력하고 있습니다.

차후에도 GraphQL을 사용하면서 의미가 있는 부분이 있으면 공개하도록 하겠습니다.

2016년 중반 마이크로서비스로의 전환을 결정했습니다. 마이크로서비스는 이론상 다른 서비스에 영향을 주지 않고 내부 기술을 바꿀 수 있습니다. 하지만 마이크로서비스 간의 통신 방법은 한번 결정하면 쉽게 바뀌기 어려울 것 같아서 가장 많이 고민했습니다. 그리고 Thrift를 선택했습니다. 이번 글에서는 그 이유와 이후의 상황에 관해 설명하겠습니다.

마이크로서비스에 대한 글들을 찾아보면 대부분은 통신 방법도 REST API를 많이 얘기하고 있습니다. (예. Introduction to Microservices | NGINX) 그러나 이전 글에서 썼듯이 REST API에 불편함을 느끼고 있었습니다. 특히 마이크로서비스 간에는 REST로 표현하기 어려운 더 다양한 API가 필요해질 것 같았습니다. 그래서 대안을 찾아봤습니다.

그 당시 고려했던 대안은 Thrift, Avro, Protocol Buffers였던 것으로 기억합니다. 지금 시점에 괜찮아 보이는 gRPC는 이상하게 당시 고려대상에서 벗어나 있었던 것 같습니다. 아마 RPC 보다는 데이터 직렬화 쪽을 더 중점적으로 생각했던 것 같습니다. 데이터- 특히 배열 -를 JSON으로 만들면 크기가 매우 크기 때문에 데이터 크기가 줄어드는 게 매력적으로 다가왔습니다.

그중에서 Thrift를 선택한 건 다음과 같은 이유가 있었던 것 같습니다.

  1. Avro 방식보다는 Thrift / Protocol Buffers 방식이 더 작은 사이즈를 만들 것 같았습니다.
  2. Protocol Buffers는 JavaScript를 잘 지원하는 듯이 보이지 않았습니다. (공식 문서의 튜토리얼에 없음)
  3. VCNC에서 Thrift를 쓰고 있다는 글을 봐서 어느 정도 검증이 됐으리라 봤습니다.

그렇게 Thrift를 마이크로서비스 간의 통신에 적용하는 작업을 시작했는데 생각만큼 잘 동작하지는 않았습니다.

  1. JavaScript를 지원하지만, 막상 해보니 클리어언트 JavaScript 용이었고, Node.js에 어울리는 코드를 만들어주지 않았습니다. TypeScript 지원을 포함해 몇 가지 수정을 가해서 사용하고 있습니다. (https://github.com/croquiscom/thrift/tree/croquis-171130)
  2. TCP 소켓 통신을 통해 요청마다 연결을 만들어야 하는 낭비를 없애기를 기대했는데, 오토 스케일링과 어울리지 않아서 결국 HTTP 통신을 했습니다.

몇 가지 문제가 있긴 하지만, 오랜 시간에 걸쳐 안정화되어 현재 수백개의 Thrift API가 존재하고 있습니다.

크로키닷컴만의 특이한 규칙이 하나 있다면 Read API에서 모든 필드 요청을 피하기 위해서 받기를 원하는 필드를 지정할 수 있도록 구성되어 있다는 것입니다. Update API도 하나로 여러 요구 사항에 대응하기 위해 업데이트를 할 필드를 같이 주도록 했습니다.

/// 지그재그 공지사항
struct ZigzagNotice {
  /// 레코드 ID
  1: optional i32 id
  /// 공지사항 내용
  2: optional string contents
  /// 공지 날짜
  3: optional i32 date
  /// 배포 대상 OS 타입 (0:common,1:none,2:iOS,3:Android)
  4: optional i32 os
  /// 공지사항에 필요한 링크 URL
  5: optional string link
}

service ZigzagNoticeService {
  /**
   * 해당 레코드 ID의 공지사항을 반환한다.
   *
   * fields는 ZigzagNotice의 구조체 필드 ID로 빈 경우 레코드 ID만 반환한다.
   */
  ZigzagNotice getZigzagNotice(1: i32 notice_id, 2: list<i32> fields)

  /**
   * 해당 레코드 ID의 공지사항을 업데이트 한다.
   *
   * fields는 ZigzagNotice의 구조체 필드 ID로 정의된 필드의 값만 업데이트 된다.
   */
  void updateZigzagNotice(1: i32 notice_id, 2: ZigzagNotice notice, 3: list<i32> fields)
}

이렇게 1년 이상 Thrift를 사용해왔지만 여러 가지 불편함이 발생했습니다.

  1. 라이브러리가 활발하게 개발되지 않고 있습니다. 특히 JavaScript/Node.js 쪽은 큰 변화가 없습니다.
  2. TypeScript와 잘 어울리지 않았습니다.
  3. API 추가/변경 시마다 코드를 생성해야 하는 것이 생각보다 불편했습니다.

그래서 2017년 말에 GraphQL을 살펴보기 시작해서 현재는 새로 작성하는 모든 API를 GraphQL로 만들고 있습니다. 다음번에는 크로키닷컴에서 GraphQL을 적용하는 과정에 관해서 설명하겠습니다.