SRE / DevOps 시작하기 (3) - 기반공사
SRE / DevOps 시작하기 (3) - 기반공사
KMS: AWS Secret Manager vs Vault
KMS와 같은 시스템은 on-premise 또는 이에 준하는 환경에서 구현되어야 한다는 것이 나의 기본적인 관점이다. 회사 건물 내에 물리적으로 존재하고, 외부 망으로부터 분리된 서버를 완전히 대체할 수 있는 옵션은 현실적으로 없다.
데이터를 독립된 키로 암호화하는 방식 등이 존재하긴 하지만, 그럼에도 불구하고 내가 직접 제어할 수 있는 인스턴스 위에서 보안 시스템을 독립적으로 구축한다는 것은 여전히 의미가 있다.
AWS Secret Manager를 써본 사람은 알 거다. 간단한 키 하나 관리하려다가 AWS 계정 구조, IAM 정책, 서비스 연동이 덕지덕지 붙는다. 추상화가 직관적이지 않아서 뭘 하고 있는지 모르게 되는 순간이 온다. 그리고 그 불편함에 돈을 낸다.
두 가지 질문으로 정리된다.
첫째, 보안성을 신뢰할 수 있나.
AWS의 인프라가 뚫릴 가능성보다, 내가 통제할 수 없는 곳에 시크릿이 있다는 구조적 문제가 더 크다. 벤더가 정책을 바꾸거나, 계정이 잠기거나, 서비스가 변경되는 순간 내 시크릿 관리 체계 전체가 흔들린다.
둘째, 비용 대비 효율성이 존재하나.
그 불편함을 감수하면서 돈까지 내야 한다.
Vault는 다르다. CLI로 직접 제어하고, 의존성이 없고, 내가 띄운 인스턴스 위에서 돌아간다. 1편에서 말한 것과 같은 원칙이다 — 벤더가 정책을 바꿔도 내 시크릿 관리 체계는 흔들리지 않아야 한다.
KV(Key-Value) 시크릿 엔진 위에서 동작하고, 초기 구성은 단순하다.
# KV v2 엔진 활성화
vault secrets enable -path=secret kv-v2
# 시크릿 저장
vault kv put secret/infra/github username="..." password="..."
vault kv put secret/services/jenkins vault-token="..."
경로 구조만 잡히면 끝이다. infra/는 공통 인프라 시크릿, services/는 서비스별 시크릿. Jenkins 파이프라인은 이 경로를 참조할 뿐이다.
Infrastructure-as-Code vs Configuration-as-Code
두 기술 모두 일반적인 초기 프로젝트에 사용하기에는 오버엔지니어링이라고 생각하는 사람들도 있을 것이다. 그럼에도 불구하고 내가 이런 작업을 하는 이유는 두 가지다.
첫째, 반복 작업에 시간을 쓰기 싫다. Git이나 vsftp로 코드를 관리하고, PM2로 CLI에서 직접 프로세스를 올리고 내리는 시간에, 아키텍처를 한 번 더 고민하고 서비스 구조를 조금이라도 더 구체화하고 싶다. 그게 지금 나한테 더 가치 있는 시간이다.
둘째, 배포가 균질해진다. 사람이 수동으로 배포하면 순서가 달라지고, 피곤할 때 실수가 끼어든다. 커밋 하나로 트리거된 파이프라인은 새벽 3시에 돌아도, 월요일 아침에 돌아도 동일하다.
휴먼 에러가 구조적으로 사라진다. 빌드 번호가 이미지 태그가 되고, 그게 롤백의 기준이 된다. 수동 배포였으면 "그때 뭘 했더라"가 되는 상황이, 자동화되면 Git 로그가 곧 배포 이력이 된다.
IaC는 지금 단계에서 도입하기엔 너무 크다. 백엔드, 프론트엔드, 인프라, 기획 보조를 혼자 감당하면서 동시에 깔 수 있는 한계선이 있다. Jenkins 배포 자동화까지가 그 선이다.
CaC는 다르다. Jenkins CasC, Prometheus yml, docker-compose — 이것들은 이미 만들고 있는 것들의 설정을 코드로 굳히는 작업이다. 새로운 레이어를 추가하는 게 아니라, 지금 있는 것을 재현 가능하게 만드는 것이다.
/etc/httpd/conf.d/09-{your-service}-{your-domain}.conf
# /etc/httpd/conf.d/09-{your-service}-{your-domain}.conf
<VirtualHost *:80>
ServerName {your-service}.{your-domain}
RewriteEngine On
RewriteRule ^/(.*) https://{your-service}.{your-domain}/$1 [R=301,L]
</VirtualHost>
<VirtualHost *:443>
ServerName {your-service}.{your-domain}
SSLEngine on
SSLCertificateFile /etc/letsencrypt/live/{your-domain}/fullchain.pem
SSLCertificateKeyFile /etc/letsencrypt/live/{your-domain}/privkey.pem
ProxyPreserveHost On
ProxyRequests Off
AllowEncodedSlashes NoDecode
<Proxy http://localhost:{your-port}/*>
<RequireAny>
# 허용할 IP 목록 (본인 IP, 팀원 IP 등)
Require ip {your-public-ip}
# GitHub Webhook IP 대역
Require ip 192.30.252.0/22
Require ip 185.199.108.0/22
Require ip 140.82.112.0/20
Require ip 143.55.64.0/20
Require ip 2a0a:a440::/29
Require ip 2606:50c0::/32
# Cloudflare IP 대역
Require ip 173.245.48.0/20
Require ip 103.21.244.0/22
Require ip 103.22.200.0/22
Require ip 103.31.4.0/22
Require ip 141.101.64.0/18
Require ip 108.162.192.0/18
Require ip 190.93.240.0/20
Require ip 188.114.96.0/20
Require ip 197.234.240.0/22
Require ip 198.41.128.0/17
Require ip 162.158.0.0/15
Require ip 104.16.0.0/13
Require ip 104.24.0.0/14
Require ip 172.64.0.0/13
Require ip 131.0.72.0/22
</RequireAny>
</Proxy>
<!-- Cloudflare 경유 시 CF-Connecting-IP로 실제 클라이언트 IP 재검증 -->
<Location "/">
<RequireAny>
Require expr "%{HTTP:CF-Connecting-IP} == '{your-public-ip}'"
# GitHub Webhook
Require expr "%{HTTP:CF-Connecting-IP} -ipmatch '192.30.252.0/22'"
Require expr "%{HTTP:CF-Connecting-IP} -ipmatch '185.199.108.0/22'"
Require expr "%{HTTP:CF-Connecting-IP} -ipmatch '140.82.112.0/20'"
Require expr "%{HTTP:CF-Connecting-IP} -ipmatch '143.55.64.0/20'"
Require expr "%{HTTP:CF-Connecting-IP} -ipmatch '2a0a:a440::/29'"
Require expr "%{HTTP:CF-Connecting-IP} -ipmatch '2606:50c0::/32'"
</RequireAny>
</Location>
RewriteEngine On
# WebSocket
RewriteCond %{HTTP:Upgrade} websocket [NC]
RewriteCond %{HTTP:Connection} upgrade [NC]
RewriteRule ^/(.*) ws://localhost:{your-port}/$1 [P,L]
# HTTP
RewriteRule ^/(.*)$ http://localhost:{your-port}/$1 [P,L,NE,B=\,BNP]
ProxyPassReverse / http://localhost:{your-port}/
RequestHeader set X-Forwarded-Proto "https"
RequestHeader set X-Forwarded-Port "443"
RequestHeader set X-Real-IP "%{HTTP:CF-Connecting-IP}e"
RequestHeader set X-Forwarded-For "%{HTTP:CF-Connecting-IP}e"
RequestHeader set CF-Connecting-IP "%{HTTP:CF-Connecting-IP}e"
<IfModule mod_security2.c>
<LocationMatch "/">
SecRuleEngine Off
</LocationMatch>
</IfModule>
</VirtualHost>
Nginx, Jenkins, Vault를 사용한 실전구축
설정파일은 Apache 설정파일이긴 한데, 설계 구조는 동일하니 nginx 설정파일로 각자 알아서 재작성 해보도록 하자.
이 파일 하나에 세 가지가 고정되어 있다.
접근 제어 — Jenkins는 아무나 열면 안 된다. 내 IP, 팀원 IP, GitHub Webhook IP, Cloudflare IP만 허용한다.
Cloudflare를 앞에 두면 실제 클라이언트 IP가 CF-Connecting-IP 헤더로 들어오기 때문에, X-Forwarded-For만 보는 단순한 IP 필터링은 우회가 가능하다. 그래서 Location 블록에서 CF-Connecting-IP를 직접 검사하는 레이어를 하나 더 깐다.
WebSocket — Jenkins의 빌드 로그는 WebSocket으로 스트리밍된다. Upgrade 헤더를 감지해서 ws://로 리라이트하지 않으면 빌드 콘솔이 안 열린다. 이걸 빠뜨리면 Jenkins는 뜨는데 로그가 안 보이는 상황이 된다.
SSL 종단 — Let's Encrypt 인증서를 Apache에서 처리하고, 백엔드 Jenkins에는 HTTP로 넘긴다. X-Forwarded-Proto: https를 명시적으로 세팅하지 않으면 Jenkins가 자기가 HTTP로 서빙되고 있다고 착각하고 리다이렉트 루프에 빠진다.
이 설정이 파일로 존재한다는 건 — 서버가 날아가도, 인스턴스를 새로 띄워도, 이 파일 하나 올리면 똑같이 재현된다. 그게 CaC다.
nginx만을 오래 사용해온 엔지니어라면 09가 왜 붙었는지 궁금할 수 있다. Apache는 conf.d/ 디렉토리의 파일을 알파벳 순서로 로딩한다. 숫자 prefix는 로딩 순서를 명시적으로 제어하기 위한 컨벤션이다.
00-default.conf가 먼저 로딩되어 기본 VirtualHost를 잡고, 서비스별 설정이 그 위에 순서대로 올라온다. Jenkins가 09인 건 — 기본 설정과 공통 모듈이 먼저 로딩된 이후에 올라와야 하기 때문이다.
nginx였다면 include 순서를 직접 제어하거나 신경 쓸 필요가 없었겠지만, Apache에서 멀티 서비스를 운영할 때 이 컨벤션이 없으면 설정 파일이 늘어날수록 로딩 순서 문제가 생긴다. 파일 이름 하나가 CaC의 일부다.
하나 첨언하자면, 나는 Cloudflare 또한 의존하지 않는다. SSL 이중화와 mod_security를 통한 이중 WAF 구축의 이유다. Cloudflare가 죽어도, 또는 Cloudflare가 정책을 바꿔도 서비스는 바로 복구될 수 있어야 한다. 벤더가 무엇이든 마찬가지다.
{your-project}-backend/Jenkinsfile
library identifier: 'shared-library@main', retriever: modernSCM([
$class: 'GitSCMSource',
remote: 'https://github.com/{your-org}/jenkins.git',
credentialsId: 'github-token'
])
def loadPipelineSecrets() {
withCredentials([string(credentialsId: 'vault-token', variable: 'VAULT_TOKEN')]) {
ocirHelper.loadCredentials('{your-service}')
def svc = readJSON text: vaultHelper.fetchJson('secret/data/services/dev/{your-service}')
vaultHelper.validateFields(svc, [
// 서비스별 필수 필드 정의
], '{your-service} config')
// 인증 (Keycloak)
env.KEYCLOAK_URL = svc.KEYCLOAK_URL.toString()
env.KEYCLOAK_REALM = svc.KEYCLOAK_REALM.toString()
env.KEYCLOAK_CLIENT_ID = svc.KEYCLOAK_CLIENT_ID.toString()
env.KEYCLOAK_CLIENT_SECRET = svc.KEYCLOAK_CLIENT_SECRET.toString()
// DB
env.DB_SCHEMA_NAME = svc.DB_SCHEMA_NAME.toString()
env.DB_SCHEMA_PASSWORD = svc.DB_SCHEMA_PASSWORD.toString()
// 캐시 (Redis) — optional
env.REDIS_HOST = svc.containsKey('REDIS_HOST') ? svc.REDIS_HOST.toString() : ''
env.REDIS_PORT = svc.containsKey('REDIS_PORT') ? svc.REDIS_PORT.toString() : ''
env.REDIS_PASSWORD = svc.containsKey('REDIS_PASSWORD') ? svc.REDIS_PASSWORD.toString() : ''
// 스토리지 (Cloudflare R2)
def r2 = readJSON text: vaultHelper.fetchJson('secret/data/infra/cloudflare/r2')
env.R2_ACCOUNT_ID = r2.R2_ACCOUNT_ID.toString()
env.R2_ACCESS_KEY_ID = r2.R2_ACCESS_KEY_ID.toString()
env.R2_SECRET_ACCESS_KEY = r2.R2_SECRET_ACCESS_KEY.toString()
env.R2_BUCKET_NAME = r2.R2_BUCKET_NAME.toString()
env.R2_PUBLIC_URL = r2.R2_PUBLIC_URL.toString()
// 메일 (SMTP)
def smtp = readJSON text: vaultHelper.fetchJson('secret/data/infra/smtp')
env.SMTP_HOST = smtp.SMTP_HOST.toString()
env.SMTP_PORT = smtp.SMTP_PORT.toString()
env.SMTP_USERNAME = smtp.SMTP_USERNAME.toString()
env.SMTP_PASSWORD = smtp.SMTP_PASSWORD.toString()
env.SMTP_FROM_ADDRESS = smtp.SMTP_FROM_ADDRESS.toString()
// DB 연결 (Oracle ADB)
def db = readJSON text: vaultHelper.fetchJson('secret/data/infra/oci/autonomous_db')
env.CONNECTION_STRING = db.CONNECTION_STRING.toString()
env.TNS_NAME = db.TNS_NAME.toString()
}
}
pipeline {
agent none
options {
buildDiscarder(logRotator(numToKeepStr: '20'))
disableConcurrentBuilds()
skipDefaultCheckout(true)
timestamps()
timeout(time: 30, unit: 'MINUTES')
}
parameters {
string(name: 'BUILD_AGENT_LABEL', defaultValue: 'service-node-{color}', trim: true)
string(name: 'DEPLOY_AGENT_LABEL', defaultValue: 'service-node-{color}', trim: true)
string(name: 'HEALTHCHECK_URL', defaultValue: 'http://127.0.0.1:{port}/healthz', trim: true)
}
environment {
APP_NAME = '{your-service}'
VAULT_ADDR = 'http://127.0.0.1:8200'
DEPLOY_ENV_FILE = '.deploy.env'
IMAGE_TAG = "${env.BUILD_NUMBER}"
}
stages {
stage('Build & Push') {
agent { label "${params.BUILD_AGENT_LABEL}" }
stages {
stage('Checkout') {
steps { checkout scm }
}
stage('Load Secrets') {
steps {
script {
loadPipelineSecrets()
sh "rm -f '${env.DEPLOY_ENV_FILE}'"
deployHelper.writeEnvFile(env.DEPLOY_ENV_FILE, currentDeployEnv(env.IMAGE_TAG))
sh "chmod 600 '${env.DEPLOY_ENV_FILE}'"
stash name: 'deploy-env', includes: "${env.DEPLOY_ENV_FILE}"
}
}
}
stage('Test') {
steps {
sh '''#!/bin/bash
set -euo pipefail
# 언어/프레임워크에 맞게 테스트 실행
docker run --rm \
-v "$PWD:/workspace" -w /workspace \
{runtime-image} \
sh -lc '{test-command}'
'''
}
}
stage('Build & Push Image') {
steps {
script { ocirHelper.buildAndPush('', params.DEPLOY_AGENT_LABEL) }
}
}
}
}
stage('Deploy') {
agent { label "${params.DEPLOY_AGENT_LABEL}" }
stages {
stage('Pull & Run') {
steps {
checkout scm
unstash 'deploy-env'
script {
sh "chmod 600 '${env.DEPLOY_ENV_FILE}'"
env.PREVIOUS_IMAGE_TAG = deployHelper.getPreviousImageTag(env.APP_NAME)
deployHelper.composeDeploy(env.DEPLOY_ENV_FILE)
}
}
}
stage('Health Check') {
steps {
sh """#!/bin/bash
set -eo pipefail
for attempt in \$(seq 1 12); do
if curl -fsS "${params.HEALTHCHECK_URL}" >/dev/null; then
exit 0
fi
sleep 5
done
exit 1
"""
}
}
}
}
}
post {
failure {
script {
if (env.PREVIOUS_IMAGE_TAG?.trim()) {
node(params.DEPLOY_AGENT_LABEL) {
unstash 'deploy-env'
deployHelper.rollback(env.DEPLOY_ENV_FILE, env.PREVIOUS_IMAGE_TAG)
}
}
}
}
always {
script {
node(params.BUILD_AGENT_LABEL) { ocirHelper.cleanup() }
node(params.DEPLOY_AGENT_LABEL) {
try { deployHelper.showLogs() } catch (e) {}
ocirHelper.cleanup()
}
}
}
}
}
Vault + Jenkins: 시크릿이 코드에 없는 파이프라인
Vault를 선택한 이유는 앞에서 말했다. 이제 실제로 어떻게 연결되는지를 보자.
Jenkins 파이프라인에서 시크릿을 다루는 방법은 크게 두 가지다. 환경변수에 직접 박거나, 외부에서 런타임에 끌어오거나. 전자는 코드에 시크릿이 남는다. .env 파일이 Git에 올라가거나, 빌드 로그에 찍히거나, 이미지 레이어에 박히는 사고가 생긴다.
간단한 프로젝트였다면 Jenkins Credentials 관리 시스템에 직접 주입하는 방식을 선택했을 것이다. 그럼에도 Vault 연동을 선택한 데는 두 가지 이유가 있다.
첫째, Jenkins가 날아가도 시크릿은 살아있어야 한다. Jenkins Credentials는 Jenkins 인스턴스에 종속된다. 커널이 꼬이거나 인스턴스가 날아가는 순간 Credentials도 같이 사라진다. Vault는 별도 인스턴스에서 독립적으로 돌아간다. Jenkins를 새로 띄워도 Vault 주소만 알면 시크릿을 그대로 참조할 수 있다.
둘째, 키 로테이션이 한 곳에서 끝난다. Jenkins Credentials 방식이면 키가 바뀔 때마다 Jenkins에 들어가서 하나씩 업데이트해야 한다. Vault 경로 하나만 바꾸면 다음 파이프라인 실행부터 자동으로 새 값을 쓴다.
withCredentials([string(credentialsId: 'vault-token', variable: 'VAULT_TOKEN')]) {
def github = readJSON text: vaultHelper.fetchJson('secret/data/infra/github')
def jenkinsCreds = readJSON text: vaultHelper.fetchJson('secret/data/services/jenkins')
...
}
vault-token은 Jenkins Credentials에만 존재한다. 파이프라인이 실행되는 순간 Vault에서 필요한 시크릿을 끌어오고, .deploy.env로 조립해서 배포에 쓰고, 파이프라인이 끝나면 사라진다. 코드 어디에도 실제 값이 없다.
여기서 Vault AppRole을 사용하지 않은 이유는, Vault 서버를 물리적으로 격리시키는 것이 아닌, 하나의 보안컨테이너에서 Jenkins과 Vault 모두를 운영하는 선택을 하였기 때문이다.
wall-backend 파이프라인은 여기서 한 단계 더 간다. Keycloak, Oracle ADB, Redis, Cloudflare R2, SMTP — 서비스 하나가 의존하는 인프라가 다섯 개다. 이걸 전부 Vault 경로별로 분리해서 관리한다.
def svc = readJSON text: vaultHelper.fetchJson('secret/data/services/dev/backend')
def r2 = readJSON text: vaultHelper.fetchJson('secret/data/infra/cloudflare/r2')
def smtp = readJSON text: vaultHelper.fetchJson('secret/data/infra/smtp')
def oracleDb = readJSON text: vaultHelper.fetchJson('secret/data/infra/oci/db')
경로 구조가 곧 인프라 구조다. infra/는 공통 인프라, services/는 서비스별 시크릿. 새 서비스가 추가되면 Vault에 경로 하나 추가하고, 파이프라인에서 그 경로를 참조하면 끝이다.
롤백도 코드에 있다.
post {
failure {
deployHelper.rollback(env.DEPLOY_ENV_FILE, env.PREVIOUS_IMAGE_TAG)
}
}
배포 전에 현재 이미지 태그를 저장해두고, 헬스체크가 실패하면 이전 태그로 되돌린다. 수동으로 개입할 필요가 없다. 새벽에 배포가 터져도 자동으로 이전 버전으로 복구된다.
이게 기반공사다. Vault가 시크릿을 들고 있고, Jenkins가 그걸 런타임에 끌어와서 배포하고, 실패하면 스스로 롤백한다. 이 구조가 잡혀 있으면 새 서비스를 추가할 때 보안 체계를 다시 설계할 필요가 없다. Vault에 경로 추가하고, Jenkinsfile에서 참조하면 된다.
jenkins/Jenkinsfile
library identifier: '{project_name}-shared@main', retriever: legacySCM(scm)
pipeline {
agent none
environment {
APP_NAME = 'jenkins'
VAULT_ADDR = 'http://127.0.0.1:8200'
DEPLOY_ENV_FILE = '.deploy.env'
IMAGE_TAG = "${env.BUILD_NUMBER}"
JENKINS_HTTP_PORT = '8080'
}
stages {
stage('Build & Push') {
agent { label 'service-node-{color}' }
steps {
checkout scm
withCredentials([string(credentialsId: 'vault-token', variable: 'VAULT_TOKEN')]) {
script {
ocirHelper.loadCredentials('jenkins')
def github = readJSON text: vaultHelper.fetchJson('secret/data/infra/github')
def appCreds = readJSON text: vaultHelper.fetchJson('secret/data/services/jenkins')
deployHelper.writeEnvFile(env.DEPLOY_ENV_FILE, [
IMAGE_TAG : env.IMAGE_TAG,
OCIR_URL : env.OCIR_URL,
OCIR_NAMESPACE : env.OCIR_NAMESPACE,
OCIR_REPO : env.OCIR_REPO,
JENKINS_HTTP_PORT : env.JENKINS_HTTP_PORT,
GITHUB_USERNAME : github.username,
GITHUB_PASSWORD : github.password,
VAULT_TOKEN_SECRET : appCreds['vault-token'],
// ... 서비스별 필요 환경변수
])
stash name: 'deploy-env', includes: env.DEPLOY_ENV_FILE
}
}
script { ocirHelper.buildAndPush() }
}
}
stage('Deploy') {
agent { label 'service-node-{color}' }
steps {
checkout scm
unstash 'deploy-env'
script {
env.PREVIOUS_IMAGE_TAG = deployHelper.getPreviousImageTag(env.APP_NAME)
ocirHelper.login()
deployHelper.composeDeploy(env.DEPLOY_ENV_FILE, 'jenkins')
sleep 30
deployHelper.healthCheck("http://127.0.0.1:${env.JENKINS_HTTP_PORT}/login")
}
}
}
}
post {
failure {
node('service-node-{color}') {
script {
deployHelper.rollback(env.DEPLOY_ENV_FILE, env.PREVIOUS_IMAGE_TAG, 'jenkins')
}
}
}
always {
node('service-node-{color}') {
script {
deployHelper.showLogs('jenkins')
ocirHelper.cleanup()
}
}
}
}
}
Jenkins 자신도 자동화 될 수 있어야 한다
Jenkins가 다른 서비스를 배포한다. 그러면 Jenkins 자신은 누가 배포하나.
답은 Jenkins다. jenkins 저장소에는 Jenkins 자신을 배포하는 Jenkinsfile이 있다. Vault에서 크레덴셜을 끌어오고, OCIR에서 이미지를 받아서, docker-compose로 자기 자신을 올린다. 헬스체크가 실패하면 이전 버전으로 롤백한다.
이게 중요한 이유가 있다. Jenkins가 수동으로만 복구 가능하다면, Jenkins가 날아가는 순간 전체 배포 체계가 멈춘다. 자동화의 자동화가 있어야 기반공사가 진짜로 완성된다.
Docker out of Docker
Jenkins는 컨테이너 위에서 돌아간다. 그런데 Jenkins가 하는 일이 다른 컨테이너를 빌드하고 배포하는 것이다. 컨테이너 안에서 Docker를 실행해야 한다는 뜻이다. 이걸 Docker out of Docker(DooD)라고 한다.
구현 방식은 간단하다. 호스트의 Docker 소켓을 Jenkins 컨테이너 안으로 마운트한다.
volumes:
- /var/run/docker.sock:/var/run/docker.sock
Jenkins 컨테이너 안에서 실행하는 docker build, docker push가 실제로는 호스트의 Docker 데몬에서 실행된다. Jenkins가 자기 자신을 배포할 때도 마찬가지다. 새 이미지를 빌드하고, 기존 컨테이너를 내리고, 새 컨테이너를 올리는 것 전부 호스트 데몬이 처리한다.
OCIR: 이미지 저장소도 비용이다
마지막 퍼즐은 컨테이너 이미지 저장소다. OCIR, Oracle Cloud Infrastructure Container Registry다. Harbor를 통한 자체 컨테이너 구축은 너무 공수가 많이 든다.
DockerHub는 무료 플랜에서 pull 횟수 제한이 있다. GitHub Container Registry는 GitHub Actions와 묶여 있을 때 가장 자연스럽다. Jenkins로 빌드하는 이 구조에서 굳이 GitHub 생태계에 의존할 이유가 없다.
OCIR은 완전 무료다. 같은 리전이든 아니든, 외부 서비스에서 pull하든 상관없다. OCI 계정만 있으면 된다. OCI ARM 인스턴스를 이미 쓰고 있는 이 구조에서 선택지가 아니라 당연한 결론이다.
ocirHelper.buildAndPush()
ocirHelper.login()
ocirHelper.cleanup()
빌드하고, 푸시하고, 배포 노드에서 풀어서 올리고, 정리한다. Vault가 시크릿을 들고, Jenkins가 빌드하고, OCIR이 이미지를 보관하고, docker-compose가 올린다. 기반공사가 끝났다.