Amazon Web Services 한국 블로그

AWS Batch, 신규 멀티 컨테이너 작업 기반 대규모 시뮬레이션 실행하기

자동차, 로봇, 금융과 같은 산업에서는 제품 개선을 위해 시뮬레이션, 기계 학습(ML) 모델 훈련, 빅 데이터 분석과 같은 컴퓨팅 워크로드를 더 많이 구현하고 있습니다. 예를 들어 자동차 제조업체는 시뮬레이션을 사용하여 자율 주행 기능을 테스트하고, 로봇 회사는 ML 알고리즘을 훈련하여 로봇 인식 기능을 강화하며, 금융 회사는 심층 분석을 실행하여 위험 관리, 거래 처리, 사기 탐지를 개선합니다.

시뮬레이션을 비롯한 이러한 워크로드 중 일부는 구성 요소의 다양성과 집약적인 컴퓨팅 요구 사항 때문에 실행하기가 특히 복잡합니다. 예를 들어 주행 시뮬레이션에는 3D 가상 환경, 차량 센서 데이터, 자동차 동작을 제어하는 차량 역학 생성 작업이 포함됩니다. 로봇 공학 시뮬레이션은 대규모 웨어하우스 환경에서 수백 대의 자율 전송 로봇이 시스템과 해당 로봇 사이에서 서로 상호 작용하는 과정을 테스트할 수 있습니다.

AWS Batch는 Amazon Elastic Container Service(Amazon ECS), Amazon Elastic Kubernetes Service(Amazon EKS), AWS Fargate, Amazon EC2 스팟 또는 온디맨드 인스턴스를 포함하여 다양한 AWS 컴퓨팅 오퍼링에서 배치 워크로드를 실행하도록 지원하는 완전관리형 서비스입니다.

이전에 AWS Batch는 단일 컨테이너 작업만 허용했으며, 모든 구성 요소를 모놀리식 컨테이너로 병합하려면 추가 단계가 필요했습니다. 또한 데이터 로깅과 같은 추가 서비스를 제공하여 기본 애플리케이션을 보완하는 보조 컨테이너인 별도의 ‘사이드카’ 컨테이너도 사용할 수 없었습니다. 이러한 추가 작업을 수행하려면 전체 컨테이너를 다시 구축하기 위해 코드를 변경해야 하므로 소프트웨어 개발, IT 운영 및 품질 보증(QA)과 같은 여러 팀 사이에서 조정이 필요했습니다.

이제 AWS Batch는 다중 컨테이너 작업을 제공하므로 자율 주행 차량 및 로봇 공학과 같은 분야에서 대규모 시뮬레이션을 더 쉽고 빠르게 실행할 수 있습니다. 이러한 워크로드는 일반적으로 시뮬레이션 자체, 그리고 시뮬레이션과 상호 작용하는 테스트 대상 시스템(에이전트라고도 함)으로 구분됩니다. 이 두 구성 요소는 서로 다른 팀에서 개발하고 최적화하는 경우가 많습니다.

작업당 여러 컨테이너를 실행할 수 있으므로 AWS Batch에서 제공하는 고급 규모 조정, 일정 관리 및 비용 최적화 기능을 활용하고, 3D 환경, 로봇 센서 또는 모니터링 사이드카와 같은 다양한 구성 요소를 대표하는 모듈식 컨테이너를 사용할 수 있습니다. 실제로 IPG Automotive, MORAI, Robotec.ai와 같은 고객은 이미 AWS Batch 다중 컨테이너 작업을 사용하여 클라우드에서 시뮬레이션 소프트웨어를 실행하고 있습니다.

간단한 예제를 통해 실제로 어떻게 작동하는지 살펴보고 미로를 풀어보는 재미를 느껴보세요.

컨테이너에서 실행되는 시뮬레이션 구축
프로덕션 환경에서 기존 시뮬레이션 소프트웨어를 사용할 수도 있습니다. 이 게시물에서는 간단한 버전의 에이전트와 모델 시뮬레이션을 만들었습니다. 코드 세부 정보에 관심이 없다면 이 섹션을 건너뛰고 AWS Batch를 구성하는 방법으로 바로 이동해도 됩니다.

