Deployment, Service 등의 Manifest 파일을 하나씩(또는 하나의 파일에) 정의하고, kubectl apply로 해당 파일에 맞게 리소스를 생성 = 개별 리소스를 하나씩 배포하는 방식Hashicorp Vault, External Secret Operator처럼 앱 자체를 패키지화해서 하나의 Helm Chart 배포 단위로 관리할 수 있다면 보다 편리한 배포가 가능할 것$ kubectl delete -f spring-deployment.yaml
$ kubectl delete -f redis-deployment.yaml
templates 폴더의 기본 생성 파일을 모두 제거 & 앞에서 사용한 spring-deployment.yaml, redis-deployment.yaml을 폴더 내부로 복사values.yaml과 함께 Helm Chart의 template 파일로 기능--- 단위로 나뉜 리소스를 개별 파일로 나누어 저장 가능(deployment, service, serviceAccount...)$ helm create spring-app
# 다음과 같은 트리 구조
spring-app/
├── Chart.yaml
├── values.yaml
├── templates/
│ ├── spring-deployment.yaml
│ ├── redis-deployment.yaml
│ └── ...
values.yaml 편집→ 사용자는 Helm Chart를 받아서 values.yaml을 수정하여 환경에 맞게 사용 가능
values.yamlapp:
name: spring
image:
repository: endermaru/22-5-team1-server
tag: latest
deployment:
replicas: 1
revisionHistoryLimit: 1
service:
type: LoadBalancer
port: 8080
targetPort: 8080
config:
name: spring-config
springProfilesActive: local
healthShowDetails: "always"
serviceAccount:
name: spring
namespace: default
vault:
server: "http://vault.default.svc.cluster.local:8200"
path: "secret"
version: "v2"
mountPath: "kubernetes"
role: spring-app
secretPath: myapp
secret:
name: spring-secret
refreshInterval: 1h
externalSecret:
name: spring-secrets
resources:
limits:
cpu: 500m
memory: 512Mi
requests:
cpu: 250m
memory: 256Mi
redis:
name: redis
image:
repository: redis
tag: 7.0-alpine
deployment:
replicas: 1
service:
port: 6379
targetPort: 6379
resources:
limits:
cpu: 200m
memory: 256Mi
requests:
cpu: 100m
memory: 128Mi
values.yaml에 정의한 값들에 기반해 manifest를 Helm 템플릿으로 변환spring-deployment.yaml# deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Values.app.name }}
namespace: {{ .Values.namespace }}
spec:
replicas: {{ .Values.deployment.replicas }}
revisionHistoryLimit: {{ .Values.deployment.revisionHistoryLimit }}
selector:
matchLabels:
app: {{ .Values.app.name }}
template:
metadata:
labels:
app: {{ .Values.app.name }}
spec:
containers:
- name: {{ .Values.app.name }}
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
ports:
- containerPort: {{ .Values.service.targetPort }}
resources:
limits:
cpu: {{ .Values.resources.limits.cpu }}
memory: {{ .Values.resources.limits.memory }}
requests:
cpu: {{ .Values.resources.requests.cpu }}
memory: {{ .Values.resources.requests.memory }}
envFrom:
- configMapRef:
name: {{ .Values.config.name }}
- secretRef:
name: {{ .Values.secret.name }}
---
# service
apiVersion: v1
kind: Service
metadata:
name: {{ .Values.app.name }}
namespace: {{ .Values.namespace }}
spec:
type: {{ .Values.service.type }}
selector:
app: {{ .Values.app.name }}
ports:
- port: {{ .Values.service.port }}
targetPort: {{ .Values.service.targetPort }}
---
# ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ .Values.config.name }}
namespace: {{ .Values.namespace }}
data:
SPRING_PROFILES_ACTIVE: {{ .Values.config.springProfilesActive }}
MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS: {{ .Values.config.healthShowDetails | quote }}
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ .Values.serviceAccount.name }}
namespace: {{ .Values.namespace }}
---
apiVersion: external-secrets.io/v1
kind: ClusterSecretStore
metadata:
name: vault-store
spec:
provider:
vault:
server: {{ .Values.vault.server | quote }}
path: {{ .Values.vault.path | quote }}
version: {{ .Values.vault.version | quote }}
auth:
kubernetes:
mountPath: {{ .Values.vault.mountPath | quote }}
role: {{ .Values.vault.role | quote }}
serviceAccountRef:
name: {{ .Values.serviceAccount.name }}
namespace: {{ .Values.namespace }}
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: {{ .Values.externalSecret.name }}
namespace: {{ .Values.namespace }}
spec:
refreshInterval: {{ .Values.secret.refreshInterval }}
secretStoreRef:
name: vault-store
kind: ClusterSecretStore
target:
name: {{ .Values.secret.name }}
creationPolicy: Owner
dataFrom:
- extract:
key: {{ .Values.vault.secretPath }}
redis-deployment.yaml# Redis Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ .Values.redis.name }}
namespace: {{ .Values.namespace }}
spec:
replicas: {{ .Values.redis.deployment.replicas }}
selector:
matchLabels:
app: {{ .Values.redis.name }}
template:
metadata:
labels:
app: {{ .Values.redis.name }}
spec:
containers:
- name: {{ .Values.redis.name }}
image: {{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}
ports:
- containerPort: {{ .Values.redis.service.targetPort }}
resources:
limits:
cpu: {{ .Values.redis.resources.limits.cpu }}
memory: {{ .Values.redis.resources.limits.memory }}
requests:
cpu: {{ .Values.redis.resources.requests.cpu }}
memory: {{ .Values.redis.resources.requests.memory }}
---
# Redis Service
apiVersion: v1
kind: Service
metadata:
name: {{ .Values.redis.name }}
namespace: {{ .Values.namespace }}
spec:
type: ClusterIP
selector:
app: {{ .Values.redis.name }}
ports:
- port: {{ .Values.redis.service.port }}
targetPort: {{ .Values.redis.service.targetPort }}
values.yaml에 의해 값이 적용된 template을 출력, 잘못 구성되면 에러 발생$ helm template spring-app ./spring-app
---
# Source: spring-app/templates/spring-deployment.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: spring
namespace: default
---
# Source: spring-app/templates/spring-deployment.yaml
# ConfigMap
apiVersion: v1
kind: ConfigMap
metadata:
name: spring-config
namespace: default
data:
SPRING_PROFILES_ACTIVE: local
MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS: "always"
---
...
-debug: 어떤 값이 들어갔는지 디버그용 출력도 같이 나옴$ helm install spring-app ./spring-app --dry-run --debug
install.go:225: 2025-06-25 16:17:56.308445 +0900 KST m=+0.080271701 [debug] Original chart version: ""
install.go:242: 2025-06-25 16:17:56.3089554 +0900 KST m=+0.080782101 [debug] CHART PATH: D:\desktop\toyProject\k8s2\spring-app
NAME: spring-app
...
$ helm install spring-app ./spring-app
# default namespace의 모든 리소스 확인
$ kubectl get all -n default
# external secret 연결 & spring-secret 생성 확인
$ kubectl get externalsecret -n default
$ kubectl get secret spring-secret -n default -o yaml
# (Optional) 앱을 삭제
# 설치된 릴리스 목록 확인(default 네임스페이스) & 삭제
$ helm list
$ helm uninstall spring-app
Error: secret "spring-secret" not found 에러가 뜬다면, Vault 설정을 다시 확인 & Vault 설정 후 spring-app을 삭제 후 재설치ArgoCDspring-app, ESO)$ helm uninstall spring-app
$ helm uninstall external-secrets -n external-secrets-system
# namespace가 default가 아니면 직접 지정 필요
spring-app-gitops/
└── helm/
└── spring-app/
├── Chart.yaml
├── values.yaml
└── templates/...
# Argo CD 리소스를 위한 새 네임스페이스 argocd를 생성
$ kubectl create namespace argocd
# Argo CD 공식 설치 매니페스트를 가져와서 argocd 네임스페이스에 설치
$ kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
spring-app-gitops/
├── apps/ # Child apps
│ ├── argocd-spring.yaml
│ ├── argocd-eso.yaml
│
├── helm/
│ └── spring-app/ # spring 앱의 Helm Chart
└── root-app.yaml # Root app
ArgoCd에서는 vault를 제외한 ESO, spring-app을 한 번에 설치Root App: 다른 Application들을 관리하는 상위 ApplicationChild Apps: 실제 워크로드를 배포하는 하위 Application들spec.destination.namespace에 정의된 네임스페이스에 추가됨namespace 구분하기
argocd: 관리자 권한이 필요한 영역
→ 배포 설정을 담은 ArgoCD Application(Root, Child App)destination: 실제 애플리케이션이 실행되는 영역
→ 실제로 배포되는 워크로드(Deployment, Service, Pod)
./apps/argocd-spring.yaml./helm/spring-app에 위치한 Helm Chart인 spring-app을 ArgoCD에 등록apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: spring-app # 식별할 이름
namespace: argocd # app이 위치할 namespace = argocd가 설치된 namespace
finalizers:
- resources-finalizer.argocd.argoproj.io # 앱이 삭제될 때 리소스가 삭제되는 cascading 삭제
spec:
project: default
source:
repoURL: https://github.com/endermaru/spring-app-gitops
targetRevision: main # 배포할 기준이 되는 브랜치
path: helm/spring-app # Helm Chart 위치
helm:
valueFiles:
- values.yaml # Helm에 넘길 values 파일
destination:
server: https://kubernetes.default.svc
namespace: default # 앱이 배포될 네임스페이스
syncPolicy:
automated:
prune: true # Git에서 제거된 리소스를 클러스터에서도 삭제
selfHeal: true # 클러스터 상태가 Git과 다르면 자동 복구
./apps/argocd-eso.yamlExternal Secret Operator Helm Chart를 ArgoCD에 등록apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: external-secrets
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://charts.external-secrets.io
chart: external-secrets
targetRevision: 0.16.1
destination:
server: https://kubernetes.default.svc
namespace: external-secrets-system
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true # 네임스페이스 자동 생성
root-app.yamlspec.destination.namespace에서 별도로 정의)# root-app.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: root-app
namespace: argocd
finalizers:
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://github.com/endermaru/spring-app-gitops
targetRevision: main
path: apps # 여러 Child apps의 yaml들이 있는 디렉토리
directory:
recurse: false # apps/ 디렉토리만 읽고 하위 디렉토리는 무시
destination:
server: https://kubernetes.default.svc
namespace: argocd # root-app 자체는 argocd에 생성
syncPolicy:
automated:
prune: true
selfHeal: true
root-app.yaml에 기반하여 설치됨 - 앞으로 root-app.yaml만 있어도 배포 가능minikube service spring으로 앱 접근 가능# git push
$ git add .
$ git commit -m "init"
$ git push origin main
$ kubectl apply -f root-app.yaml
# 앱 확인 - 설치 & 실행에 시간이 걸릴 수 있음
$ kubectl get application -n argocd
NAME SYNC STATUS HEALTH STATUS
external-secrets Synced Healthy
root-app Synced Healthy
spring-app Synced Progressing
# LoadBalancer인 서비스가 실제로는 NodePort로 작동 = ExternalIP가 Pending
spring-app의 Health Status가Progressing인 이유
- 일반적인 Kubernetes에서는
type: LoadBalancer서비스가 클라우드 로드밸런서 (예: AWS ELB)로 연결- 클러스터 밖에서도
EXTERNAL-IP로 접근 가능- Minikube는 로컬 클러스터이기 때문에
- 실제 LoadBalancer가 없음,
EXTERNAL-IP: <pending>상태- 외부에서 접근할 수 있는 포트가 열리지 않음
# 비밀번호 조회
$ kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d
Pe6-LrrcQS3om6o5
# 로컬 머신의 localhost:8080 ↔ Argo CD 서버의 443(HTTPS) 포트를 연결
# http://localhost:8080로 Argo CD 웹 UI 접속 가능
$ kubectl port-forward svc/argocd-server -n argocd 8080:443
# 해당 터미널이 열려 있어야 함
http://localhost:8080로 Argo CD web ui 접속adminPe6-LrrcQS3om6o5)

OutOfSync 상태일 때 클릭 한 번으로 Sync 가능
# 1. 앱, 리소스 삭제
$ kubectl delete application root-app -n argocd
# 2. Argo CD 리소스 삭제
$ kubectl delete ns argocd
# 3. Minikube 정지 + 삭제(optional)
minikube stop
minikube delete