로컬 E2E · macOS

Nullus를 로컬에서 처음부터 끝까지

처음 설치하고 띄운 뒤 GitLab + Argo CD + SonarQube 스택을 깔고, CI/CD 파이프라인을 돌리고, 백업 → 삭제 → 복구를 거쳐 그 파이프라인을 이어서 돌리는 한 바퀴다. 각 단계는 실제 스크립트와 코드에서 확인한 명령만 적었다.

기준 main 040c480 + 설치 화면·파이프라인 SAST 변경 · 2026-10-10 소요 반나절 (스택 설치만 40분~2시간) 대상 처음 로컬 환경을 꾸리는 개발자
  1. 설치
  2. 실행
  3. 스택
  4. CI/CD 템플릿
  5. 파이프라인
  6. 백업
  7. 삭제
  8. 복구
  9. 이어서 진행
SonarQube는 설치부터 파이프라인 분석까지 된다템플릿 gitlab-argocd-sonarqube-v1(또는 설치 화면 Security 탭의 SonarQube)이 SonarQube를 스택에 함께 깔고, 관리자 비밀번호를 바꾸고, Keycloak(SAML) 로그인을 붙인다. 그 스택의 파이프라인에는 sast 단계가 생겨 Quality Gate 를 통과해야 배포된다(스택 정책으로 경고로 바꿀 수 있다 — 부록 B).
삭제는 "네임스페이스 삭제"로 한다실제 스택으로 리허설된 경로가 이것뿐이다. 화면의 스택 삭제(마지막 스택이면 공용 자원까지 회수) 뒤의 복구는 백업이 Gateway CRD·GatewayClass·내부 CA를 되살리지만 cert-manager는 먼저 다시 깔아야 하고, 전 과정 리허설은 아직 없다(11단계).
배지 읽는 법실측 기록은 CHANGELOG·리허설에 실제로 돌린 기록이 있는 단계, 코드 기준은 코드를 읽고 쓴 단계다. 후자는 처음 돌리는 사람이 확인하게 된다.
몇 군데는 손이 간다GitLab 포트포워딩, 백업 설정, 복구 후 주소 갱신은 아직 자동화되지 않았다. 해당 단계에 수동 표시가 있다.

준비

01

준비물

실측 기록

도구는 Homebrew로 한 번에 깐다. Docker는 Docker Desktop이나 OrbStack 중 하나를 쓴다.

터미널
brew install go node kind kubectl helm jq libpq
brew install --cask orbstack      # 또는 Docker Desktop
리눅스에서는Go·Node·kind·kubectl·helm 은 각 배포처의 바이너리를 받아 ~/.local/bin 에 두면 된다. pg_dump 는 주의가 필요하다 — 10단계의 백업이 서버와 같은 메이저 버전의 pg_dump 를 요구하는데(스택 Postgres 는 17) Ubuntu 24.04 의 postgresql-client 는 16 까지만 준다. PGDG 저장소를 붙여 postgresql-client-17 을 깐다. 컨테이너에서 바이너리만 꺼내 쓰는 방법은 통하지 않는다 — postgres:17 이미지는 glibc 2.41 로 빌드돼 있어 호스트(2.39)에서 실행되지 않는다.
도구버전비고
Go1.26.1 이상README의 "1.24+"는 낡았다. go.mod가 1.26.1을 요구한다
Node22.12 이상Vite 8 요구사항
helm3.x 또는 4.xREADME 기준은 3.x. preflight는 "선택"이라 표시하지만 up은 없으면 멈춘다
kind · kubectl최신로컬 클러스터 2개를 만든다
jq · python3—스크립트가 쓴다. python3는 macOS 기본
libpqPostgres 17 이상백업이 pg_dump를 부른다(10단계)
Docker에 자원을 넉넉히 준다GitLab 스택은 무겁다. 템플릿 계획값이 12 vCPU · 24 GiB(SonarQube 포함)이고, 4 vCPU 환경에서는 GitLab 스택이 뜨지 않았다는 기록이 있다. SonarQube는 분석 없이 떠 있기만 해도 메모리를 2.7 GiB 쓴다. Docker 설정에서 CPU 8개 이상, 메모리 20 GB 이상(가능하면 24 GB)을 준다.

저장소를 받는다.

터미널
git clone https://github.com/cloud-nullus/nullus.git
cd nullus
PATH 확인go·kubectl·helm이 "command not found"면 export PATH=/opt/homebrew/bin:$PATH를 셸 설정에 넣는다. API는 로그인 셸(bash -l)로 뜨므로 ~/.bash_profile이나 ~/.zprofile에서도 보여야 한다.
02

플랫폼 띄우기

실측 기록

up --kind 하나가 Postgres·Redis·MinIO·Keycloak을 띄우고, 마이그레이션을 돌리고, API(8090)와 웹(5173)을 띄우고, kind 클러스터 둘을 만들어 Nullus에 등록한다.

터미널
# 설치되는 도구가 쓸 Keycloak 주소를 정해 둔다 — up 보다 앞서야 한다
export NULLUS_LOCAL_DOMAIN=nullus.local

./scripts/runbook_local.sh preflight
./scripts/runbook_local.sh up --kind
./scripts/setup-local-domain.sh --apply-hosts   # CoreDNS 와 호스트 /etc/hosts 둘 다에 keycloak.<도메인> 을 넣는다 (sudo)
./scripts/runbook_local.sh status
./scripts/runbook_local.sh smoke
서비스주소계정
웹http://localhost:51733단계 참고
APIhttp://localhost:8090개발 모드라 인증 없음
Postgreslocalhost:5433nullus / nullus_dev
MinIOlocalhost:9000 (콘솔 9001)nullus / nullus_dev
Keycloakhttp://localhost:8180admin / admin
kindkind-nullus-platform (컨트롤 1 + 워커 2)
kind-nullus-develop (컨트롤 1 + 워커 1)
스택은 platform에 깐다
NULLUS_LOCAL_DOMAIN 을 빼면 4단계에서 설치가 멈춘다이 값이 없으면 설치되는 도구의 OIDC issuer 가 http://localhost:8180 으로 박힌다. 그 주소는 파드 안에서 파드 자신을 가리키므로 Keycloak 에 닿지 못한다. MinIO 는 OIDC 초기화가 끝나야 admin API 를 열기 때문에, 차트의 버킷 부트스트랩 Job 이 Server not initialized yet 으로 계속 실패하고 installing_minio 에서 설치가 멈춘다(2026-10-17 실측: 18분간 4회 재시도 후 Error). setup-local-domain.sh 를 스택 설치 뒤(5단계)에 돌리면 이미 늦다 — 그 시점에는 닿지 못하는 주소가 도구에 들어간 상태다.
--apply-hosts 를 빼면 3단계 로그인이 안 된다up 이 NULLUS_LOCAL_DOMAIN 을 보고 API·포털의 OIDC issuer 를 http://keycloak.<도메인>:8180/realms/nullus 로 잡는다. 그 이름은 호스트의 /etc/hosts 에 있어야 풀린다 — setup-local-domain.sh 가 넣는 것은 클러스터 안의 CoreDNS 뿐이고, 호스트 쪽은 --apply-hosts 를 붙이거나 그 스크립트가 출력하는 sudo 명령을 직접 돌려야 들어간다. 2026-10-17 실측(/etc/hosts 를 비운 새 환경): 넣기 전에는 curl http://keycloak.nullus.local:8180/... 가 000 Could not resolve host, localhost:8180 은 200 이었다 — 포털이 리다이렉트할 주소를 브라우저가 찾지 못하는 상태다. 넣은 뒤 같은 주소가 200, 로그인 3/3 이 됐다. 5단계의 port-forward-gateway.sh --hosts 는 도구 이름만 다루고 keycloak.<도메인> 은 포함하지 않으므로 그것으로 대신할 수 없다.
all 대신 up을 쓴다runbook_local.sh all은 도중에 실패하면 띄운 것을 전부 내린다. 처음에는 up과 smoke를 따로 돌려야 어디서 막혔는지 남는다.

확인

  • status에서 API /health가 OK이고 kind 노드가 Ready다
  • smoke가 실패 없이 끝난다
  • 로그는 ./scripts/runbook_local.sh logs api로 본다
03

로그인·클러스터 확인

실측 기록

http://localhost:5173을 열면 Keycloak 로그인으로 넘어간다. admin@nullus.io / nullus123!로 들어간다.

Keycloak 없이 가볍게 보려면up --kind --auth=none으로 띄우면 모의 로그인 화면이 나온다(admin@nullus.dev / admin123). 이때 이후의 refresh에도 똑같이 --auth=none을 붙여야 한다.

이제부터 쓸 터미널 변수를 정해 둔다. 새 터미널을 열 때마다 다시 넣으므로 파일로 둔다.

~/.nullus-e2e.env 만들기
cat > ~/.nullus-e2e.env <<'EOF'
export API=http://localhost:8090
export CTX=kind-nullus-platform
export NS=nullus-gitlab-argocd-sonarqube # 스택 이름이자 네임스페이스 (stack-up 기본값)
export DOMAIN=nullus.local
export NULLUS_LOCAL_DOMAIN=nullus.local # 런북·설치기가 읽는다. 빠지면 issuer 가 localhost 로 되돌아간다
export ORG=11111111-1111-1111-1111-111111111111
export APP=demo-api                     # 8단계에서 만들 앱
export APP_NS=demo                      # 앱이 배포될 네임스페이스
EOF
source ~/.nullus-e2e.env