이 시뮬레이션에서는 무작위로 생성된 2D 미로를 살펴보려고 합니다. 에이전트는 미로를 탐험하며 열쇠를 찾아 출구에 도달해야 합니다. 세 곳의 경로 찾기 문제에 관한 전형적인 예제입니다.

다음은 시작(S), 끝(E), 열쇠(K) 위치가 강조 표시된 미로 맵 샘플입니다.

ASCII 미로 맵 샘플.

에이전트와 모델을 두 개의 개별 컨테이너로 분리하면 서로 다른 팀이 각 컨테이너에서 개별적으로 작업할 수 있습니다. 각 팀은 시뮬레이션에 세부 사항을 추가하거나 에이전트가 미로를 탐색하는 방법에 관한 효율적인 전략을 찾는 등 맡은 부분을 개선하는 데 집중할 수 있습니다.

다음은 미로 모델 코드(app.py)입니다. 두 예제에서 모두 Python을 사용했습니다. 이 모델은 에이전트가 미로 주변을 돌아다니며 열쇠를 찾아 출구에 도달했는지 확인하는 데 사용할 수 있는 REST API를 제공합니다. 미로 모델은 REST API에 Flask를 사용합니다.

import json
import random
from flask import Flask, request, Response

ready = False

# How map data is stored inside a maze
# with size (width x height) = (4 x 3)
#
#    012345678
# 0: +-+-+ +-+
# 1: | |   | |
# 2: +-+ +-+-+
# 3: | |   | |
# 4: +-+-+ +-+
# 5: | | | | |
# 6: +-+-+-+-+
# 7: Not used

class WrongDirection(Exception):
    pass

class Maze:
    UP, RIGHT, DOWN, LEFT = 0, 1, 2, 3
    OPEN, WALL = 0, 1
    

    @staticmethod
    def distance(p1, p2):
        (x1, y1) = p1
        (x2, y2) = p2
        return abs(y2-y1) + abs(x2-x1)


    @staticmethod
    def random_dir():
        return random.randrange(4)


    @staticmethod
    def go_dir(x, y, d):
        if d == Maze.UP:
            return (x, y - 1)
        elif d == Maze.RIGHT:
            return (x + 1, y)
        elif d == Maze.DOWN:
            return (x, y + 1)
        elif d == Maze.LEFT:
            return (x - 1, y)
        else:
            raise WrongDirection(f"Direction: {d}")


    def __init__(self, width, height):
        self.width = width
        self.height = height        
        self.generate()
        

    def area(self):
        return self.width * self.height
        

    def min_lenght(self):
        return self.area() / 5
    

    def min_distance(self):
        return (self.width + self.height) / 5
    

    def get_pos_dir(self, x, y, d):
        if d == Maze.UP:
            return self.maze[y][2 * x + 1]
        elif d == Maze.RIGHT:
            return self.maze[y][2 * x + 2]
        elif d == Maze.DOWN:
            return self.maze[y + 1][2 * x + 1]
        elif d ==  Maze.LEFT:
            return self.maze[y][2 * x]
        else:
            raise WrongDirection(f"Direction: {d}")


    def set_pos_dir(self, x, y, d, v):
        if d == Maze.UP:
            self.maze[y][2 * x + 1] = v
        elif d == Maze.RIGHT:
            self.maze[y][2 * x + 2] = v
        elif d == Maze.DOWN:
            self.maze[y + 1][2 * x + 1] = v
        elif d ==  Maze.LEFT:
            self.maze[y][2 * x] = v
        else:
            WrongDirection(f"Direction: {d}  Value: {v}")


    def is_inside(self, x, y):
        return 0 <= y < self.height and 0 <= x < self.width


    def generate(self):
        self.maze = []
        # Close all borders
        for y in range(0, self.height + 1):
            self.maze.append([Maze.WALL] * (2 * self.width + 1))
        # Get a random starting point on one of the borders
        if random.random() < 0.5:
            sx = random.randrange(self.width)
            if random.random() < 0.5:
                sy = 0
                self.set_pos_dir(sx, sy, Maze.UP, Maze.OPEN)
            else:
                sy = self.height - 1
                self.set_pos_dir(sx, sy, Maze.DOWN, Maze.OPEN)
        else:
            sy = random.randrange(self.height)
            if random.random() < 0.5:
                sx = 0
                self.set_pos_dir(sx, sy, Maze.LEFT, Maze.OPEN)
            else:
                sx = self.width - 1
                self.set_pos_dir(sx, sy, Maze.RIGHT, Maze.OPEN)
        self.start = (sx, sy)
        been = [self.start]
        pos = -1
        solved = False
        generate_status = 0
        old_generate_status = 0                    
        while len(been) < self.area():
            (x, y) = been[pos]
            sd = Maze.random_dir()
            for nd in range(4):
                d = (sd + nd)% 4
                if self.get_pos_dir(x, y, d) != Maze.WALL:
                    continue
                (nx, ny) = Maze.go_dir(x, y, d)
                if (nx, ny) in been:
                    continue
                if self.is_inside(nx, ny):
                    self.set_pos_dir(x, y, d, Maze.OPEN)
                    been.append((nx, ny))
                    pos = -1
                    generate_status = len(been) / self.area()
                    if generate_status - old_generate_status > 0.1:
                        old_generate_status = generate_status
                        print(f"{generate_status * 100:.2f}%")
                    break
                elif solved or len(been) < self.min_lenght():
                    continue
                else:
                    self.set_pos_dir(x, y, d, Maze.OPEN)
                    self.end = (x, y)
                    solved = True
                    pos = -1 - random.randrange(len(been))
                    break
            else:
                pos -= 1
                if pos < -len(been):
                    pos = -1
                    
        self.key = None
        while(self.key == None):
            kx = random.randrange(self.width)
            ky = random.randrange(self.height)
            if (Maze.distance(self.start, (kx,ky)) > self.min_distance()
                and Maze.distance(self.end, (kx,ky)) > self.min_distance()):
                self.key = (kx, ky)


    def get_label(self, x, y):
        if (x, y) == self.start:
            c = 'S'
        elif (x, y) == self.end:
            c = 'E'
        elif (x, y) == self.key:
            c = 'K'
        else:
            c = ' '
        return c

                    
    def map(self, moves=[]):
        map = ''
        for py in range(self.height * 2 + 1):
            row = ''
            for px in range(self.width * 2 + 1):
                x = int(px / 2)
                y = int(py / 2)
                if py % 2 == 0: #Even rows
                    if px % 2 == 0:
                        c = '+'
                    else:
                        v = self.get_pos_dir(x, y, self.UP)
                        if v == Maze.OPEN:
                            c = ' '
                        elif v == Maze.WALL:
                            c = '-'
                else: # Odd rows
                    if px % 2 == 0:
                        v = self.get_pos_dir(x, y, self.LEFT)
                        if v == Maze.OPEN:
                            c = ' '
                        elif v == Maze.WALL:
                            c = '|'
                    else:
                        c = self.get_label(x, y)
                        if c == ' ' and [x, y] in moves:
                            c = '*'
                row += c
            map += row + '\n'
        return map


