INNORIX
전송 빌더전송 파인더개발자리소스고객사
무료 시작하기
INNORIX

LET FILES
MOVE THEMSELVES

INNORIX는 모든 시스템과 환경에서 파일 이동과 자동화를 제공하는 엔터프라이즈 파일 인프라 기업입니다.
5,000개 이상의 기업 및 공공기관에서 사용하고 있습니다.

시작하기

  • 필요한 전송 만들기
  • 필요한 전송 찾기

주요 전송

  • 팀 업무 폴더 동기화
  • 고객에게 대용량 파일 전송
  • 여러 시스템 파일 탐색
  • FTP·SFTP·SCP·rsync 전환
  • 앱에 파일 전송 추가
  • 웹 업로드·다운로드 적용
  • AI·데이터 워크플로 구축
  • 모든 전송 둘러보기→

개발자

  • 개발자 센터
  • 구현 예제
  • API 빠른 시작
  • 개발자 가이드
  • API 레퍼런스
  • GitHub

리소스

  • 리소스 센터
  • 제품 가이드
  • 외부 서비스 연동
  • 배포 및 관리
  • 도움말 센터

고객사

  • 정부
  • 공공부문
  • 제조
  • 엔지니어링
  • 금융
  • 유통
  • IT/통신
  • 미디어
  • 의료
  • 교육

플랜

  • 가격 및 플랜

회사

INNORIX 소개

Exabyter를 찾고 계신가요?

이제 INNORIX Platform에 통합되었습니다

기타 INNORIX 제품

Al.bert — 스마트 교통 AI

글로벌 오피스

  • 미국 뉴욕
  • 대한민국 서울
  • 베트남 호찌민
  • 오피스 위치 보기→

(C)2026 INNORIX. All rights reserved.

  • 보안
  • 상태
  • 이용약관
  • 개인정보 처리방침
  • 쿠키 정책
  1. 가이드
  2. Git 밖의 대용량 파일과 빌드 결과물 자동 전달하기

Git 밖의 대용량 파일과 빌드 결과물 자동 전달하기

INNORIX에서 Git 외부 대용량 파일 전송을 구성하고 연결된 시스템 간 파일 작업을 일관되게 운영하는 방법을 알아보세요.

IT 엔지니어개발자
  • AI 학습 데이터와 모델 파일의 전체 흐름 자동화하기
  • AI 모델 파일을 여러 엣지 장비에 배포하기
  • 폐쇄망·망분리 환경에서 파일을 승인 반입·반출하기
  • 승인된 파일을 여러 지점에 동시에 배포하기
  • Azure Blob에서 사내 서버로 파일 전달하기
  • 데이터베이스 덤프, 백업, 아카이브 파일을 원격 보관처로 자동 전달하기
  • 지점·공장·엣지 장비의 파일을 중앙으로 수집하기
  • CI/CD 빌드 결과물을 여러 서버에 배포하기
  • 서로 다른 클라우드 스토리지 사이에서 파일 이동하기
  • 서로 다른 AWS 계정의 S3 버킷 사이에 파일 전송하기
  • 고객별 파일 작업 공간 제공하기
  • 고객에게 만료 조건이 있는 대용량 다운로드 링크 제공하기
  • 고객이 브라우저에서 대용량 파일을 업로드하게 하기
  • 고객 작업 공간으로 대용량 파일 직접 보내기
  • 데이터베이스 백업 파일을 오브젝트 스토리지에 보관하기
  • Datadog으로 파일 전송 실패와 복구 알림 받기
  • 웹, 앱, 업무 시스템에 파일 전송 기능 추가하기
  • 파일 도착 후 검증·변환·후속 작업 실행하기
  • NAS, 파일 서버의 대량 파일을 클라우드로 이전하기
  • FTP 배치 작업을 관리형 파일 흐름으로 전환하기
  • Google Cloud Storage에서 Amazon S3로 직접 전송하기
  • Grafana에서 파일 전송 상태 대시보드 만들기
  • 전송된 파일의 해시값으로 무결성 자동 검증하기
  • 새 파일이 생기면 지정한 곳으로 자동 전송하기
  • Kubernetes에서 오브젝트 스토리지로 결과 파일 보내기
  • Git 밖의 대용량 파일과 빌드 결과물 자동 전달하기
  • Git으로 관리하기 어려운 파일을 자동 전달하기
  • 분산 서버의 로그·진단 파일을 중앙으로 모으기
  • 미디어 원본과 처리 결과를 단계별로 자동 전달하기
  • 수신 파일을 Microsoft Defender로 검사하고 후속 처리하기
  • 여러 단계의 파일 전송을 하나의 흐름으로 자동화하기
  • 승인된 파일을 여러 팀·지점에 자동 배포하기
  • 협력사 파일을 업무 시스템으로 자동 분류하기
  • 협력사·공급망과 정기적으로 파일 교환하기
  • 매일·매주 반복되는 파일 전송 자동화하기
  • 네트워크 중단 후 파일 전송을 자동으로 재개하기
  • rsync 작업을 관리형 파일 흐름으로 전환하기
  • 정해진 시간에 반복 파일 전송 예약하기
  • 소프트웨어·펌웨어를 여러 장비에 배포하고 결과 확인하기
  • 소프트웨어 패키지를 여러 서버·지점에 배포하기
  • 팀 폴더의 변경 파일을 여러 장비에 자동 반영하기
  • 팀 업무 폴더를 여러 PC에 자동 반영하기
  • 여러 장비의 파일을 한곳에서 찾고 직접 전송하기
  • 웹사이트에 대용량 파일 업로드·다운로드 기능 추가하기
  • 채널을 구독해 새 파일을 자동으로 받기
  • Amazon S3에서 Azure Blob으로 직접 전송하기
  • Amazon S3에서 Cloudflare R2로 파일 이전하기
  • Amazon S3 파일을 Linux 서버로 자동 내려받기
  • SCP 셸 스크립트를 CLI 기반 전송으로 전환하기
  • SFTP 계정과 배치 전송을 중앙에서 관리하기
  • Slack·Teams에서 파일 전송을 실행하고 상태 알림 받기
  • Windows 폴더의 파일을 Amazon S3로 자동 업로드하기
  • Windows와 Linux 서버 사이에 파일을 직접 전송하기