클러스터 두 개가 등록됐는지 본다.

터미널
curl -s $API/api/v1/admin/clusters | jq '.items[] | {name, types}'

확인

  • kind-nullus-platform(pipeline)과 kind-nullus-develop(target)이 보인다
  • 안 보이면 API가 떠 있는 상태에서 ./scripts/register-kind-clusters.sh를 돌린다

스택

04

GitLab + Argo CD + SonarQube 스택 설치

실측 기록 (GitLab + Argo CD)실측 기록 (SonarQube 단계)코드 기준 (템플릿 전체)

템플릿 gitlab-argocd-sonarqube-v1은 GitLab CE · GitLab Runner · GitLab Registry · MinIO · Argo CD · Prometheus · Grafana에 SonarQube Community를 더해 깐다. SonarQube는 스택의 PostgreSQL 안에 전용 DB(sonarqube)를 만들어 쓴다. CLI로 깔아야 설치가 끝난 뒤 kind 노드에 레지스트리 주소와 내부 CA까지 넣어 준다. 화면으로 깔면 이 배선이 빠진다.

터미널
./scripts/runbook_local.sh stack-up --template=gitlab-argocd-sonarqube-v1 --wait
STACK_ID=$(curl -s $API/api/v1/stacks | jq -r --arg n "$NS" '.items[] | select(.name==$n) | .id' | head -1)
echo "export STACK_ID=$STACK_ID" >> ~/.nullus-e2e.env      # 출력의 "stack created: stk_..." 와 같아야 한다

기본값은 이름·네임스페이스 nullus-gitlab-argocd-sonarqube, 도메인 nullus.local, 클러스터 kind-nullus-platform이다. 다른 터미널에서 파드가 뜨는 것을 본다.

다른 터미널
source ~/.nullus-e2e.env
kubectl --context $CTX -n $NS get pods -w
60분에서 기다림이 끊기면--wait는 60분까지만 기다린다. 끊겼으면 ./scripts/runbook_local.sh stack-status $STACK_ID --wait로 이어서 기다린다 — 완료되면 노드 배선과 GitLab 포트포워드까지 같이 한다. 화면에서 설치했거나 복구로 되살아난 스택처럼 런북이 끝을 보지 못한 스택은 ./scripts/runbook_local.sh wire(또는 wire --stack=$STACK_ID)로 같은 배선을 한다. 몇 번 돌려도 안전하다.
보안 도구를 더 넣으려면 --security=image_scanner,sca(Trivy 이미지 스캔 · 의존성 스캔)를 붙인다 — 설치 마법사 Security 탭과 같다. 시연 영상은 이 옵션으로 깔아 파이프라인이 시크릿 · 의존성 · 빌드 · SAST · 이미지 · 배포 6단계가 되고 보안 대시보드의 설치 OSS 탭이 채워진다.
SonarQube 없이 가볍게 하려면--template=gitlab-argocd-v1로 깔고 환경 파일의 NS를 nullus-gitlab-argocd로 바꾼다. 5단계의 SonarQube 줄과 부록 B는 건너뛴다.

확인

  • 마지막에 kind 노드 배선 완료가 찍힌다
  • sonarqube-0이 1/1 Running이고 nullus-sonarqube-bootstrap Job이 Complete다(SonarQube가 처음 뜨는 데 2분쯤 걸린다)
  • curl -s $API/api/v1/stacks/$STACK_ID | jq .state 가 "completed"다
  • 파드가 실제로 떠 있다: kubectl --context $CTX -n $NS get pods --no-headers | wc -l이 0이 아니다(0이면 시뮬레이션으로 "완료"된 것이다)
05

도구 열기

실측 기록

스택 도구는 게이트웨이 뒤에서 gitlab.nullus.local, argocd.nullus.local, sonarqube.nullus.local 같은 이름으로 갈린다. kind에는 외부 로드밸런서가 없으므로 스크립트가 도커 네트워크 안에 443 라우터를 세운다. sudo가 필요 없다.

터미널
./scripts/port-forward-gateway.sh
./scripts/port-forward-gateway.sh --hosts          # /etc/hosts 에 넣을 줄을 보여 준다
./scripts/port-forward-gateway.sh --hosts | sudo tee -a /etc/hosts

./scripts/setup-local-domain.sh                    # 내부 CA 를 꺼내고 신뢰 등록 명령을 알려 준다
# macOS
sudo security add-trusted-cert -d -r trustRoot \
  -k /Library/Keychains/System.keychain .runbook-logs/nullus-internal-ca.crt
# 리눅스 — 스크립트가 출력하는 명령과 같다
sudo cp .runbook-logs/nullus-internal-ca.crt /usr/local/share/ca-certificates/ \
  && sudo update-ca-certificates

초기 비밀번호는 클러스터의 Secret에서 꺼낸다.

터미널
# GitLab — 사용자 root
kubectl --context $CTX -n $NS get secret gitlab-initial-root-password \
  -o jsonpath='{.data.password}' | base64 -d; echo
# Argo CD — 사용자 admin. 키는 clearPassword 다(password 키는 없다)
kubectl --context $CTX -n $NS get secret argocd-secret \
  -o jsonpath='{.data.clearPassword}' | base64 -d; echo
# SonarQube — 관리자 admin. 일반 사용자는 로그인 화면의 Keycloak 버튼으로 들어간다
kubectl --context $CTX -n $NS get secret nullus-sonarqube-credentials \
  -o jsonpath='{.data.admin-password}' | base64 -d; echo
Argo CD 는 clearPassword 키다argocd-secret 에는 password 라는 키가 없다 — 들어 있는 것은 admin.password(bcrypt 해시)와 clearPassword(평문)다. {.data.password} 를 읽으면 빈 값이 나오고, 그 값으로 로그인하면 {"error":"no credentials supplied","code":16} 가 돌아온다. 차트가 만드는 argocd-initial-admin-secret 은 이 스택에 없다(2026-10-17 실측).
Secret 이름연결 정보 창과 설치기가 같은 gitlab-initial-root-password(금고 값을 ESO 가 프로비저닝)를 가리키고, GitLab 차트가 이것을 초기 root 비밀번호로 읽는다. 이 변경 전에 설치한 스택은 차트가 스스로 만든 gitlab-gitlab-initial-root-password가 실제 비밀번호다 — 로그인이 안 되면 그쪽을 읽는다.

확인

  • https://gitlab.nullus.local에 root로 로그인된다
  • https://argocd.nullus.local에 admin으로 로그인된다
  • https://sonarqube.nullus.local에 admin으로 로그인되고, admin / admin은 거부된다
  • SonarQube 로그인 화면의 Keycloak 버튼으로 admin@nullus.io가 들어가진다(일반 사용자 권한). --auth=none으로 띄웠으면 버튼이 없다 실측 기록 (SAML 흐름)
06

API 연결 설정 — GitLab 주소와 백업

코드 기준

로컬 API는 클러스터 밖(맥)에서 돈다. GitLab 주소는 런북이 넣고, 백업 설정은 환경 파일에 둔다.

  • GitLab 주소: 파이프라인을 만들 때 API가 GitLab에 저장소를 만든다. 기본 주소는 클러스터 내부 DNS라 맥에서 풀리지 않는다. stack-up --wait가 끝나면 런북이 GitLab 스택을 찾아 재접속 루프 포트포워드(:8181부터)를 띄우고 NULLUS_GITLAB_URL(네임스페이스=주소 형식, 스택이 여럿이면 쉼표로 이음)을 넣어 API를 다시 띄운다. up·refresh도 같은 일을 하므로 따로 할 것은 없다. 포워드는 status의 gitlab[...] 줄로 확인하고, down이 함께 내린다.
  • 백업 설정: 백업 모듈은 암호화 키와 목적지가 없으면 꺼진 채로 뜬다. ~/.nullus-api.env에 두면 up·refresh가 읽는다(다른 경로는 NULLUS_API_ENV_FILE).

① 포워드 확인. 5단계를 --wait로 돌렸으면 이미 떠 있다. 아니면 한 번 부른다.

터미널
./scripts/runbook_local.sh gitlab-forward      # NULLUS_GITLAB_URL=nullus-gitlab-argocd-sonarqube=http://127.0.0.1:8181
./scripts/runbook_local.sh status | grep gitlab

② 백업 목적지 준비. 로컬 MinIO(localhost:9000)를 목적지로 쓴다. 스택 안의 MinIO는 스택과 함께 지워지므로 쓰면 안 된다. 로컬 Keycloak은 Postgres DB가 없으므로 빈 DB를 대역으로 만든다.

터미널
docker compose -f docker-compose.dev.yaml exec minio \
  mc alias set local http://localhost:9000 nullus nullus_dev
docker compose -f docker-compose.dev.yaml exec minio mc mb local/nullus-backup
docker compose -f docker-compose.dev.yaml exec postgres \
  psql -U nullus -d nullus -c 'CREATE DATABASE keycloak;'

③ API 환경 파일을 만들고 재기동한다. 런북이 이 파일을 읽으므로 source는 필요 없다. GitLab 주소를 여기 적으면 자동 탐지보다 우선한다(보통은 적지 않는다).

