diff --git a/.github/workflows/promote-portfolio-image.yml b/.github/workflows/promote-portfolio-image.yml
new file mode 100644
index 0000000..5fd7c0d
--- /dev/null
+++ b/.github/workflows/promote-portfolio-image.yml
@@ -0,0 +1,40 @@
+name: Promote Portfolio Image
+
+on:
+ workflow_dispatch:
+ inputs:
+ image_tag:
+ description: Docker tag to promote into the production overlay.
+ required: true
+ type: string
+
+permissions:
+ contents: write
+
+jobs:
+ promote:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Update production image tag
+ env:
+ IMAGE_TAG: ${{ inputs.image_tag }}
+ run: |
+ sed -i "s/^ newTag: .*/ newTag: ${IMAGE_TAG}/" apps/portfolio/overlays/prod/kustomization.yaml
+
+ - name: Commit manifest update
+ env:
+ IMAGE_TAG: ${{ inputs.image_tag }}
+ run: |
+ if git diff --quiet -- apps/portfolio/overlays/prod/kustomization.yaml; then
+ echo "No manifest change detected."
+ exit 0
+ fi
+
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add apps/portfolio/overlays/prod/kustomization.yaml
+ git commit -m "chore(portfolio): promote image to ${IMAGE_TAG}"
+ git push
diff --git a/.github/workflows/validate-gitops.yml b/.github/workflows/validate-gitops.yml
new file mode 100644
index 0000000..703f8a1
--- /dev/null
+++ b/.github/workflows/validate-gitops.yml
@@ -0,0 +1,31 @@
+name: Validate GitOps Manifests
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+jobs:
+ kustomize-build:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v4
+
+ - name: Install kustomize
+ uses: imranismail/setup-kustomize@v2
+
+ - name: Build bootstrap manifests
+ run: |
+ kustomize build bootstrap/home-mgmt/argocd-install >/dev/null
+ kustomize build bootstrap/home-mgmt/root-app >/dev/null
+
+ - name: Build Argo CD application set
+ run: |
+ kustomize build argocd >/dev/null
+
+ - name: Build OCI platform and workload manifests
+ run: |
+ kustomize build clusters/oci-prod >/dev/null
+ kustomize build apps/portfolio/overlays/prod >/dev/null
diff --git a/Jenkins/.env b/Jenkins/.env
deleted file mode 100644
index afadd0b..0000000
--- a/Jenkins/.env
+++ /dev/null
@@ -1,5 +0,0 @@
-DOCKER_GROUP_ID=1001
-JENKINS_HOME=/home/youngwon/jenkins_home
-DOCKER_SOCK=/var/run/docker.sock
-SECRETS_ENV_FILE=/home/youngwon/secrets/serverSecrets
-PORT=8080
\ No newline at end of file
diff --git a/Jenkins/Dockerfile b/Jenkins/Dockerfile
deleted file mode 100644
index c293a26..0000000
--- a/Jenkins/Dockerfile
+++ /dev/null
@@ -1,20 +0,0 @@
-FROM jenkins/jenkins:lts
-
-USER root
-RUN apt-get update \
- && apt-get install ca-certificates curl gnupg \
- && install -m 0755 -d /etc/apt/keyrings \
- && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
- && chmod a+r /etc/apt/keyrings/docker.gpg
-
-RUN echo \
- "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian \
- "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
- tee /etc/apt/sources.list.d/docker.list > /dev/null
-
-RUN apt-get update && apt-get install -y docker-ce-cli
-
-ARG DOCKER_GROUP_ID
-RUN groupadd -g ${DOCKER_GROUP_ID} docker && usermod -aG docker jenkins
-
-USER jenkins
\ No newline at end of file
diff --git a/Jenkins/docker-compose.yml b/Jenkins/docker-compose.yml
deleted file mode 100644
index fbd6706..0000000
--- a/Jenkins/docker-compose.yml
+++ /dev/null
@@ -1,18 +0,0 @@
-services:
- jenkins:
- image: yw7148/jenkins:latest
- container_name: jenkins
- build:
- context: .
- dockerfile: Dockerfile
- args:
- - DOCKER_GROUP_ID=${DOCKER_GROUP_ID}
- restart: unless-stopped
- volumes:
- - ${JENKINS_HOME}:/var/jenkins_home
- - ${DOCKER_SOCK}:/var/run/docker.sock
- env_file:
- - ${SECRETS_ENV_FILE}
- ports:
- - ${PORT}:8080
- - 50000:50000
\ No newline at end of file
diff --git a/Nginx/.env b/Nginx/.env
deleted file mode 100644
index 7de1467..0000000
--- a/Nginx/.env
+++ /dev/null
@@ -1,3 +0,0 @@
-CONFD_DIR=/home/opc/server/Nginx/conf.d
-NGINX_CONF_FILE=/home/opc/server/Nginx/nginx.conf
-SSL_DIR=/home/opc/ssl
\ No newline at end of file
diff --git a/Nginx/docker-compose.yml b/Nginx/docker-compose.yml
deleted file mode 100644
index ecff0ac..0000000
--- a/Nginx/docker-compose.yml
+++ /dev/null
@@ -1,17 +0,0 @@
-services:
- nginx:
- image: nginx:latest
- container_name: nginx
- volumes:
- - ${NGINX_CONF_FILE}:/etc/nginx/nginx.conf
- - ${CONFD_DIR}:/etc/nginx/conf.d
- - ${SSL_DIR}:/etc/nginx/ssl
- networks:
- - youngwon
- ports:
- - 80:80
- - 443:443
-
-networks:
- youngwon:
- external: true
\ No newline at end of file
diff --git a/Nginx/nginx.conf b/Nginx/nginx.conf
deleted file mode 100644
index 5f03fd6..0000000
--- a/Nginx/nginx.conf
+++ /dev/null
@@ -1,29 +0,0 @@
-events {}
-
-http {
- server {
- listen 80;
- return 301 https://$host$request_uri;
- }
-
- server {
- listen 443 ssl;
- server_name youngwon.me;
-
- ssl_certificate /etc/nginx/ssl/youngwon.me_202411031E137.crt.pem;
- ssl_certificate_key /etc/nginx/ssl/youngwon.me_202411031E137.key.pem;
-
- location = / {
- return 301 http://youngwon.me/portfolio;
- }
-
- location /portfolio {
- proxy_pass http://158.180.88.84:9001;
- }
-
- location /nginx {
- add_header Content-Type text/html;
- return 200 '
Nginx';
- }
- }
-}
\ No newline at end of file
diff --git a/README.md b/README.md
index 9c0cc45..fe409f6 100644
--- a/README.md
+++ b/README.md
@@ -1,88 +1,65 @@
# server
Everything About my Oracle Cloud Server Setting

