Overview
CI 파이프라인은 결국 어딘가로 파일을 전달하는 과정을 거친다. 클라이언트에 노출되는 정적 에셋이라면 보통 CDN으로 바로 전송하면 되지만, 게임의 마스터 데이터 JSON이나 버전 매니페스트처럼 백엔드 서버가 부팅 시 읽어야 하는 설정 파일은 상황이 다르다. 이런 파일들은 대개 공유 EFS 마운트에 위치하는데, CI 러너 환경에서는 해당 볼륨으로 직접 접근할 수 있는 네트워크 경로가 구성되어 있지 않은 경우가 많다.
전통적인 해결책은 EFS 볼륨이 마운트된 서버(인스턴스)에 SSH를 열고 scp나 rsync로 파일을 전송하는 방식이었다. 하지만 이 구조는 CI 러너에 SSH 개인키를 보관해야 하고, 대상 호스트 주소가 하드코딩되어야 하며, 주기적으로 교체되지 않는 장기(Long-term) 자격 증명을 방치해야 한다는 심각한 보안 및 관리상의 문제를 안고 있다.
이 글에서는 이러한 문제를 근본적으로 해결하기 위해 직접 구축한 키리스(Keyless) 배포 아키텍처를 소개한다. CI 러너가 파일을 S3 트랜짓(Transit) 버킷에 먼저 업로드해 두면, AWS Systems Manager(SSM) Run Command를 통해 배스천(Bastion) 호스트에 신호를 보내고, 배스천이 자신의 IAM 인스턴스 프로파일 권한을 사용해 S3에서 EFS로 파일을 당겨오는(Pull) 구조이다.
이 방식을 사용하면 러너에는 SSH 키나 AWS 장기 자격 증명이 전혀 남지 않으며, 배스천 인스턴스로 향하는 유일한 접근 통로는 안전한 SSM 컨트롤 플레인뿐이다.
이 아키텍처의 흥미로운 점은 동일한 설계를 GitLab CI(재사용 가능한 YAML 앵커)와 Jenkins(선언형 파이프라인 내 인라인 셸)라는 서로 다른 환경에 각각 구현했다는 것이다. 하나의 설계 사상이 서로 다른 두 CI 문법으로 어떻게 표현되는지 비교해 보면, 아키텍처 패턴과 도구의 문법을 분리하여 이해하는 데 큰 도움이 될 것이다.