~/.nullus-api.env 만들기 + 재기동
cat > ~/.nullus-api.env <<'EOF'
NULLUS_BACKUP_ENABLED=true
# 정확히 32바이트, ENCRYPTION_KEY·DB 비밀번호와 다른 값
NULLUS_BACKUP_SEAL_KEY='nullus-backup-seal-key-32bytes!!'
NULLUS_BACKUP_DESTINATION_ENDPOINT=localhost:9000
NULLUS_BACKUP_DESTINATION_BUCKET=nullus-backup
NULLUS_BACKUP_DESTINATION_ACCESS_KEY=nullus
NULLUS_BACKUP_DESTINATION_SECRET_KEY=nullus_dev
NULLUS_BACKUP_DESTINATION_USE_SSL=false
NULLUS_BACKUP_KEYCLOAK_DATABASE_HOST=localhost
NULLUS_BACKUP_KEYCLOAK_DATABASE_PORT=5433
NULLUS_BACKUP_KEYCLOAK_DATABASE_NAME=keycloak
NULLUS_BACKUP_KEYCLOAK_DATABASE_USER=nullus
NULLUS_BACKUP_KEYCLOAK_DATABASE_PASSWORD=nullus_dev
EOF

export PATH="$(brew --prefix libpq)/bin:$PATH"     # pg_dump
source ~/.nullus-e2e.env                           # NULLUS_LOCAL_DOMAIN 을 물려받는다 — 아래 경고
./scripts/runbook_local.sh refresh                 # up 을 --auth=none 으로 띄웠으면 여기도 붙인다
grep '백업 모듈 준비 완료' .runbook-logs/api.log
./scripts/runbook_local.sh status | grep gitlab          # NULLUS_GITLAB_URL 은 런북이 stdout 으로 찍는다
refresh 도 NULLUS_LOCAL_DOMAIN 을 봐야 한다이 값은 셸 환경 변수라 ~/.nullus-api.env 에 들어 있지 않다. 없는 셸에서 refresh 를 돌리면 API 와 web/.env.development.local 의 issuer 가 localhost:8180 으로 되돌아가는데, 이미 설치된 도구는 keycloak.<도메인>:8180 을 계속 쓰므로 포털과 도구의 issuer 가 갈라져 SSO 가 끊긴다. 2026-10-17 실측: refresh 한 번으로 NULLUS_AUTH_OIDC_ISSUER_URL 이 http://keycloak.nullus.local:8180/realms/nullus → http://localhost:8180/realms/nullus 로 바뀌었고, 같은 시점 Grafana·MinIO 의 설정은 keycloak.nullus.local:8180 그대로였다. 3단계의 환경 파일을 source 했으면 물려받으므로 따로 할 것이 없다. 그 뒤에 스택을 새로 깔면 2단계의 installing_minio 실패가 다시 나타난다.
재기동 경로가 셋이어도 환경은 같다up·refresh·stack-up --wait 끝의 재기동이 모두 같은 함수(start_api)로 API를 띄운다 — 환경 파일을 읽고 GitLab 포워드를 보장한 뒤 띄우므로 어느 길로 다시 떠도 GitLab 주소와 백업 설정이 빠지지 않는다. 백업 설정이 틀리면 API가 아예 뜨지 않는다(의도된 동작) — logs api에서 이유를 본다.

확인

  • ./scripts/runbook_local.sh status에 gitlab[nullus-gitlab-argocd-sonarqube]: forwarded on :8181이 있다
  • API 기동 로그(런북 출력)에 NULLUS_GITLAB_URL=nullus-gitlab-argocd-sonarqube=http://127.0.0.1:8181 (auto: gitlab port-forward)가 있다
  • API 로그에 백업 모듈 준비 완료 destination=localhost:9000 bucket=nullus-backup가 있다

CI/CD

07

CI/CD 템플릿 고르기

코드 기준

CI/CD 템플릿은 미리 들어 있다. 따로 "설치"할 것은 없고 하나를 골라 파이프라인 설정으로 넘어간다.

  1. 왼쪽 메뉴 CI/CD 템플릿을 연다(admin·devops에게만 보인다).
  2. Nullus Sample App — Backend 카드에서 기본 템플릿 사용을 누른다.
  3. Pipeline 설정 화면으로 넘어간다.
템플릿id만들어지는 앱
Nullus Sample App — Backendnullus-sample-backend-v1nginx 자리표시자 이미지 (가장 빠르다)
Nullus Sample App — Frontendnullus-sample-frontend-v1React 19 + Vite 앱
User Custom Pipelineweb-backend-v1nginx 자리표시자 이미지
Batch Job Pipelinebatch-job-v1nginx 자리표시자 이미지
"템플릿 생성"으로 만든 템플릿은 DB 에 저장된다목록과 아래 배포 화면에 바로 나오고, 같은 ID 로 다시 만들면 409 로 거부된다. 폼의 애플리케이션 유형은 web·backend·batch 중 하나다(파이프라인의 app_type ENUM 과 같다). 기본 템플릿을 수정하면 이름·설명·유형·기능만 바뀌고 Dockerfile 경로·환경 변수 같은 빌드 설정은 그대로 남는다. 어느 템플릿이든 저장소에 만들어지는 파일 구성은 같고, 템플릿은 화면 입력값을 채워 주는 역할을 한다.
08

파이프라인 만들고 실행

코드 기준

Pipeline 설정 화면에서 아래만 정하면 된다. 나머지 칸은 템플릿 값이 채워져 있다.

칸값설명
앱 이름demo-api소문자·숫자·하이픈. GitLab 프로젝트·Deployment·Argo CD 앱·호스트 이름이 된다
Stack 선택nullus-gitlab-argocd-sonarqube스택이 있어야 GitOps 경로가 된다
클러스터kind-nullus-platform스택이 있는 클러스터를 고른다. 다른 클러스터를 주면 API 가 STACK_CLUSTER_MISMATCH 로 거부한다 — 스택의 Argo CD 는 자기 클러스터에만 배포한다(internal/cicd/domain/deploy_cluster.go)
네임스페이스새 네임스페이스 demo앱이 배포될 곳

매니페스트 확인으로 미리 본 뒤 실행을 누른다. 그러면 다음이 자동으로 일어난다.

  1. GitLab에 그룹 nullus와 프로젝트 nullus/demo-api를 만든다.
  2. .gitlab-ci.yml·Dockerfile·deploy/ 매니페스트를 첫 커밋으로 올린다. 이 커밋이 첫 GitLab 파이프라인을 시작한다.
  3. CI 변수(배포 토큰 등), 이미지 pull Secret, Argo CD 저장소 Secret과 Application demo-api를 만든다.
"실행"은 GitLab 파이프라인을 만든다화면은 파이프라인을 만든 뒤 Nullus 쪽 "실행"을 한 번 더 부른다. 이 넘김이 GitLab 스택에서는 POST /projects/:id/pipeline로 main 파이프라인을 만들고, 실행 기록의 Trigger CI 단계에 파이프라인 주소를 남긴다. 생성 직후처럼 그 브랜치의 최신 파이프라인(위 2번의 스캐폴딩 커밋)이 아직 돌고 있으면 새로 만들지 않고 그 실행에 붙는다 — 같은 커밋의 배포 잡 둘이 되커밋을 밀면 둘째가 실패한다. 프로젝트가 아직 없으면 "프로비저닝이 끝나지 않았다"로 단계가 실패한다.
스택에 SonarQube 가 있으면 sast 단계가 생긴다 실측 기록 빌드와 함께 돌아 sonar-scanner 로 소스를 분석하고, Quality Gate 를 통과해야 deploy 가 돈다. 분석 토큰(SONAR_TOKEN, 가려짐)과 정책 변수는 Nullus 가 프로젝트 CI/CD 변수로 넣는다 — 스택 설치가 발급해 둔 토큰이라 따로 만들 것이 없다.
화면 대신 API로 만들기
터미널
# 스택이 선 클러스터를 쓴다. target 클러스터를 주면 STACK_CLUSTER_MISMATCH 로 거부된다.
STACK_CLUSTER_ID=$(curl -s $API/api/v1/admin/clusters \
  | jq -r '.items[] | select(.name=="kind-nullus-platform") | .id')

curl -s -X POST $API/api/v1/cicd/pipelines -H 'Content-Type: application/json' \
  -d "{\"name\":\"$APP\",\"template_id\":\"nullus-sample-backend-v1\",\"app_type\":\"backend\",
       \"cluster_id\":\"$STACK_CLUSTER_ID\",\"stack_id\":\"$STACK_ID\",\"namespace\":\"$APP_NS\",
       \"port\":8080,\"replicas\":1}" \
  | tee ~/nullus-pipeline.json \
  | jq '{id: .pipeline.id, repository_path, argo_application_created, warnings}'

GitLab 스택에서는 /deploy를 부르지 않는다. 다시 돌리려면 커밋을 올리거나 GitLab에서 Run pipeline을 누른다.

파이프라인 ID를 받아 둔다.

터미널
PIPELINE_ID=$(curl -s $API/api/v1/cicd/pipelines | jq -r --arg n "$APP" '.items[] | select(.name==$n) | .id')
echo "export PIPELINE_ID=$PIPELINE_ID" >> ~/.nullus-e2e.env
"파이프라인은 만들어졌지만 준비가 끝나지 않았습니다"가 뜨면만들면서 경고가 났다는 뜻이다. 화면은 여기서 멈추지만 GitLab 파이프라인은 이미 시작됐을 수 있다. 경고 내용은 API 응답의 warnings에 있다.
09

결과 확인·지문 기록

코드 기준

성공하면 네 곳이 서로 맞물린다. GitLab이 이미지를 커밋 SHA 태그로 올리고, deploy 잡이 그 태그를 deploy/deployment.yaml에 되커밋하고, Argo CD가 그것을 배포한다.