시작하기#

기본 개념#

Git으로 관리하지 않는 파일과 결과물을 별도로 전송하기

Git에서는 소스 코드와 변경 이력을 관리하고, 빌드 결과물이나 배포 패키지, 대용량 데이터와 같은 파일은 별도의 전송 흐름으로 관리할 수 있습니다.

Git 작업 이후 생성되거나 준비된 파일을 필요한 장비로 전송하도록 연결하면, 파일의 특성과 활용 환경에 맞춰 관리 흐름을 구성할 수 있습니다.

Git Repository
      │
      │ Code Change
      ▼
Build / Processing
      │
      │ Output Files
      ▼
File Transfer
      │
      ▼
Target Device

예를 들어 소스 코드를 변경한 후 빌드 작업이 완료되면 생성된 패키지나 결과 파일을 테스트 서버, 배포 서버, 데이터 처리 장비로 자동 전송할 수 있습니다.

연계 흐름#

Git 작업부터 파일 전송까지 자동으로 이어가기

Git 작업을 기준으로 빌드나 파일 처리 작업을 진행하고, 필요한 파일이 준비되면 지정된 조건에 따라 파일 전송을 시작합니다.

전송이 완료되면 대상 장비에서 테스트, 배포, 데이터 처리와 같은 다음 업무를 이어갈 수 있습니다.

Git 작업
   │
   ▼
빌드 · 파일 처리
   │
   ▼
결과 파일 생성
   │
   ▼
전송 작업 실행
   │
   ▼
대상 장비 반영
   │
   ▼
다음 작업 진행

분리 효과#

소스 코드와 업무 파일을 각각의 방식으로 관리하기

Git과 파일 전송을 함께 활용하면 소스 코드의 변경 관리와 별도 파일의 전송 및 활용을 하나의 업무 흐름 안에서 연결할 수 있습니다.

구분 Git 파일 전송
관리 대상 소스 코드와 변경 이력 결과 파일과 업무 파일
주요 역할 코드 변경과 버전 관리 파일 처리와 대상 장비 반영
실행 시점 코드 작업과 이벤트 발생 설정한 작업 조건 충족
활용 환경 개발과 형상 관리 테스트, 배포, 업무 장비

이를 통해 각 파일을 목적에 맞게 관리하면서 필요한 시점에는 Git 작업 이후의 파일 전송을 자동으로 실행할 수 있습니다.

IT 엔지니어#

환경 연결#

Git 작업과 파일 전송 장비 연결하기

먼저 Git 작업과 파일 전송에 사용할 장비를 하나의 흐름으로 연결합니다.

Git에서 발생하는 작업과 빌드 또는 파일 처리 환경, 결과 파일을 사용할 대상 장비를 연결하면 전체 파일 흐름을 구성할 수 있습니다.

┌──────────────┐
│     Git      │
└──────┬───────┘
       │
       ▼
┌──────────────┐
│ Build Server │
└──────┬───────┘
       │
       ▼
┌──────────────┐
│ File Transfer│
└──────┬───────┘
       │
       ▼
┌──────────────┐
│Target Device │
└──────────────┘

각 장비와 작업을 연결하면 Git 작업 이후 생성되는 파일이 다음 전송 단계까지 이어지는 기본 환경이 준비됩니다.

전송 규칙#

파일과 대상, 실행 조건을 하나의 기준으로 설정하기

별도로 관리할 파일과 전송 대상 장비를 지정하고, Git 또는 후속 작업의 어떤 시점에 파일 전송을 시작할지 함께 설정합니다.

전송할 파일의 경로와 종류를 지정하고, 대상 서버 또는 장비를 연결합니다. 이후 코드 변경, 빌드 완료, 결과 파일 생성과 같은 작업 조건을 전송 시작 기준으로 구성할 수 있습니다.

Git / Build Event
        │
        ▼
 Start Condition
        │
        ├── Source
        │      └── File / Path
        │
        └── Target
               └── Device
                      │
                      ▼
                 Transfer Run

구성 항목 설정 내용
시작 조건 Git 작업 또는 빌드 완료 등 전송을 시작할 기준
Source 결과 파일과 파일 경로
Filter 전송할 파일 이름과 확장자 조건
Target 파일을 사용할 서버 또는 장비
Transfer 설정된 조건에 따라 실행할 파일 전송

이렇게 구성하면 어떤 작업 이후 어떤 파일을 어느 장비로 전송할지를 하나의 실행 기준으로 관리할 수 있습니다.

자동 흐름#

Git 작업 이후 파일을 지정된 장비로 자동 전송하기

앞에서 구성한 장비와 전송 규칙을 기준으로 Git 작업부터 파일 전송까지 자동 흐름을 완성합니다.

Git 작업이 발생하면 연결된 빌드 또는 처리 작업이 진행되고, 설정한 조건에 따라 준비된 파일을 지정된 장비로 전송합니다.

Git Push
   │
   ▼
Build
   │
   ▼
Output Ready
   │
   ├──────────────┐
   │              │
   ▼              ▼
Test Server   Deploy Server
   │              │
   └──────┬───────┘
          ▼
       Complete

하나의 결과 파일을 여러 테스트 또는 배포 환경으로 전송하도록 구성하면, Git 작업 이후 필요한 업무 환경까지 파일을 자동으로 연결할 수 있습니다.