app = Flask(__name__)

@app.route('/')
def hello_maze():
    return "<p>Hello, Maze!</p>"

@app.route('/maze/map', methods=['GET', 'POST'])
def maze_map():
    if not ready:
        return Response(status=503, retry_after=10)
    if request.method == 'GET':
        return '<pre>' + maze.map() + '</pre>'
    else:
        moves = request.get_json()
        return maze.map(moves)

@app.route('/maze/start')
def maze_start():
    if not ready:
        return Response(status=503, retry_after=10)
    start = { 'x': maze.start[0], 'y': maze.start[1] }
    return json.dumps(start)

@app.route('/maze/size')
def maze_size():
    if not ready:
        return Response(status=503, retry_after=10)
    size = { 'width': maze.width, 'height': maze.height }
    return json.dumps(size)

@app.route('/maze/pos/<int:y>/<int:x>')
def maze_pos(y, x):
    if not ready:
        return Response(status=503, retry_after=10)
    pos = {
        'here': maze.get_label(x, y),
        'up': maze.get_pos_dir(x, y, Maze.UP),
        'down': maze.get_pos_dir(x, y, Maze.DOWN),
        'left': maze.get_pos_dir(x, y, Maze.LEFT),
        'right': maze.get_pos_dir(x, y, Maze.RIGHT),

    }
    return json.dumps(pos)


WIDTH = 80
HEIGHT = 20
maze = Maze(WIDTH, HEIGHT)
ready = True

미로 모델의 유일한 요구 사항(requirements.txt)은 Flask 모듈입니다.

