규모가 커지면 커질 수록 자동화된 워크플로우는 필수라고 생각합니다. 하지만 부끄럽게도 크로키닷컴은 잘 구축된 편은 아닙니다.

유닛 테스트는 초기부터 있었지만 그걸 PR, 머지마다 자동으로 수행하지는 못했습니다. 그러다가 2017년 중반 겨우 Jenkins를 세팅해서 자동 테스트만은 수행했습니다. 하지만 그게 워크플로우와 잘 어우러지지 못했습니다. 2019년에는 CodeBuild로 전환을 했고 비로서 PR 생성시 자동 테스트를 수행해 실패하면 머지를 할 수 없도록 구성이 됐습니다.

그럭저럭 아쉬운 대로 쓰고는 있었지만 매 테스트마다 수십분씩 걸리고 수정도 어려웠습니다. 가장 대중적으로 널리 쓰이는 Jenkins, 저희가 만든 OSS에 연결해 둔 Travis CI, CircleCI등을 계속 두드려봤지만 썩 마음에 드는게 없었습니다.

가장 방해가 되던건 저희가 마이크로서비스 아키텍처로 서비스들이 잘게 쪼개져 있는데, 저장소는 단일 저장소(monorepo)라는 점이였습니다. 그러나 사람이 늘면서 도저히 단일 저장소로는 감당이 안 되어 저장소를 분리하기 시작했고, 분리된 저장소에서 새로운 자동화 시스템을 고민하는데 그 때 눈에 띈 것이 GitHub Actions였습니다. 작성이 쉬우면서도 확장성이 좋아서 그 뒤로 여러가지 워크플로우에 GitHub Actions를 사용하고 있습니다.

이번 글에서는 현재 저희 팀이 세팅한 GitHub Actions의 workflow 파일을 공유하려고 합니다. 이 내용이 독자분들에게 도움이 됐으면 합니다.

테스트 자동화

PR이나 머지 완료시 테스트를 수행합니다.

name: 테스트 실행

# PR이 만들어졌거나 master 브랜치에 머지되어 올라갈 때 수행합니다.
on:
  push:
    branches:
      - master
  pull_request:
    branches:
      - "**"

jobs:
  test:
    runs-on: ubuntu-latest

    steps:
      # MySQL 데몬을 띄웁니다.
      # service container를 쓸 수도 있습니다.
      - name: Setup MySQL
        uses: mirromutth/mysql-action@v1.1
        with:
          host port: 7777
          container port: 3306
          mysql version: '5.7'
          mysql database: testdb
          mysql user: 'croquis_test'
          mysql password: 'test_password'

      # 소스를 가져옵니다.
      - name: Checkout code
        uses: actions/checkout@v2

      # 실행 속도를 빠르게 하기 위해 설치된 Node 모듈을 캐시하도록 설정합니다.
      - name: Cache node modules
        uses: actions/cache@v2
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      # Node 14.x를 사용합니다.
      - name: Use Node.js
        uses: actions/setup-node@v1
        with:
          node-version: '14.x'

      # 모듈을 설치합니다.
      - name: Install packages
        run: npm ci

      # 테스트를 수행합니다.
      - name: Run unit test
        run: npm run test

      # 중간에 실패한 경우 슬랙으로 알려줍니다.
      # GitHub 저장소나 조직의 Secrets 항목에 슬랙 Webhook URL을 등록해야 합니다.
      - name: Notify failure
        uses: 8398a7/action-slack@v3
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
        with:
          status: ${{ job.status }}
          username: github-actions
          channel: '#github'
        if: failure()

문서 빌드 후 업로드

현재 Docusaurus를 사용해 코드와 같은 저장소에서 문서를 작성하고 있습니다. 이를 자동으로 문서 공유 웹사이트에 올립니다.

PR시에도 문서를 생성해 별도 경로로 올립니다. 잘 구성하면 하나의 step으로 만들 수도 있을 것 같은데 빠르게 구현하느라 if를 써서 별도 step으로 구현했습니다.