결과 관리#

전송 상태와 재처리 흐름을 함께 관리하기

실행된 파일 전송 작업은 Runs와 실행 상세 정보를 통해 확인합니다.

Git 작업을 기준으로 어떤 전송 작업이 실행되었는지 확인하고, 대상 장비별 처리 상태와 전송된 파일 정보를 함께 관리할 수 있습니다.

Git Workflow
      │
      ▼
 Transfer Run
      │
 ┌────┼───────┐
 ▼    ▼       ▼
Files Status Progress
      │
      ▼
 Result Review
      │
 ┌────┴─────────────┐
 ▼                  ▼
Completed      Check Required
                    │
                    ▼
              Run Details
                    │
                    ▼
               Condition Check
                    │
                    ▼
                  Retry
                    │
                    ▼
                 Complete

주요 확인 항목은 다음과 같습니다.

확인 항목 확인 내용
Trigger 파일 전송을 시작한 Git 또는 후속 작업
Source 전송한 파일과 파일 경로
Target 파일을 사용할 대상 장비
Progress 전송 진행 상태
Status 실행 상태와 처리 결과
Files 처리된 파일 수와 용량
Audit Log 작업별 실행 기록과 상세 정보

실행 결과에서 추가 확인이 필요한 경우에는 해당 Run의 상세 정보와 Audit Log를 통해 파일 준비 상태, 전송 경로, 대상 장비의 연결 상태를 확인한 후 필요한 작업을 다시 실행할 수 있습니다.

개발자#

빌드 산출물을 여러 배포 대상에 보내고 전송 결과를 파이프라인 종료 코드로 반영하기

연동 준비#

공통 호출 코드와 CI 자격 준비하기

import os
import requests

BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com").rstrip("/")
TOKEN = os.environ["INNORIX_ACCESS_TOKEN"]
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID")   # optional; falls back to the current workspace

STATUS_COMPLETE = 2
TERMINAL = {2, 4, 5, 9, 99}          # complete / error / cancelled / partial / failed
NOT_SUCCEEDED = {4, 5, 9, 99}


def api(method, path, body=None, params=None):
    headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {TOKEN}",
    }

    if WORKSPACE_ID:
        headers["x-workspace-id"] = WORKSPACE_ID

    response = requests.request(
        method, BASE_URL + path,
        headers=headers, json=body, params=params, timeout=30,
    )

    payload = response.json() if response.content else {}

    if not response.ok:
        raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")

    return payload.get("data")


def is_terminal(detail):
    return detail.get("isTerminal", detail.get("status") in TERMINAL)
// InnorixClient.java
public static final String BASE_URL =
        env("INNORIX_BASE_URL", "https://app.innorix.com").replaceAll("/+
quot;
, ""); public static final String WORKSPACE_ID = env("INNORIX_WORKSPACE_ID", null); public static final int STATUS_COMPLETE = 2; // States the transfer no longer moves out of public static final Set<Integer> TERMINAL = Set.of(2, 4, 5, 9, 99); // Terminal states that are not a full success public static final Set<Integer> NOT_SUCCEEDED = Set.of(4, 5, 9, 99); private HttpRequest.Builder headers(HttpRequest.Builder builder) { builder.header("Content-Type", "application/json") .header("Authorization", "Bearer " + session.accessToken()); // When omitted the account's current workspace is used. if (workspaceId != null) builder.header("x-workspace-id", workspaceId); return builder; } /** Unwraps and returns data from the response. Throws ApiError on failure. */ public Object api(String method, String path, Object body, Map<String, Object> params) { Resp response = request(method, path, body, params); Object payload = null; try { payload = Json.parse(response.text()); } catch (RuntimeException ignored) { payload = null; } if (!response.ok()) { Map<String, Object> map = Json.asObj(payload); String message = Json.str(map, "message", Json.str(map, "error", "unknown error")); throw new ApiError(response.status, message, map); } return Json.get(payload, "data"); } /** Use the server flag when present, otherwise fall back to the status code. */ public static boolean isTerminal(Map<String, Object> record) { Boolean flag = Json.boolOrNull(record, "isTerminal"); if (flag != null) return flag; Integer status = Json.intOrNull(record, "status"); return status != null && TERMINAL.contains(status); }
// innorix-client.js
const BASE_URL = (process.env.INNORIX_BASE_URL
  || "https://app.innorix.com").replace(/\/+$/, "");
const TOKEN = process.env.INNORIX_ACCESS_TOKEN;
const WORKSPACE_ID = process.env.INNORIX_WORKSPACE_ID || null;

export const STATUS_COMPLETE = 2;
export const TERMINAL = new Set([2, 4, 5, 9, 99]);       // complete / error / cancelled / partial / failed
export const NOT_SUCCEEDED = new Set([4, 5, 9, 99]);

export async function api(method, path, body = null, params = null) {
  const url = new URL(BASE_URL + path);

  if (params) {
    for (const [key, value] of Object.entries(params)) {
      if (value === undefined || value === null) continue;
      url.searchParams.set(key, String(value));
    }
  }

  const headers = {
    "Content-Type": "application/json",
    Authorization: `Bearer ${TOKEN}`,
  };

  // When omitted the account's current workspace is used.
  if (WORKSPACE_ID) headers["x-workspace-id"] = WORKSPACE_ID;

  const response = await fetch(url, {
    method,
    headers,
    body: body === null ? undefined : JSON.stringify(body),
    signal: AbortSignal.timeout(30_000),
  });

  const payload = await response.json().catch(() => ({}));

  if (!response.ok) {
    throw new Error(payload.message || `HTTP ${response.status}`);
  }

  return payload.data;
}