-## WAS Server (150.230.252.102)
+## Current Direction
-## DevOps server (158.180.85.209)
-### Network
-```
-docker network create youngwon
-```
+이 리포는 Jenkins/Nginx 기반 운영에서 Home node의 Argo CD 관리 클러스터와 OCI k3s 런타임 클러스터로 전환한 상태를 관리한다.
-### Nginx
-```
-docker run -d \
- -p 80:80 -p 443:443\
- -v {PATH_TO_NGINX_CONF}:/etc/nginx/nginx.conf \
- --network youngwon \
- --name nginx \
- nginx:latest
-```
- - My Server:
-```
-docker run -d \
- -p 80:80 -p 443:443\
- -v /home/opc/server/Nginx/nginx.conf:/etc/nginx/nginx.conf \
- --network youngwon \
- --name nginx \
- nginx:latest
-```
-or with docker-compose:
-```
-docker compose -f Nginx/docker-compose.yml up -d
-```
-## Local Server
- > Moved from Oracle Cloud server because of server performance issue.
+- 설계 문서: `docs/k3s-argocd-migration-design.md`
+- OCI 적용 가이드: `docs/oci-k3s-apply-guide.md`
+- Argo CD bootstrap: `bootstrap/home-mgmt/`
+- Argo CD application/app-of-apps: `argocd/`
+- OCI 공통 플랫폼: `clusters/oci-prod/`
+- 서비스별 앱 배포 선언: `apps/`
-### Jenkins
- > To use host docker engine in jenkins container, build Jenkins/Dockerfile with {HOST_DOCKER_GROUP_ID}
-```
-docker build --build-arg DOCKER_GROUP_ID={HOST_DOCKER_GROUP_ID} -t yw7148/jenkins:latest Jenkins/.
-```
-```
-docker run -d \
- -p 50000:50000 -p 8080:8080 \
- -v {PATH_TO_JENKINS_HOME}:/var/jenkins_home \
- -v /var/run/docker.sock:/var/run/docker.sock \
- --name jenkins \
- --env-file {SECRETS_ENV_PATH}\
- yw7148/jenkins:latest
-```
-- My Server (WSL):
-```
-docker build --build-arg DOCKER_GROUP_ID=1001 -t yw7148/jenkins:latest Jenkins/.
-```
-```
-docker run -d \
- -p 50000:50000 -p 8080:8080 \
- -v /home/youngwon/jenkins_home:/var/jenkins_home \
- -v /var/run/docker.sock:/var/run/docker.sock \
- --name jenkins \
- --env-file /home/youngwon/secrets/serverSecrets \
- --restart unless-stopped \
- yw7148/jenkins:latest
-```
-or with docker-compose
-```
-docker compose -f Jenkins/docker-compose.yml up -d --build
-```
-#### To support multi-platform image (linux/amd64, linux/arm64, ...)
-```
-docker buildx create --name multiplatform --bootstrap --use
-```
-#### To deploy with docker hub ( [Youngwon's DockerHub](https://hub.docker.com/repositories/yw7148) )
- - login to docker hub
-> If you get "Error saving credentials: error storing credentials" error, open ~/.docker/config.json file and set "credsStore": "".
-```
-docker login
-```
- - create docker context to deploy more easiliy
-```
-docker context create jenkins_was --docker host='ssh://jenkins@150.230.250.174'
-docker context create jenkins_devops --docker host='ssh://jenkins@152.67.206.246'
-```
- - now deploy to server with
+## Apply To OCI
+
+상세 절차는 `docs/oci-k3s-apply-guide.md`를 보면 된다.
+
+핵심 순서는 다음과 같다.
+
+1. OCI `instance-control`, `instance-worker-1`, `instance-worker-2`에 k3s cluster를 구성한다.
+2. Home node에 management k3s와 Argo CD를 설치한다.
+3. Home node의 Argo CD에 OCI cluster를 `oci-prod`로 등록한다.
+4. root app을 적용해 `cert-manager`와 애플리케이션을 GitOps로 배포한다.
+5. `youngwon.me` DNS를 OCI ingress public IP로 전환한다.
+
+주의:
+
+- `instance-control`에서는 `server` 설치만 실행한다.
+- `instance-worker-1`, `instance-worker-2`에서만 `K3S_URL=...` join 명령을 실행한다.
+- `--node-ip`는 현재 로그인한 노드의 private IP와 정확히 일치해야 한다.
+
+빠른 시작 명령:
+
+```bash
+kubectl apply --server-side --force-conflicts -k bootstrap/home-mgmt/argocd-install
+argocd cluster add --name oci-prod
+kubectl apply -k bootstrap/home-mgmt/root-app
```
-docker --context {jenkins_was|jenkins_devops} run {yw7148/image}
-```
\ No newline at end of file
+
+## Portfolio Deployment
+
+`portfolio`는 Docker Hub 이미지 `yw7148/portfolio`로 배포한다.
+
+- base manifests: `apps/portfolio/base/`
+- production overlay: `apps/portfolio/overlays/prod/`
+- prod ingress host/path: `youngwon.me/portfolio`
+- 기본 태그 승격 workflow: `.github/workflows/promote-portfolio-image.yml`
+
+## Multi-Service Structure
+
+현재 구조는 `portfolio` 한 개만 들어가 있지만, 여러 서비스를 추가할 수 있게 나눠져 있다.
+
+- 서비스별 매니페스트: `apps//base/`
+- 환경별 오버레이: `apps//overlays/prod/`
+- Argo CD 앱 선언: `argocd/applications/-prod.yaml`
+- 서비스별 namespace: `clusters/oci-prod/namespaces/.yaml`
+
+예시는 `apps/README.md`에 정리해두었다.
+
+민감한 값 주의:
+
+- 실제 `K3S_TOKEN`, Argo CD 비밀번호, kubeconfig, 운영용 공인 IP는 문서에 하드코딩하지 않는다.
+- 설치 예시는 항상 ``, ``, `` 같은 placeholder를 사용한다.
diff --git a/apps/README.md b/apps/README.md
new file mode 100644
index 0000000..7806e37
--- /dev/null
+++ b/apps/README.md
@@ -0,0 +1,20 @@
+# Apps Layout
+
+현재 구조는 서비스별로 독립적인 Kubernetes 매니페스트를 추가할 수 있게 잡아두었다.
+
+패턴:
+
+```text
+apps//base/
+apps//overlays/prod/
+argocd/applications/-prod.yaml
+clusters/oci-prod/namespaces/.yaml
+```
+
+새 서비스를 추가할 때는 아래 순서를 따른다.
+
+1. `clusters/oci-prod/namespaces/` 아래에 namespace를 추가한다.
+2. `apps//base/`에 Deployment, Service, Ingress를 추가한다.
+3. `apps//overlays/prod/`에서 이미지 태그와 prod 설정을 덮어쓴다.
+4. `argocd/applications/` 아래에 새 Application을 추가한다.
+5. `argocd/kustomization.yaml`에 새 Application을 등록한다.
diff --git a/apps/portfolio/base/deployment.yaml b/apps/portfolio/base/deployment.yaml
new file mode 100644
index 0000000..1eca1d5
--- /dev/null
+++ b/apps/portfolio/base/deployment.yaml
@@ -0,0 +1,57 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: portfolio
+ labels:
+ app.kubernetes.io/name: portfolio
+ app.kubernetes.io/part-of: youngwon-platform
+spec:
+ replicas: 2
+ selector:
+ matchLabels:
+ app.kubernetes.io/name: portfolio
+ template:
+ metadata:
+ labels:
+ app.kubernetes.io/name: portfolio
+ app.kubernetes.io/part-of: youngwon-platform
+ spec:
+ affinity:
+ nodeAffinity:
+ requiredDuringSchedulingIgnoredDuringExecution:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: node-role.kubernetes.io/control-plane
+ operator: DoesNotExist
+ podAntiAffinity:
+ preferredDuringSchedulingIgnoredDuringExecution:
+ - weight: 100
+ podAffinityTerm:
+ labelSelector:
+ matchLabels:
+ app.kubernetes.io/name: portfolio
+ topologyKey: kubernetes.io/hostname
+ containers:
+ - name: portfolio
+ image: yw7148/portfolio:latest
+ imagePullPolicy: IfNotPresent
+ ports:
+ - name: http
+ containerPort: 9001
+ readinessProbe:
+ tcpSocket:
+ port: http
+ initialDelaySeconds: 10
+ periodSeconds: 10
+ livenessProbe:
+ tcpSocket:
+ port: http
+ initialDelaySeconds: 30
+ periodSeconds: 20
+ resources:
+ requests:
+ cpu: 100m
+ memory: 256Mi
+ limits:
+ cpu: 500m
+ memory: 512Mi
diff --git a/apps/portfolio/base/ingress.yaml b/apps/portfolio/base/ingress.yaml
new file mode 100644
index 0000000..a2a973c
--- /dev/null
+++ b/apps/portfolio/base/ingress.yaml
@@ -0,0 +1,23 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata:
+ name: portfolio
+ annotations:
+ cert-manager.io/cluster-issuer: letsencrypt-prod
+spec:
+ ingressClassName: traefik
+ tls:
+ - hosts:
+ - youngwon.me
+ secretName: youngwon-me-tls
+ rules:
+ - host: youngwon.me
+ http:
+ paths:
+ - path: /portfolio
+ pathType: Prefix
+ backend:
+ service:
+ name: portfolio
+ port:
+ number: 80
diff --git a/apps/portfolio/base/kustomization.yaml b/apps/portfolio/base/kustomization.yaml
new file mode 100644
index 0000000..1d4f62e
--- /dev/null
+++ b/apps/portfolio/base/kustomization.yaml
@@ -0,0 +1,7 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+ - deployment.yaml
+ - service.yaml
+ - ingress.yaml
diff --git a/apps/portfolio/base/service.yaml b/apps/portfolio/base/service.yaml
new file mode 100644
index 0000000..adc3254
--- /dev/null
+++ b/apps/portfolio/base/service.yaml
@@ -0,0 +1,14 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: portfolio
+ labels:
+ app.kubernetes.io/name: portfolio
+ app.kubernetes.io/part-of: youngwon-platform
+spec:
+ selector:
+ app.kubernetes.io/name: portfolio
+ ports:
+ - name: http
+ port: 80
+ targetPort: http
diff --git a/apps/portfolio/overlays/prod/kustomization.yaml b/apps/portfolio/overlays/prod/kustomization.yaml
new file mode 100644
index 0000000..c33f419
--- /dev/null
+++ b/apps/portfolio/overlays/prod/kustomization.yaml
@@ -0,0 +1,11 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+namespace: portfolio
+
+resources:
+ - ../../base
+
+images:
+ - name: yw7148/portfolio
+ newTag: latest
diff --git a/argocd/applications/oci-base.yaml b/argocd/applications/oci-base.yaml
new file mode 100644
index 0000000..0638a1d
--- /dev/null
+++ b/argocd/applications/oci-base.yaml
@@ -0,0 +1,25 @@
+apiVersion: argoproj.io/v1alpha1
+kind: Application
+metadata:
+ name: oci-base
+ namespace: argocd
+ annotations:
+ argocd.argoproj.io/sync-wave: "0"
+ finalizers:
+ - resources-finalizer.argocd.argoproj.io
+spec:
+ project: oci-platform
+ source:
+ repoURL: https://github.com/yw7148/server.git
+ targetRevision: HEAD
+ path: clusters/oci-prod
+ destination:
+ name: oci-prod
+ namespace: cert-manager
+ syncPolicy:
+ automated:
+ prune: true
+ selfHeal: true
+ syncOptions:
+ - CreateNamespace=true
+ - ServerSideApply=true
diff --git a/argocd/applications/portfolio-prod.yaml b/argocd/applications/portfolio-prod.yaml
new file mode 100644
index 0000000..91347b2
--- /dev/null
+++ b/argocd/applications/portfolio-prod.yaml
@@ -0,0 +1,24 @@
+apiVersion: argoproj.io/v1alpha1
+kind: Application
+metadata:
+ name: portfolio-prod
+ namespace: argocd
+ annotations:
+ argocd.argoproj.io/sync-wave: "1"
+ finalizers:
+ - resources-finalizer.argocd.argoproj.io
+spec:
+ project: oci-platform
+ source:
+ repoURL: https://github.com/yw7148/server.git
+ targetRevision: HEAD
+ path: apps/portfolio/overlays/prod
+ destination:
+ name: oci-prod
+ namespace: portfolio
+ syncPolicy:
+ automated:
+ prune: true
+ selfHeal: true
+ syncOptions:
+ - CreateNamespace=true
diff --git a/argocd/kustomization.yaml b/argocd/kustomization.yaml
new file mode 100644
index 0000000..3e443bd
--- /dev/null
+++ b/argocd/kustomization.yaml
@@ -0,0 +1,7 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+ - projects/oci-platform.yaml
+ - applications/oci-base.yaml
+ - applications/portfolio-prod.yaml
diff --git a/argocd/projects/oci-platform.yaml b/argocd/projects/oci-platform.yaml
new file mode 100644
index 0000000..94cd15d
--- /dev/null
+++ b/argocd/projects/oci-platform.yaml
@@ -0,0 +1,18 @@
+apiVersion: argoproj.io/v1alpha1
+kind: AppProject
+metadata:
+ name: oci-platform
+ namespace: argocd
+ annotations:
+ argocd.argoproj.io/sync-wave: "-1"
+spec:
+ description: OCI k3s platform and workloads managed from the Home node Argo CD instance.
+ sourceRepos:
+ - https://github.com/yw7148/server.git
+ - git@github.com:yw7148/server.git
+ destinations:
+ - server: '*'
+ namespace: '*'
+ clusterResourceWhitelist:
+ - group: '*'
+ kind: '*'
diff --git a/bootstrap/home-mgmt/argocd-install/kustomization.yaml b/bootstrap/home-mgmt/argocd-install/kustomization.yaml
new file mode 100644
index 0000000..85aec5d
--- /dev/null
+++ b/bootstrap/home-mgmt/argocd-install/kustomization.yaml
@@ -0,0 +1,8 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+namespace: argocd
+
+resources:
+ - namespace.yaml
+ - https://raw.githubusercontent.com/argoproj/argo-cd/v3.3.6/manifests/install.yaml
diff --git a/bootstrap/home-mgmt/argocd-install/namespace.yaml b/bootstrap/home-mgmt/argocd-install/namespace.yaml
new file mode 100644
index 0000000..a040f2b
--- /dev/null
+++ b/bootstrap/home-mgmt/argocd-install/namespace.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: argocd
diff --git a/bootstrap/home-mgmt/root-app/kustomization.yaml b/bootstrap/home-mgmt/root-app/kustomization.yaml
new file mode 100644
index 0000000..9a43af5
--- /dev/null
+++ b/bootstrap/home-mgmt/root-app/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+ - server-root.yaml
diff --git a/bootstrap/home-mgmt/root-app/server-root.yaml b/bootstrap/home-mgmt/root-app/server-root.yaml
new file mode 100644
index 0000000..b2cbf58
--- /dev/null
+++ b/bootstrap/home-mgmt/root-app/server-root.yaml
@@ -0,0 +1,20 @@
+apiVersion: argoproj.io/v1alpha1
+kind: Application
+metadata:
+ name: server-root
+ namespace: argocd
+ finalizers:
+ - resources-finalizer.argocd.argoproj.io
+spec:
+ project: default
+ source:
+ repoURL: https://github.com/yw7148/server.git
+ targetRevision: HEAD
+ path: argocd
+ destination:
+ server: https://kubernetes.default.svc
+ namespace: argocd
+ syncPolicy:
+ automated:
+ prune: true
+ selfHeal: true
diff --git a/clusters/oci-prod/cert-manager/clusterissuer-letsencrypt-prod.yaml b/clusters/oci-prod/cert-manager/clusterissuer-letsencrypt-prod.yaml
new file mode 100644
index 0000000..0ddbe9f
--- /dev/null
+++ b/clusters/oci-prod/cert-manager/clusterissuer-letsencrypt-prod.yaml
@@ -0,0 +1,14 @@
+apiVersion: cert-manager.io/v1
+kind: ClusterIssuer
+metadata:
+ name: letsencrypt-prod
+spec:
+ acme:
+ email: ops@youngwon.me
+ server: https://acme-v02.api.letsencrypt.org/directory
+ privateKeySecretRef:
+ name: letsencrypt-prod
+ solvers:
+ - http01:
+ ingress:
+ ingressClassName: traefik
diff --git a/clusters/oci-prod/cert-manager/kustomization.yaml b/clusters/oci-prod/cert-manager/kustomization.yaml
new file mode 100644
index 0000000..31545ce
--- /dev/null
+++ b/clusters/oci-prod/cert-manager/kustomization.yaml
@@ -0,0 +1,6 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+ - https://github.com/cert-manager/cert-manager/releases/download/v1.20.1/cert-manager.yaml
+ - clusterissuer-letsencrypt-prod.yaml
diff --git a/clusters/oci-prod/kustomization.yaml b/clusters/oci-prod/kustomization.yaml
new file mode 100644
index 0000000..14c104f
--- /dev/null
+++ b/clusters/oci-prod/kustomization.yaml
@@ -0,0 +1,6 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+ - namespaces
+ - cert-manager
diff --git a/clusters/oci-prod/namespaces/kustomization.yaml b/clusters/oci-prod/namespaces/kustomization.yaml
new file mode 100644
index 0000000..ed24f15
--- /dev/null
+++ b/clusters/oci-prod/namespaces/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+ - portfolio.yaml
diff --git a/clusters/oci-prod/namespaces/portfolio.yaml b/clusters/oci-prod/namespaces/portfolio.yaml
new file mode 100644
index 0000000..f48ed69
--- /dev/null
+++ b/clusters/oci-prod/namespaces/portfolio.yaml
@@ -0,0 +1,7 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: portfolio
+ labels:
+ app.kubernetes.io/part-of: youngwon-platform
+ app.kubernetes.io/managed-by: argocd
diff --git a/docs/k3s-argocd-migration-design.md b/docs/k3s-argocd-migration-design.md
new file mode 100644
index 0000000..6c181e5
--- /dev/null
+++ b/docs/k3s-argocd-migration-design.md
@@ -0,0 +1,367 @@
+# k3s / Argo CD Migration Design
+
+## Summary
+
+이 문서는 현재 `Jenkins` + `Nginx` 중심 운영 구조를 `Home node`의 Argo CD 관리 클러스터와 `OCI`의 k3s 런타임 클러스터로 전환하기 위한 기준 설계 문서다.
+
+최종 목표는 다음과 같다.
+
+- Home node에 단일 노드 k3s management cluster를 두고 Argo CD를 설치한다.
+- OCI의 `instance-control` 1대와 `instance-worker-1`, `instance-worker-2` 2대로 k3s workload cluster를 구성한다.
+- 외부 노출은 OCI k3s의 Traefik ingress를 사용한다.
+- TLS는 `cert-manager`와 ACME를 사용해 자동화한다.
+- CI는 GitHub Actions로, CD는 Argo CD GitOps로 분리한다.
+- 기존 `youngwon.me/portfolio` 경로는 초기 마이그레이션 동안 그대로 유지한다.
+
+## Current State
+
+현재 리포와 Terraform 기준으로 운영 상태는 다음과 같다.
+
+- Home node
+ - Jenkins가 Docker socket 바인딩 기반으로 로컬 컨테이너에서 동작한다.
+- OCI
+ - `instance-control`
+ - `instance-worker-1`
+ - `instance-worker-2`
+ - 세 인스턴스 모두 같은 OCI subnet에 있으며 public IP가 할당되어 있다.
+- Public edge
+ - Nginx가 `youngwon.me`의 TLS 종료를 수행한다.
+ - `/portfolio` 요청은 OCI의 별도 public endpoint `:9001`로 프록시된다.
+
+현재 구조의 한계는 다음과 같다.
+
+- Jenkins가 Home node의 Docker 환경에 강하게 결합되어 있다.
+- Nginx와 실제 애플리케이션 런타임이 분리되어 있어 경로, 인증서, 백엔드 주소를 수동으로 맞춰야 한다.
+- 배포 선언이 GitOps 형태로 정리되어 있지 않아 변경 이력과 목표 상태 추적이 어렵다.
+- OCI 노드가 있어도 클러스터 관점의 일관된 운영 모델이 없다.
+
+## Goals
+
+- Argo CD를 중심으로 원하는 상태를 Git에 선언하고 자동 동기화한다.
+- OCI 3노드를 하나의 k3s 클러스터로 운영한다.
+- Home node는 관리 plane, OCI는 workload plane으로 역할을 분리한다.
+- 기존 도메인과 서비스 경로를 크게 바꾸지 않고 무중단에 가깝게 전환한다.
+- Jenkins 제거 이후에도 이미지 빌드부터 배포까지의 경로가 끊기지 않게 한다.
+
+## Non-Goals
+
+- 이번 설계는 multi-control-plane HA k3s까지 다루지 않는다.
+- OCI Load Balancer 신규 도입은 이번 기본안에 포함하지 않는다.
+- Secret manager 신규 도입은 이번 기본안에 포함하지 않는다.
+- 모든 애플리케이션의 동시 이전은 목표가 아니다. 우선순위는 `portfolio`다.
+
+## Final Decisions
+
+- Argo CD는 Home node의 단일 노드 k3s management cluster 위에 둔다.
+- OCI k3s cluster는 `instance-control` 1대의 control plane과 `instance-worker-1`, `instance-worker-2` 2대의 worker로 구성한다.
+- 일반 애플리케이션 workload는 기본적으로 두 worker에 배치하고, `instance-control`은 control plane 역할을 우선한다.
+- 이 `server` 리포를 GitOps source of truth로 사용한다.
+- 외부 ingress는 k3s 기본 Traefik를 사용한다.
+- TLS 발급과 갱신은 `cert-manager` + Let's Encrypt 기준으로 설계한다.
+- Jenkins 대체 CI는 GitHub Actions로 한다.
+- Argo CD UI는 기본적으로 public exposure 없이 private access를 원칙으로 한다.
+
+## Target Architecture
+
+```mermaid
+flowchart TD
+ DEV[Developer / Operator]
+ GH[GitHub Repository
server]
+ GHA[GitHub Actions
Build + Push]
+ HOME[Home node
k3s management cluster]
+ ARGO[Argo CD]
+ OCICTL[instance-control
k3s server]
+ OCIW1[instance-worker-1
k3s agent]
+ OCIW2[instance-worker-2
k3s agent]
+ TRAEFIK[Traefik Ingress]
+ CERT[cert-manager]
+ APP[portfolio workload]
+
+ DEV --> GH
+ GH --> GHA
+ GH --> ARGO
+ HOME --> ARGO
+ ARGO --> OCICTL
+ ARGO --> OCIW1
+ ARGO --> OCIW2
+ CERT --> TRAEFIK
+ TRAEFIK --> APP
+```
+
+## Node Roles
+
+### Home node
+
+- 단일 노드 k3s management cluster를 운영한다.
+- Argo CD를 `argocd` namespace에 설치한다.
+- OCI workload cluster를 remote cluster로 등록해 GitOps 컨트롤러 역할만 수행한다.
+- 외부 서비스 트래픽의 public edge 역할은 맡지 않는다.
+
+### OCI `instance-control`
+
+- k3s server 역할을 맡는다.
+- Kubernetes API endpoint의 기준 노드가 된다.
+- 운영 원칙상 control plane 역할이 우선이며, 일반 workload는 가급적 worker에 배치한다.
+
+현재 Terraform 기준 사양:
+
+- Shape: `VM.Standard.A1.Flex`
+- OCPU: `1`
+- Memory: `6GB`
+- Private IP: `10.0.0.195`
+
+### OCI `instance-worker-1`
+
+- k3s agent 역할을 맡는다.
+- 일반 application workload의 기본 배치 대상이다.
+
+현재 Terraform 기준 사양:
+
+- Shape: `VM.Standard.A1.Flex`
+- OCPU: `2`
+- Memory: `9GB`
+- Private IP: `10.0.0.149`
+
+### OCI `instance-worker-2`
+
+- k3s agent 역할을 맡는다.
+- 일반 application workload의 두 번째 배치 대상이다.
+
+현재 Terraform 기준 사양:
+
+- Shape: `VM.Standard.A1.Flex`
+- OCPU: `1`
+- Memory: `9GB`
+- Private IP: `10.0.0.81`
+
+## Networking, DNS, and Security
+
+### Traffic Model
+
+- 외부 사용자 요청은 `youngwon.me`를 통해 OCI k3s ingress로 직접 유입된다.
+- 초기 기본안은 DNS를 `instance-control`의 public IP로 연결한다.
+- 이후 필요 시 다중 A record 또는 OCI Load Balancer로 확장할 수 있다.
+- `/portfolio` 경로는 Traefik ingress rule로 유지한다.
+
+### TLS
+
+- `cert-manager`가 Let's Encrypt certificate를 발급한다.
+- `youngwon.me` 인증서는 Kubernetes secret으로 관리된다.
+- HTTP-01 challenge를 기본값으로 두고 ingress class는 `traefik`를 사용한다.
+
+### Required Ports
+
+- Internet -> OCI ingress
+ - `80/tcp`
+ - `443/tcp`
+- Home node -> OCI control plane
+ - `6443/tcp`
+- OCI node internal communication
+ - k3s overlay 및 agent/server 통신에 필요한 내부 포트 허용
+- Admin access
+ - `22/tcp`는 운영자 IP 범위로 제한
+
+### Argo CD Access
+
+- Argo CD UI는 public exposure를 하지 않는다.
+- 접속은 Home node에서 직접 접근하거나 SSH tunnel 같은 private path를 사용한다.
+
+## GitOps Repository Model
+
+이 리포는 문서, 플랫폼 선언, 애플리케이션 선언을 같이 관리하는 단일 source of truth가 된다.
+
+예상 구조:
+
+```text
+docs/
+bootstrap/
+ home-mgmt/
+argocd/
+ projects/
+ applications/
+clusters/
+ oci-prod/
+apps/
+ portfolio/
+.github/
+ workflows/
+```
+
+역할 분리는 다음과 같다.
+
+- `docs/`
+ - 아키텍처, 운영 규칙, 마이그레이션 문서
+- `bootstrap/home-mgmt/`
+ - Home management cluster 초기 설치용 자산
+- `argocd/projects/`
+ - Argo CD AppProject
+- `argocd/applications/`
+ - OCI cluster에 배포할 child application
+- `clusters/oci-prod/`
+ - namespace, cluster issuer, 공통 platform manifest
+- `apps/portfolio/`
+ - `portfolio` 배포 선언
+- `.github/workflows/`
+ - 이미지 빌드, 푸시, 배포용 manifest promotion workflow
+
+## CI/CD Operating Model
+
+### CI
+
+- GitHub Actions가 애플리케이션 이미지를 빌드한다.
+- 빌드된 이미지는 Docker Hub `yw7148` namespace로 push한다.
+- 이미지 태그는 immutable tag를 사용한다.
+- 기본안은 Git SHA 또는 release tag 기반 tag를 사용한다.
+
+### CD
+
+- GitHub Actions는 배포 대상 manifest의 이미지 태그를 업데이트한다.
+- Argo CD는 Git 변경을 감지해 OCI cluster와 동기화한다.
+- v1에서는 Argo CD Image Updater 대신 GitHub Actions가 manifest promotion을 수행한다.
+
+이 방식의 장점은 다음과 같다.
+
+- 빌드 책임과 배포 책임이 분리된다.
+- 실제 배포 상태가 항상 Git에 남는다.
+- Jenkins의 Docker socket 의존성을 제거할 수 있다.
+
+## Bootstrap Sequence
+
+### 1. OCI Infrastructure Verification
+
+- `/home/youngwon/terraform` 리포 기준으로 `instance-control`, `instance-worker-1`, `instance-worker-2`가 정상 상태인지 확인한다.
+- 세 노드 모두 SSH 접근과 내부 네트워크 통신이 가능한지 확인한다.
+
+### 2. Home Management Cluster Bootstrap
+
+- Home node에 k3s single-node cluster를 설치한다.
+- Argo CD를 설치한다.
+- Argo CD admin 초기 접근 경로와 비밀번호 변경 절차를 설정한다.
+
+### 3. OCI Workload Cluster Bootstrap
+
+- `instance-control`에 k3s server를 설치한다.
+- `instance-worker-1`, `instance-worker-2`를 k3s agent로 join한다.
+- control plane 노드에는 운영 원칙상 일반 workload를 최대한 배치하지 않도록 taint 또는 scheduling rule을 적용한다.
+
+### 4. Cluster Registration
+
+- Home node의 Argo CD에서 OCI cluster를 remote cluster로 등록한다.
+- cluster name은 `oci-prod`를 기본값으로 사용한다.
+
+### 5. Base Platform Installation
+
+- OCI cluster에 아래 공통 자산을 배포한다.
+ - namespace
+ - `cert-manager`
+ - `ClusterIssuer`
+ - 공통 ingress policy
+
+### 6. First Workload Onboarding
+
+- `portfolio`를 첫 번째 GitOps-managed workload로 등록한다.
+- `/portfolio` 경로와 TLS가 정상 동작하는지 확인한다.
+
+## Portfolio Migration Plan
+
+### Before Cutover
+
+- 현재 Nginx가 프록시하는 `portfolio` backend의 동작 포트와 health check 경로를 확인한다.
+- OCI k3s cluster에 동일 이미지를 배포해 병렬 검증한다.
+- Traefik ingress로 `/portfolio`가 서비스 가능한지 먼저 검증한다.
+
+### Cutover
+
+- `youngwon.me/portfolio`의 실제 ingress를 OCI k3s로 전환한다.
+- 기존 Nginx 설정과 새 ingress를 일정 시간 병행 검증한다.
+- 응답 코드, 정적 리소스 경로, HTTPS 동작을 확인한다.
+
+### After Cutover
+
+- 일정 기간 관찰 후 기존 Nginx의 `/portfolio` 프록시를 제거한다.
+- Jenkins가 더 이상 배포 경로에 필요 없음을 확인한 뒤 종료한다.
+
+## Rollback Plan
+
+### Rollback Point 1
+
+- Home node management cluster 구축 전 단계
+- 이 시점에서는 기존 Jenkins/Nginx 운영을 그대로 유지한다.
+
+### Rollback Point 2
+
+- OCI k3s cluster bootstrap 완료 후
+- 아직 public cutover 전이면 ingress만 비활성화하고 기존 경로를 유지한다.
+
+### Rollback Point 3
+
+- `portfolio` cutover 직후
+- 문제가 있으면 DNS 또는 ingress route를 기존 Nginx 경로로 즉시 되돌린다.
+
+### Rollback Point 4
+
+- Jenkins/Nginx 종료 직전
+- 최소 1회 이상 정상 배포와 재배포를 GitHub Actions + Argo CD로 검증한 뒤에만 종료한다.
+
+## Operational Rules
+
+- Argo CD는 운영 기준 source of truth가 Git임을 전제로 한다.
+- emergency hotfix도 가능하면 Git을 통해 반영한다.
+- cluster 안에서 수동 변경이 발생하면 반드시 Git 선언으로 되돌려 정합성을 맞춘다.
+- Home node와 OCI의 책임을 섞지 않는다.
+ - Home node는 management
+ - OCI는 runtime
+
+## Risks and Mitigations
+
+### Single Control Plane
+
+위험:
+
+- `instance-control` 장애 시 Kubernetes API 운영성이 떨어진다.
+
+대응:
+
+- 이번 단계에서는 단순성을 우선하고 single control plane을 유지한다.
+- 이후 필요 시 multi-server k3s 또는 외부 datastore 구성을 검토한다.
+
+### Public Ingress Direct to OCI
+
+위험:
+
+- 초기에는 OCI Load Balancer 없이 단일 ingress 진입점에 의존한다.
+
+대응:
+
+- 먼저 구조 단순화를 우선한다.
+- 서비스 증가 시 다중 A record 또는 OCI LB를 도입한다.
+
+### Secret Management
+
+위험:
+
+- 초기 bootstrap에서 수동 secret 관리가 남을 수 있다.
+
+대응:
+
+- Git에는 secret을 저장하지 않는다.
+- 추후 SOPS 또는 Sealed Secrets 도입을 후속 과제로 둔다.
+
+## Acceptance Criteria
+
+아래 조건을 만족하면 이번 전환 설계가 유효한 것으로 본다.
+
+- 운영자가 Argo CD, OCI k3s, GitHub Actions의 역할 경계를 한 번에 설명할 수 있다.
+- Home node와 OCI node 각각에 무엇이 올라가는지 모호하지 않다.
+- `youngwon.me/portfolio`를 어떤 순서로 옮길지 절차가 분명하다.
+- Jenkins 종료 전 필요한 검증 단계가 정의되어 있다.
+- rollback 시점과 기준이 명확하다.
+
+## Assumptions
+
+- OCI 런타임 클러스터의 최종 노드는 `instance-control`, `instance-worker-1`, `instance-worker-2`다.
+- `portfolio`는 OCI k3s 위로 먼저 이전할 대표 workload다.
+- Docker Hub `yw7148` repository를 계속 사용할 수 있다.
+- Domain은 계속 `youngwon.me`를 사용한다.
+- Terraform 리포는 인프라 source of truth이고, 이 리포는 운영 선언과 GitOps source of truth 역할을 맡는다.
+
diff --git a/docs/oci-k3s-apply-guide.md b/docs/oci-k3s-apply-guide.md
new file mode 100644
index 0000000..869c48d
--- /dev/null
+++ b/docs/oci-k3s-apply-guide.md
@@ -0,0 +1,472 @@
+# OCI k3s Apply Guide
+
+## Summary
+
+이 문서는 현재 리포의 Kubernetes/Argo CD 설정을 실제 OCI 인스턴스와 Home node에 적용하는 절차를 정리한다.
+
+대상 노드:
+
+- Home node: Argo CD 관리 클러스터
+- OCI `instance-control`: k3s server
+- OCI `instance-worker-1`: k3s agent
+- OCI `instance-worker-2`: k3s agent
+
+## 1. OCI Security Rules
+
+OCI security list 또는 NSG에서 최소한 아래 포트를 허용한다.
+
+- Internet -> `instance-control`
+ - `80/tcp`
+ - `443/tcp`
+ - `6443/tcp`
+- Operator IP -> all nodes
+ - `22/tcp`
+- OCI private subnet internal
+ - all node-to-node traffic for k3s overlay and agent/server communication
+
+`6443/tcp`는 Home node의 Argo CD가 OCI 클러스터에 접근하기 위해 필요하다.
+
+권장:
+
+- `6443/tcp`는 전체 인터넷에 열지 말고 Home node의 공인 IP로만 제한한다.
+- Home node에서 OCI kubeconfig가 가리키는 API 서버 주소는 실제로 Home node와 Argo CD pod가 모두 도달 가능한 주소여야 한다.
+
+## 2. Install k3s on OCI
+
+중요:
+
+- `instance-control`에서는 `server` 설치만 실행한다.
+- `instance-worker-1`, `instance-worker-2`에서는 `agent` join만 실행한다.
+- `--node-ip` 값은 "지금 접속해 있는 머신"에 실제로 붙어 있는 private IP와 반드시 같아야 한다.
+- `K3S_URL`을 주면 설치 스크립트는 agent로 동작한다. 따라서 `instance-control`에서 `K3S_URL=...` 형태의 join 명령을 실행하면 안 된다.
+
+실행 전 빠른 확인:
+
+```bash
+hostname
+ip -4 addr show | grep "10.0.0."
+```
+
+예상 결과:
+
+- `instance-control`에서는 `10.0.0.195`
+- `instance-worker-1`에서는 `10.0.0.149`
+- `instance-worker-2`에서는 `10.0.0.81`
+
+### 2.1 Control Plane on `instance-control`
+
+`instance-control`에서 실행:
+
+```bash
+curl -sfL https://get.k3s.io | \
+ INSTALL_K3S_EXEC="server \
+ --write-kubeconfig-mode 644 \
+ --node-ip 10.0.0.195 \
+ --advertise-address 10.0.0.195 \
+ --tls-san " \
+ sh -
+```
+
+토큰 확인:
+
+```bash
+sudo cat /var/lib/rancher/k3s/server/node-token
+```
+
+### 2.2 Join `instance-worker-1`
+
+`instance-worker-1`에서 실행:
+
+먼저 control plane 연결 확인:
+
+```bash
+curl -k https://10.0.0.195:6443/cacerts
+```
+
+여기서 CA 인증서가 내려오지 않으면 join 전에 아래를 먼저 확인한다.
+
+- `instance-control`에서 `sudo systemctl status k3s --no-pager -l`
+- OCI security list / NSG 에서 worker -> control plane `6443/tcp` 허용 여부
+- OS firewall 에서 `6443/tcp` 허용 여부
+
+정상 응답이 오면 join:
+
+```bash
+curl -sfL https://get.k3s.io | \
+ K3S_URL=https://10.0.0.195:6443 \
+ K3S_TOKEN= \
+ INSTALL_K3S_EXEC="agent --node-ip 10.0.0.149" \
+ sh -
+```
+
+### 2.3 Join `instance-worker-2`
+
+`instance-worker-2`에서 실행:
+
+먼저 control plane 연결 확인:
+
+```bash
+curl -k https://10.0.0.195:6443/cacerts
+```
+
+정상 응답이 오면 join:
+
+```bash
+curl -sfL https://get.k3s.io | \
+ K3S_URL=https://10.0.0.195:6443 \
+ K3S_TOKEN= \
+ INSTALL_K3S_EXEC="agent --node-ip 10.0.0.81" \
+ sh -
+```
+
+### 2.4 Verify OCI Cluster
+
+`instance-control`에서 실행:
+
+```bash
+sudo kubectl get nodes -o wide
+```
+
+정상 결과는 노드 3대가 모두 `Ready` 상태여야 한다.
+
+## 2.5 Troubleshooting Wrong-Node Join
+
+증상 예시:
+
+- `instance-control`에서 worker join 명령을 실행했다.
+- `systemctl status k3s-agent`가 실패한다.
+- `--node-ip 10.0.0.149` 같은 worker IP를 control plane 노드에서 사용했다.
+
+이 경우 원인은 대체로 다음 둘 중 하나다.
+
+- control plane 노드에 agent를 설치하려고 했다.
+- 현재 노드에 없는 IP를 `--node-ip`로 넘겼다.
+
+복구 절차는 아래와 같다.
+
+### `instance-control`에서 agent를 잘못 실행한 경우
+
+```bash
+sudo /usr/local/bin/k3s-agent-uninstall.sh
+sudo rm -f /etc/systemd/system/k3s-agent.service /etc/systemd/system/k3s-agent.service.env
+sudo systemctl daemon-reload
+```
+
+그다음 control plane 명령만 다시 실행:
+
+```bash
+curl -sfL https://get.k3s.io | \
+ INSTALL_K3S_EXEC="server \
+ --write-kubeconfig-mode 644 \
+ --node-ip 10.0.0.195 \
+ --advertise-address 10.0.0.195 \
+ --tls-san " \
+ sh -
+```
+
+확인:
+
+```bash
+sudo systemctl status k3s --no-pager
+sudo kubectl get nodes -o wide
+sudo cat /var/lib/rancher/k3s/server/node-token
+```
+
+### worker에서 join이 꼬인 경우
+
+```bash
+sudo /usr/local/bin/k3s-agent-uninstall.sh
+sudo rm -f /etc/systemd/system/k3s-agent.service /etc/systemd/system/k3s-agent.service.env
+sudo rm -rf /etc/rancher/node /etc/rancher/k3s /var/lib/rancher/k3s
+sudo systemctl daemon-reload
+```
+
+그다음 해당 worker의 올바른 private IP로 다시 join:
+
+```bash
+curl -sfL https://get.k3s.io | \
+ K3S_URL=https://10.0.0.195:6443 \
+ K3S_TOKEN= \
+ INSTALL_K3S_EXEC="agent --node-ip " \
+ sh -
+```
+
+만약 이전에 같은 hostname으로 join 시도가 있었다면, control plane에서 기존 node 정보를 먼저 지우는 편이 안전하다.
+
+```bash
+sudo /usr/local/bin/k3s kubectl delete node instance-worker-1 || true
+sudo /usr/local/bin/k3s kubectl delete node instance-worker-2 || true
+```
+
+## 2.6 Worker Join Failure Checklist
+
+worker 로그에 아래 같은 메시지가 보이면:
+
+```text
+Failed to validate connection to cluster at https://10.0.0.195:6443
+failed to get CA certs
+Get "https://127.0.0.1:6444/cacerts": read tcp 127.0.0.1:*->127.0.0.1:6444: read: connection reset by peer
+```
+
+대개 원인은 아래 셋 중 하나다.
+
+- `instance-control`의 `k3s.service`가 실제로 떠 있지 않다.
+- worker에서 `10.0.0.195:6443`에 접근할 수 없다.
+- worker에 남아 있는 이전 join 상태가 새 시도와 충돌한다.
+
+확인 순서:
+
+1. `instance-control`
+
+```bash
+sudo systemctl status k3s --no-pager -l
+sudo ss -lntp | grep 6443
+sudo /usr/local/bin/k3s kubectl get nodes -o wide
+```
+
+2. worker
+
+```bash
+curl -k https://10.0.0.195:6443/cacerts
+sudo journalctl -u k3s-agent -n 100 --no-pager -l
+```
+
+3. Oracle Linux firewall 사용 중이면 `instance-control`에서:
+
+```bash
+sudo firewall-cmd --permanent --add-port=6443/tcp
+sudo firewall-cmd --permanent --zone=trusted --add-source=10.42.0.0/16
+sudo firewall-cmd --permanent --zone=trusted --add-source=10.43.0.0/16
+sudo firewall-cmd --reload
+```
+
+필요하면 worker 재join 전에 OS firewall도 꺼둘 수 있다.
+
+```bash
+sudo systemctl disable firewalld --now
+```
+
+## 3. Prepare Kubeconfig for Home node
+
+Home node에서 OCI kubeconfig를 가져온다.
+
+```bash
+mkdir -p ~/.kube
+ssh opc@ "sudo cat /etc/rancher/k3s/k3s.yaml" > ~/.kube/oci-prod.yaml
+sed -i "s/127.0.0.1//" ~/.kube/oci-prod.yaml
+```
+
+중요:
+
+- 여기서 ``는 Home node에서 실제로 접근 가능한 IP여야 한다.
+- `argocd cluster add`는 이 kubeconfig의 server 주소로 직접 접속해 `kube-system`에 `argocd-manager` service account를 만든다.
+- 따라서 Home node에서 아래 테스트가 먼저 성공해야 한다.
+
+```bash
+curl -k https://:6443/cacerts
+```
+
+이 테스트가 실패하면 `argocd cluster add`도 실패한다.
+
+컨텍스트 이름을 정리한다.
+
+```bash
+KUBECONFIG=~/.kube/oci-prod.yaml kubectl config rename-context default oci-prod
+KUBECONFIG=~/.kube/oci-prod.yaml kubectl get nodes
+```
+
+## 4. Install Management Cluster on Home node
+
+Home node에서 관리용 k3s를 설치한다.
+
+```bash
+curl -sfL https://get.k3s.io | \
+ INSTALL_K3S_EXEC="server --write-kubeconfig-mode 644" \
+ sh -
+```
+
+기본 kubeconfig 확인:
+
+```bash
+sudo kubectl get nodes
+```
+
+Home node에서 사용할 `kubectl` 기본 컨텍스트는 management cluster로 두는 편이 안전하다.
+
+## 5. Install Argo CD on Home node
+
+리포 루트에서 실행:
+
+```bash
+sudo kubectl apply --server-side --force-conflicts -k bootstrap/home-mgmt/argocd-install
+sudo kubectl -n argocd get pods
+```
+
+중요:
+
+- Argo CD의 `ApplicationSet` CRD는 크기가 커서 일반 `kubectl apply`로는 `metadata.annotations: Too long` 오류가 날 수 있다.
+- 이 경우 설치 명령은 반드시 `--server-side --force-conflicts` 옵션을 사용한다.
+
+초기 admin 비밀번호 확인:
+
+```bash
+sudo kubectl -n argocd get secret argocd-initial-admin-secret \
+ -o jsonpath="{.data.password}" | base64 -d && echo
+```
+
+필요 시 로컬 포트포워딩:
+
+```bash
+sudo kubectl -n argocd port-forward svc/argocd-server 8080:443
+```
+
+## 6. Register OCI Cluster to Argo CD
+
+Home node에 Argo CD CLI가 없다면 설치:
+
+```bash
+VERSION=v3.3.6
+curl -sSL -o /tmp/argocd-linux-amd64 \
+ https://github.com/argoproj/argo-cd/releases/download/${VERSION}/argocd-linux-amd64
+sudo install -m 0755 /tmp/argocd-linux-amd64 /usr/local/bin/argocd
+```
+
+Argo CD 로그인:
+
+```bash
+argocd login localhost:8080 --username admin --password --insecure
+```
+
+OCI 클러스터 등록:
+
+```bash
+KUBECONFIG=~/.kube/oci-prod.yaml argocd cluster add oci-prod --name oci-prod --yes
+```
+
+만약 아래처럼 timeout이 나면:
+
+```text
+failed to create service account "argocd-manager" in namespace "kube-system"
+dial tcp :6443: i/o timeout
+```
+
+아래를 순서대로 확인한다.
+
+1. Home node에서 API 서버 도달성 확인
+
+```bash
+curl -k https://:6443/cacerts
+```
+
+2. `instance-control`에서 API 서버 리스닝 확인
+
+```bash
+sudo systemctl status k3s --no-pager -l
+sudo ss -lntp | grep 6443
+```
+
+3. OCI security list / NSG에서 `6443/tcp`가 Home node 공인 IP에 대해 허용돼 있는지 확인
+
+4. `instance-control`의 OS firewall에서 `6443/tcp` 허용 여부 확인
+
+```bash
+sudo systemctl status firewalld --no-pager
+sudo firewall-cmd --list-ports
+```
+
+등록 확인:
+
+```bash
+argocd cluster list
+```
+
+## 7. Apply GitOps Root Application
+
+리포 루트에서 실행:
+
+```bash
+sudo kubectl apply -k bootstrap/home-mgmt/root-app
+sudo kubectl -n argocd get applications
+```
+
+몇 분 뒤 아래 두 앱이 보여야 한다.
+
+- `oci-base`
+- `portfolio-prod`
+
+동기화 강제 실행:
+
+```bash
+argocd app sync server-root
+argocd app sync oci-base
+argocd app sync portfolio-prod
+```
+
+## 8. Verify OCI Runtime Resources
+
+OCI cluster 기준으로 확인:
+
+```bash
+KUBECONFIG=~/.kube/oci-prod.yaml kubectl get ns
+KUBECONFIG=~/.kube/oci-prod.yaml kubectl get pods -A
+KUBECONFIG=~/.kube/oci-prod.yaml kubectl get ingress -A
+KUBECONFIG=~/.kube/oci-prod.yaml kubectl -n portfolio get deploy,svc,pods
+```
+
+중점 확인 항목:
+
+- `cert-manager` pod 정상 기동
+- `portfolio` deployment replica 2개 정상 기동
+- `portfolio` ingress에 `youngwon.me`와 `/portfolio` rule 반영
+
+## 9. DNS Cutover
+
+현재 `youngwon.me` DNS를 기존 Nginx가 아닌 OCI ingress 진입 public IP로 변경한다.
+
+기본안:
+
+- `A` record `youngwon.me` -> ``
+
+변경 후 확인:
+
+```bash
+curl -I http://youngwon.me/portfolio
+curl -I https://youngwon.me/portfolio
+```
+
+처음에는 cert-manager 인증서 발급까지 수 분이 걸릴 수 있다.
+
+## 10. Promote a New Portfolio Image
+
+기본 배포 이미지는 `apps/portfolio/overlays/prod/kustomization.yaml`에서 관리한다.
+
+새 이미지 태그 반영 방법:
+
+```bash
+gh workflow run promote-portfolio-image.yml -f image_tag=
+```
+
+또는 직접 파일 수정:
+
+```bash
+sed -i 's/^ newTag: .*/ newTag: /' apps/portfolio/overlays/prod/kustomization.yaml
+git commit -am "chore(portfolio): promote image"
+git push
+```
+
+push 후 Argo CD가 자동 동기화한다.
+
+## 11. Add More Services
+
+현재 구조는 `portfolio` 하나만 배포하지만, 여러 서비스가 추가될 수 있게 이미 분리돼 있다.
+
+새 서비스를 추가할 때 필요한 최소 경로는 아래다.
+
+```text
+apps//base/
+apps//overlays/prod/
+argocd/applications/-prod.yaml
+clusters/oci-prod/namespaces/.yaml
+```
+
+같은 패턴으로 서비스별 namespace, deployment, service, ingress, overlay를 독립적으로 늘릴 수 있다.