미로 모델을 실행하는 컨테이너 이미지를 생성하기 위해 다음 Dockerfile을 사용합니다.

FROM --platform=linux/amd64 public.ecr.aws/docker/library/python:3.12-alpine

WORKDIR /app

COPY requirements.txt requirements.txt
RUN pip3 install -r requirements.txt

COPY . .

CMD [ "python3", "-m" , "flask", "run", "--host=0.0.0.0", "--port=5555"]

다음은 에이전트의 코드(agent.py)입니다. 먼저, 에이전트는 모델에 미로의 크기와 시작 위치를 묻습니다. 그런 다음, 자체 전략을 적용하여 미로를 탐험하고 해결합니다. 이 구현에서 에이전트는 동일한 경로를 두 번 이상 따라가지 않으면서 무작위로 경로를 선택합니다.

import random
import requests
from requests.adapters import HTTPAdapter, Retry

HOST = '127.0.0.1'
PORT = 5555

BASE_URL = f"http://{HOST}:{PORT}/maze"

UP, RIGHT, DOWN, LEFT = 0, 1, 2, 3
OPEN, WALL = 0, 1

s = requests.Session()

retries = Retry(total=10,
                backoff_factor=1)

s.mount('http://', HTTPAdapter(max_retries=retries))

r = s.get(f"{BASE_URL}/size")
size = r.json()
print('SIZE', size)

r = s.get(f"{BASE_URL}/start")
start = r.json()
print('START', start)

y = start['y']
x = start['x']

found_key = False
been = set((x, y))
moves = [(x, y)]
moves_stack = [(x, y)]

while True:
    r = s.get(f"{BASE_URL}/pos/{y}/{x}")
    pos = r.json()
    if pos['here'] == 'K' and not found_key:
        print(f"({x}, {y}) key found")
        found_key = True
        been = set((x, y))
        moves_stack = [(x, y)]
    if pos['here'] == 'E' and found_key:
        print(f"({x}, {y}) exit")
        break
    dirs = list(range(4))
    random.shuffle(dirs)
    for d in dirs:
        nx, ny = x, y
        if d == UP and pos['up'] == 0:
            ny -= 1
        if d == RIGHT and pos['right'] == 0:
            nx += 1
        if d == DOWN and pos['down'] == 0:
            ny += 1
        if d == LEFT and pos['left'] == 0:
            nx -= 1 

        if nx < 0 or nx >= size['width'] or ny < 0 or ny >= size['height']:
            continue

        if (nx, ny) in been:
            continue

        x, y = nx, ny
        been.add((x, y))
        moves.append((x, y))
        moves_stack.append((x, y))
        break
    else:
        if len(moves_stack) > 0:
            x, y = moves_stack.pop()
        else:
            print("No moves left")
            break

print(f"Solution length: {len(moves)}")
print(moves)

r = s.post(f'{BASE_URL}/map', json=moves)

print(r.text)

s.close()

에이전트의 유일한 종속성(requirements.txt)은 requests 모듈입니다.

다음은 에이전트의 컨테이너 이미지를 생성하는 데 사용하는 Dockerfile입니다.

FROM --platform=linux/amd64 public.ecr.aws/docker/library/python:3.12-alpine

WORKDIR /app

COPY requirements.txt requirements.txt
RUN pip3 install -r requirements.txt

COPY . .

CMD [ "python3", "agent.py"]

이 간소화된 버전의 시뮬레이션은 로컬에서 쉽게 실행할 수 있지만, 클라우드를 사용하면 훨씬 더 크고 더 세밀한 미로와 같이 더 큰 규모로 실행하고 여러 에이전트를 테스트하여 사용할 최적의 전략을 찾을 수 있습니다. 실제 시나리오에서는 에이전트의 개선 사항은 자율 주행 자동차나 로봇 청소기와 같은 물리적 장치에 구현됩니다.

다중 컨테이너 작업을 사용하여 시뮬레이션 실행
AWS Batch로 작업을 실행하려면 세 가지 리소스를 구성해야 합니다.

  • 작업을 실행할 컴퓨팅 환경
  • 작업을 제출할 작업 대기열
  • 사용할 컨테이너 이미지를 포함하며 작업 실행 방법을 설명하는 작업 정의