export function isTerminal(detail) {
  return detail.isTerminal !== undefined
    ? detail.isTerminal
    : TERMINAL.has(detail.status);
}
// InnorixClient.cs
public static readonly string BaseUrl =
    Env("INNORIX_BASE_URL", "https://app.innorix.com").TrimEnd('/');
public static readonly string WorkspaceIdFromEnv = Env("INNORIX_WORKSPACE_ID", null);

public const int StatusComplete = 2;
/// <summary>States the transfer no longer moves out of</summary>
public static readonly HashSet<int> Terminal = new HashSet<int> { 2, 4, 5, 9, 99 };
/// <summary>Terminal states that are not a full success</summary>
public static readonly HashSet<int> NotSucceeded = new HashSet<int> { 4, 5, 9, 99 };

// Applied on every request
request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Session.AccessToken);
// When omitted the account's current workspace is used.
if (WorkspaceId != null) request.Headers.TryAddWithoutValidation("x-workspace-id", WorkspaceId);

public async Task<JsonNode> ApiAsync(string method, string path, JsonNode body = null,
    IDictionary<string, object> parameters = null)
{
    Resp response = await RequestAsync(method, path, body, parameters).ConfigureAwait(false);
    JsonNode payload = null;

    try
    {
        payload = J.Parse(response.Text());
    }
    catch (Exception)
    {
        payload = null;
    }

    if (!response.Ok)
    {
        JsonObject map = J.AsObj(payload);
        string message = J.Str(map, "message", J.Str(map, "error", "unknown error"));
        throw new ApiError(response.Status, message, map);
    }

    return J.Get(payload, "data");
}

/// <summary>Use the server flag when present, otherwise fall back to the status code.</summary>
public static bool IsTerminal(JsonObject record)
{
    bool? flag = J.BoolOrNull(record, "isTerminal");
    if (flag != null) return flag.Value;

    int? status = J.IntOrNull(record, "status");
    return status != null && Terminal.Contains(status.Value);
}

전송 상태는 아래 값으로 판단합니다. 종료 상태는 다섯 개이고 성공에 해당하는 값은 완료(2)입니다.

상태 값 의미 종료
2 완료 예
4 오류 예
5 취소 예
9 부분 완료 예
99 실패 예
1 · 6 · 12 · 13 시작·전송 중·동기화 중·수신 중 아니오

CI에서 호출한다면 토큰을 파이프라인 시크릿으로 주입합니다. 저장소에 커밋하지 않습니다.

yaml
# GitHub Actions example
env:
  INNORIX_BASE_URL: https://app.innorix.com
  INNORIX_ACCESS_TOKEN: ${{ secrets.INNORIX_ACCESS_TOKEN }}

산출물 경로 설계#

커밋이나 태그를 경로에 담아 배포 이력 남기기

어느 코드에서 나온 산출물인지 경로에 기록합니다. 버전 폴더를 따로 두면 이전 산출물이 남아 있어 문제가 생겼을 때 되돌리기 쉽습니다.

import os
import subprocess


def git_ref():
    try:
        sha = subprocess.check_output(
            ["git", "rev-parse", "--short", "HEAD"], text=True).strip()
    except (subprocess.CalledProcessError, FileNotFoundError):
        sha = "unknown"

    return os.getenv("GIT_TAG") or sha


def target_path(base, ref):
    return f"{base}/{ref}"
String gitRef() {
    String sha;
    try {
        Process process = new ProcessBuilder("git", "rev-parse", "--short", "HEAD")
                .redirectErrorStream(true).start();
        sha = new String(process.getInputStream().readAllBytes(),
                StandardCharsets.UTF_8).strip();
        if (process.waitFor() != 0) sha = "unknown";
    } catch (Exception error) {
        sha = "unknown";
    }

    String tag = System.getenv("GIT_TAG");
    return tag != null ? tag : sha;
}

String targetPath(String base, String ref) {
    return base + "/" + ref;
}
import { execFileSync } from "node:child_process";

function gitRef() {
  let sha;
  try {
    sha = execFileSync("git", ["rev-parse", "--short", "HEAD"],
      { encoding: "utf8" }).trim();
  } catch {
    sha = "unknown";
  }

  return process.env.GIT_TAG || sha;
}

const targetPath = (base, ref) => `${base}/${ref}`;
string GitRef()
{
    string sha;
    try
    {
        var psi = new ProcessStartInfo("git", "rev-parse --short HEAD")
        {
            RedirectStandardOutput = true,
        };
        using var process = Process.Start(psi);
        sha = process.StandardOutput.ReadToEnd().Trim();
        process.WaitForExit();
        if (process.ExitCode != 0) sha = "unknown";
    }
    catch
    {
        sha = "unknown";
    }

    return Environment.GetEnvironmentVariable("GIT_TAG") ?? sha;
}

string TargetPath(string baseDir, string ref) => 
quot;{baseDir}/{ref}"
;

한 경로에 덮어쓰면 롤백할 대상이 없습니다.

산출물 전송#

빌드 결과 파일을 대상 장비로 보내기

파일을 보낼 때는 sourceItem에 isDir: false로 명시합니다. sourcePaths는 모든 경로를 폴더로 취급하므로, 산출물 파일을 넣으면 서버가 폴더로 스캔하려 해 느려지거나 시간이 초과됩니다.

def deploy_artifact(source, target, artifact_path, base):
    ref = git_ref()

    transfer = api("POST", "/api/transfers/manual", {
        "sourceDevice": source,
        "targetDevice": target,
        "targetPath": target_path(base, ref),
        "sourceItem": [{
            "path": artifact_path,
            "isDir": False,
            "isFolder": False,
            "fileSize": os.path.getsize(artifact_path),
        }],
        "sendAllFolder": False,
        "transferOptions": {"target-action": "overwrite"},
    })

    return transfer["monitorId"], target_path(base, ref)