scp/rsync를 쓰지 않는 이유
기존 방식은 다음과 같았다(운영 환경이 아닌 곳에서는 지금도 사용 중이다).
rsync -avzr --delete --exclude='*.meta' \
"${localVersionDir}/" \
-e "ssh -i /path/to/private_key" \
"deploy@nas.example.com:/mnt/nfs/myapp/dev/server/version"
기존 방식도 동작은 하지만, 실제 운영 환경에서는 반드시 피해야 할 세 가지 치명적인 단점이 있다.
- CI 러너에 보관되는 개인키: 러너의 작업 공간이 노출되거나 빌드 로그가 유출될 경우, 대상 호스트가 장악당하는 것은 시간문제다. 게다가 이런 키는 한 번 등록되면 로테이션 없이 장기간 방치되는 경우가 많다.
- 하드코딩된 타겟 호스트: 호스트 정보가 하드코딩되어 있으면, 서버가 교체될 때마다 누군가 파이프라인을 수동으로 수정해주기 전까지는 배포가 실패하게 된다.
- 네트워크 라우팅의 한계: 러너에서 타겟 환경으로 통신할 수 있는 네트워크 경로가 필수적이다. 하지만 대상이 프라이빗 VPC 내부에 있는 EFS 마운트라면 러너 측에 이러한 라우팅 경로가 없는 것이 일반적이다. 애초에 러너가 파일을 직접 쓸 수 없는 근본적인 이유가 바로 여기에 있다.
새로운 운영 아키텍처에서는 이 세 가지 제약을 모두 걷어낸다. 전송 매체는 오브젝트 스토리지(S3)로, 실행 트리거는 SSM으로, 고정된 자격 증명(키)은 IAM 역할(Role)로 전면 교체한다.
아키텍처 개요
CI 러너 (GitLab 또는 Jenkins)
|
| 1. 아티팩트 빌드 (서버 JSON / APK + 버전 매니페스트)
|
+--> 클라이언트 에셋 ──> S3 CDN 버킷 ──> CloudFront 무효화 ──> 클라이언트
|
| 2. 서버 파일: 러너는 EFS에 직접 쓸 수 없음
v
S3 트랜짓 버킷 (스테이징: aws s3 cp / aws s3 sync)
^ |
| 4. 회수 | 3. ssm:SendCommand (AWS-RunShellScript)
| (aws s3 rm) v
| 배스천 EC2 ──(인스턴스 프로파일 자격증명)──> aws s3 sync ──> EFS
| |
+──── CI 역할 +──── 배스천 인스턴스 역할
동일한 빌드 과정에서 데이터의 이동 경로는 두 갈래로 나뉜다.
- 클라이언트 경로 (낮은 보안 민감도): 에셋 파일들은 CDN용 S3 버킷으로 곧바로 업로드되며, CloudFront 무효화(Invalidation)를 통해 배포된다. (이 부분은 별도의 섹션에서 따로 다루겠다.)
- 서버 경로 (이 글의 핵심): 러너가 최종 목적지에 직접 접근할 수 없으므로 파일을 경유지인 트랜짓(Transit) 버킷에 먼저 올려두면, 배스천(Bastion) 호스트가 이를 당겨와서(Pull) EFS에 저장한다.
Transit 버킷 + SSM 메커니즘
서버 경로는 총 일곱 단계로 구성되며, 두 CI 모두 동일하게 구현한다.
- 러너에서 아티팩트를 빌드한다.
- 고유한 프리픽스로 Transit 버킷에 스테이징한다. 단일 파일이면 aws s3 cp, 디렉토리 전체를 그대로 미러링해야 하면 `aws s3 sync ... --delete` 를 사용한다.
- Name 태그로 Bastion을 조회한다. 인스턴스 ID를 하드코딩하지 않기 때문에 인스턴스가 교체되어도 패턴이 그대로 유지된다.
BASTION_ID=$(aws ec2 describe-instances \
--filters "Name=tag:Name,Values=my-bastion" \
"Name=instance-state-name,Values=running" \
--query 'Reservations[].Instances[].InstanceId' --output text --region ap-northeast-2)
- `aws ssm send-command --document-name` AWS-RunShellScript로 풀 작업을 트리거한다. 명령은 Bastion에서 자신의 인스턴스 프로파일 자격증명으로 실행되며, 러너는 Bastion에 셸 접근 권한을 얻지도, 해당 경로의 S3 읽기 자격증명을 보유하지도 않는다.
- `aws ssm wait command-executed` 로 대기한 뒤 `aws ssm get-command-invocation` 으로 Status와 StandardOutputContent를 읽어와 CI 로그에 출력함으로써 어떤 파일이 실제로 반영되었는지 확인할 수 있게 한다.
- Success가 아닌 상태라면 StandardErrorContent를 함께 출력하고 빌드를 실패 처리한다.
- `aws s3 rm ... --recursive` 로 트랜짓 프리픽스를 회수한다. 빌드가 중간에 죽는 경우를 대비해 약 7일짜리 버킷 라이프사이클 규칙을 백업으로 둔다.
핵심 원리는 아주 단순하다.
S3는 물건을 잠시 맡겨두는 '무인 택배함(또는 임시 보관함)'이고, SSM은 '초인종' 역할을 한다. 러너가 S3에 파일을 올려두고 초인종을 누르면, 배스천이 안에서 문을 열고 파일을 가져간다. 이 구조의 가장 큰 장점은 양측 모두 서로의 시스템에 직접 접근할 수 있는 '열쇠(인증 정보)'를 공유하지 않는다는 점이다."
GitLab CI 구현
GitLab CI에서는 이 전체 과정을 재사용 가능한 하나의 앵커(템플릿)로 구성한 뒤, 각 Job에서 `!reference` 태그를 사용해 간편하게 불러올 수 있다. 이를 호출하는 Job에서는 단 3개의 환경 변수만 설정(주입)해 주면 된다.
# .gitlab-ci.yml — 재사용 엔진
.templates:
# 키리스 "CI -> EFS" 업로드. scp/rsync-over-SSH를 대체한다.
# 호출 전에 설정: TRANSIT_PREFIX, EFS_DEST_DIR, SSM_SYNC_FLAGS (선택, 예: --delete)
.efs_sync_via_ssm:
- |
BASTION_ID=$(aws ec2 describe-instances \
--filters "Name=tag:Name,Values=${BASTION_NAME_TAG}" \
"Name=instance-state-name,Values=running" \
--query 'Reservations[].Instances[].InstanceId' --output text --region "$AWS_REGION")
if [ -z "$BASTION_ID" ] || [ "$BASTION_ID" = "None" ]; then
echo "ERROR: no running bastion with tag Name=${BASTION_NAME_TAG}"; exit 1
fi
# 따옴표 이스케이프 지옥을 피하기 위해 printf로 SSM 파라미터 JSON 생성
SSM_PARAMS=$(printf '{"commands":["set -e","mkdir -p %s","aws s3 sync s3://%s/%s/ %s/ %s --exact-timestamps --region %s"]}' \
"$EFS_DEST_DIR" "$EFS_TRANSIT_BUCKET" "$TRANSIT_PREFIX" "$EFS_DEST_DIR" "$SSM_SYNC_FLAGS" "$AWS_REGION")
CMD_ID=$(aws ssm send-command \
--instance-ids "$BASTION_ID" \
--document-name AWS-RunShellScript \
--comment "efs sync ${EFS_DEST_DIR} <- transit" \
--parameters "$SSM_PARAMS" \
--query 'Command.CommandId' --output text --region "$AWS_REGION")
aws ssm wait command-executed --command-id "$CMD_ID" --instance-id "$BASTION_ID" --region "$AWS_REGION" || true
STATUS=$(aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$BASTION_ID" --region "$AWS_REGION" --query Status --output text)
echo "SSM status: $STATUS"
echo "----- bastion EFS sync output -----"
aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$BASTION_ID" --region "$AWS_REGION" --query StandardOutputContent --output text
if [ "$STATUS" != "Success" ]; then
aws ssm get-command-invocation --command-id "$CMD_ID" --instance-id "$BASTION_ID" --region "$AWS_REGION" --query StandardErrorContent --output text
exit 1
fi
# 트랜짓 프리픽스 회수; 약 7일 버킷 라이프사이클 규칙이 백업 역할을 한다
aws s3 rm "s3://${EFS_TRANSIT_BUCKET}/${TRANSIT_PREFIX}/" --recursive --region "$AWS_REGION"
호출부는 아주 짧게 유지된다. 디렉토리 전체 미러링과 단일 버전 파일은 스테이징 명령과 `SSM_SYNC_FLAGS` 만 다르다.
# 서버 데이터 — 전체 미러링 (삭제된 키 제거)
deploy-server-data:
script:
- TRANSIT_PREFIX="myapp/$REF_NAME/data/$CI_COMMIT_SHORT_SHA"
- aws s3 sync json/ "s3://${EFS_TRANSIT_BUCKET}/${TRANSIT_PREFIX}/" --delete --region "$AWS_REGION"
- EFS_DEST_DIR="${AWS_DATA_MOUNT_BASE}/$REF_NAME/data"
- SSM_SYNC_FLAGS="--delete"
- !reference [.templates, .efs_sync_via_ssm]
# 버전 매니페스트 — 단일 파일, 삭제 없음
deploy-version:
script:
- TRANSIT_PREFIX="myapp/$REF_NAME/version/$CI_COMMIT_SHORT_SHA"
- aws s3 cp version/DataVersion.json "s3://${EFS_TRANSIT_BUCKET}/${TRANSIT_PREFIX}/DataVersion.json" --region "$AWS_REGION"
- EFS_DEST_DIR="${AWS_DATA_MOUNT_BASE}/$REF_NAME/version"
- SSM_SYNC_FLAGS=""
- !reference [.templates, .efs_sync_via_ssm]
`!reference [.templates, .efs_sync_via_ssm]` 태그는 앵커의 스크립트 라인을 잡 안으로 그대로 이어붙인다. GitLab이 제공하는, 복사-붙여넣기 없이 블록을 공유하는 방식이다.
Jenkins 구현
Jenkins에는 GitLab CI의 `!reference` 같은 기능이 없기 때문에, 동일한 7단계를 선언형 파이프라인(Declarative Pipeline)의 스테이지(Stage) 내에 인라인 `sh` 스크립트로 직접 작성해야 한다. 여기서 파이프라인을 실행하는 에이전트(Agent)는 Unity 빌드도 함께 수행하는 셀프 호스팅 Mac mini인데, 바로 이 환경 구성 때문에 실무에서 맞닥뜨리게 되는 문제점이 하나 발생한다.
environment {
// 이 에이전트의 비대화형 셸에는 aws-cli가 PATH에 없음 — 명시적으로 추가해야 함
PATH = "$PATH:/usr/local/bin"
}
배포 스테이지는 환경 파라미터에 따라 분기한다. 운영(prod)이면 S3 + SSM 경로를 타고, 그 외에는 기존 NAS 방식을 유지한다.
stage('Deploy server version to EFS') {
steps {
script {
def envKey = params.GAME_SERVER.toLowerCase() // 일부 잡은 GameServer로 표기됨
if (envKey == 'prod') {
sh """
KEY=${env.BUILD_TAG}
aws s3 cp "${versionJsonPath}" "s3://my-efs-transit/version/\$KEY/DataVersion.json" \
--profile myapp-ci --region ap-northeast-2
BASTION_ID=\$(aws ec2 describe-instances \
--filters "Name=tag:Name,Values=my-bastion" "Name=instance-state-name,Values=running" \
--query 'Reservations[0].Instances[0].InstanceId' --output text \
--profile myapp-ci --region ap-northeast-2)
CMD_ID=\$(aws ssm send-command --instance-ids \$BASTION_ID \
--document-name AWS-RunShellScript \
--comment "prod DataVersion.json -> EFS" \
--parameters commands='["set -e","mkdir -p /mnt/data/prod/version","aws s3 sync s3://my-efs-transit/version/'\$KEY'/ /mnt/data/prod/version/ --exact-timestamps --region ap-northeast-2"]' \
--profile myapp-ci --region ap-northeast-2 \
--query 'Command.CommandId' --output text)
aws ssm wait command-executed --command-id "\$CMD_ID" --instance-id \$BASTION_ID \
--profile myapp-ci --region ap-northeast-2 || true
STATUS=\$(aws ssm get-command-invocation --command-id "\$CMD_ID" --instance-id \$BASTION_ID \
--profile myapp-ci --region ap-northeast-2 --query Status --output text)
echo "SSM status: \$STATUS"
[ "\$STATUS" = "Success" ] || exit 1
"""
} else {
// dev/qa/staging: 기존 NAS over SSH (그대로 유지)
sh """
rsync -avzr --delete --exclude='*.meta' \
"${localVersionDir}/" \
-e "ssh -i ${UPLOAD_SSH_KEY}" \
"${UPLOAD_USER}:/mnt/nfs/myapp/${envKey}/server/version"
"""
}
}
}
}
Jenkins 환경에서의 구현을 살펴보면 크게 두 가지 특이점이 있다.
- 플러그인 대신 AWS CLI 프로파일 사용: withAWS나 s3Upload 같은 Jenkins 전용 플러그인에 의존하지 않고, 에이전트에 설정된 `--profile myapp-ci --region ...` 형태의 명명된(Named) CLI 프로파일을 직접 사용한다. 덕분에 로컬 터미널에서 실행하는 명령어와 100% 동일한 형태를 유지할 수 있다. 이 프로파일은 다음 섹션에서 다룰 'AssumeRole(역할 수임) 체인'이 적용되는 핵심 지점이기도 하다.
- 초기화 작업(Job)에도 동일한 메커니즘 재사용: 초기화를 담당하는 InitVersion Job 역시 이 구조를 그대로 활용한다. 빈 매니페스트([])를 업로드한 뒤, SSM을 통해 prod와 review 디렉터리 양쪽에 동기화하는 방식이다. 즉, 동일한 SendCommand 블록을 재사용해 시스템 상태를 한 번에 초기화한다.
참고 (부가 기능): 이 핵심 배포 흐름 전후로는 slackSend를 통한 파이프라인 상태 알림(STARTED / SUCCESS / FAILURE)이 동작한다. 또한, 클라이언트 APK 업로드가 완료되면 간단한 curl 호출을 통해 S3 다운로드 URL을 슬랙 봇에 전달하여 QR 코드를 생성한다. 단, 이 부분은 편의를 위한 부가 기능이며 핵심 아키텍처 패턴은 아니다.
--exact-timestamps 함정
이 부분은 실전 배포 환경에서 디버깅에 상당한 시간을 소모하게 만든 함정(Trap)이다.
원인은 aws s3 sync의 파일 비교 최적화 방식에 있다. 기본적으로 S3에서 로컬로 파일을 가져올 때, 타겟 경로에 이미 동일한 크기의 파일이 있다면 (원본이 더 최신이 아닌 한) 다운로드를 건너뛴다. 성능상 이점은 있지만, 바이트 크기는 그대로인 채 내용만 수정된 파일에는 문제가 생길 수밖에 없다.
예를 들어 설정 JSON의 특정 값을 100에서 200으로 변경할 경우, 파일의 내용은 달라졌지만 전체 바이트 수는 변하지 않을 수 있다. 그러면 aws s3 sync는 이를 변경되지 않은 파일로 간주해 동기화를 스킵한다. 변경 사항이 EFS에 반영되지 않았음에도 아무런 오류 메시지가 발생하지 않는, 최악의 '무증상 실패(Silent Failure)'를 겪게 되는 것이다.
"그래서 결국 --exact-timestamps 옵션을 추가해, 크기뿐만 아니라 타임스탬프까지 엄격하게 비교하도록 강제해야만 이 조용한 누락을 막을 수 있었다."
# 기본값: "크기가 같은" 파일은 건너뛸 수 있음 -> EFS에 조용히 오래된 데이터가 남음
aws s3 sync s3://my-efs-transit/version/KEY/ /mnt/data/prod/version/
# 수정: 크기뿐 아니라 타임스탬프도 비교 -> 타임스탬프가 정확히 일치하지 않으면 재동기화
aws s3 sync s3://my-efs-transit/version/KEY/ /mnt/data/prod/version/ --exact-timestamps
`--exact-timestamps` 옵션은 동기화 생략 기준을 "파일 크기와 타임스탬프가 정확히 일치할 때만"으로 엄격하게 변경하여 이러한 사각지대를 원천 차단한다.
이번 아키텍처에서도 GitLab과 Jenkins의 배스천 측 동기화 명령어에 빠짐없이 이 플래그를 추가했다.
앞으로 S3에서 로컬 디스크(EFS)로 파일을 내려받는 파이프라인을 구축한다면, 이 플래그는 선택이 아닌 필수라고 생각하는 편이 안전하다.
키리스(Keyless) 보안 모델
아키텍처에 추가적인 구성 요소가 들어가 다소 복잡해지는 것을 감수하고서라도 이 설계를 채택해야 하는 핵심 이유는 바로 자격 증명(Credential) 구조에 있다. 로테이션 되지 않는 장기(Long-term) 자격 증명이나 SSH 키 같은 민감한 보안 정보가 CI 러너 환경에는 일절 노출되지 않기 때문이다.
IAM 권한 분리
이 아키텍처는 의도적으로 권한이 단 하나도 겹치지 않도록 설계된 두 개의 IAM 역할(Role)로 구성된다.
| 역할 | 허용된 작업 (Can Do) | 제한된 작업 (Cannot Do) |
| CI 역할 (러너) | • S3 트랜짓 경로에 객체 업로드/삭제 (Put/Delete) • SSM 제어 (DescribeInstances, SendCommand 등) |
• 업로드한 객체를 다시 읽기 (Get) • EFS 직접 접근 |
| Bastion 인스턴스 역할 | • S3 트랜짓 경로에서 객체 조회/다운로드 (Get/List) • EFS에 파일 쓰기 |
• S3 트랜짓 경로에 객체 업로드/삭제 (Put/Delete) • SSH 인바운드 접속 (접근 불가) |
러너는 파일을 업로드하고 지울 수는 있지만 읽을 수는 없으며, 반대로 배스천은 파일을 읽을 수는 있지만 새롭게 업로드하거나 삭제할 수 없다. 어느 쪽도 상대의 역할을 대신할 수 없도록 권한이 철저히 분리되어 있으므로, 둘 중 한쪽이 침해당하더라도 전체 시스템으로의 피해 반경(Blast Radius)은 엄격하게 제한된다.
권한을 최소화하여 정제한 CI 역할(Role)의 IAM 정책은 다음과 같다.
{
"Version": "2012-10-17",
"Statement": [
{ "Sid": "StageAndReclaimTransit",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:DeleteObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::my-efs-transit", "arn:aws:s3:::my-efs-transit/*"] },
{ "Sid": "ResolveBastion",
"Effect": "Allow",
"Action": "ec2:DescribeInstances",
"Resource": "*" },
{ "Sid": "TriggerBastionPull",
"Effect": "Allow",
"Action": ["ssm:SendCommand", "ssm:GetCommandInvocation"],
"Resource": "*" }
]
}
Jenkins의 2단계 AssumeRole 구조: Jenkins 에이전트에 설정된 myapp-ci 프로파일 자체는 실질적인 인프라 제어 권한을 전혀 갖지 않는다. 이 프로파일의 기반이 되는 IAM 사용자는 오직 sts:AssumeRole 권한 하나만 가지고 있으며, 실제 S3, EC2, SSM 제어 권한이 부여된 IAM 역할을 수임(Assume)하도록 연결되어 있을 뿐이다.
# 에이전트의 ~/.aws/config
[profile myapp-ci]
role_arn = arn:aws:iam::111122223333:role/ci-role
source_profile = ci-bootstrap # sts:AssumeRole만 가진 사용자
region = ap-northeast-2
이렇게 구성하면 에이전트에 정적 자격 증명이 남아있더라도 그 자체로는 S3에 직접 접근할 수 없다. 오직 권한을 가진 역할로 임시 전환(AssumeRole)할 수 있을 뿐이며, 이때 발급되는 세션 토큰의 유효 기간 또한 매우 짧게 유지된다.
참고로 GitLab CI의 경우, 러너가 부여받은 자체 아이덴티티(OIDC 등)를 통해 CI 역할을 즉시 수임(Assume)하는 방식이 이와 동일한 역할을 수행한다. 최종적인 보안 결과는 같지만, Jenkins 방식보다 관리해야 할 정적 자격 증명(Static Credentials) 요소가 훨씬 적다는 장점이 있다.
[참고] 클라이언트 에셋의 CloudFront 배포 경로
빌드 파이프라인의 모든 파일이 무거운 배스천(Bastion) 경로를 거칠 필요는 없다. 이미지, 에셋 번들, APK 등 클라이언트가 직접 받아가는 정적 파일들은 CDN용 S3 버킷으로 즉시 업로드되어 CloudFront를 통해 서비스된다. 이 경로에서 추가로 필요한 작업은 에지(Edge) 캐시를 무효화(Invalidation)하여 클라이언트가 즉각적으로 최신 버전을 내려받도록 갱신하는 것뿐이다.
aws s3 sync ./client-assets/ "s3://cdn.example.com/client/res/${version}/" \
--exclude '*.meta' --delete --region ap-northeast-2
aws s3 cp ./ResVersion.json "s3://cdn.example.com/client/res/${version}/ResVersion.json" \
--region ap-northeast-2
aws cloudfront create-invalidation \
--distribution-id E1234567890ABC \
--paths "/client/res/${version}/*"
데이터 성격에 따른 이러한 파이프라인 분리 전략은 아키텍처 설계 시 눈여겨볼 만한 포인트다. 외부에 공개되는 읽기(Read) 위주의 데이터는 CDN 경로로, 격리되어야 하는 내부 서버용 파일은 트랜짓 버킷 + SSM 경로를 거쳐 EFS로 안전하게 배포한다.
동일한 빌드 파이프라인에서 출발하지만, '누가 어디서 읽는 파일인가'에 따라 완벽하게 다른 배포 메커니즘을 적용한 것이다.
GitLab CI vs Jenkins
두 환경에서 동일한 아키텍처를 구현하면서 체감한 주요 차이점은 다음과 같다.
| 구분 | GitLab CI | Jenkins |
| 코드 재사용 | `!reference` 앵커를 활용한 깔끔한 템플릿화 | 선언형 파이프라인 내 인라인 sh (수동 복사/붙여넣기) |
| 파라미터 주입 | TRANSIT_PREFIX, EFS_DEST_DIR 등 단순 환경 변수 | `params.GAME_SERVER` 등을 활용한 동적 환경 분기 |
| AWS 인증 | 러너 아이덴티티 기반의 직접 AssumeRole | 2단계 AssumeRole이 적용된 CLI 프로파일(Named Profile) |
| 실행 환경(PATH) | 러너 컨테이너 이미지에 aws-cli 기본 포함 | Mac 에이전트의 로컬 환경 변수(/usr/local/bin) 직접 수정 |
| 알림 및 연동 | Job 상태 연동 및 다운스트림(Downstream) 트리거 | slackSend를 활용한 단계별 알림 및 QR 코드 봇 연동 |
| SSM 제어 블록 | 동일 (S3 업로드 → SSM SendCommand 실행) | 동일 (S3 업로드 → SSM SendCommand 실행) |
여기서 얻을 수 있는 핵심적인 교훈은, 아키텍처 패턴 자체는 플랫폼을 넘나들며 이식(Porting)될 수 있지만 구체적인 구현 방식은 CI 도구의 문법에 철저히 종속된다는 점이다.
GitLab CI의 `!reference` 기능은 코드 중복을 없애고 선언적이며 재사용성 높은 '배포 엔진'을 구성하는 데 압도적으로 유리했다. 반면 Jenkins는 동일한 셸 스크립트 블록을 반복해서 작성해야 하는 수고로움이 따랐지만, 파이프라인 내부에서 동적인 환경 분기나 스테이지별 세밀한 알림 제어 같은 명령형(Imperative) 워크플로우를 구성하기에는 더 수월했다.
도구의 특성에 따라 구현체의 겉모습은 달라졌지만, 그 밑바탕을 이루는 'S3 트랜짓 버킷과 SSM을 활용한 배스천 풀(Bastion Pull)'이라는 핵심 보안 아키텍처 사상은 양쪽 환경 모두에서 완벽하게 동일한 가치를 증명해 냈다.
언제 이 아키텍처를 도입하고, 어떤 트레이드오프를 감수할 것인가
모든 아키텍처가 그렇듯 이 방식 역시 은탄환(Silver Bullet)은 아니다. 상황에 따라 도입을 적극 권장할 수도, 불필요한 오버헤드가 될 수도 있다.
도입을 적극 권장하는 경우
- 네트워크 단절: CI 러너에서 최종 타겟(프라이빗 VPC 내의 EFS 등 격리된 환경)으로 파일을 직접 쓸 수 있는 네트워크 경로가 없을 때.
- 보안 강화 (Keyless): CI 러너 환경에서 SSH 개인키와 장기(Long-term) AWS 자격 증명을 완벽하게 제거하고 싶을 때.
- SSM 활용 가능 환경: 타겟(배스천) 인스턴스에 SSM 에이전트가 실행 중이거나 설치 가능하며, IAM 인스턴스 역할을 부여할 수 있을 때.
도입을 피해야 하는 경우 (건너뛴다)
- 직접 통신 가능: CI 러너가 타겟 환경에 직접 접근할 수 있고 현재의 IAM 구조로도 보안 요구사항이 충분히 충족될 때. (이 경우 S3 트랜짓 경유는 순수한 오버헤드가 된다.)
- Non-AWS 환경: 타겟이 AWS 외부에 있거나 SSM 에이전트를 사용할 수 없을 때. (SSM을 대체할 별도의 제어 평면(Control Plane)을 직접 구축해야 하므로 배보다 배꼽이 더 커진다.)
- 성능 민감도: 전송해야 할 페이로드가 매우 크고 배포 지연(Latency)에 극도로 민감할 때. (S3를 왕복하는 과정이 물리적인 네트워크 홉(Hop)을 하나 더 추가하기 때문이다.)
감수해야 할 트레이드오프
이 방식을 선택했다면 다음 세 가지 운영상의 비용을 지불해야 한다.
- 인프라 복잡도 증가: 단 한 줄의 rsync 명령어로 끝날 작업이 트랜짓 버킷, S3 라이프사이클 규칙, 철저히 분리된 2개의 IAM 역할, SSM 연결 등 다수의 구성 요소를 관리하는 작업으로 늘어난다.
- 간접적인 디버깅: 배포 실패 시 직관적으로 오류를 파악하기 어렵다. get-command-invocation을 호출해 배스천의 실행 결과를 간접적으로 읽어와야 한다. (파이프라인 스크립트에서 StandardOutputContent를 CI 로그에 명시적으로 남기도록 구성한 이유가 바로 이 때문이다.)
- 가비지 컬렉션(정리)의 의무화: 파이프라인 중간에 빌드가 비정상 종료되면 S3에 올린 임시 객체(Prefix)를 스스로 회수하지 못하는 상황이 발생한다. 따라서 스크립트 내부에 aws s3 rm 회수 로직을 구현하는 것은 물론, 최후의 보루로 S3 버킷 라이프사이클을 통한 자동 만료(Expiration) 정책을 반드시 함께 설정해 두어야 한다.
결론
"CI 러너에 어떠한 인증 키도 남기지 않는다"는 철칙이 필수적인 운영 환경이라면, 추가된 구성 요소와 복잡도는 그 이상의 충분한 값어치를 한다. 하드코딩을 걷어냈기에 대상 서버가 교체되더라도 더 이상 배포 파이프라인이 깨지지 않으며, 애초에 러너 환경에는 유출될 SSH 키 자체가 존재하지 않는 안전하고 우아한 배포 시스템을 얻을 수 있다.
Reference
- AWS Systems Manager — Run Command
- Running commands using Systems Manager Run Command (CLI walkthrough)
- aws s3 sync — CLI reference (–exact-timestamps 참고)
- Invalidating files — Amazon CloudFront
- Using an IAM role to grant permissions to applications on EC2
- GitLab CI YAML optimization — reference tags
- Jenkins Pipeline — Declarative syntax
Somaz | DevOps Engineer | Kubernetes & Cloud Infrastructure Specialist
'AWS' 카테고리의 다른 글
| EKS + Karpenter 무중단 업그레이드 — 노드 잔존 문제부터 검증까지 (0) | 2026.07.16 |
|---|---|
| Cluster Autoscaler vs Karpenter: ASG 위에서 도느냐, ASG를 대체하느냐 (0) | 2026.07.14 |
| RDS IAM 인증으로 데이터베이스 비밀번호 관리에서 벗어나기 (1) | 2026.07.13 |
| EKS 무중단 배포 — lifecycle(preStop) · Pod Readiness Gatestrategy(RollingUpdate) · PodDisruptionBudget (1) | 2026.07.09 |
| EKS Pod Identity vs IRSA: 파드에 IAM 권한을 주는 두 가지 방식 (0) | 2026.06.30 |