AWS Batch 콘솔의 탐색 창에서 컴퓨팅 환경을 선택하고 생성을 선택합니다. 이제 Fargate, Amazon EC2 또는 Amazon EKS를 선택할 수 있습니다. Fargate를 사용하면 작업 정의에 지정한 리소스 요구 사항을 대부분 준수할 수 있습니다. 그러나 보통 시뮬레이션은 크지만 정적인 양의 리소스에 액세스하고 GPU를 사용하여 계산 속도를 높여야 합니다. 그래서 Amazon EC2를 선택했습니다.

콘솔 스크린샷.

AWS Batch에서 EC2 인스턴스를 확장하고 구성할 수 있도록 관리형 오케스트레이션 유형을 선택합니다. 그런 다음, 컴퓨팅 환경의 이름을 입력하고 서비스 연결 역할(AWS Batch에서 이전에 생성함) 및 ECS 컨테이너 에이전트에서 사용하는 인스턴스 역할(EC2 인스턴스에서 실행)을 선택하여 자동으로 AWS API를 직접 호출합니다. 다음을 선택합니다.

콘솔 스크린샷.

인스턴스 구성 설정에서 EC2 인스턴스의 크기와 유형을 선택합니다. 예를 들어 GPU가 있거나 Graviton 프로세서를 사용하는 인스턴스 유형을 선택할 수 있습니다. 특정 요구 사항이 없으므로 모든 설정을 기본값으로 둡니다. 네트워크 구성의 경우 콘솔에는 이미 기본 VPC기본 보안 그룹이 선택되었습니다. 마지막 단계로, 모든 구성을 검토하고 컴퓨팅 환경 생성을 완료합니다.

이제 탐색 창에서 작업 대기열을 선택하고 생성을 선택합니다. 그런 다음, 컴퓨팅 환경에서 사용한 것과 동일한 오케스트레이션 유형(Amazon EC2)을 선택합니다. 작업 대기열 구성에서 작업 대기열 이름을 입력합니다. 연결된 컴퓨팅 환경 드롭다운에서 방금 생성한 컴퓨팅 환경을 선택하고 대기열 생성을 완료합니다.

콘솔 스크린샷.

이제 탐색 창에서 작업 정의를 선택하고 생성을 선택합니다. 전과 마찬가지로 오케스트레이션 유형으로 Amazon EC2를 선택합니다.

컨테이너를 두 개 이상 사용하려면 레거시 containerProperties 구조 사용 옵션을 비활성화하고 다음 단계로 넘어갑니다. 기본적으로 콘솔은 계정에 레거시 작업 정의가 이미 있는 경우 레거시 단일 컨테이너 작업 정의를 생성합니다. 이 경우도 그렇습니다. 레거시 작업 정의가 없는 계정의 경우 콘솔에서 이 옵션은 비활성화됩니다.

콘솔 스크린샷.

작업 정의 이름을 입력합니다. 그런 다음, 이 작업에 필요한 권한을 고려해야 합니다. 이 작업에 사용하려는 컨테이너 이미지는 Amazon ECR 프라이빗 리포지토리에 저장되어 있습니다. AWS Batch에서 이러한 이미지를 컴퓨팅 환경에 다운로드할 수 있도록 작업 속성 섹션에서 ECR 리포지토리에 대한 읽기 전용 액세스를 제공하는 실행 역할을 선택합니다. 시뮬레이션 코드가 AWS API를 직접 호출하지 않기 때문에 작업 역할을 구성할 필요는 없습니다. 예를 들어 코드가 Amazon Simple Storage Service(S3) 버킷에 결과를 업로드한다면 여기에서 이를 수행할 권한을 제공하는 역할을 선택할 수 있습니다.

다음 단계로 이 작업에 사용되는 두 개의 컨테이너를 구성합니다. 첫 번째는 maze-model입니다. 이름과 이미지 위치를 입력합니다. 여기서는 vCPU, 메모리, GPU 측면에서 컨테이너의 리소스 요구 사항을 지정할 수 있습니다. ECS 작업에 대한 컨테이너 구성과 유사합니다.

콘솔 스크린샷.