String[] deployArtifact(String source, String target,
                        String artifactPath, String base) {
    String ref = gitRef();
    String targetPath = targetPath(base, ref);

    Map<String, Object> transfer = client.apiObj("POST", "/api/transfers/manual", Json.newObj(
            "sourceDevice", source,
            "targetDevice", target,
            "targetPath", targetPath,
            "sourceItem", List.of(Json.newObj(
                    "path", artifactPath,
                    "isDir", false,
                    "isFolder", false,
                    "fileSize", new File(artifactPath).length())),
            "sendAllFolder", false,
            "transferOptions", Json.newObj("target-action", "overwrite")));

    return new String[]{Json.str(transfer, "monitorId"), targetPath};
}
import { statSync } from "node:fs";

async function deployArtifact(source, target, artifactPath, base) {
  const ref = gitRef();
  const path = targetPath(base, ref);

  const transfer = await client.api("POST", "/api/transfers/manual", {
    sourceDevice: source,
    targetDevice: target,
    targetPath: path,
    sourceItem: [{
      path: artifactPath,
      isDir: false,
      isFolder: false,
      fileSize: statSync(artifactPath).size,
    }],
    sendAllFolder: false,
    transferOptions: { "target-action": "overwrite" },
  });

  return [transfer.monitorId, path];
}
async Task<(string MonitorId, string Path)> DeployArtifactAsync(
    string source, string target, string artifactPath, string baseDir)
{
    string ref = GitRef();
    string path = TargetPath(baseDir, ref);

    JsonObject transfer = await client.ApiObjAsync("POST", "/api/transfers/manual",
        new JsonObject
        {
            ["sourceDevice"] = source,
            ["targetDevice"] = target,
            ["targetPath"] = path,
            ["sourceItem"] = new JsonArray
            {
                new JsonObject
                {
                    ["path"] = artifactPath,
                    ["isDir"] = false,
                    ["isFolder"] = false,
                    ["fileSize"] = new FileInfo(artifactPath).Length,
                },
            },
            ["sendAllFolder"] = false,
            ["transferOptions"] = new JsonObject { ["target-action"] = "overwrite" },
        });

    return (J.Str(transfer, "monitorId"), path);
}

빌드 디렉터리를 폴더 단위로 보낼 때는 sourcePaths와 sendAllFolder: True를 씁니다.

api("POST", "/api/transfers/manual", {
    "sourceDevice": source,
    "targetDevice": target,
    "targetPath": target_path(base, ref),
    "sourcePaths": ["/build/output"],
    "sendAllFolder": True,
    "transferOptions": {"target-action": "overwrite"},
})
client.api("POST", "/api/transfers/manual", Json.newObj(
        "sourceDevice", source,
        "targetDevice", target,
        "targetPath", targetPath(base, ref),
        "sourcePaths", List.of("/build/output"),
        "sendAllFolder", true,
        "transferOptions", Json.newObj("target-action", "overwrite")));
await client.api("POST", "/api/transfers/manual", {
  sourceDevice: source,
  targetDevice: target,
  targetPath: targetPath(base, ref),
  sourcePaths: ["/build/output"],
  sendAllFolder: true,
  transferOptions: { "target-action": "overwrite" },
});
await client.ApiAsync("POST", "/api/transfers/manual", new JsonObject
{
    ["sourceDevice"] = source,
    ["targetDevice"] = target,
    ["targetPath"] = TargetPath(baseDir, ref),
    ["sourcePaths"] = new JsonArray { "/build/output" },
    ["sendAllFolder"] = true,
    ["transferOptions"] = new JsonObject { ["target-action"] = "overwrite" },
});

같은 참조로 다시 빌드해 올리는 경우가 대부분이므로 overwrite를 씁니다. numbering을 쓰면 재배포할 때마다 사본이 쌓입니다.

여러 대상 배포#

하나의 산출물을 여러 환경으로 보내기

하나의 전송은 대상 장비 하나를 다룹니다. 테스트와 스테이징으로 함께 보내려면 전송을 나눠 만듭니다.

TARGETS = [
    ("device-test-01", "/deploy/app"),
    ("device-stage-01", "/deploy/app"),
]

transfers = {
    target: deploy_artifact("device-build-01", target,
                            "/build/output/app.tar.gz", base)[0]
    for target, base in TARGETS
}
List<String[]> targets = List.of(
        new String[]{"device-test-01", "/deploy/app"},
        new String[]{"device-stage-01", "/deploy/app"});

Map<String, String> transfers = new LinkedHashMap<>();
for (String[] t : targets) {
    String monitorId = deployArtifact("device-build-01", t[0],
            "/build/output/app.tar.gz", t[1])[0];
    transfers.put(t[0], monitorId);
}
const TARGETS = [
  ["device-test-01", "/deploy/app"],
  ["device-stage-01", "/deploy/app"],
];

const transfers = {};
for (const [target, base] of TARGETS) {
  const [monitorId] = await deployArtifact("device-build-01", target,
    "/build/output/app.tar.gz", base);
  transfers[target] = monitorId;
}
var targets = new[]
{
    ("device-test-01", "/deploy/app"),
    ("device-stage-01", "/deploy/app"),
};

var transfers = new Dictionary<string, string>();
foreach (var (target, baseDir) in targets)
{
    var (monitorId, _) = await DeployArtifactAsync("device-build-01", target,
        "/build/output/app.tar.gz", baseDir);
    transfers[target] = monitorId;
}
def wait(monitor_id, timeout=3600, interval=3):
    deadline = time.time() + timeout

    while time.time() < deadline:
        detail = api("GET", f"/api/transfers/{monitor_id}")

        if is_terminal(detail):
            return detail

        time.sleep(interval)

    raise TimeoutError(monitor_id)