name: 문서 빌드

# PR이 만들어졌거나 master 브랜치에 머지되어 올라갈 때 수행합니다.
on:
  push:
    branches:
      - master
  pull_request:
    branches:
      - "**"

env:
  # 마이크로서비스 이름입니다.
  SERVICE: user
  BRANCH: ${{ github.head_ref }}
  # 문서 업로드를 위한 AWS Access Key / Secret Key를 Secrets에 등록해둡니다.
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_UPLOAD_DOC_ACCESS_KEY }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_UPLOAD_DOC_SECRET_KEY }}

jobs:
  doc:
    runs-on: ubuntu-latest

    steps:
      # 소스를 가져옵니다.
      - name: Checkout code
        uses: actions/checkout@v2

      # 실행 속도를 빠르게 하기 위해 설치된 Node 모듈을 캐시하도록 설정합니다.
      - name: Cache node modules
        uses: actions/cache@v2
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      # Node 14.x를 사용합니다.
      - name: Use Node.js
        uses: actions/setup-node@v1
        with:
          node-version: '14.x'

      # 모듈을 설치합니다.
      - name: Install packages
        run: npm ci

      # 문서를 빌드합니다. (PR인 경우)
      - name: Build documentation (PR)
        # Docusaurus가 문서를 절대 경로로 생성하기 때문에 경로를 알려줘야 합니다.
        run: npm run doc /branches/${SERVICE}/${BRANCH}/
        if: github.ref != 'refs/heads/master'

      # 문서를 업로드합니다. (PR인 경우)
      - name: Upload documentation (PR)
        run: |
          aws s3 sync out-doc s3://doc.mysite.com/branches/${SERVICE}/${BRANCH} --delete --cache-control max-age=0
        if: github.ref != 'refs/heads/master'

      # 생성된 주소를 커밋 상태에 설정해서 바로 열어볼 수 있도록 합니다. (PR인 경우)
      - name: Set documentation url status (PR)
        uses: actions/github-script@v3
        with:
          github-token: ${{secrets.GITHUB_TOKEN}}
          script: |
            github.repos.createCommitStatus({
              context: 'documentaion',
              owner: context.repo.owner,
              repo: context.repo.repo,
              sha: context.eventName === 'pull_request' ? context.payload.pull_request.head.sha : context.sha,
              state: 'success',
              target_url: `https://doc.mysite.com/branches/${process.env.SERVICE}/${process.env.BRANCH}/index.html`,
            });
        if: github.ref != 'refs/heads/master'

      # 문서를 빌드합니다. (주 브랜치에 푸시한 경우)
      - name: Build documentation (master)
        run: npm run doc /${SERVICE}/
        if: github.ref == 'refs/heads/master'

      # 문서를 업로드합니다. (주 브랜치에 푸시한 경우)
      - name: Upload documentation (master)
        run: |
          aws s3 sync out-doc s3://doc.mysite.com/${SERVICE} --delete --cache-control max-age=0
        if: github.ref == 'refs/heads/master'

      # 생성된 주소를 커밋 상태에 설정해서 바로 열어볼 수 있도록 합니다. (주 브랜치에 푸시한 경우)
      - name: Set documentaion url status (master)
        uses: actions/github-script@v3
        with:
          github-token: ${{secrets.GITHUB_TOKEN}}
          script: |
            github.repos.createCommitStatus({
              context: 'documentaion',
              owner: context.repo.owner,
              repo: context.repo.repo,
              sha: context.eventName === 'pull_request' ? context.payload.pull_request.head.sha : context.sha,
              state: 'success',
              target_url: `https://doc.mysite.com/${process.env.SERVICE}/index.html`,
            });
        if: github.ref == 'refs/heads/master'

      # 중간에 실패한 경우 슬랙으로 알려줍니다.
      # 저장소나 조직의 Secrets 항목에 슬랙 Webhook URL을 등록해야 합니다.
      - name: Notify failure
        uses: 8398a7/action-slack@v3
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }}
        with:
          status: ${{ job.status }}
          username: github-actions
          channel: '#github'
        if: failure()