어디서무엇을 본다
GitLabhttps://gitlab.nullus.local/nullus/demo-api/-/pipelines에서 build → deploy 성공, 봇 커밋 chore(deploy): <sha> [skip ci]
레지스트리프로젝트의 Container Registry에 registry.nullus.local/nullus/demo-api:<short-sha>
Argo CDApplication demo-api가 Synced / Healthy
NullusCI/CD 목록 → 파이프라인 → History에 실행 #1(ci). 10분마다 GitLab에서 끌어오며, 아래 deployments 조회가 즉시 끌어온다
터미널
kubectl --context $CTX -n $NS get applications.argoproj.io $APP
kubectl --context $CTX -n $APP_NS get deploy,pods,svc
curl -s "$API/api/v1/cicd/deployments?pipeline_id=$PIPELINE_ID" \
  | jq '.items[] | {version, status, triggeredBy}'

# 앱 응답 (다른 터미널에서 포트포워딩)
kubectl --context $CTX -n $APP_NS port-forward svc/$APP 8080:8080
curl -s localhost:8080 | head

복구를 확인할 지문을 지금 남긴다. "화면이 뜬다"는 복구 확인이 아니다. 백업 전 값과 복구 후 값을 비교한다.

지문 기록
cat > ~/.nullus-fingerprint.sh <<'EOF'
fingerprint() {
  echo "image=$(kubectl --context $CTX -n $APP_NS get deploy $APP -o jsonpath='{.spec.template.spec.containers[0].image}')"
  echo "argo=$(kubectl --context $CTX -n $NS get applications.argoproj.io $APP -o jsonpath='{.status.sync.revision}')"
  curl -s "$API/api/v1/cicd/deployments?pipeline_id=$PIPELINE_ID" \
    | jq -r '.items[] | "run=\(.version) \(.status)"' | sort
}
EOF
source ~/.nullus-e2e.env; source ~/.nullus-fingerprint.sh
fingerprint | tee ~/nullus-before.txt
빈 값이 있으면 다음으로 넘어가지 않는다image=나 argo= 뒤가 비었으면 수집이 실패한 것이다. 빈 지문끼리 비교하면 "일치"로 보여 복구를 잘못 통과시킨다.
잠깐 :bootstrap 태그로 ImagePullBackOff 가 보인다 — 실패가 아니다스캐폴딩 커밋의 deploy/deployment.yaml 은 자리표시자 태그 :bootstrap 을 담고 있고, 그 태그는 레지스트리에 없다. deploy 잡이 실제 태그로 되커밋한 뒤 Argo CD 가 그 커밋을 가져와야 바뀌는데, Argo CD 의 기본 폴링 주기(3분) 안에는 앞 커밋에 Synced 로 앉아 있다. 기다리거나 한 번 밀어 준다.
kubectl --context $CTX -n $NS annotate applications.argoproj.io $APP argocd.argoproj.io/refresh=hard --overwrite

확인

  • 앱 이미지 태그가 :bootstrap이 아니라 커밋 SHA다 — 바로 위의 안내를 먼저 읽는다
  • SonarQube 스택이면 GitLab 파이프라인에 build · sast · deploy 가 있고 sast 잡 로그 끝에 QUALITY GATE STATUS: PASSED 가 있다. https://sonarqube.nullus.local 에 앱 이름의 프로젝트가 생겼다. History 의 그 실행에 SAST · Quality Gate passed 가 붙는다
  • ~/nullus-before.txt의 세 항목이 모두 채워져 있다

백업·복구

10

백업

실측 기록API로

full 백업은 플랫폼 DB · Keycloak DB · OpenBao · 스택 네임스페이스 리소스 · 볼륨(PVC)을 모두 담는다. 볼륨을 담는 동안 스택의 Deployment·StatefulSet을 0으로 내렸다가 되돌리므로 그 사이 스택이 멈춘다. SonarQube의 분석 결과는 스택 PostgreSQL의 sonarqube DB에, 검색 색인은 자기 볼륨에 있어 같은 백업에 함께 담긴다.

먼저 복구 뒤에 쓸 게이트웨이 주소를 적어 둔다(13단계).

터미널
OLD_IP=$(kubectl --context $CTX -n $NS get svc -l gateway.envoyproxy.io/owning-gateway-name \
  -o jsonpath='{.items[0].spec.clusterIP}')
echo "export OLD_IP=$OLD_IP" >> ~/.nullus-e2e.env

백업을 실행한다. 요청이 끝날 때까지 붙잡고 있는 동기 호출이라 몇 분 걸린다.

터미널
curl -sX POST $API/api/v1/admin/backups \
  -H 'Content-Type: application/json' -H "X-Org-Id: $ORG" \
  -d "{\"mode\":\"full\",\"stack_id\":\"$STACK_ID\",\"namespace\":\"$NS\",\"confirm\":\"$NS\"}" \
  | tee ~/nullus-backup.json | jq '{status, error}'

BID=$(curl -s $API/api/v1/admin/backups -H "X-Org-Id: $ORG" | jq -r '.items[0].id')
echo "export BID=$BID" >> ~/.nullus-e2e.env

curl -sX POST $API/api/v1/admin/backups/$BID/verify -H "X-Org-Id: $ORG" | jq
docker compose -f docker-compose.dev.yaml exec minio mc ls --recursive local/nullus-backup
백업 중에 Ctrl-C로 끊지 않는다워크로드를 되돌리는 처리가 같은 요청에 묶여 있어, 끊으면 스택이 0으로 내려간 채 남을 수 있다. confirm은 다운타임을 알고 누른다는 확인이라 네임스페이스와 같아야 한다.
스택이 둘 이상이면 — 전체 백업(세트)

한 실행은 플랫폼 하나 또는 스택 하나만 담는다. "플랫폼 전체"는 세트로 뜬다 — 플랫폼 platform_only 실행 하나 + 설치 완료 스택마다 stack_only(+ 금고) 실행 하나가 같은 set_id로 묶인다. 모든 스택이 차례로 멈추므로 확인 문구는 네임스페이스가 아니라 all이다. 화면(관리 → 백업)의 대상 맨 위 전체 — 플랫폼 + 모든 스택도 같은 것이다.

터미널
curl -sX POST $API/api/v1/admin/backups/sets \
  -H 'Content-Type: application/json' -H "X-Org-Id: $ORG" \
  -d '{"confirm":"all"}' | tee ~/nullus-backup-set.json | jq '{set_id, status, failed}'
SET_ID=$(jq -r .set_id ~/nullus-backup-set.json); echo "export SET_ID=$SET_ID" >> ~/.nullus-e2e.env

복구는 POST /admin/restores/sets에 {"set_id":…,"confirm":…}(확인 = 세트 ID)로 — 플랫폼을 먼저, 스택을 하나씩 되돌린다. 절차와 확인 항목은 백업복구 로컬 테스트 가이드 §4.11(2026-10-12 kind 두 클러스터 실측 통과 — 백업 137초, 복구 323초). 복구 뒤 멤버마다 integrity_report.vault_import를 본다.

화면(관리 → 백업)으로는 스택 백업이 안 된다화면이 네임스페이스를 nullus-app으로 고정하고 스택 ID를 보내지 않아, 볼륨·리소스를 고르면 실패한 채 "running"에 머문다. 기존 문서 예시의 X-Org-Id: 0000…0001도 쓰지 않는 조직이라 목록에 안 보인다. 위처럼 1111…을 쓴다.