def failed_files(monitor_id):
    result = api("GET", f"/api/transfers/{monitor_id}/files", params={
        "state": "any", "size": 500,
    }) or {}

    return [r for r in (result.get("children") or [])
            if r.get("status") in NOT_SUCCEEDED]


def retry_failed(monitor_id):
    rows = failed_files(monitor_id)

    if not rows:
        return 0

    api("POST", f"/api/transfers/{monitor_id}/retry", {
        "filesRetry": [
            {"filePath": r["sourceFilePath"], "isDir": bool(r.get("isFolder"))}
            for r in rows
        ]
    })

    return len(rows)
public Map<String, Object> wait(String monitorId, int timeoutSeconds, int intervalSeconds) {
    long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;

    while (System.currentTimeMillis() < deadline) {
        Map<String, Object> detail = apiObj("GET", "/api/transfers/" + monitorId);

        if (isTerminal(detail)) return detail;

        sleep(intervalSeconds * 1000L);
    }

    throw new ApiError(0, "timeout waiting for " + monitorId);
}

public List<Map<String, Object>> failedFiles(String monitorId) {
    List<Map<String, Object>> failed = new ArrayList<>();

    for (Map<String, Object> row : transferFiles(monitorId)) {
        Integer status = Json.intOrNull(row, "status");
        if (status != null && NOT_SUCCEEDED.contains(status)) failed.add(row);
    }

    return failed;
}