웹 클라이언트 배포

현재 웹 클라이언트는 S3에 올려 운영하고 있습니다. 빌드해서 S3에 올리는 workflow입니다.

name: '백오피스 사이트 배포'

# 배포는 GitHub Actions 화면에서 수동으로 실행시켜야 합니다.
on:
  workflow_dispatch:

jobs:
  release:
    runs-on: ubuntu-latest

    steps:
      # 소스를 가져옵니다.
      - name: Checkout code
        uses: actions/checkout@v2

      # 실행 속도를 빠르게 하기 위해 설치된 Node 모듈을 캐시하도록 설정합니다.
      - name: Cache node modules
        uses: actions/cache@v2
        with:
          path: ~/.npm
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.os }}-node-

      # Node 14.x를 사용합니다.
      - name: Use Node.js
        uses: actions/setup-node@v1
        with:
          node-version: '14.x'

      # 모듈을 설치합니다.
      - name: Install packages
        run: npm ci

      # 결과물을 만듭니다.
      - name: Build
        run: npm run build

      # S3에 결과물을 업로드합니다.
      - name: Deploy
        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.AWS_DEPLOY_FRONT_ACCESS_KEY }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_DEPLOY_FRONT_SECRET_KEY }}
        run: |
          aws s3 sync dist s3://backoffice.website.mysite.com/

      # 배포가 성공한 경우 알립니다.
      # Secrets 항목에 슬랙 Webhook URL을 등록해야 합니다.
      - name: Slack notification success
        uses: Ilshidur/action-slack@2.1.0
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
          SLACK_USERNAME: ${{ github.actor }}
          SLACK_CHANNEL: release
        with:
          args: '{{ GITHUB_REF }}({{ GITHUB_SHA }}): 백오피스 사이트를 배포했습니다.'

      # 배포가 실패한 경우 알립니다.
      # Secrets 항목에 슬랙 Webhook URL을 등록해야 합니다.
      - name: Slack notification failure
        uses: Ilshidur/action-slack@2.1.0
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
          SLACK_USERNAME: ${{ github.actor }}
          SLACK_CHANNEL: release
        with:
          args: '{{ GITHUB_REF }}({{ GITHUB_SHA }}): 백오피스 사이트 배포에 실패했습니다.'
        if: failure()

출시 후보 준비

현재 저희는 개발 브랜치로 작업을 하다가 특정 시점에 출시 후보(Release Candidate)를 준비해서 최종 검증 후 출시를 하는 프로세스를 해보고 있습니다. 이를 자동으로 준비하는 workflow입니다.

이 workflow는 기본 브랜치 기준으로 실행되므로 개발 브랜치가 기본 브랜치여야 합니다.

name: 출시 후보 준비

# 새벽에 출시를 위한 후보 브랜치를 준비합니다.
on:
  schedule:
    - cron: '0 19 * * WED' # 목요일 새벽 4시