확인

  • 응답 status가 succeeded다(partial이면 error가 빠진 항목을 말한다. 흔한 원인은 pg_dump가 PATH에 없는 것)
  • verify가 통과하고 MinIO에 backup-…/volumes/*.tar가 보인다
  • 스택 파드가 원래 개수로 돌아왔다
11

삭제

실측 기록

스택 네임스페이스를 통째로 지운다. 볼륨까지 함께 사라지므로 백업 없이는 되돌릴 수 없는 상태가 된다. Nullus의 스택 기록과 앱 네임스페이스(demo)는 그대로 남는다.

터미널
kubectl --context $CTX delete namespace $NS --wait=false
kubectl --context $CTX wait --for=delete namespace/$NS --timeout=20m
옛 스택이 Terminating 에서 멈추면

지금 설치하는 스택은 위 두 줄로 끝난다 — 2026-10-17 실측, ExternalSecret 14개가 있는 완전한 스택에서 201초. #327·#328·#330 이전에 깐 스택만 막히고, 그때는 아래로 회수한다. finalizer 는 파드가 다 사라진 뒤에 비운다 — 컨트롤러가 살아 있는 동안 지우면 다시 붙는다.

터미널
kubectl --context $CTX delete validatingwebhookconfiguration \
  externalsecret-validate secretstore-validate --ignore-not-found
kubectl --context $CTX delete apiservice v1beta1.metrics.k8s.io --ignore-not-found

while [ "$(kubectl --context $CTX -n $NS get pods --no-headers 2>/dev/null | wc -l)" -ne 0 ]; do
  sleep 10
done

for es in $(kubectl --context $CTX -n $NS get externalsecrets \
    -o jsonpath='{range .items[*]}{.metadata.name}{"\n"}{end}' 2>/dev/null); do
  kubectl --context $CTX -n $NS patch externalsecret "$es" \
    --type merge -p '{"metadata":{"finalizers":null}}'
done
화면의 스택 삭제·stack-down·pipeline-down은 이 시나리오에서 쓰지 않는다

이 가이드는 네임스페이스 삭제 경로다. 제품 기능으로 클러스터의 마지막 스택을 지우면 cert-manager·metrics-server·GatewayClass·Gateway API CRD·내부 CA까지 회수되고(#284), 그 뒤의 복구는 다른 길이다 — 복구가 cert-manager·metrics-server를 스택 설치기의 같은 단계로 스스로 다시 세운 뒤 백업의 shared_resources로 Gateway CRD·GatewayClass·내부 CA를 되살린다(2026-10-12 실측, 백업복구 설계 §10.2.3). 그 절차는 백업복구 로컬 테스트 가이드 §4.10에 있다.

스택 기록도 soft delete되므로 되살리려면 플랫폼 DB까지(full) 복구해야 한다. pipeline-down은 파이프라인 기록을 지운다.

확인

  • kubectl --context $CTX get ns $NS가 NotFound다(Terminating이 아니다)
12

복구

실측 기록 (full)코드 기준 (stack_only)

복구가 네임스페이스를 만든다(없을 때만). 지운 네임스페이스가 아직 Terminating이면 끝날 때까지 기다린 뒤 부른다. 확인값 confirm은 백업 ID와 같아야 한다.

터미널
kubectl --context $CTX wait --for=delete namespace/$NS --timeout=300s

curl -sX POST $API/api/v1/admin/restores \
  -H 'Content-Type: application/json' -H "X-Org-Id: $ORG" \
  -d "{\"backup_run_id\":\"$BID\",\"namespace\":\"$NS\",\"mode\":\"stack_only\",\"confirm\":\"$BID\"}" \
  | tee ~/nullus-restore.json | jq '{status, schema_check, integrity_report}'

kubectl --context $CTX -n $NS get pods -w
mode되돌리는 것언제 쓰나
stack_only (권장)네임스페이스 리소스와 볼륨이 가이드처럼 네임스페이스만 지웠을 때. 플랫폼 DB를 건드리지 않는다
full위 + 플랫폼 DB · Keycloak DB · OpenBao KV리허설된 모드. 플랫폼 DB가 백업 시점으로 돌아간다. 백업 이력(백업·복구 기록)은 되돌리지 않는다 — 이후 백업과 이번 복구 기록이 그대로 남는다

확인

  • schema_check.allowed가 true다
  • integrity_report.dangling이 비어 있다. stack_only 에서는 남는 것이 정상이다 — 이 모드는 플랫폼 DB 를 건드리지 않으므로 DB 의 token_sources 행은 백업 뒤에 생긴 것까지 그대로인데 금고는 백업 시점으로 돌아간다. 2026-10-17 실측: 파이프라인·아티팩트 토큰 5건이 남았고, 14단계의 파이프라인 재실행에는 지장이 없었다(러너가 쓰는 CI 변수는 네임스페이스와 함께 복구된다). 그 토큰을 실제로 쓰는 기능이 있으면 다시 등록한다
  • 스택 파드가 모두 Running이고, PVC에 출처가 남았다: kubectl --context $CTX -n $NS get pvc -o json | jq -r '.items[] | "\(.metadata.name)\t\(.metadata.annotations["backup.nullus.io/restored-from"] // "-")"'
13

복구 후 배선

실측 기록 (재설정)수동 (kind 노드)

러너와 Argo CD는 설치 때의 게이트웨이 주소를 hostAliases로 박아 두었고, kind 노드의 /etc/hosts도 그 주소를 가리킨다. 복구는 Service를 백업 때의 ClusterIP로 되살리고, 그래도 주소가 바뀌면(다른 클러스터로 복구, ClusterIP가 없는 옛 백업) 복구 끝에 러너·Argo CD의 hostAliases를 새 주소로 맞춘다 — 결과는 복구 응답의 integrity_report.gateway_aliases에 있다(updated가 비었으면 바꿀 것이 없었다 — 주소가 그대로였거나 박아 둔 주소가 없었다, error가 있으면 일부를 맞추지 못했다. Argo CD는 같은 차트 버전을 저장소에서 다시 받으므로 외부에 닿지 못하면 Argo CD만 실패로 남는다). 손으로 할 것은 kind 노드의 /etc/hosts와 맥의 포워딩뿐이다.

터미널
jq '.integrity_report.gateway_aliases' ~/nullus-restore.json   # 12단계에서 저장한 응답
NEW_IP=$(kubectl --context $CTX -n $NS get svc -l gateway.envoyproxy.io/owning-gateway-name \
  -o jsonpath='{.items[0].spec.clusterIP}')
echo "$OLD_IP -> $NEW_IP"   # 같으면 아래 접힌 노드 배선은 건너뛴다

# 맥에서 도구를 여는 라우터와 GitLab 포트포워딩
./scripts/port-forward-gateway.sh
curl -s -o /dev/null -w '%{http_code}\n' localhost:8181/users/sign_in   # 200. 런북의 포워드 루프가 다시 붙는다

# API 캐시를 비운다 (환경 파일과 GitLab 주소는 런북이 넣는다)
source ~/.nullus-e2e.env                           # 6단계 경고 — NULLUS_LOCAL_DOMAIN 이 빠지면 issuer 가 되돌아간다
./scripts/runbook_local.sh refresh
주소가 바뀌었으면 — kind 노드 /etc/hosts

kind 노드는 스택 레지스트리(registry.<도메인>)에서 앱 이미지를 받으려고 /etc/hosts에 게이트웨이 주소를 둔다. kind에서만 하는 로컬 배선이라 복구가 고치지 않는다. 그대로 두면 앱 파드가 ImagePullBackOff에 걸린다.

터미널
for node in $(kind get nodes --name nullus-platform); do
  for host in gitlab.$DOMAIN registry.$DOMAIN; do
    docker exec "$node" sh -c "grep -v ' $host\$' /etc/hosts > /tmp/h && cat /tmp/h > /etc/hosts && rm -f /tmp/h; echo '$NEW_IP $host' >> /etc/hosts"
  done
done

integrity_report.gateway_aliases.error가 있으면 러너·Argo CD도 손으로 맞춘다 — 러너 values의 runners.config(TOML host_aliases)와 Argo CD values의 global.hostAliases에서 옛 주소를 새 주소로 바꿔 스택 화면의 OSS 설정 편집으로 적용한다.

확인

  • https://gitlab.nullus.local과 https://argocd.nullus.local이 다시 열린다
  • https://sonarqube.nullus.local에 백업 전 비밀번호로 로그인되고, 9단계에서 분석했다면 그 프로젝트가 남아 있다 실측 기록
  • 주소가 바뀐 경우에만 kubectl --context $CTX -n $NS get deploy,sts -o json | grep -c "$OLD_IP"가 0이다. 복구가 ClusterIP 를 그대로 되살렸다면(OLD_IP == NEW_IP, 보통 이쪽이다) 러너·Argo CD 의 hostAliases 가 그 주소를 계속 가리키는 것이 정상이므로 0 이 될 수 없다 — 이 검사는 건너뛴다

이어서

14

파이프라인 이어 가기

실측 기록 (다른 스택)코드 기준 (GitLab)

① 지문 비교. Argo CD를 한 번 강제로 갱신하고 9단계에서 남긴 값과 비교한다. 다르면 다음으로 넘어가지 않는다.

터미널
kubectl --context $CTX -n $NS annotate applications.argoproj.io $APP \
  argocd.argoproj.io/refresh=hard --overwrite

source ~/.nullus-e2e.env; source ~/.nullus-fingerprint.sh
fingerprint | tee ~/nullus-after.txt
diff ~/nullus-before.txt ~/nullus-after.txt && echo "지문 일치"

② 새 커밋을 올린다. 가장 간단한 길은 GitLab 화면이다.

  1. https://gitlab.nullus.local/nullus/demo-api를 연다.
  2. README.md를 열어 Edit로 한 줄 고치고 main에 커밋한다.
  3. Build → Pipelines에서 새 파이프라인이 build → deploy까지 가는지 본다.

③ 이어서 배포됐는지 본다.

터미널
kubectl --context $CTX -n $NS get applications.argoproj.io $APP
kubectl --context $CTX -n $APP_NS get deploy $APP \
  -o jsonpath='{.spec.template.spec.containers[0].image}'; echo
curl -s "$API/api/v1/cicd/deployments?pipeline_id=$PIPELINE_ID" \
  | jq '.items[] | {version, status, triggeredBy}'

확인

  • GitLab 파이프라인 번호가 백업 전보다 하나 늘었고 성공했다
  • 앱 이미지 태그가 새 커밋 SHA로 바뀌었고 Argo CD가 Synced / Healthy다
  • Nullus History에 백업 전 실행(#1…)과 새 실행이 함께 보인다
새 파이프라인이 clone이나 push에서 실패하면대개 게이트웨이 주소가 바뀐 것이다(13단계의 OLD_IP·NEW_IP 비교). 잡 로그에 Could not resolve host나 연결 거부가 보이면 13단계의 접힌 수동 배선을 한다.
15

전자정부 표준프레임워크 앱 배포

실측 기록

CI/CD 템플릿에는 원본 저장소를 가져오는 템플릿이 있다 — 전자정부 표준프레임워크 샘플 2종이다. 파이프라인을 만들면 원본(eGovFramework/egovframe-boot-sample-java-config · eGovFramework/egovframe-web-sample, v5.0.0)의 파일을 새 GitLab 저장소의 첫 커밋으로 복사하고(fork 가 아니다 — 이력은 가져오지 않고, 그 뒤 원본이 바뀌어도 따라가지 않는다), 8단계와 같은 보안 게이트(시크릿 탐지 · 의존성 스캔 · 빌드 · 이미지 스캔 · SAST · 배포)를 거쳐 Argo CD 로 배포한다. 둘 다 내장 HSQLDB 로 돌아 DB 를 따로 띄우지 않는다. 원본을 받으려면 인터넷이 필요하다.

템플릿앱 이름(예)무엇빌드
eGovFrame Boot Sample (Spring Boot)egov-boot부트 기반 심플 게시판(Java Config) — 백엔드Maven · 플랫폼 Dockerfile(원본 v5.0.0 에는 없다) — 빌드 약 2분
eGovFrame Web Sample (JSP)egov-web웹 기반 심플 게시판(XML 설정 · JSP) — 프런트엔드Maven WAR → Tomcat

① 템플릿에서 파이프라인 만들기. CI/CD 템플릿(/cicd/templates)에서 eGovFrame Boot Sample (Spring Boot) 카드의 템플릿 사용을 누른다(/cicd/developer-deploy?template=egovframe-boot-sample-v1 로 바로 가도 된다). 8단계와 같은 화면이다 — 앱 이름 egov-boot, 스택은 설치한 스택, 네임스페이스는 8단계에서 만든 demo 를 고른다. 원본 저장소 · 버전을 새 저장소로 복사한다는 안내가 보인다. 실행을 누르면 원본 복사 · 첫 커밋 · Argo CD Application 등록이 끝나고 파이프라인 로그 화면으로 간다(1~2분). eGovFrame Web Sample (JSP) 로 egov-web 도 같은 순서로 만든다.

② 첫 실행 — 보안 게이트가 막는다. GitLab(https://gitlab.nullus.local/nullus/egov-boot/-/pipelines)에서 보면 시크릿 탐지 · 의존성 스캔 · 빌드(Maven, 약 2분) · SAST 는 통과하고 이미지 스캔에서 멈춘다. 원본 v5.0.0 이 쓰는 Spring 6.2.11 · Thymeleaf 3.1.3 · Tomcat 10.1.46 에 수정판이 있는 CRITICAL 이 있어서다(2026-10-26 기준 Boot 12건 · Web 3건, 잡 로그의 표에 나온다). 보안 대시보드(관측성 › 보안)에도 두 앱이 차단으로 보인다. 이것이 정상 동작이다 — 알려진 CRITICAL 이 있는 이미지는 배포하지 않는다.

③ 검토한 CVE 를 예외로 등록하고 다시 실행. 샘플 검증을 이어 가려면 저장소에 .trivyignore 를 커밋한다 — 한 줄에 CVE 하나, 검토 사유를 주석으로. Trivy 가 작업 디렉터리에서 읽으므로 의존성 스캔 · 이미지 스캔 모두 그 CVE 를 판정에서 뺀다. 커밋이 올라가면 파이프라인이 다시 돌고 배포까지 간다(앱마다 4~5분). 운영에서는 라이브러리를 올려 고치는 것이 정석이다 — 예외는 저장소에 남아 누가 언제 무엇을 받아들였는지 감사할 수 있다. 끝나면 Argo CD 에 egov-boot · egov-web 이 Synced / Healthy 로 보인다.

예외 파일 — GitLab 의 nullus/egov-boot 저장소에 커밋
# eGovFrame sample v5.0.0 (원본 그대로) 의 알려진 CRITICAL — 검토일 · 담당자 · 사유를 적는다
CVE-2026-47884  # org.springframework:spring-webmvc 6.2.11 → 7.0.9
CVE-2026-40477  # org.thymeleaf:thymeleaf 3.1.3.RELEASE → 3.1.4.RELEASE
CVE-2026-41293  # org.apache.tomcat.embed:tomcat-embed-core 10.1.46 → 10.1.55
…

④ 앱 열기. 게이트웨이 라우터의 주소표는 라우터를 띄울 때 고정되므로, 앱을 배포한 뒤 5단계의 ./scripts/port-forward-gateway.sh 를 한 번 더 돌려 egov-boot.nullus.local · egov-web.nullus.local 을 반영하고(--hosts 출력이 /etc/hosts 줄도 알려 준다) 브라우저로 연다.

터미널
source ~/.nullus-e2e.env
LOCAL_HTTPS_PORT=8443 ./scripts/port-forward-gateway.sh         # 새 앱 주소를 라우터에 반영(443 을 쓰면 변수 생략)
kubectl --context $CTX -n $NS get applications.argoproj.io      # demo-api · egov-boot · egov-web 모두 Synced / Healthy
kubectl --context $CTX -n $APP_NS get deploy,httproute          # egov-boot · egov-web 1/1, 호스트 egov-*.nullus.local
curl -sk -o /dev/null -w '%{http_code}\n' https://egov-boot.nullus.local/   # 200 — 라우터를 8443 으로 띄웠으면 :8443
의존성 스캔이 429 Too Many Requests 로 막히면 파이프라인 정의가 오래된 것이다. Trivy 가 Maven 의존성을 풀려고 Maven Central 에서 pom 을 하나씩 받다 차단당한다 — 2026-10-26 부터 --offline-scan 으로 원격 저장소를 묻지 않는다(전이 의존성은 이미지 스캔이 본다). 파이프라인 정의를 다시 만들고(POST /api/v1/cicd/pipelines/$PIPELINE_ID/refresh-definition) 다시 실행한다. 빌드 단계가 Maven Central 429 로 실패하면 30분 뒤 다시 실행한다.

확인

  • GitLab 의 nullus/egov-boot · nullus/egov-web 저장소에 원본 파일이 첫 커밋으로 들어 있고 .gitlab-ci.yml 이 붙어 있다
  • 첫 실행이 이미지 스캔에서 막혔고(CRITICAL), 보안 대시보드에 차단으로 보였다
  • .trivyignore 커밋 뒤 두 파이프라인의 6단계가 모두 성공했다
  • Argo CD 에서 egov-boot · egov-web 이 Synced / Healthy 이고 게시판 화면이 열린다
  • CI/CD 목록에 demo-api 와 함께 두 파이프라인이 보인다
16

OSS 업그레이드

실측 기록

설치된 OSS 를 검증된 경로로만 올린다. 후보는 관리자가 등록한 업그레이드 번들 중 상태가 검증됨이고 지금 설치된 차트 버전에서 출발하는 것만 보이고, 경로가 없으면 "검증된 업그레이드 경로 없음"이다(최신이라고 말하지 않는다). 실행 전 사전 점검(스택 상태 · 진행 중인 다른 업그레이드 · Helm 릴리스 · 현재 버전 · 24시간 내 백업 · 서버 측 dry-run)을 통과해야 시작되고, Helm 적용 뒤 상태 검사가 대상 앱 버전을 확인한다. 실패하면 자동으로 되돌린다. 10단계의 백업이 "24시간 내 백업" 조건을 채운다(15단계의 Maven 빌드가 길어 24시간을 넘겼으면 백업을 다시 받는다).

① 번들 등록. 관리 › 스택 버전 관리의 업그레이드 번들 탭을 연다(/admin/stack-versions?tab=upgrade-bundles). 리허설을 마친 경로만 등록한다 — 이 가이드의 스택은 Argo CD 차트 7.7.16(v2.13.3)이므로 7.7.22(v2.13.4)로 가는 번들을 넣는다(OSS 업그레이드 설계 §12.2 의 리허설 경로).

  1. 새 번들을 누른다. 도구 Argo CD를 고르면 Helm 릴리스 이름 · 차트 이름 · 차트 저장소가 채워진다.
  2. 출발 차트 버전 7.7.16 · 출발 앱 버전 v2.13.3 · 대상 차트 버전 7.7.22 · 대상 앱 버전 v2.13.4, 위험도 위험 낮음, 예상 중단 API 서버 · 컨트롤러가 차례로 재시작(약 1~2분), 릴리스 노트 https://github.com/argoproj/argo-cd/releases/tag/v2.13.4를 적는다. "실행 전 24시간 이내 백업을 요구"는 켜 둔다. 대상 앱 버전은 업그레이드 뒤 상태 검사가 서버 이미지 태그와 비교하는 값이라 틀리면 되돌려진다.
  3. 등록 상태를 검증됨으로 두고 이 경로를 실클러스터에서 리허설했다에 표시한 뒤 등록한다. 표시하지 않으면 등록이 거부된다 — 리허설 없이 실행 가능한 경로가 생기지 않게 하는 장치다. 목록에 argocd-7.7.16-7.7.22가 검증됨으로 보인다.

화면 대신 API 로 넣어도 같다(관리자 권한, 감사 로그 register_upgrade_bundle).

터미널
source ~/.nullus-e2e.env
curl -sS -X POST $API/api/v1/admin/upgrade-bundles -H 'Content-Type: application/json' -d '{
  "tool": "argocd", "release_name": "argo-cd", "chart_name": "argo-cd", "repo_url": "https://argoproj.github.io/argo-helm",
  "source_chart_version": "7.7.16", "source_app_version": "v2.13.3",
  "target_chart_version": "7.7.22", "target_app_version": "v2.13.4",
  "risk_level": "low", "expected_disruption": "API 서버 · 컨트롤러가 차례로 재시작(약 1~2분)",
  "release_notes": "https://github.com/argoproj/argo-cd/releases/tag/v2.13.4",
  "requires_backup": true, "supported_architectures": ["amd64", "arm64"],
  "status": "verified", "rehearsal_confirmed": true
}' | jq '{id, status}'                                           # 같은 ID · 같은 경로가 이미 있으면 409
curl -s $API/api/v1/admin/upgrade-bundles | jq '.items[] | {id, status}'

② 화면에서 업그레이드. 스택 목록에서 스택을 고르고 버전 업그레이드 탭을 연다(/stack/list?stack=$STACK_ID&tab=version-upgrade). 후보 카드에 Argo CD 7.7.16 (v2.13.3) → 7.7.22 (v2.13.4) · 위험 낮음이 보인다.

  1. 업그레이드를 누르면 사전 점검 6항목이 돈다. 모두 통과해야 시작 버튼이 열린다.
  2. 사유를 적고(예: Argo CD 패치 릴리스 적용) 업그레이드 시작을 누른다. 화면을 떠나도 서버에서 계속 진행된다.
  3. Helm 적용 → 상태 검사가 끝나면 탭의 현재 버전이 7.7.22로 바뀌고, 탭 아래 실행 이력에 완료 · 7.7.16 → 7.7.22 · 요청자 · 사유가 남는다(2~4분).

③ 터미널로 확인.

터미널
source ~/.nullus-e2e.env
curl -s $API/api/v1/stacks/$STACK_ID/upgrade-runs \
  | jq '.items[] | {status, from: .source_chart_version, to: .target_chart_version, requested_by}'   # succeeded 7.7.16 → 7.7.22
helm --kube-context $CTX -n $NS list -f '^argo-cd$'              # CHART argo-cd-7.7.22, APP VERSION v2.13.4
kubectl --context $CTX -n $NS get deploy argo-cd-argocd-server \
  -o jsonpath='{.spec.template.spec.containers[0].image}'; echo  # …argocd:v2.13.4
kubectl --context $CTX -n $NS get applications.argoproj.io $APP  # 기존 앱이 Synced / Healthy 그대로
사전 점검이 "24시간 내 백업 없음"으로 막히면 10단계의 full 백업을 먼저 한다. 백업이 24시간보다 오래됐으면 다시 뜬다. 후보가 아예 보이지 않으면 번들의 상태가 검증됨인지(초안은 후보가 아니다), 출발 차트 버전이 helm list 의 설치된 차트 버전과 같은지 업그레이드 번들 탭에서 대조한다 — 번들은 설치된 버전에서 출발하는 것만 후보가 된다.

확인

  • 업그레이드 번들 탭에 argocd-7.7.16-7.7.22가 검증됨으로 있다
  • 사전 점검 6항목이 모두 통과하고 업그레이드가 succeeded로 끝났다 — 탭의 현재 버전이 7.7.22, 실행 이력에 완료가 남았다
  • argo-cd-argocd-server 이미지가 v2.13.4다
  • https://argocd.nullus.local이 다시 열리고 demo-api가 Synced / Healthy 그대로다
17

AI 어시스턴트

실측 기록

자연어로 플랫폼 상태를 묻는 패널이다. 1단계는 조회 도구만 쓴다 — MCP 도구 stack_list · stack_status · stack_logs_tail · template_list · compat_check · cluster_list. 배포 · 롤백 같은 쓰기 도구는 목록에서 아예 빠져 있어 모델이 부를 수 없다. 도구는 질문한 사용자의 권한으로 돈다(권한 밖이면 403 을 받아 "권한이 없다"고 답한다).

준비물. 모델 공급자 API 키 하나. 기본은 Upstage Solar(모델 solar-pro4)이고 OpenAI · Anthropic · Gemini · OpenAI 호환 사내 서버도 고를 수 있다. 키는 환경 변수나 차트가 아니라 관리 화면에서 조직마다 넣고, 서버가 ENCRYPTION_KEY 로 암호화해 보관한다. 감사 로그에는 키 원문이 남지 않는다.

① 키 등록. 관리 › AI 어시스턴트(/admin/assistant)에서 공급자를 고르고 API 키를 넣은 뒤 연결 테스트를 누른다(저장 전에 모델을 한 번 부른다). 연결됨이 뜨면 이 조직에서 어시스턴트 켜기를 켜고 저장한다. 저장 뒤 화면은 키의 끝 네 자리만 힌트로 보인다.

키가 화면 · 로그 · 녹화에 남지 않게입력칸은 비밀번호 칸이라 글자가 보이지 않지만, 화면을 녹화하거나 캡처할 때는 입력칸과 힌트를 가린다. 키를 터미널에 echo 하거나 파일로 cat 하지 않는다.

② 질문. 모든 화면의 오른쪽 아래 AI 어시스턴트 버튼이 패널을 연다. 빈 대화에는 추천 질문 3개가 뜬다.

  1. 추천 질문 실패한 스택이 있어? — stack_list 로 스택 상태를 읽고 답한다. 답변 아래 사용한 도구가 붙는다.
  2. 등록된 클러스터 목록과 각 클러스터 용도를 알려줘 — cluster_list. platform(pipeline) · develop(target) 두 개가 나온다.
  3. 설치할 수 있는 스택 템플릿은 몇 개야? — template_list.

답변의 목록 · 굵게 · 코드 · 표가 서식대로 그려진다. 앞 대화의 조회 결과를 다시 쓰면 '사용한 도구' 줄이 안 생길 수 있다 — 끝 판정은 "생각하는 중…"이 사라지는 것으로 본다.

터미널
curl -s $API/api/v1/assistant/status | jq            # 켜짐 여부 · 공급자 · 모델
curl -s $API/api/v1/admin/assistant -H "X-Org-Id: $ORG" | jq '{provider, effective_model, has_key, key_hint, enabled}'

확인

  • 연결 테스트가 연결됨이고 저장 뒤 has_key가 true다(힌트는 끝 네 자리만)
  • 세 질문 모두 답을 받았고 적어도 하나에 사용한 도구가 보인다
  • 패널에 배포 · 롤백을 시키는 질문을 하면 할 수 없다고 답한다(쓰기 도구 없음)
18

정리

실측 기록
터미널
# 포트포워딩 반복문과 게이트웨이 라우터
./scripts/runbook_local.sh gitlab-forward --down   # down 이 함께 내리므로 보통은 생략
./scripts/port-forward-gateway.sh --down

# 잠시 멈추기 — 데이터는 남는다
./scripts/runbook_local.sh down

# 처음 상태로 — 파이프라인 → 스택 → 플랫폼 → kind → 볼륨 순으로 전부 지운다
./scripts/runbook_local.sh purge
purge와 down --volumes는 백업도 지운다백업 파일(MinIO)과 그 목록(플랫폼 DB)이 같은 도커 볼륨에 있다. 남겨야 할 백업이 있으면 purge --keep-volumes를 쓴다.

부록

A

문제 해결

증상원인조치
스택이 40초 만에 completed인데 파드가 0개설치기가 클러스터를 못 열어 시뮬레이션으로 끝났다(ENCRYPTION_KEY)logs api에서 step executor is nil을 찾는다. runbook 기본 키를 덮어쓰지 않았는지 본다
앱이 ImagePullBackOffkind 노드가 registry.nullus.local을 못 풀거나 내부 CA를 모른다. 화면 설치나 --wait 시간 초과 때 생긴다아래 "노드 배선"을 돌린다
파이프라인을 만들 때 GitLab 연결 실패GitLab 포워드가 죽었거나 API가 NULLUS_GITLAB_URL 없이 떠 있다(런북 밖에서 띄운 API)status의 gitlab[...] 줄을 보고 gitlab-forward 뒤 refresh. 환경 파일은 런북이 읽는다
같은 앱 이름으로 다시 만들었더니 scaffold_skippedGitLab 프로젝트가 남아 있어 첫 커밋을 건너뛰었다GitLab에서 프로젝트를 지우거나 다른 이름을 쓴다
stack-up이 "스택 생성 실패"같은 이름의 스택이 이미 있다--name=·--namespace=를 바꾸거나 기존 스택을 지운다
재설치가 "이전 설치의 볼륨이 남아 있습니다"로 막힌다남은 PVC가 옛 DB를 물려받는 것을 막는 가드kubectl --context $CTX delete namespace $NS 후 다시 설치
백업 목록이 비어 있다X-Org-Id가 다른 조직11111111-1111-1111-1111-111111111111을 쓴다
백업이 partialpg_dump가 없거나 서버보다 오래됐다brew install libpq 후 PATH에 넣고 refresh
설정을 넣었더니 API가 안 뜬다백업 설정 검증 실패(키 길이, 같은 키 재사용, 빈 Keycloak 호스트)./scripts/runbook_local.sh logs api의 마지막 오류
복구가 is being terminated로 실패지운 네임스페이스가 아직 Terminatingwait --for=delete 뒤 다시 복구
복구 뒤 앱 이미지가 :bootstrapArgo CD가 아직 조정하지 않았다argocd.argoproj.io/refresh=hard 주석을 단다(14단계)
전자정부 파이프라인이 image-scan 에서 멈춘다(CRITICAL)원본 v5.0.0 의 Spring 6.2 · Thymeleaf 3.1.3 · Tomcat 10.1.46 — 게이트의 정상 동작15단계 ③ 처럼 검토한 CVE 를 .trivyignore 로 등록하거나 라이브러리를 올린다
전자정부 파이프라인이 dependency-scan 에서 429 Too Many RequestsTrivy 가 Maven Central 에서 pom 을 받다 차단됐다(오래된 파이프라인 정의)정의를 다시 만들어(--offline-scan) 다시 실행한다 — 15단계 안내문
업그레이드 후보가 없다 ("검증된 업그레이드 경로 없음")번들이 없거나 상태가 검증됨이 아니거나, 출발 차트 버전이 설치된 차트 버전과 다르다16단계 ① 처럼 관리 › 스택 버전 관리 › 업그레이드 번들에서 번들을 등록하고(검증됨) helm list 의 차트 버전과 대조한다
사전 점검이 "24시간 내 백업"에서 막힌다최근 24시간 안의 성공한 백업이 없다10단계 full 백업을 먼저 한다
어시스턴트 연결 테스트 실패키가 틀리거나 공급자 주소에 API 서버가 못 나간다키를 다시 넣고 logs api의 마지막 오류를 본다. 사내 OpenAI 호환 서버는 base URL 을 함께 적는다

노드 배선 — stack-up --wait·stack-status --wait·wire가 하는 일을 손으로 한다. 보통은 ./scripts/runbook_local.sh wire 한 줄로 끝난다.

터미널
GW_IP=$(kubectl --context $CTX -n $NS get svc -l gateway.envoyproxy.io/owning-gateway-name \
  -o jsonpath='{.items[0].spec.clusterIP}')
CA_NS=$(kubectl --context $CTX get secret -A \
  -o jsonpath="{range .items[?(@.metadata.name=='nullus-internal-ca')]}{.metadata.namespace}{'\n'}{end}" | head -1)
kubectl --context $CTX -n "$CA_NS" get secret nullus-internal-ca -o jsonpath='{.data.tls\.crt}' \
  | base64 -d > /tmp/nullus-internal-ca.crt

for node in $(kind get nodes --name nullus-platform); do
  for host in gitlab.$DOMAIN registry.$DOMAIN; do
    docker exec "$node" sh -c "grep -v ' $host\$' /etc/hosts > /tmp/h && cat /tmp/h > /etc/hosts && rm -f /tmp/h; echo '$GW_IP $host' >> /etc/hosts"
  done
  docker cp /tmp/nullus-internal-ca.crt "$node:/usr/local/share/ca-certificates/nullus-internal-ca.crt"
  docker exec "$node" update-ca-certificates
  docker exec "$node" systemctl restart containerd
done
kubectl --context $CTX wait --for=condition=Ready nodes --all --timeout=180s
B

SonarQube 쓰기

실측 기록 (설치·수동 분석)코드 기준 (clone)

스택에 SonarQube가 있으면 지금은 여기까지 된다.

항목상태설명
설치된다템플릿 gitlab-argocd-sonarqube-v1을 stack-up으로 깐다(4단계). 스택 PostgreSQL 안에 전용 DB sonarqube를 만들어 쓰고, 그래서 스택 백업에 함께 담긴다
관리자 계정된다설치가 admin/admin을 바꿔 둔다. 비밀번호는 Secret nullus-sonarqube-credentials의 admin-password(5단계)이고, 연결 정보 창도 이 Secret을 가리킨다
Keycloak 로그인된다SonarQube Community는 SSO로 SAML만 받는다. 설치가 Keycloak에 SAML 클라이언트를 등록하고 렐름 서명 인증서를 SonarQube에 넣어, 로그인 화면에 Keycloak 버튼이 생긴다. 처음 들어온 사용자는 일반 사용자(sonar-users)로 만들어지고 로그인 ID 뒤에 숫자가 붙는다(SonarQube의 외부 계정 규칙). 관리는 admin 계정으로 한다. Keycloak 렐름 키를 회전하면 SonarQube 단계를 다시 돌려야 새 인증서를 받는다
설치 화면에서 고르기된다설치 마법사 Security 탭에서 Static Analysis (SAST) = SonarQube 를 고른다. 템플릿 gitlab-argocd-sonarqube-v1을 고르면 미리 채워진다. 다만 화면 설치는 kind 노드 배선을 하지 않으므로 부록 A 를 따른다
파이프라인 분석·차단된다파이프라인에 sast 단계가 생긴다(GitLab · GitHub Actions · Jenkins). 기본은 Quality Gate 실패 시 차단이고, 아래처럼 스택 정책으로 경고로 바꾼다. SonarQube 에 닿지 못하면 이미지 스캔과 같은 장애 정책(on_scanner_unreachable)을 따른다
결과 화면된다CI/CD 목록 → 파이프라인 → History 에서 실행마다 SAST 판정(통과 · 경고 · 차단 · 분석 실패)이 붙고, 실행을 고르면 통과하지 못한 조건 · 지표 · SonarQube 링크가 보인다. sast 잡이 남긴 sast-report.json 을 실행 기록 동기화가 읽어 만든다 실측 기록

정책을 경고로 바꾸기. Quality Gate 를 막 들인 팀이 기존 코드의 문제로 배포가 멈추지 않게 하려면 스택 정책을 바꾼다. 저장하면 그 스택의 파이프라인 변수(NULLUS_SAST_ON_GATE_FAILURE)에 바로 실리고, 다음 실행부터 따른다(실측: 같은 커밋을 다시 돌리자 sast 가 "경고 정책에 따라 통과" 로 끝나고 deploy 가 돌았다).

스택 정책
curl -s -X PUT $API/api/v1/stacks/$STACK_ID/image-scan-policy -H 'Content-Type: application/json' \
  -d '{"block_severity":"CRITICAL","ignore_unfixed":true,"on_scanner_unreachable":"block","sast_on_gate_failure":"warn"}' \
  | jq '{policy, pushes}'                        # 되돌리려면 "block"

손으로 분석 돌려 보기(선택). 파이프라인 밖에서 확인하고 싶을 때 쓴다. 8단계에서 만든 앱 저장소를 받아 스캐너 컨테이너로 분석한다. SonarQube에는 포트포워딩으로 붙는다. 로컬 MinIO가 9000을 쓰므로 19000으로 연다.

① 분석 토큰
source ~/.nullus-e2e.env
SQ_PW=$(kubectl --context $CTX -n $NS get secret nullus-sonarqube-credentials \
  -o jsonpath='{.data.admin-password}' | base64 -d)
kubectl --context $CTX -n $NS port-forward svc/sonarqube 19000:9000 >/dev/null 2>&1 &
SQ_TOKEN=$(curl -s -u "admin:$SQ_PW" -X POST http://localhost:19000/api/user_tokens/generate \
  --data-urlencode "name=e2e-$(date +%s)" -d type=GLOBAL_ANALYSIS_TOKEN | jq -r .token)
echo "${SQ_TOKEN:0:4}"                        # sqa_ 로 시작하면 된다
② 앱 저장소 받기
# 내부 CA 는 5단계의 setup-local-domain.sh 가 꺼내 둔 것. 사용자 root 와 5단계 비밀번호를 묻는다
git -c http.sslCAInfo=$PWD/.runbook-logs/nullus-internal-ca.crt \
  clone https://gitlab.$DOMAIN/nullus/$APP.git ~/$APP
③ 분석
# 스캐너 이미지는 amd64 만 있다. Apple Silicon 에서는 에뮬레이션으로 돈다(30초 남짓)
docker run --rm --platform linux/amd64 -v ~/$APP:/usr/src \
  -e SONAR_HOST_URL=http://host.docker.internal:19000 -e SONAR_TOKEN=$SQ_TOKEN \
  sonarsource/sonar-scanner-cli:12.2 -Dsonar.projectKey=$APP -Dsonar.qualitygate.wait=true
echo "exit=$?"

curl -s -u "admin:$SQ_PW" "http://localhost:19000/api/qualitygates/project_status?projectKey=$APP" \
  | jq -r '.projectStatus.status'

확인

  • 스캐너가 QUALITY GATE STATUS: PASSED와 EXECUTION SUCCESS로 끝나고 exit=0이다
  • https://sonarqube.nullus.local/dashboard?id=demo-api에 프로젝트와 이슈가 보인다
첫 분석은 문제가 있어도 통과한다기본 Quality Gate(Sonar way)는 "새 코드" 조건만 본다. 첫 분석에는 비교할 기준이 없어서, 실측에서 eval을 CRITICAL로 잡고도 PASSED였다. 그 뒤 분석에서 새 문제가 하나라도 생기면 new_violations로 FAILED가 되고, 스캐너가 EXECUTION FAILURE와 종료 코드 3으로 끝난다. 파이프라인 잡을 멈추는 것은 이 종료 코드다 — 실제 파이프라인에서도 첫 실행은 통과했고, eval 을 넣은 커밋에서 sast 가 실패하고 deploy 가 건너뛰어졌다.

컨테이너 이미지 취약점은 SonarQube가 아니라 Trivy가 본다. stack-up --template=gitlab-harbor-trivy-v1로 깔면 스택에 Trivy가 들어가고 파이프라인에 차단 게이트(image-scan 잡)가 붙는다. 소스 정적 분석(SAST)은 아니다.

끝나면 포트포워딩을 닫는다: pkill -f 'port-forward svc/sonarqube'

C

알려진 제약 — 개발팀 참고

가이드를 쓰며 코드에서 확인한 것들이다. 위 단계의 우회책은 모두 이것 때문에 생겼다. 2026-10-12 기준 남은 항목이 없다 — 적혀 있던 다섯 건(공용 자원 복구 · GitLab Secret 이름 · 노드 배선 · skip ci 기록 · 금고 KV · 낡은 문서)은 PR #304~#309 와 이 문서 현행화로 고쳤다. 새로 발견되면 여기 적는다.