public int retryFailed(String monitorId) {
    List<Map<String, Object>> rows = failedFiles(monitorId);
    if (rows.isEmpty()) return 0;

    List<Object> filesRetry = new ArrayList<>();

    for (Map<String, Object> row : rows) {
        String path = Json.str(row, "sourceFilePath");
        if (path == null) continue;
        filesRetry.add(Json.newObj("filePath", path, "isDir", Json.bool(row, "isFolder", false)));
    }

    api("POST", "/api/transfers/" + monitorId + "/retry", Json.newObj("filesRetry", filesRetry));

    return rows.size();
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

export async function wait(monitorId, { timeout = 3600, interval = 3 } = {}) {
  const deadline = Date.now() + timeout * 1000;

  while (Date.now() < deadline) {
    const detail = await api("GET", `/api/transfers/${monitorId}`);

    if (isTerminal(detail)) return detail;

    await sleep(interval * 1000);
  }

  throw new Error(`timeout waiting for ${monitorId}`);
}

export async function failedFiles(monitorId) {
  const result = (await api("GET", `/api/transfers/${monitorId}/files`, null, {
    state: "any", size: 500,
  })) || {};

  return (result.children || []).filter((row) => NOT_SUCCEEDED.has(row.status));
}

export async function retryFailed(monitorId) {
  const rows = await failedFiles(monitorId);

  if (rows.length === 0) return 0;

  await api("POST", `/api/transfers/${monitorId}/retry`, {
    filesRetry: rows
      .filter((row) => row.sourceFilePath)
      .map((row) => ({ filePath: row.sourceFilePath, isDir: Boolean(row.isFolder) })),
  });

  return rows.length;
}
public async Task<JsonObject> WaitAsync(string monitorId, int timeoutSeconds = 3600,
    int intervalSeconds = 3)
{
    long deadline = Environment.TickCount64 + timeoutSeconds * 1000L;

    while (Environment.TickCount64 < deadline)
    {
        JsonObject detail = await ApiObjAsync("GET", "/api/transfers/" + monitorId)
            .ConfigureAwait(false);

        if (IsTerminal(detail)) return detail;

        await Task.Delay(intervalSeconds * 1000).ConfigureAwait(false);
    }

    throw new ApiError(0, "timeout waiting for " + monitorId);
}

public async Task<List<JsonObject>> FailedFilesAsync(string monitorId)
{
    List<JsonObject> rows = await TransferFilesAsync(monitorId).ConfigureAwait(false);

    return rows.Where(row =>
    {
        int? status = J.IntOrNull(row, "status");
        return status != null && NotSucceeded.Contains(status.Value);
    }).ToList();
}

/// <summary>Only callable once the transfer has reached a terminal state.</summary>
public async Task<int> RetryFailedAsync(string monitorId)
{
    List<JsonObject> rows = await FailedFilesAsync(monitorId).ConfigureAwait(false);
    if (rows.Count == 0) return 0;

    var filesRetry = new JsonArray();

    foreach (JsonObject row in rows)
    {
        string path = J.Str(row, "sourceFilePath");
        if (path == null) continue;

        filesRetry.Add(new JsonObject
        {
            ["filePath"] = path,
            ["isDir"] = J.Bool(row, "isFolder", false),
        });
    }

    await ApiAsync("POST", "/api/transfers/" + monitorId + "/retry",
        new JsonObject { ["filesRetry"] = filesRetry }).ConfigureAwait(false);

    return rows.Count;
}
results = {}

for target, monitor_id in transfers.items():
    results[target] = wait(monitor_id, timeout=3600)

failed = [t for t, d in results.items() if d["status"] != STATUS_COMPLETE]
Map<String, Map<String, Object>> results = new LinkedHashMap<>();
for (var entry : transfers.entrySet()) {
    results.put(entry.getKey(), client.await(entry.getValue(), 3600, null));
}

List<String> failed = new ArrayList<>();
results.forEach((target, detail) -> {
    if (Json.intOr(detail, "status", -1) != InnorixClient.STATUS_COMPLETE) {
        failed.add(target);
    }
});
const results = {};
for (const [target, monitorId] of Object.entries(transfers)) {
  results[target] = await wait(monitorId, { timeout: 3600 });
}

const failed = Object.entries(results)
  .filter(([, d]) => d.status !== STATUS_COMPLETE)
  .map(([target]) => target);
var results = new Dictionary<string, JsonObject>();
foreach (var (target, monitorId) in transfers)
{
    results[target] = await client.WaitAsync(monitorId, 3600);
}

var failed = results
    .Where(kv => J.IntOrNull(kv.Value, "status") != InnorixClient.StatusComplete)
    .Select(kv => kv.Key).ToList();

한 대상이 실패해도 나머지를 계속 확인해야 어디까지 반영됐는지 알 수 있습니다. 첫 실패에서 멈추면 재배포 범위를 정할 수 없습니다.

대용량 산출물 전송#

대용량 산출물의 처리량을 속도·동시성 옵션으로 조절하기

컨테이너 이미지나 데이터셋처럼 크기가 큰 산출물은 처리량 옵션으로 전송 속도를 조절합니다.

api("POST", "/api/transfers/manual", {
    "sourceDevice": source,
    "targetDevice": target,
    "targetPath": target_path(base, ref),
    "sourcePaths": ["/build/output"],
    "sendAllFolder": True,
    "transferOptions": {
        "target-action": "overwrite",
        "networkLevel": 3,           # throughput priority level
        "concurrentTransfers": 8,    # concurrent transfers
    },
})
client.api("POST", "/api/transfers/manual", Json.newObj(
        "sourceDevice", source,
        "targetDevice", target,
        "targetPath", targetPath(base, ref),
        "sourcePaths", List.of("/build/output"),
        "sendAllFolder", true,
        "transferOptions", Json.newObj(
                "target-action", "overwrite",
                "networkLevel", 3,           // throughput priority level
                "concurrentTransfers", 8))); // concurrent transfers
await client.api("POST", "/api/transfers/manual", {
  sourceDevice: source,
  targetDevice: target,
  targetPath: targetPath(base, ref),
  sourcePaths: ["/build/output"],
  sendAllFolder: true,
  transferOptions: {
    "target-action": "overwrite",
    networkLevel: 3,           // throughput priority level
    concurrentTransfers: 8,    // concurrent transfers
  },
});
await client.ApiAsync("POST", "/api/transfers/manual", new JsonObject
{
    ["sourceDevice"] = source,
    ["targetDevice"] = target,
    ["targetPath"] = TargetPath(baseDir, ref),
    ["sourcePaths"] = new JsonArray { "/build/output" },
    ["sendAllFolder"] = true,
    ["transferOptions"] = new JsonObject
    {
        ["target-action"] = "overwrite",
        ["networkLevel"] = 3,           // throughput priority level
        ["concurrentTransfers"] = 8,    // concurrent transfers
    },
});

CI 러너와 배포 대상이 같은 회선을 쓰는 환경이라면 속도 제한을 걸어 다른 작업에 영향이 가지 않게 합니다.

json
"transferOptions": {
  "target-action": "overwrite",
  "limitRate": 51200
}

limitRate의 단위는 KB/s입니다.

파이프라인 연결#

전송 결과로 파이프라인 종료 코드 내기

CI는 종료 코드로 성공 여부를 판단합니다. 전송이 실패하면 파이프라인도 실패해야 합니다.

import sys

if __name__ == "__main__":
    monitor_id, path = deploy_artifact(
        os.environ["BUILD_DEVICE"],
        os.environ["TARGET_DEVICE"],
        os.environ["ARTIFACT_PATH"],
        os.environ["DEPLOY_BASE"],
    )

    detail = wait(monitor_id)

    if detail["status"] != STATUS_COMPLETE:
        for row in failed_files(monitor_id)[:10]:
            print(row["sourceFilePath"], row.get("errorCode"), file=sys.stderr)

        print(f"retried {retry_failed(monitor_id)} files", file=sys.stderr)
        sys.exit(1)

    print(f"deployed: {path}")
public static void main(String[] args) throws Exception {
    String[] out = deployArtifact(
            System.getenv("BUILD_DEVICE"),
            System.getenv("TARGET_DEVICE"),
            System.getenv("ARTIFACT_PATH"),
            System.getenv("DEPLOY_BASE"));
    String monitorId = out[0], path = out[1];

    Map<String, Object> detail = client.await(monitorId, 3600, null);

    if (Json.intOr(detail, "status", -1) != InnorixClient.STATUS_COMPLETE) {
        for (Map<String, Object> row : client.failedFiles(monitorId).subList(0,
                Math.min(10, client.failedFiles(monitorId).size()))) {
            System.err.println(Json.str(row, "sourceFilePath") + " "
                    + Json.str(row, "errorCode"));
        }
        System.err.println("재전송 " + client.retryFailed(monitorId) + "건");
        System.exit(1);
    }

    System.out.println("배포 완료: " + path);
}
const [monitorId, path] = await deployArtifact(
  process.env.BUILD_DEVICE,
  process.env.TARGET_DEVICE,
  process.env.ARTIFACT_PATH,
  process.env.DEPLOY_BASE,
);

const detail = await wait(monitorId);

if (detail.status !== STATUS_COMPLETE) {
  for (const row of (await failedFiles(monitorId)).slice(0, 10)) {
    console.error(row.sourceFilePath, row.errorCode);
  }
  console.error(`재전송 ${await retryFailed(monitorId)}건`);
  process.exit(1);
}

console.log(`배포 완료: ${path}`);
var (monitorId, path) = await DeployArtifactAsync(
    Environment.GetEnvironmentVariable("BUILD_DEVICE"),
    Environment.GetEnvironmentVariable("TARGET_DEVICE"),
    Environment.GetEnvironmentVariable("ARTIFACT_PATH"),
    Environment.GetEnvironmentVariable("DEPLOY_BASE"));

JsonObject detail = await client.WaitAsync(monitorId, 3600);

if (J.IntOrNull(detail, "status") != InnorixClient.StatusComplete)
{
    foreach (JsonObject row in (await client.FailedFilesAsync(monitorId)).Take(10))
    {
        Console.Error.WriteLine(
quot;{J.Str(row, "sourceFilePath")} "
+
quot;{J.Str(row, "errorCode")}"
); } Console.Error.WriteLine(
quot;재전송 {await client.RetryFailedAsync(monitorId)}건"
); Environment.Exit(1); } Console.WriteLine(
quot;배포 완료: {path}"
);

배포 이력 연결#

monitorId와 커밋 정보를 짝지어 기록하기

monitorId와 커밋 정보를 함께 남기면 배포 이력과 코드 이력이 연결됩니다.

record = {
    "monitorId": monitor_id,
    "commit": subprocess.check_output(
        ["git", "rev-parse", "HEAD"], text=True).strip(),
    "branch": os.getenv("GIT_BRANCH"),
    "buildNumber": os.getenv("BUILD_NUMBER"),
    "targetPath": path,
}

db.insert("deployments", record)
Map<String, Object> record = Json.newObj(
        "monitorId", monitorId,
        "commit", new String(new ProcessBuilder("git", "rev-parse", "HEAD")
                .start().getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip(),
        "branch", System.getenv("GIT_BRANCH"),
        "buildNumber", System.getenv("BUILD_NUMBER"),
        "targetPath", path);

db.insert("deployments", record);
const record = {
  monitorId,
  commit: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(),
  branch: process.env.GIT_BRANCH,
  buildNumber: process.env.BUILD_NUMBER,
  targetPath: path,
};

await db.insert("deployments", record);
var record = new
{
    monitorId,
    commit = RunGit("rev-parse HEAD").Trim(),
    branch = Environment.GetEnvironmentVariable("GIT_BRANCH"),
    buildNumber = Environment.GetEnvironmentVariable("BUILD_NUMBER"),
    targetPath = path,
};

await db.InsertAsync("deployments", record);
기록 항목 내용
monitorId 전송 식별자
commit · branch 산출물이 나온 코드 지점
buildNumber CI 실행 번호
targetPath 대상 장비의 반영 경로

배포에 문제가 생겼을 때 monitorId로 이력을 찾고, 거기서 커밋까지 거슬러 올라갈 수 있습니다.

최근 배포 이력은 기간으로 조회합니다.

from datetime import datetime, timedelta, timezone


def paginate(path, params=None, limit=200, max_pages=50):
    query = dict(params or {})
    query["limit"] = limit
    cursor = None

    for _ in range(max_pages):
        if cursor:
            query["cursor"] = cursor

        result = api("GET", path, params=query) or {}

        for record in result.get("data") or []:
            yield record

        pagination = result.get("pagination") or {}

        if not pagination.get("hasMore"):
            return

        cursor = pagination.get("nextCursor")

        if not cursor:
            return


end = datetime.now(timezone.utc)
fmt = "%Y-%m-%dT%H:%M:%SZ"

for row in paginate("/api/transfer-history", params={
    "startDate": (end - timedelta(days=7)).strftime(fmt),
    "endDate": end.strftime(fmt),
}):
    print(row["monitorId"], row.get("statusName"),
          row.get("targetDeviceName"), row.get("startDate"))
Instant end = Instant.now();
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
        .withZone(ZoneOffset.UTC);

List<Map<String, Object>> rows = client.paginate("/api/transfer-history",
        Json.newObj("startDate", fmt.format(end.minus(7, ChronoUnit.DAYS)),
                    "endDate", fmt.format(end)), 200, 50);

for (Map<String, Object> row : rows) {
    System.out.println(Json.str(row, "monitorId") + " "
            + Json.str(row, "statusName") + " "
            + Json.str(row, "targetDeviceName") + " "
            + Json.str(row, "startDate"));
}
const end = new Date();
const fmt = (d) => d.toISOString().replace(/\.\d{3}Z$/, "Z");

const params = {
  startDate: fmt(new Date(end.getTime() - 7 * 86400000)),
  endDate: fmt(end),
};

for await (const row of paginate("/api/transfer-history", params, 200)) {
  console.log(row.monitorId, row.statusName, row.targetDeviceName, row.startDate);
}
DateTime end = DateTime.UtcNow;
const string Fmt = "yyyy-MM-dd'T'HH:mm:ss'Z'";

List<JsonObject> rows = await client.PaginateAsync("/api/transfer-history",
    new Dictionary<string, object>
    {
        ["startDate"] = end.AddDays(-7).ToString(Fmt),
        ["endDate"] = end.ToString(Fmt),
    }, 200);

foreach (JsonObject row in rows)
{
    Console.WriteLine(
quot;{J.Str(row, "monitorId")} {J.Str(row, "statusName")} "
+
quot;{J.Str(row, "targetDeviceName")} {J.Str(row, "startDate")}"
); }
확인 항목 확인 내용
산출물 전송한 파일과 경로
참조 커밋 또는 태그
대상 반영된 장비와 경로
상태 대상별 성공과 실패
실패 파일 파일별 오류와 재전송 결과
이전Kubernetes에서 오브젝트 스토리지로 결과 파일 보내기다음Git으로 관리하기 어려운 파일을 자동 전달하기

이 페이지에서

  • 시작하기
  • 기본 개념
  • 연계 흐름
  • 분리 효과
  • IT 엔지니어
  • 환경 연결
  • 전송 규칙
  • 자동 흐름
  • 결과 관리
  • 개발자
  • 연동 준비
  • 산출물 경로 설계
  • 산출물 전송
  • 여러 대상 배포
  • 대용량 산출물 전송
  • 파이프라인 연결
  • 배포 이력 연결