jobs:
  create_rc:
    runs-on: ubuntu-latest

    steps:
      # RC 브랜치를 생성합니다.
      - name: Create RC branch
        uses: peterjgrainger/action-create-branch@v2.0.1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          branch: 'rc'

      # RC 브랜치를 가지고 출시용 PR을 생성합니다.
      # JavaScript 코드로 GitHub API를 자유롭게 사용할 수 있는 github-script 모듈을 사용합니다.
      - name: Create PR
        id: cpr
        uses: actions/github-script@v3
        with:
          github-token: ${{secrets.GITHUB_TOKEN}}
          result-encoding: string
          script: |
            const target = new Date(); // UTC 기준 수요일 저녁
            const month_start = new Date(target.getFullYear(), target.getMonth(), 1);
            const month_week = Math.ceil(( ( (target - month_start) / 86400000) + 1)/7); // 주차 계산
            const title = `${target.getFullYear()}년 ${target.getMonth() + 1}월 ${month_week}주`;
            try {
              // rc에서 main으로의 PR을 생성합니다.
              const result = await github.pulls.create({
                owner: context.repo.owner,
                repo: context.repo.repo,
                title,
                head: 'rc',
                base: 'main',
              });
              return `${context.repo.repo} 저장소에 대해 ${title} RC PR을 생성했습니다. ${result.data.html_url}`;
            } catch (error) {
              return `${context.repo.repo} 저장소에 대해 변경 내용이 없어서 RC PR을 생성하지 않았습니다.`;
            }

      # 슬랙 채널에 내용을 공유합니다.
      - name: Slack notification success
        uses: Ilshidur/action-slack@2.1.0
        env:
          SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
          SLACK_USERNAME: ${{ github.actor }}
          SLACK_CHANNEL: 'release'
        with:
          args: ${{ steps.cpr.outputs.result }}

응용프로그램을 작성하다 보면 여러 가지 환경 설정(configuration)이 필요합니다. 이번 글에서는 TypeScript에서 환경 설정을 관리하는 저희의 방법을 설명합니다.

프로젝트 초기에는 환경 설정을 코드 중간에 넣어서 개발하는 경우가 많을 것으로 생각합니다. 그러다가 규모가 커지면 여러 가지 이유로 코드에서 분리해서 관리할 필요성이 늘어납니다. 첫 번째는 보안 측면의 이슈입니다. 암호나 접근키(access key) 등을 코드에 넣는 것은 보통 좋은 방향이 아닙니다. 이런 값은 보통 환경 변수(environment variable)에 담아서 사용하는 경우가 많습니다. 프로세스 구동 시에 환경 변수를 설정해두는 것은 꽤 귀찮은 작업이기 때문에 dotenv 같은 모듈을 사용하기도 합니다. 두 번째는 상황에 따라 다른 값을 적용해야 하는 경우입니다. 개발 중, 운영 환경, 자동화 테스트 등의 상황마다 다른 값이 필요한 경우가 많습니다. 이런 경우를 지원하는 것으로 config라는 모듈이 있습니다.

크로키의 경우 첫 번째 이슈는 그렇게 크지 않았습니다. 운영 환경에 적절한 환경 변수를 설정하는 것도 꽤 번거롭기 때문에 AWS Instance Role이나 Parameter Store 같은 방법을 써서 회피하고 있습니다. 하지만 환경에 따라 설정이 달라지는 경우는 많았기 때문에 config 모듈은 사용하고 있었습니다. config 모듈은 필요한 기능을 제공했지만, 오타 등의 이유로 개발 중에는 잘 동작하는데 운영 환경에서는 잘못된 설정이 적용되거나 하는 실수가 종종 발생했습니다. 그래서 저희가 사용하는 TypeScript 언어의 장점을 살려 설정도 타입 검사가 이루어졌으면 하는 바람이 생겼습니다. node-config-ts라는 모듈이 있었지만, 원하는 형태와는 조금 거리가 있었습니다.

여러 가지를 고민하던 중에 외부 모듈 없이도 타입 안정성 있는 설정을 구현할 수 있다는 생각이 들었습니다. 저희는 처음부터 설정 파일을 JSON이 아닌 JavaScript로 관리하고 있었습니다. 이를 그대로 TypeScript로 전환하면 그 자체가 타입 정의가 될 것 같았습니다. 이렇게 만들어진 저희 설정 파일 형식은 다음과 같습니다.

우선 설정의 기본 구조는 config/default.ts에 정의합니다. 이후 다른 코드에서는 이 파일의 내용을 기반으로 타입 검사가 이루어집니다.