에이전트에 대한 두 번째 컨테이너를 추가하고 전과 같이 이름, 이미지 위치, 리소스 요구 사항을 입력합니다. 에이전트는 시작 후 바로 미로에 액세스해야 하므로 종속성 섹션을 사용하여 컨테이너 종속성을 추가합니다. 컨테이너 이름으로 maze-model을 선택하고 조건으로 START를 선택합니다. 이 종속성을 추가하지 않으면 maze-model 컨테이너가 실행되어 응답하기 전에 agent 컨테이너가 작업에 실패할 수 있습니다. 이 작업 정의에서는 두 컨테이너 모두에 essential 플래그가 지정되었므로 실패와 동시에 전체 작업이 종료됩니다.

콘솔 스크린샷.

모든 구성을 검토하고 작업 정의를 완료합니다. 이제 작업을 시작할 수 있습니다.

탐색 창의 작업 섹션에서 새 작업을 제출합니다. 이름을 입력하고 방금 생성한 작업 대기열 및 작업 정의를 선택합니다.

콘솔 스크린샷.

다음 단계로 구성을 재정의하고 작업을 생성할 필요가 없습니다. 몇 분 후 작업이 성공하고 두 컨테이너의 로그에 액세스할 수 있습니다.

콘솔 스크린샷.

에이전트가 미로를 해결했고, 로그에서 모든 세부 정보를 확인할 수 있습니다. 다음은 에이전트가 시작되어 열쇠를 찾아 출구를 찾는 방법을 확인할 수 있는 작업 출력입니다.

SIZE {'width': 80, 'height': 20}
START {'x': 0, 'y': 18}
(32, 2) key found
(79, 16) exit
Solution length: 437
[(0, 18), (1, 18), (0, 18), ..., (79, 14), (79, 15), (79, 16)]

맵에서 빨간색 별표(*)는 에이전트가 시작 위치(S), 열쇠 위치(K), 종료 위치(E) 사이에서 사용한 경로를 따릅니다.

해결된 미로의 ASCII 기반 맵.

사이드카 컨테이너로 관찰성 개선
여러 구성 요소를 사용하여 복잡한 작업을 실행하는 경우 이러한 구성 요소가 수행하는 작업을 더 잘 파악할 수 있습니다. 예를 들어 오류나 성능 문제가 있는 경우 이 정보를 이용하면 문제의 위치와 상태를 확인할 수 있습니다.

애플리케이션을 계측하기 위해 AWS Distro for OpenTelemetry를 사용합니다.

이렇게 수집된 텔레메트리 데이터를 사용하여 대시보드(예: CloudWatch 또는 Amazon Managed Grafana 사용) 및 경보(CloudWatch 또는 Prometheus 사용)를 설정할 수 있습니다. 이를 바탕으로 상황을 더 잘 파악하고 문제 해결에 걸리는 시간을 단축할 수 있습니다. 일반적으로 사이드카 컨테이너는 AWS Batch 작업의 텔레메트리 데이터를 모니터링 및 관찰성 플랫폼과 통합하는 데 도움이 될 수 있습니다.

알아야 할 사항
다중 컨테이너 작업에 대한 AWS Batch 지원은 현재 Batch가 제공되는 모든 AWS 리전AWS Management Console, AWS Command Line Interface(AWS CLI), AWS SDK에서 사용 가능합니다. 자세한 내용은 AWS 서비스 리전별 목록을 참조하세요.

AWS Batch에서 다중 컨테이너 작업을 사용하는 데 따른 추가 비용은 없습니다. 실제로 AWS Batch를 사용하는 데 추가 요금은 없습니다. EC2 인스턴스 및 Fargate 컨테이너와 같이 애플리케이션을 저장하고 실행하기 위해 생성한 AWS 리소스에 대해서만 비용을 지불하면 됩니다. 컴퓨팅 환경에서 예약 인스턴스, 절감형 플랜, EC2 스팟 인스턴스 및 Fargate를 사용하여 비용을 최적화할 수 있습니다.

다중 컨테이너 작업을 사용하면 작업 준비에 필요한 노력이 줄어 개발 시간이 단축되고, 여러 팀의 작업을 단일 컨테이너로 병합하기 위한 사용자 지정 도구가 필요하지 않습니다. 또한 구성 요소와 관련한 책임을 명확히 정의하여 DevOps를 간소화하므로 팀은 아무런 간섭을 받지 않고 팀의 전문 분야에서 문제를 신속하게 식별하고 해결할 수 있습니다.

자세한 내용은 AWS Batch 사용 설명서에서 다중 컨테이너 작업 설정 방법을 참조하세요.

Danilo