const Config = {
  test_mode: false,
  database: {
    host: 'mydb.mydomain.com',
    port: 3306,
    user: 'myuser',
    password: 'mypassword',
  },
};

export default Config;

다른 환경에 대한 설정은 다른 부분에 대해서만 정의하면 됩니다. 다음은 테스트 환경을 위한 config/test.ts 파일입니다.

const Config = {
  test_mode: true,
  database: {
    host: 'localhost',
    port: '5678',
  },
};

export default Config;

이제 이를 묶어서 다른 코드에 제공할 config/index.ts 파일을 정의합니다.

import _ from 'lodash';
import BaseConfig from './default';

const Config = _.cloneDeep(BaseConfig);

if (process.env.NODE_ENV) {
  try {
    const EnvConfig = require(`./${process.env.NODE_ENV}`).default;
    _.merge(Config, EnvConfig);
  } catch (e) {
    console.log(`Cannot find configs for env=${process.env.NODE_ENV}`);
  }
}

export { Config };

이 설정 파일은 다음과 같이 사용하면 됩니다. 이 코드는 NODE_ENV에 따라 다른 결과를 출력합니다. 물론 타입 검사도 완벽히 동작합니다.

import { Config } from './config';

console.log(Config.database);

다만 이대로는 부족한 점이 있습니다. test.ts 파일에서 오타(예 test_mode -> testmode)나 타입 오류가 있어도 알려주지 않습니다. 이는 다음 방법으로 해결할 수 있습니다. default.ts에 다음 내용을 추가합니다.

// from https://github.com/krzkaczor/ts-essentials
type DeepPartial<T> = {
  [P in keyof T]?: T[P] extends Array<infer U>
  ? Array<DeepPartial<U>>
  // tslint:disable-next-line:no-shadowed-variable
  : T[P] extends ReadonlyArray<infer U>
  ? ReadonlyArray<DeepPartial<U>>
  : DeepPartial<T[P]>
};

export type BaseConfigType = DeepPartial<typeof Config>;

이제 test.ts를 다음과 같이 수정합니다.

import { BaseConfigType } from './default';

const Config: BaseConfigType = {
  ...
};

export default Config;

처음 정의 시 port의 타입이 달랐는데(string vs number) 이제는 다음과 같은 컴파일 오류가 발생합니다.

config/test.ts(5,3): error TS2322: Type '{ host: string; port: string; }' is not assignable to type 'DeepPartial<{ host: string; port: number; user: string; password: string; }>'.
  Types of property 'port' are incompatible.
    Type 'string' is not assignable to type 'number | undefined'.

저희는 이런 식으로 설정을 정의해서 잘 사용하고 있습니다. 다만 아쉬운 점이 아예 없지는 않습니다. lodash의 merge를 사용하기 때문에 default 설정에서 정의한 값을 undefined로 덮어씌우지는 못합니다. 또 default 설정의 타입이 기준이 되기 때문에 환경마다 설정의 형태가 매우 다르다면 default에서 타입을 명시적으로 써줘야 할 수도 있습니다.

// default
import Redis from 'ioredis';

const Config = {
  database: {
    password: 'mypassword' as string | null,
  },
  cache: {
    ...
  } as Redis.RedisOptions,
};

// test
const Config = {
  database: {
    password: null,
  },
};

제 생각에 JavaScript 생태계에서 TypeScript는 뺄 수 없는 부분이 된 것 같습니다. 이 글이 TypeScript를 사용하시는 데 도움이 되었으면 합니다.

서비스를 운영하다 보면 주기적으로 실행이 필요한 작업이 생깁니다. 이런 작업을 실행하는 방법은 여러 가지가 있을 수 있습니다. 다음은 크로키에서 현재 선택해서 전환 중인 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는 기능이 많은 편은 아니지만, 간단하게 쓰기에는 충분했습니다. 물론 나중에 서비스 규모가 더 커지면 다른 도구를 도입할 가능성은 계속 열려있습니다.