이번 포스트에서는 Kubernetes 환경에서 Secret 관리를 위해서 Vault를 활용하는 방식을 살펴 보겠습니다.

실습에서는 Vault에 Secret을 저장하고, 애플리케이션에서 Vault의 Secret을 참조하는 방식을 살펴봅니다. Vault의 Secret을 활용하는 방식에는 세가지가 있습니다.

  1. The Vault Sidecar Agent Injector
  2. The Vault Container Storage Interface provider
  3. The Vault Secrets Operator

이를 실습을 통해서 살펴보겠습니다.

 

목차

  1. 실습 환경 구성
  2. Valut Sidecar Agent Injector 실습
  3. Vault CSI Driver 실습
  4. Vault Secrets Operator 실습

 

1. 실습 환경 구성

실습 환경은 kind로 생성한 쿠버네티스 환경을 통해 진행합니다.

# 클러스터 배포 전 확인
docker ps
mkdir cicd-labs
cd cicd-labs

# WSL2 Ubuntu eth0 IP를 지정
ip -br -c a

MyIP=<각자 자신의 WSL2 Ubuntu eth0 IP>
MyIP=172.28.157.42

# cicd-labs 디렉터리에서 아래 파일 작성
cat > kind-3node.yaml <<EOF
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
networking:
  apiServerAddress: "$MyIP"
nodes:
- role: control-plane
  extraPortMappings:
  - containerPort: 30000
    hostPort: 30000
  - containerPort: 30001
    hostPort: 30001
  - containerPort: 30002
    hostPort: 30002
  - containerPort: 30003
    hostPort: 30003
  - containerPort: 30004
    hostPort: 30004
  - containerPort: 30005
    hostPort: 30006
- role: worker
- role: worker
EOF
kind create cluster --config kind-3node.yaml --name myk8s --image kindest/node:v1.32.2

# 확인
kind get nodes --name myk8s
myk8s-worker2
myk8s-control-plane
myk8s-worker

# kind 는 별도 도커 네트워크 생성 후 사용 : 기본값 172.18.0.0/16
docker network ls
NETWORK ID     NAME      DRIVER    SCOPE
9b31821d4e20   bridge    bridge    local
31ba753a11a7   host      host      local
d91da96d2114   kind      bridge    local
f2e13485b121   none      null      local
docker inspect kind |jq
[
  {
    "Name": "kind",
    "Id": "d91da96d2114ac123cfadc41d90d4ef2be33b049c1e369b4acaa38249d367a4d",
    "Created": "2025-03-27T22:07:47.108546778+09:00",
    "Scope": "local",
    "Driver": "bridge",
    "EnableIPv6": true,
    "IPAM": {
      "Driver": "default",
      "Options": {},
      "Config": [
        {
          "Subnet": "172.18.0.0/16",
          "Gateway": "172.18.0.1"
        },
        {
          "Subnet": "fc00:f853:ccd:e793::/64",
          "Gateway": "fc00:f853:ccd:e793::1"
        }
      ]
    },
    "Internal": false,
    "Attachable": false,
    "Ingress": false,
    "ConfigFrom": {
      "Network": ""
    },
    "ConfigOnly": false,
    "Containers": {
      "10a9d53e4bd22ce9fc210c6693f8087185686eb51aa9d58696e0d95dafa24b6c": {
        "Name": "myk8s-worker2",
        "EndpointID": "b3cb0b47353edc3e6aaa258bd6729d94ea648bc7fcba661fa6c1287e7f349923",
        "MacAddress": "02:42:ac:12:00:03",
        "IPv4Address": "172.18.0.3/16",
        "IPv6Address": "fc00:f853:ccd:e793::3/64"
      },
      "595658837c5ff4934afbd6a2bcf1c23047490e22825e78697ff311a92cef88d9": {
        "Name": "myk8s-worker",
        "EndpointID": "6d011d2675ca64750be2c1c35a9cdff22f714de2ca72bebee05bc62e11686205",
        "MacAddress": "02:42:ac:12:00:04",
        "IPv4Address": "172.18.0.4/16",
        "IPv6Address": "fc00:f853:ccd:e793::4/64"
      },
      "ae13bcf2982f31e171f89953a89471524480e994c924349162d31e7795e0c369": {
        "Name": "myk8s-control-plane",
        "EndpointID": "e35cfd5f5280307273fc4a6c26fd8311b3dcb32d1fb3d9dd0f55f2ad32f0b515",
        "MacAddress": "02:42:ac:12:00:02",
        "IPv4Address": "172.18.0.2/16",
        "IPv6Address": "fc00:f853:ccd:e793::2/64"
      }
    },
    "Options": {
      "com.docker.network.bridge.enable_ip_masquerade": "true",
      "com.docker.network.driver.mtu": "1500"
    },
    "Labels": {}
  }
]

# k8s api 주소 확인
kubectl cluster-info
Kubernetes control plane is running at https://172.28.157.42:41243
CoreDNS is running at https://172.28.157.42:41243/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.

# 노드 정보 확인 : CRI 는 containerd 사용
kubectl get node -o wide
NAME                  STATUS   ROLES           AGE   VERSION   INTERNAL-IP   EXTERNAL-IP   OS-IMAGE                         KERNEL-VERSION                       CONTAINER-RUNTIME
myk8s-control-plane   Ready    control-plane   12m   v1.32.2   172.18.0.2    <none>        Debian GNU/Linux 12 (bookworm)   5.15.167.4-microsoft-standard-WSL2   containerd://2.0.3
myk8s-worker          Ready    <none>          11m   v1.32.2   172.18.0.4    <none>        Debian GNU/Linux 12 (bookworm)   5.15.167.4-microsoft-standard-WSL2   containerd://2.0.3
myk8s-worker2         Ready    <none>          11m   v1.32.2   172.18.0.3    <none>        Debian GNU/Linux 12 (bookworm)   5.15.167.4-microsoft-standard-WSL2   containerd://2.0.3

# 파드 정보 확인 : CNI 는 kindnet 사용
kubectl get pod -A -o wide
NAMESPACE            NAME                                          READY   STATUS    RESTARTS   AGE   IP           NODE                  NOMINATED NODE   READINESS GATES
kube-system          coredns-668d6bf9bc-cxqxb                      1/1     Running   0          12m   10.244.0.4   myk8s-control-plane   <none>           <none>
kube-system          coredns-668d6bf9bc-g45j9                      1/1     Running   0          12m   10.244.0.3   myk8s-control-plane   <none>           <none>
kube-system          etcd-myk8s-control-plane                      1/1     Running   0          12m   172.18.0.2   myk8s-control-plane   <none>           <none>
kube-system          kindnet-fclmw                                 1/1     Running   0          12m   172.18.0.3   myk8s-worker2         <none>           <none>
kube-system          kindnet-gfxg4                                 1/1     Running   0          12m   172.18.0.2   myk8s-control-plane   <none>           <none>
kube-system          kindnet-vvqvp                                 1/1     Running   0          12m   172.18.0.4   myk8s-worker          <none>           <none>
kube-system          kube-apiserver-myk8s-control-plane            1/1     Running   0          12m   172.18.0.2   myk8s-control-plane   <none>           <none>
kube-system          kube-controller-manager-myk8s-control-plane   1/1     Running   0          12m   172.18.0.2   myk8s-control-plane   <none>           <none>
kube-system          kube-proxy-7sgcd                              1/1     Running   0          12m   172.18.0.4   myk8s-worker          <none>           <none>
kube-system          kube-proxy-8xck8                              1/1     Running   0          12m   172.18.0.3   myk8s-worker2         <none>           <none>
kube-system          kube-proxy-tpvwq                              1/1     Running   0          12m   172.18.0.2   myk8s-control-plane   <none>           <none>
kube-system          kube-scheduler-myk8s-control-plane            1/1     Running   0          12m   172.18.0.2   myk8s-control-plane   <none>           <none>
local-path-storage   local-path-provisioner-7dc846544d-p9l6q       1/1     Running   0          12m   10.244.0.2   myk8s-control-plane   <none>           <none>


# 컨트롤플레인/워커 노드(컨테이너) 확인 : 도커 컨테이너 이름은 myk8s-control-plane , myk8s-worker/worker-2 임을 확인
docker ps
CONTAINER ID   IMAGE                  COMMAND                  CREATED          STATUS          PORTS                                                                                           NAMES
10a9d53e4bd2   kindest/node:v1.32.2   "/usr/local/bin/entr…"   13 minutes ago   Up 13 minutes                                                                                                   myk8s-worker2
ae13bcf2982f   kindest/node:v1.32.2   "/usr/local/bin/entr…"   13 minutes ago   Up 13 minutes   0.0.0.0:30000-30004->30000-30004/tcp, 172.28.157.42:41243->6443/tcp, 0.0.0.0:30006->30005/tcp   myk8s-control-plane
595658837c5f   kindest/node:v1.32.2   "/usr/local/bin/entr…"   13 minutes ago   Up 13 minutes                                                                                                   myk8s-worker

 

이제 생성된 쿠버네티스 환경에 Vault를 설치하도록 하겠습니다.

# Create a Kubernetes namespace.
kubectl create namespace vault

# Setup Helm repo
helm repo add hashicorp https://helm.releases.hashicorp.com

# Check that you have access to the chart.
helm search repo hashicorp/vault
NAME                                    CHART VERSION   APP VERSION     DESCRIPTION
hashicorp/vault                         0.30.0          1.19.0          Official HashiCorp Vault Chart
hashicorp/vault-secrets-gateway         0.0.2           0.1.0           A Helm chart for Kubernetes
hashicorp/vault-secrets-operator        0.10.0          0.10.0          Official Vault Secrets Operator Chart

 

Helm 차트에 사용할 values 파일을 생성합니다.

cat <<EOF > vault-values.yaml
global:
  enabled: true
  tlsDisable: true  # Disable TLS for demo purposes

server:
  image:
    repository: "hashicorp/vault"
    tag: "1.19.0"
  standalone:
    enabled: true
    replicas: 1
    config: |
      ui = true

      listener "tcp" {
        address = "[::]:8200"
        cluster_address = "[::]:8201"
        tls_disable = 1
      }

      storage "file" {
        path = "/vault/data"
      }

  service:
    enabled: true
    type: NodePort
    port: 8200
    targetPort: 8200
    nodePort: 30000   # 🔥 Kind에서 열어둔 포트 중 하나 사용

injector:
  enabled: true

csi:
  enabled: true
EOF

 

실습에서는 테스트 환경이므로 standalone 방식으로 구성하였습니다.

또한 injector를 enable하여, sidecar injector를 사용할 수 있도록 정의하였고, csi 드라이버 테스트를 위해 csi 또한 enable하였습니다.

 

이제 Helm으로 Vault를 배포합니다.

# Helm Install 실행
helm upgrade vault hashicorp/vault -n vault -f vault-values.yaml --install

# 배포확인
kubectl get pods,svc,pvc -n vault
NAME                                        READY   STATUS    RESTARTS   AGE
pod/vault-0                                 0/1     Running   0          4m45s
pod/vault-agent-injector-56459c7545-fnv9t   1/1     Running   0          4m45s
pod/vault-csi-provider-79rg5                2/2     Running   0          4m45s
pod/vault-csi-provider-x58j8                2/2     Running   0          4m45s

NAME                               TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)                         AGE
service/vault                      NodePort    10.96.116.44   <none>        8200:30000/TCP,8201:31459/TCP   4m45s
service/vault-agent-injector-svc   ClusterIP   10.96.166.73   <none>        443/TCP                         4m45s
service/vault-internal             ClusterIP   None           <none>        8200/TCP,8201/TCP               4m45s

NAME                                 STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
persistentvolumeclaim/data-vault-0   Bound    pvc-e5065e5d-dc6e-43ef-a647-da4b0685a79e   10Gi       RWO            standard       <unset>                 4m45s

 

배포된 내용을 확인해보면 vault-0가 statefulset으로 실행 중이며, 옵션으로 추가한 vault-agent-injector-56459c7545-9n94t가 이후에 sidecar injection을 수행합니다. 또한 vault-csi-provider가 실행 중인 것을 알 수 있습니다.

 

확인해보면 vault-0가 아직 READY가 되지 않은 상태(0/1)인 것을 알 수 있습니다. Vault를 배포한다고 바로 실행되는 것은 아니며, 초기화 되지 않은 상태에서는 기본적으로 Sealed 되어 있습니다.

 

아래와 같이 상태를 확인하고 초기화를 진행합니다.

# Vault Status 명령으로 Sealed 상태확인
kubectl exec -it vault-0 -n vault -- vault status
Key                Value
---                -----
Seal Type          shamir
Initialized        false
Sealed             true
Total Shares       0
Threshold          0
Unseal Progress    0/0
Unseal Nonce       n/a
Version            1.19.0
Build Date         2025-03-04T12:36:40Z
Storage Type       file
HA Enabled         false
command terminated with exit code 2

 

실습에서는 아래 스크립트를 통해서 unseal key를 획득하고 이 key를 등록하여 초기화를 진행합니다.

cat <<EOF > init-unseal.sh
#!/bin/bash

# Vault Pod 이름
VAULT_POD="vault-0"

# Vault 명령 실행
VAULT_CMD="kubectl exec -it \$VAULT_POD -n vault -- vault"

# 출력 저장 파일
VAULT_KEYS_FILE="./vault-keys.txt"
UNSEAL_KEY_FILE="./vault-unseal-key.txt"
ROOT_TOKEN_FILE="./vault-root-token.txt"

# Vault 초기화 (Unseal Key 1개만 생성되도록 설정)
\$VAULT_CMD operator init -key-shares=1 -key-threshold=1 | sed \$'s/\\x1b\\[[0-9;]*m//g' | tr -d '\r' > "\$VAULT_KEYS_FILE"

# Unseal Key / Root Token 추출
grep 'Unseal Key 1:' "\$VAULT_KEYS_FILE" | awk -F': ' '{print \$2}' > "\$UNSEAL_KEY_FILE"
grep 'Initial Root Token:' "\$VAULT_KEYS_FILE" | awk -F': ' '{print \$2}' > "\$ROOT_TOKEN_FILE"

# Unseal 수행
UNSEAL_KEY=\$(cat "\$UNSEAL_KEY_FILE")
\$VAULT_CMD operator unseal "\$UNSEAL_KEY"

# 결과 출력
echo "[🔓] Vault Unsealed!"
echo "[🔐] Root Token: \$(cat \$ROOT_TOKEN_FILE)"
EOF

# 실행 권한 부여
chmod +x init-unseal.sh

# 실행
./init-unseal.sh
Key             Value
---             -----
Seal Type       shamir
Initialized     true
Sealed          false
Total Shares    1
Threshold       1
Version         1.19.0
Build Date      2025-03-04T12:36:40Z
Storage Type    file
Cluster Name    vault-cluster-74a36fbf
Cluster ID      46a5b7ff-288c-eb37-408b-bf8eebd7960c
HA Enabled      false
[🔓] Vault Unsealed!
[🔐] Root Token: hvs.aRGCNIlhHVcy2VuDf2afTKCm

 

이제 vault status 명령으로 Unseal 상태를 확인 합니다.

kubectl exec -it vault-0 -n vault -- vault status
Key             Value
---             -----
Seal Type       shamir
Initialized     true
Sealed          false
Total Shares    1
Threshold       1
Version         1.19.0
Build Date      2025-03-04T12:36:40Z
Storage Type    file
Cluster Name    vault-cluster-74a36fbf
Cluster ID      46a5b7ff-288c-eb37-408b-bf8eebd7960c
HA Enabled      false

 

Unseal이 완료되고 파드 상태를 조회하면, 이제 vault-0가 READY상태가 된 것으로 확인됩니다.

kubectl get po -n vault
NAME                                    READY   STATUS    RESTARTS   AGE
vault-0                                 1/1     Running   0          10m
vault-agent-injector-56459c7545-fnv9t   1/1     Running   0          10m
vault-csi-provider-79rg5                2/2     Running   0          10m
vault-csi-provider-x58j8                2/2     Running   0          10m

 

초기화가 마무리되면 이제 UI에도 접속이 가능합니다. 앞서 확인한 Root token을 사용합니다.

 

로그인 후 Vault의 UI화면은 아래와 같습니다.

 

아래와 같이 WSL 환경에 CLI도 설치를 진행합니다.

참고: https://developer.hashicorp.com/vault/install#linux

wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install vault

# 확인
export VAULT_ADDR='http://localhost:30000'
vault status
Key             Value
---             -----
Seal Type       shamir
Initialized     true
Sealed          false
Total Shares    1
Threshold       1
Version         1.19.0
Build Date      2025-03-04T12:36:40Z
Storage Type    file
Cluster Name    vault-cluster-74a36fbf
Cluster ID      46a5b7ff-288c-eb37-408b-bf8eebd7960c
HA Enabled      false

vault login
Token (will be hidden):
Success! You are now authenticated. The token information displayed below
is already stored in the token helper. You do NOT need to run "vault login"
again. Future Vault requests will automatically use this token.

Key                  Value
---                  -----
token                hvs.aRGCNIlhHVcy2VuDf2afTKCm
token_accessor       LckN5gmpyUcQPxKYFGWIS3KU
token_duration       ∞
token_renewable      false
token_policies       ["root"]
identity_policies    []
policies             ["root"]

 

Vault를 Static Secret, Dynamic Secret, Data Encryption, Leasing and Renewal, Revocation과 같은 다양한 목적에 맞게 사용할 수 있습니다.

 

이후 Static Secret으로 활용하는 실습을 위해서 Vault의 Key Value 엔진을 활성화하겠습니다.

실습에서는 Vault KV version 2 엔진을 활성화하고 샘플 데이터를 저장합니다. 여기서 version 1 은 Key-Value에 대한 버전관리가 불가하며, version 2에서는 Key-Value에서 버전관리가 가능합니다.

# KV v2 형태로 엔진 활성화
vault secrets enable -path=secret kv-v2
Success! Enabled the kv-v2 secrets engine at: secret/

# 샘플 시크릿 저장
vault kv put secret/sampleapp/config \
  username="demo" \
  password="p@ssw0rd"

======== Secret Path ========
secret/data/sampleapp/config

======= Metadata =======
Key                Value
---                -----
created_time       2025-04-12T13:18:05.548287122Z
custom_metadata    <nil>
deletion_time      n/a
destroyed          false
version            1


# 입력된 데이터 확인
vault kv get secret/sampleapp/config

======== Secret Path ========
secret/data/sampleapp/config

======= Metadata =======
Key                Value
---                -----
created_time       2025-04-12T13:18:05.548287122Z
custom_metadata    <nil>
deletion_time      n/a
destroyed          false
version            1

====== Data ======
Key         Value
---         -----
password    p@ssw0rd
username    demo

 

Vault UI에 접속하여 [Secrets Engine] 탭에 접속 후 secret으로 이동하여, sampleapp - config] 접속하여 정보를 확인합니다.

 

Secret 탭에 접근하면 실제 저장된 Key / Value 를 확인할 수 있습니다.

 

Vault에 저장된 Secret을 활용하는 방식을 실습을 통해 살펴보겠습니다.

 

 

1. Valut Sidecar Agent Injector 실습

생성된 Vault의 Secret을 참조하기 위해서 Sidecar 패턴을 활용하는 방식을 살펴보겠습니다.

 

앞서 Helm을 통해 Vault를 배포할 때 injector를 활성화 한 것을 기억하실 겁니다.

Vault Agent Injector는 Kubernetes Pod 내부에 Vault Agent를 자동으로 주입해주는 기능입니다. 이를 통해 어플리케이션이 Vault로부터 자동으로 비밀 정보를 받아올 수 있게 됩니다.

출처: https://www.hashicorp.com/en/blog/kubernetes-vault-integration-via-sidecar-agent-injector-vs-csi-provider

 

먼저 그림의 좌측을 살펴보면 애플리케이션 파드 스펙에 Sidecar Annotation과 함께 API 서버로의 요청하면, Mutating Webhook을 통해 SideCar Injector Service를 통해서 Pods Spec을 업데이트 하는 것을 알 수 있습니다.

 

우측의 애플리케이션 파드에서는 Vault Agent Init Container를 통해서 Kubernetes Auth를 통해서 Vault Token을 획득하고, 이후 Vault Agent Sicdcar Container가 Vault Token으로 Vault에 요청해 Secret을 획득해 애플리케이션에 주입해주는 방식으로 동작합니다.

 

실제 파드에 아래와 같은 annotation을 추가해야 합니다.

vault.hashicorp.com/agent-inject: "true"
vault.hashicorp.com/role: "example-role"

 

Vault의 Kubernetes 인증 활성화합니다. 이를 통해 클라이언트가 쿠버네티스의 Service Account 토큰을 통해 인증을 받을 수 있도록 제공합니다.

# Kubernetes Auth Method 활성화
vault auth enable kubernetes
Success! Enabled kubernetes auth method at: kubernetes/

# 확인
vault auth list
Path           Type          Accessor                    Description                Version
----           ----          --------                    -----------                -------
approle/       approle       auth_approle_54604e8f       n/a                        n/a
kubernetes/    kubernetes    auth_kubernetes_986afb3c    n/a                        n/a
token/         token         auth_token_ecdae7a6         token based credentials    n/a

# Kubernetes Auth Config 설정
vault write auth/kubernetes/config \
  token_reviewer_jwt="$(kubectl get secret $(kubectl get serviceaccount vault -n vault -o jsonpath='{.secrets[0].name}') -n vault -o jsonpath="{.data.token}" | base64 --decode)" \
  kubernetes_host="$(kubectl config view --raw --minify --flatten -o jsonpath='{.clusters[0].cluster.server}')" \
  kubernetes_ca_cert="$(kubectl get secret $(kubectl get serviceaccount vault -n vault -o jsonpath='{.secrets[0].name}') -n vault -o jsonpath='{.data.ca\.crt}' | base64 --decode)"

 

그리고 생성된 Secret을 가져올 수 있는 권한(Policy)와 쿠버네티스 Service Account와 Policy를 묶어주는 Role을 생성합니다.

# 필요한 Policy 작성 (앞선 과정에서 만들었으므로 생략가능)
vault policy write sampleapp-policy - <<EOF
path "secret/data/sampleapp/*" {
  capabilities = ["read"]
}
EOF

# Role 생성 (Injector가 로그인할 수 있도록)
vault write auth/kubernetes/role/sampleapp-role \
    bound_service_account_names="vault-ui-sa" \
    bound_service_account_namespaces="vault" \
    policies="sampleapp-policy" \
    ttl="24h"

 

Vault의 권한 부여 방식은 AWS와 유사합니다. 위의 예제를 살펴보면, "secret/data/sampleapp/*" 에 ["read"]를 할 수 있는 sampleapp-policy라는 Policy를 만들고, 그 이후 sampleapp-role라는 role을 만들어 vault-ui-sa와 sampleapp-policy를 연결해 주는 것을 알 수 있습니다.

 

테스트 애플리케이션 배포합니다. 또한 위에서 정의한 vault-ui-sa 라는 Service Account도 같이 생성합니다.

cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: ServiceAccount
metadata:
  name: vault-ui-sa
  namespace: vault
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: vault-injected-ui
  namespace: vault
spec:
  replicas: 1
  selector:
    matchLabels:
      app: vault-injected-ui
  template:
    metadata:
      labels:
        app: vault-injected-ui
      annotations:
        vault.hashicorp.com/agent-inject: "true"
        vault.hashicorp.com/role: "sampleapp-role"
        vault.hashicorp.com/agent-inject-secret-config.json: "secret/data/sampleapp/config"
        vault.hashicorp.com/agent-inject-template-config.json: |
          {{- with secret "secret/data/sampleapp/config" -}}
          {
            "username": "{{ .Data.data.username }}",
            "password": "{{ .Data.data.password }}"
          }
          {{- end }}
        vault.hashicorp.com/agent-inject-output-path: "/vault/secrets"
    spec:
      serviceAccountName: vault-ui-sa
      containers:
      - name: app
        image: python:3.10
        ports:
        - containerPort: 5000
        command: ["sh", "-c"]
        args:
          - |
            pip install flask && cat <<PYEOF > /app.py
            import json, time
            from flask import Flask, render_template_string
            app = Flask(__name__)
            while True:
                try:
                    with open("/vault/secrets/config.json") as f:
                        secret = json.load(f)
                    break
                except:
                    time.sleep(1)
            @app.route("/")
            def index():
                return render_template_string("<h2>🔐 Vault Injected UI</h2><p>👤 사용자: {{username}}</p><p>🔑 비밀번호: {{password}}</p>", secret)
            app.run(host="0.0.0.0", port=5000)
            PYEOF
            python /app.py
---
apiVersion: v1
kind: Service
metadata:
  name: vault-injected-ui
  namespace: vault
spec:
  type: NodePort
  ports:
    - port: 5000
      targetPort: 5000
      nodePort: 30002
  selector:
    app: vault-injected-ui
EOF

 

Deployment의 spec을 잘 살펴보면, 아래와 같은 annotation을 명시하고 있습니다.

        vault.hashicorp.com/agent-inject: "true"
        vault.hashicorp.com/role: "sampleapp-role"

 

생성된 컨테이너 확인를 확인해보겠습니다.

# 파드 내에 사이드카 컨테이너 추가되어 2/2 확인
kubectl get pod -l app=vault-injected-ui -n vault
NAME                                 READY   STATUS    RESTARTS   AGE
vault-injected-ui-77fb865789-f6sn8   2/2     Running   0          2m

kubectl describe pod -l app=vault-injected-ui -n vault
...
Init Containers:
  vault-agent-init:
    Container ID:  containerd://aea5f02cc79d2b89d6f80a5111ae8b64640d0e13d0900fbe06f3eb3750389de1
    Image:         hashicorp/vault:1.19.0
    Image ID:      docker.io/hashicorp/vault@sha256:bbb7f98dc67d9ebdda1256de288df1cb9a5450990e48338043690bee3b332c90
    Port:          <none>
    Host Port:     <none>
    Command:
      /bin/sh
      -ec
    Args:
      echo ${VAULT_CONFIG?} | base64 -d > /home/vault/config.json && vault agent -config=/home/vault/config.json
...

Containers:
  app:
    Container ID:  containerd://e2caeef00e2e408a4db48e57cba1152b4496bd49d6b66d9571371c2efea64178
    Image:         python:3.10
    Image ID:      docker.io/library/python@sha256:e2c7fb05741c735679b26eda7dd34575151079f8c615875fbefe401972b14d85
    Port:          5000/TCP
    Host Port:     0/TCP
...

  vault-agent:
    Container ID:  containerd://a2acf0a3fda4ba1f5637d8e843718ec19382c3011d8db97e701bae0d457fb92c
    Image:         hashicorp/vault:1.19.0
    Image ID:      docker.io/hashicorp/vault@sha256:bbb7f98dc67d9ebdda1256de288df1cb9a5450990e48338043690bee3b332c90
    Port:          <none>
    Host Port:     <none>
    Command:
      /bin/sh
      -ec
...

 

앞서 살펴본 것과 같이 vault-agent-init 이라는 InitContainer가 있으며, 또한 app 컨테이너 하단을 보면 vault-agent가 사이드카로 실행 중입니다.

# 볼륨 마운트 확인
kubectl exec -it  deploy/vault-injected-ui -c app -n vault -- ls /vault/secrets
config.json

kubectl exec -it  deploy/vault-injected-ui -c app -n vault -- cat /vault/secrets/config.json
{
  "username": "demo",
  "password": "p@ssw0rd"
}

kubectl exec -it deploy/nginx-vault-demo -c vault-agent-sidecar -- ls -l /etc/secrets       
total 4
-rw-r--r--    1 vault    vault           94 Apr 10 02:09 index.html

kubectl exec -it deploy/nginx-vault-demo -c vault-agent-sidecar -- cat /etc/secrets/index.html
  <html>
  <body>
    <p>username: demo</p>
    <p>password: p@ssw0rd</p>
  </body>
  </html>

kubectl exec -it deploy/nginx-vault-demo -c nginx -- cat /usr/share/nginx/html/index.html
  <html>
  <body>
    <p>username: demo</p>
    <p>password: p@ssw0rd</p>
  </body>
  </html>


# 로그 확인
kubectl logs -l app=vault-injected-ui -c vault-agent -n vault
2025-04-12T13:37:10.607Z [INFO]  agent: (runner) creating watcher
2025-04-12T13:37:10.612Z [INFO]  agent.auth.handler: authentication successful, sending token to sinks
2025-04-12T13:37:10.612Z [INFO]  agent.auth.handler: starting renewal process
2025-04-12T13:37:10.613Z [INFO]  agent.sink.file: token written: path=/home/vault/.vault-token
2025-04-12T13:37:10.613Z [INFO]  agent.template.server: template server received new token
2025-04-12T13:37:10.614Z [INFO]  agent: (runner) stopping
2025-04-12T13:37:10.614Z [INFO]  agent: (runner) creating new runner (dry: false, once: false)
2025-04-12T13:37:10.614Z [INFO]  agent: (runner) creating watcher
2025-04-12T13:37:10.617Z [INFO]  agent: (runner) starting
2025-04-12T13:37:10.618Z [INFO]  agent.auth.handler: renewed auth token

# mutating admission
kubectl get mutatingwebhookconfigurations.admissionregistration.k8s.io
NAME                       WEBHOOKS   AGE
vault-agent-injector-cfg   1          82m


kubectl describe mutatingwebhookconfigurations.admissionregistration.k8s.io vault-agent-injector-cfg
Name:         vault-agent-injector-cfg
Namespace:
Labels:       app.kubernetes.io/instance=vault
              app.kubernetes.io/managed-by=Helm
              app.kubernetes.io/name=vault-agent-injector
Annotations:  meta.helm.sh/release-name: vault
              meta.helm.sh/release-namespace: vault
API Version:  admissionregistration.k8s.io/v1
Kind:         MutatingWebhookConfiguration
Metadata:
  Creation Timestamp:  2025-04-12T12:29:06Z
  Generation:          2
  Resource Version:    2170
  UID:                 1260c09d-a982-47f6-b15a-b3de37628915
Webhooks:
  Admission Review Versions:
    v1
    v1beta1
  Client Config:
    Ca Bundle:  LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUNTVENDQWU2Z0F3SUJBZ0lVZjZGQ0xaam5iTjg1TURSWm5Ka0VWZ1pMcnhrd0NnWUlLb1pJemowRUF3SXcKR2pFWU1CWUdBMVVFQXhNUFFXZGxiblFnU1c1cVpXTjBJRU5CTUI0WERUSTFNRFF4TWpFeU1qZ3pNRm9YRFRNMQpNRFF4TURFeU1qa3pNRm93R2pFWU1CWUdBMVVFQXhNUFFXZGxiblFnU1c1cVpXTjBJRU5CTUZrd0V3WUhLb1pJCnpqMENBUVlJS29aSXpqMERBUWNEUWdBRVhSbjR3Z0lYSUhKTFN6cnV5MGhDZkV0RWtnN0NHYUp6aFo1TkdKMTMKTXlnVkgzL2NISS9pQ0ZrbG5HWFRsQ3ZjZzUySjlXL0tnUVljSjg2NEVVaUxLS09DQVJBd2dnRU1NQTRHQTFVZApEd0VCL3dRRUF3SUNoREFUQmdOVkhTVUVEREFLQmdnckJnRUZCUWNEQVRBUEJnTlZIUk1CQWY4RUJUQURBUUgvCk1HZ0dBMVVkRGdSaEJGOHdOVG8zWlRwak56b3haam93TXpwbE1UcGtaanBpTnpwaU1EcGxOam80WlRwaVpUcGgKWWpvMk9UbzRaam81TnpvMllUbzFOam96T0RveE16bzFOem94WlRvMllUb3lZenBsTkRvNU1qb3pOem93WlRvdwpORG94TXpvNU9EbzJNekJxQmdOVkhTTUVZekJoZ0Y4d05UbzNaVHBqTnpveFpqb3dNenBsTVRwa1pqcGlOenBpCk1EcGxOam80WlRwaVpUcGhZam8yT1RvNFpqbzVOem8yWVRvMU5qb3pPRG94TXpvMU56b3haVG8yWVRveVl6cGwKTkRvNU1qb3pOem93WlRvd05Eb3hNem81T0RvMk16QUtCZ2dxaGtqT1BRUURBZ05KQURCR0FpRUExKzdRaE5HKwpkM1NRajdZRVdHRHptcXpOUVozU2dtR1ExemQwWHI5L2ZRSUNJUURBQTcwODRDOTY4WVdnUXhPSmQvKysxZm9XCndoUWJ0Rm9taUlPNnMvSkZVZz09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K
    Service:
      Name:        vault-agent-injector-svc
      Namespace:   vault
      Path:        /mutate
      Port:        443
  Failure Policy:  Ignore
  Match Policy:    Exact
  Name:            vault.hashicorp.com
  Namespace Selector:
  Object Selector:
    Match Expressions:
      Key:       app.kubernetes.io/name
      Operator:  NotIn
      Values:
        vault-agent-injector
  Reinvocation Policy:  Never
  Rules:
    API Groups:

    API Versions:
      v1
    Operations:
      CREATE
    Resources:
      pods
    Scope:          Namespaced
  Side Effects:     None
  Timeout Seconds:  30
Events:             <none>

kubectl get svc -n vault
NAME                       TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)                         AGE
vault                      NodePort    10.96.116.44   <none>        8200:30000/TCP,8201:31459/TCP   83m
vault-agent-injector-svc   ClusterIP   10.96.166.73   <none>        443/TCP                         83m
vault-injected-ui          NodePort    10.96.2.33     <none>        5000:30002/TCP                  16m
vault-internal             ClusterIP   None           <none>        8200/TCP,8201/TCP               83m

 

mutation webhook configuration 정의를 보면 vault-agent-injector-svc로 연결되는 것을 확인할 수 있습니다.

 

생성된 서비스로 접속해보면 Vault에 저장된 Secret 값이 노출되는 것을 알 수있습니다.

 

 

2. Vault CSI Driver 실습

Vault에서 setcret을 사용하는 두번째 방식은 CSI Driver를 사용하는 방식입니다.

 

이를 위해서 Vault 생성 시에 옵션을 통해서 Vault CSI provider가 생성되어 있습니다. 이후 Secrets Store CSI Driver를 설치하면 각 노드에서 Daemonset이 생성됩니다. 사용자는 SecretProviderClass를 생성하고, 여기에는 Secret을 가져오기 위해 어떤 Secret Provider를 사용할지를 선언합니다.

출처: https://www.hashicorp.com/en/blog/kubernetes-vault-integration-via-sidecar-agent-injector-vs-csi-provider

 

그림을 보면 CSI Volume을 요청하는 파드가 생성되면, CSI Secret Store Driver는 Vault CSI provider에게 요청을 보내고 Vault CSI provider가 SecretProviderClass와 파드의 service Account를 사용해 Vault로 부터 secret을 가져와 파드의 CSI volume으로 마운트 합니다.

 

아래 문서를 바탕으로 실습을 진행했습니다.

https://developer.hashicorp.com/vault/tutorials/kubernetes/kubernetes-secret-store-driver?in=vault%2Fkubernetes

 

먼저 Vault는 이미 설치된 상태이기 때문에 아래 내용은 skip하겠습니다.

# 리포 추가
helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update

# Vault 설치
helm install vault hashicorp/vault \
    --set "server.dev.enabled=true" \
    --set "injector.enabled=false" \
    --set "csi.enabled=true"

 

또한 Secret을 생성과 Kubernetes 인증 설정을 진행합니다.

# Secret 생성
# kubectl exec -it vault-0 -- /bin/sh
vault kv put secret/db-pass password="db-secret-password"
=== Secret Path ===
secret/data/db-pass

======= Metadata =======
Key                Value
---                -----
created_time       2025-04-12T14:45:47.117669981Z
custom_metadata    <nil>
deletion_time      n/a
destroyed          false
version            1

vault kv get secret/db-pass
=== Secret Path ===
secret/data/db-pass

======= Metadata =======
Key                Value
---                -----
created_time       2025-04-12T14:45:47.117669981Z
custom_metadata    <nil>
deletion_time      n/a
destroyed          false
version            1

====== Data ======
Key         Value
---         -----
password    db-secret-password

# Kubernetes 인증 설정 (앞서 설정하였으므로 skip)
vault auth enable kubernetes 
vault write auth/kubernetes/config \
    kubernetes_host="https://$KUBERNETES_PORT_443_TCP_ADDR:443"

# policy 생성
vault policy write internal-app - <<EOF
path "secret/data/db-pass" {
  capabilities = ["read"]
}
EOF

# role 생성
vault write auth/kubernetes/role/database \
    bound_service_account_names=webapp-sa \
    bound_service_account_namespaces=default \
    policies=internal-app \
    ttl=20m

 

이제 클러스터에 secrets store CSI driver를 설치합니다.

helm repo add secrets-store-csi-driver https://kubernetes-sigs.github.io/secrets-store-csi-driver/charts

helm install csi secrets-store-csi-driver/secrets-store-csi-driver \
    --set syncSecret.enabled=true -n vault

# 확인
kubectl get ds -n vault
NAME                           DESIRED   CURRENT   READY   UP-TO-DATE   AVAILABLE   NODE SELECTOR            AGE
csi-secrets-store-csi-driver   3         3         3       3            3           kubernetes.io/os=linux   77s
vault-csi-provider             2         2         2       2            2           <none>                   133m

kubectl get po -n vault
NAME                                    READY   STATUS    RESTARTS   AGE
csi-secrets-store-csi-driver-kmkqf      3/3     Running   0          71s
csi-secrets-store-csi-driver-tct7q      3/3     Running   0          71s
csi-secrets-store-csi-driver-xvfjb      3/3     Running   0          71s
vault-0                                 1/1     Running   0          133m
vault-agent-injector-56459c7545-fnv9t   1/1     Running   0          133m
vault-csi-provider-79rg5                2/2     Running   0          133m
vault-csi-provider-x58j8                2/2     Running   0          133m
vault-injected-ui-77fb865789-f6sn8      2/2     Running   0          66m

 

Vault를 provider로 지정한 SecretProviderClass를 생성하고, 앞서 생성한 role을 사용합니다.

cat > spc-vault-database.yaml <<EOF
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
  name: vault-database
spec:
  provider: vault
  parameters:
    vaultAddress: "http://vault:8200"
    roleName: "database"
    objects: |
      - objectName: "db-password"
        secretPath: "secret/data/db-pass"
        secretKey: "password"
EOF

kubectl apply --filename spc-vault-database.yaml

 

Role에 지정한 Service Account를 생성합니다.

kubectl create serviceaccount webapp-sa

 

아래와 같이 파드를 생성하면서, Volume절에 앞서 생성한 secretProviderClass를 참조하는 볼륨을 생성합니다.

cat > webapp-pod.yaml <<EOF
kind: Pod
apiVersion: v1
metadata:
  name: webapp
spec:
  serviceAccountName: webapp-sa
  containers:
  - image: jweissig/app:0.0.1
    name: webapp
    volumeMounts:
    - name: secrets-store-inline
      mountPath: "/mnt/secrets-store"
      readOnly: true
  volumes:
    - name: secrets-store-inline
      csi:
        driver: secrets-store.csi.k8s.io
        readOnly: true
        volumeAttributes:
          secretProviderClass: "vault-database"
EOF
kubectl apply --filename webapp-pod.yaml

 

확인해보면 secretProviderClass를 통해 Vault의 Secret을 마운트 한 것을 확인할 수 있습니다.

kubectl get pods
NAME     READY   STATUS    RESTARTS   AGE
webapp   1/1     Running   0          19s

kubectl exec webapp -- cat /mnt/secrets-store/db-password
db-secret-password

 

 

4. Vault Secrets Operator(VSO) 실습

앞서 살펴본 방식들은 워크로드에서 Sidecar를 설정하거나 혹은 CSI Volume를 추가하는 방식으로 변경이 필요로 하게 됩니다. 이러한 방식은 기존 리소스를 수정해야 하는 단점이 있습니다.

 

반면 VSO는 기존 쿠버네티스의 Secret을 사용하되, VSO를 통해서 관리하고자 하는 Secret을 지정하면 VSO가 Vault를 통해 Secret을 가져와 쿠버네티스의 Secret을 변경하는 방식을 사용합니다.

 

이를 위해서 Vault Secrets Operator는 Vault Role에 대응하는 Vault Auth CRD와 Vault Secret에 대응하는 VaultSecret과 같은 CRD를 생성하고, 이들 CRD의 변경 사항을 감시하여 동작합니다. 각 CRD는 Secret 소스에서 가져와 Kubernetes Secret으로 동기화할 수 있도록 필요한 사양을 제공합니다.

 

오퍼레이터는 원본 Secret 데이터를 대상 쿠버네티스 Secret에 직접 작성하며, 원본에 변경 사항이 발생할 경우 해당 내용을 대상 Secret에도 수명 주기 동안 지속적으로 반영합니다. 이렇게 함으로써 애플리케이션은 쿠버네티스 Secret에만 접근하면 Secret의 데이터를 사용할 수 있습니다.

출처: https://www.hashicorp.com/en/blog/kubernetes-vault-integration-via-sidecar-agent-injector-vs-csi-provider

 

즉, 위의 그림을 보면 Vault 연결과 관련된 VaultConnection과 VaultAuth와 같은 CRD를 가지며, 또한 Static, Dynamic Secret에 따라 아래와 같은 CRD를 가집니다.

 

Static Secrets 는 운영자가 Vault의 단일 Static Secret을 단일 쿠버네티스 Secret으로 동기화하는 데 필요한 구성입니다.

  • CRD: VaultStaticSecret

Static Secret에 대한 상세 실습은 아래 문서를 참고하실 수 있습니다.

https://developer.hashicorp.com/vault/tutorials/kubernetes/vault-secrets-operator

 

Dynamic Secrets는 Vault의 Dynamic Secret Engine을 활용하여 동적으로 변경되는 Secrets을 K8s Secrets에 동기화할 수 있습니다. 여기에 지원되는 시크릿 엔진에는 DB Credentials, Cloud Credentials(AWS, Azure, GCP 등)이 있습니다.

  • CRD: VaultDynamicSecret

 

본 실습에서 Dynamic Secret에 대해서 실습을 이어 나가겠습니다.

먼저 VSO 배포를 위한 Chart Values 파일 작성합니다.

cat > vault-operator-values.yaml  <<EOF
defaultVaultConnection:
  enabled: true
  address: "http://vault.vault.svc.cluster.local:8200"
  skipTLSVerify: false
controller:
  manager:
    clientCache:
      persistenceModel: direct-encrypted
      storageEncryption:
        enabled: true
        mount: k8s-auth-mount
        keyName: vso-client-cache
        transitMount: demo-transit
        kubernetes:
          role: auth-role-operator
          serviceAccount: vault-secrets-operator-controller-manager
          tokenAudiences: ["vault"]
EOF

 

value.yaml을 바탕으로 Vault Secret Operator를 생성합니다.

helm install vault-secrets-operator hashicorp/vault-secrets-operator \
  -n vault-secrets-operator-system \
  --create-namespace \
  --values vault-operator-values.yaml

# 확인
kubectl get po,svc -n vault-secrets-operator-system
NAME                                                             READY   STATUS    RESTARTS   AGE
pod/vault-secrets-operator-controller-manager-7f67cd89fd-qmsb2   2/2     Running   0          39s

NAME                                             TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)    AGE
service/vault-secrets-operator-metrics-service   ClusterIP   10.96.156.114   <none>        8443/TCP   39s

kubectl describe pod -n vault-secrets-operator-system                                       
...
Service Account:  vault-secrets-operator-controller-manager
...
Containers:
  kube-rbac-proxy:
    Container ID:  containerd://6500b98aa55510c0b1a5950c8c91e3b6c2b4ea312f5c7b0b5ce843d615a543ac
    Image:         quay.io/brancz/kube-rbac-proxy:v0.18.1
    Image ID:      quay.io/brancz/kube-rbac-proxy@sha256:e6a323504999b2a4d2a6bf94f8580a050378eba0900fd31335cf9df5787d9a9b
    Port:          8443/TCP
    Host Port:     0/TCP
    Args:
      --secure-listen-address=0.0.0.0:8443
      --upstream=http://127.0.0.1:8080/
      --logtostderr=true
      --v=0
  ...
  manager:
    Container ID:  containerd://c65fc319d184fe9145281db7bdfa88e9169ee4ddc86db42ded2a61b5194ca7c0
    Image:         hashicorp/vault-secrets-operator:0.10.0
    Image ID:      docker.io/hashicorp/vault-secrets-operator@sha256:3ee9b27677077cb3324ad02feb68fb7c25cfe381cb8ab5f940eee23c16f8c9a8
    Port:          <none>
    Host Port:     <none>
    Command:
      /vault-secrets-operator
    Args:
      --health-probe-bind-address=:8081
      --metrics-bind-address=127.0.0.1:8080
      --leader-elect
      --client-cache-persistence-model=direct-encrypted
      --global-vault-auth-options=allow-default-globals
      --backoff-initial-interval=5s
      --backoff-max-interval=60s
      --backoff-max-elapsed-time=0s
      --backoff-multiplier=1.50
      --backoff-randomization-factor=0.50
      --zap-log-level=info
      --zap-time-encoding=rfc3339
      --zap-stacktrace-level=panic
...

# CRD 확인
kubectl get crd |grep vault
hcpvaultsecretsapps.secrets.hashicorp.com                   2025-04-12T15:48:09Z
vaultauthglobals.secrets.hashicorp.com                      2025-04-12T15:48:09Z
vaultauths.secrets.hashicorp.com                            2025-04-12T15:48:10Z
vaultconnections.secrets.hashicorp.com                      2025-04-12T15:48:10Z
vaultdynamicsecrets.secrets.hashicorp.com                   2025-04-12T15:48:10Z
vaultpkisecrets.secrets.hashicorp.com                       2025-04-12T15:48:10Z
vaultstaticsecrets.secrets.hashicorp.com                    2025-04-12T15:48:10Z

# vault Auth 확인
kubectl get vaultauth -n vault-secrets-operator-system vault-secrets-operator-default-transit-auth -o jsonpath='{.spec}' | jq
{
  "kubernetes": {
    "audiences": [
      "vault"
    ],
    "role": "auth-role-operator",
    "serviceAccount": "vault-secrets-operator-controller-manager",
    "tokenExpirationSeconds": 600
  },
  "method": "kubernetes",
  "mount": "k8s-auth-mount",
  "storageEncryption": {
    "keyName": "vso-client-cache",
    "mount": "demo-transit"
  },
  "vaultConnectionRef": "default"
}

# vault Connnection 확인
kubectl get vaultconnection -n vault-secrets-operator-system default -o jsonpath='{.spec}' | jq
{
  "address": "http://vault.vault.svc.cluster.local:8200",
  "skipTLSVerify": false
}

 

실습에서는 Dynamic Secret을 테스트 하기 위해서 데이터베이스의 credentials을 dynamic Secret으로 사용합니다. 예제 데이터베이스인 PostgreSQL 설치합니다.

helm repo add bitnami https://charts.bitnami.com/bitnami

helm upgrade --install postgres bitnami/postgresql \
  --namespace postgres \
  --create-namespace \
  --set auth.audit.logConnections=true \
  --set auth.postgresPassword=secret-pass

# 확인
kubectl get po -n postgres
NAME                    READY   STATUS    RESTARTS   AGE
postgres-postgresql-0   1/1     Running   0          108s

# psql 로그인 확인
kubectl exec -it -n postgres postgres-postgresql-0 -- sh -c 'PGPASSWORD=secret-pass psql -U postgres -h localhost'
psql (17.4)
Type "help" for help.

postgres=#

kubectl exec -it -n postgres postgres-postgresql-0 -- sh -c "PGPASSWORD=secret-pass psql -U postgres -h localhost -c '\l'"
                                                     List of databases
   Name    |  Owner   | Encoding | Locale Provider |   Collate   |    Ctype    | Locale | ICU Rules |   Access privileges
-----------+----------+----------+-----------------+-------------+-------------+--------+-----------+-----------------------
 postgres  | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |        |           |
 template0 | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |        |           | =c/postgres          +
           |          |          |                 |             |             |        |           | postgres=CTc/postgres
 template1 | postgres | UTF8     | libc            | en_US.UTF-8 | en_US.UTF-8 |        |           | =c/postgres          +
           |          |          |                 |             |             |        |           | postgres=CTc/postgres
(3 rows)

 

Vault Database Secret Engine 을 활성화 합니다. 아래 과정에서 보면 알 수 있듯이, Database Secret Engine을 통해서 PostgreSQL 연결 정보를 등록하고, 이 연결 정보를 사용할 수 있는 Role을 지정합니다.

# demo-db라는 경로로 Database Secret Engine을 활성화
vault secrets enable -path=demo-db database

# PostgreSQL 연결 정보 등록
# 해당 과정은 postgres가 정상적으로 동작 시 적용 가능
# allowed_roles: 이후 설정할 Role 이름 지정
vault write demo-db/config/demo-db \
   plugin_name=postgresql-database-plugin \
   allowed_roles="dev-postgres" \
   connection_url="postgresql://{{username}}:{{password}}@postgres-postgresql.postgres.svc.cluster.local:5432/postgres?sslmode=disable" \
   username="postgres" \
   password="secret-pass"

 

위 작업을 통해 postgresql-database-plug을 통해 연결정보를 등록했고, 이를 사용하는데 허용된 Role을 아래와 같이 생성합니다.

해당 Role 사용 시 Vault가 동적으로 사용자 계정과 비밀번호를 생성 가능하게 됩니다. 이때 실제 postgreSQL에 대한 연결정보는 Vault의 Database Secret Engine에 저장되고, Vault가 중간에 동적으로 DB 연결에 필요한 계정/비밀번호를 생성하게 됩니다. 이를 통해 postgreSQL에 대한 관리 권한을 Vault에 위임하다고 볼 수 있습니다.

# DB 사용자 동적 생성 Role 등록 
vault write demo-db/roles/dev-postgres \
   db_name=demo-db \
   creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; \
      GRANT ALL PRIVILEGES ON DATABASE postgres TO \"{{name}}\";" \
   revocation_statements="REVOKE ALL ON DATABASE postgres FROM  \"{{name}}\";" \
   backend=demo-db \
   name=dev-postgres \
   default_ttl="1m" \
   max_ttl="1m"

 

애플리케이션에서 사용할 Policy와 Role을 생성합니다.

# Policy 설정: DB 자격증명 읽기 권한
# demo-db/creds/dev-postgres 경로에 대한 read 권한 부여
# 추후 Kubernetes 서비스 어카운트(demo-dynamic-app)에 이 정책을 연결해서 자격증명 요청 가능
vault policy write demo-auth-policy-db - <<EOF
path "demo-db/creds/dev-postgres" {
   capabilities = ["read"]
}
EOF

# Role 생성: 실행 중인 애플리케이션이 Vault로부터 DB 자격증명(동적 사용자)을 받아올 수 있도록 권한을 연결을 위한 설정
vault write auth/kubernetes/role/auth-role \
    bound_service_account_names="demo-dynamic-app" \
    bound_service_account_namespaces="demo-ns" \
    policies="demo-auth-policy-db" \
    token_ttl=0 \
    token_period=120 \
    audience=vault  

 

아래의 과정은 쿠버네티스의 토큰을 받아오는 과정을 캐싱하기 위해서 Transit Secret Engine 설정하는 과정입니다.

# kubectl exec --stdin=true --tty=true vault-0 -n vault -- /bin/sh
----------------------------------------------------------------
# Transit Secret Engine 활성화
# transit 엔진을 demo-transit 경로로 활성화.
# 데이터를 저장하지 않고 암복호화 기능만 제공하는 Vault의 기능
# 클라이언트 캐시는 리더십 변경 시에도 Vault 토큰 및 동적 비밀 임대를 계속 추적하고 갱신할 수 있으므로 원활한 업그레이드를 지원합니다
## Vault 서버에 클라이언트 캐시를 저장하고 암호화할 수 있습니다.
## Vault 동적 비밀을 사용하는 경우 클라이언트 캐시를 영구적으로 저장하고 암호화하는 것이 좋습니다.
## 이렇게 하면 재시작 및 업그레이드를 통해 동적 비밀 임대가 유지됩니다.
vault secrets enable -path=demo-transit transit
vault secrets list
Path             Type         Accessor              Description
----             ----         --------              -----------
cubbyhole/       cubbyhole    cubbyhole_baa07f5b    per-token private secret storage
demo-db/         database     database_3608860f     n/a
demo-transit/    transit      transit_e5ab4c20      n/a
identity/        identity     identity_d7905b56     identity store
secret/          kv           kv_d4b43c42           n/a
sys/             system       system_4a32a805       system endpoints used for control, policy and debugging

# vso-client-cache라는 키를 생성
# 이 키는 VSO가 암복호화 시 사용할 암호화 키 역할
vault write -force demo-transit/keys/vso-client-cache

# vso-client-cache 키에 대해 암호화(encrypt), 복호화(decrypt)를 허용하는 정책 생성
vault policy write demo-auth-policy-operator - <<EOF
path "demo-transit/encrypt/vso-client-cache" {
   capabilities = ["create", "update"]
}
path "demo-transit/decrypt/vso-client-cache" {
   capabilities = ["create", "update"]
}
EOF

# Vault Secrets Operator가 사용하는 ServiceAccount에 위 정책을 바인딩
# vso가 Vault에 로그인할 때 사용할 수 있는 JWT 기반 Role 설정
# 해당 Role을 통해 Operator는 Transit 엔진을 이용한 암복호화 API 호출 가능
vault write auth/kubernetes/role/auth-role-operator \
   bound_service_account_names=vault-secrets-operator-controller-manager \
   bound_service_account_namespaces=vault-secrets-operator-system \
   token_ttl=0 \
   token_period=120 \
   token_policies=demo-auth-policy-operator \
   audience=vault

vault read auth/kubernetes/role/auth-role-operator
Key                                         Value
---                                         -----
alias_name_source                           serviceaccount_uid
audience                                    vault
bound_service_account_names                 [vault-secrets-operator-controller-manager]
bound_service_account_namespace_selector    n/a
bound_service_account_namespaces            [vault-secrets-operator-system]
token_bound_cidrs                           []
token_explicit_max_ttl                      0s
token_max_ttl                               0s
token_no_default_policy                     false
token_num_uses                              0
token_period                                2m
token_policies                              [demo-auth-policy-operator]
token_ttl                                   0s
token_type                                  default

 

이제 샘플 애플리케이션 YAML 작성 및 배포하겠습니다.

  • demo-ns 네임스페이스 생성하고, 폴더를 생성합니다.
  • vault-auth-dynamic.yaml :
    • 애플리케이션이 Vault에 인증하기 위한 ServiceAccount 및 VaultAuth 리소스
    kubectl create ns demo-ns
    mkdir vso-dynamic
    cd vso-dynamic
    
    cat > vault-auth-dynamic.yaml <<EOF
    apiVersion: v1
    kind: ServiceAccount
    metadata:
      namespace: demo-ns
      name: demo-dynamic-app
    ---
    apiVersion: secrets.hashicorp.com/v1beta1
    kind: VaultAuth
    metadata:
      name: dynamic-auth
      namespace: demo-ns
    spec:
      method: kubernetes
      mount: kubernetes # kubernetes auth 이름
      kubernetes:
        role: auth-role
        serviceAccount: demo-dynamic-app
        audiences:
          - vault
    EOF
  • app-secret.yaml :
    • Spring App에서 PostgreSQL에 접속할 때 사용할 해당 시크릿에 username/password을 동적으로 생성
    cat > app-secret.yaml <<EOF
    apiVersion: v1
    kind: Secret
    metadata:
      name: vso-db-demo
      namespace: demo-ns
    EOF
  • vault-dynamic-secret.yaml
    • Vault에서 동적으로 PostgreSQL 접속정보 생성하고 K8s Secret에 저장
    cat > vault-dynamic-secret.yaml <<EOF
    apiVersion: secrets.hashicorp.com/v1beta1
    kind: VaultDynamicSecret
    metadata:
      name: vso-db-demo
      namespace: demo-ns
    spec:
      refreshAfter: 25s
      mount: demo-db
      path: creds/dev-postgres
      destination:
        name: vso-db-demo # 대상 secret을 지정
        create: true
        overwrite: true
      vaultAuthRef: dynamic-auth # VaultAuth 을 참조함
      rolloutRestartTargets: # secret이 변경될때 rollout되는 디플로이먼트를 지정
      - kind: Deployment
        name: vaultdemo
    EOF
  • app-spring-deploy.yaml :
    • DB 접속 테스트를 위한 Spring App
    cat > app-spring-deploy.yaml <<EOF
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: vaultdemo
      namespace: demo-ns
      labels:
        app: vaultdemo
    spec:
      replicas: 1
      selector:
        matchLabels:
          app: vaultdemo
      template:
        metadata:
          labels:
            app: vaultdemo
        spec:
          volumes:
            - name: secrets
              secret:
                secretName: "vso-db-demo"
          containers:
            - name: vaultdemo
              image: hyungwookhub/vso-spring-demo:v5
              imagePullPolicy: IfNotPresent
              env:
                - name: DB_PASSWORD
                  valueFrom:
                    secretKeyRef:
                      name: "vso-db-demo"
                      key: password
                - name: DB_USERNAME
                  valueFrom:
                    secretKeyRef:
                      name: "vso-db-demo"
                      key: username
                - name: DB_HOST
                  value: "postgres-postgresql.postgres.svc.cluster.local"
                - name: DB_PORT
                  value: "5432"
                - name: DB_NAME
                  value: "postgres"
              ports:
                - containerPort: 8088
              volumeMounts:
                - name: secrets
                  mountPath: /etc/secrets
                  readOnly: true
    ---
    apiVersion: v1
    kind: Service
    metadata:
      name: vaultdemo
      namespace: demo-ns
    spec:
      ports:
        - name: vaultdemo
          port: 8088         
          targetPort: 8088 
          nodePort: 30003
      selector:
        app: vaultdemo
      type: NodePort
    EOF
    애플리케이션 배포를 위해서 해당 폴더에서 kubectl apply -f .를 수행합니다.

실행된 파드와 서비스를 확인합니다.

kubectl get po,svc -n demo-ns
NAME                             READY   STATUS    RESTARTS   AGE
pod/vaultdemo-65766f6679-sccdh   1/1     Running   0          4s

NAME                TYPE       CLUSTER-IP    EXTERNAL-IP   PORT(S)          AGE
service/vaultdemo   NodePort   10.96.46.22   <none>        8088:30003/TCP   7m37s

 

이제 실제 동작을 UI(http://localhost:30003)에서 확인합니다.

 

샘플 애플리케이션에서 노출된 연결 정보를 통해 데이터베이스 접속이 성공하는 것을 알 수 있습니다.

 

세부적인 내용은 CLI를 통해서 살펴보겠습니다.

# Secret이 변경되고, 적용을 위해서 파드가 42~45초 사이로 재생성됨
while true; do kubectl get pod -n demo-ns ; echo; kubectl get secret -n demo-ns vso-db-demo -oyaml |egrep "username|password"; date; sleep 30 ; echo ; done
NAME                         READY   STATUS    RESTARTS   AGE
vaultdemo-7ccfbc4bc5-sv24l   1/1     Running   0          8s

  password: M1h5dkctVXFEZm05TnVvYm1Jamc=
  username: di1rdWJlcm5ldC1kZXYtcG9zdC1kOXZWSnBwWlF5S0RIa2V3VjVEUi0xNzQ0NDc3MDc4
Sun Apr 13 01:58:07 KST 2025

NAME                         READY   STATUS    RESTARTS   AGE
vaultdemo-7ccfbc4bc5-sv24l   1/1     Running   0          39s

  password: M1h5dkctVXFEZm05TnVvYm1Jamc=
  username: di1rdWJlcm5ldC1kZXYtcG9zdC1kOXZWSnBwWlF5S0RIa2V3VjVEUi0xNzQ0NDc3MDc4
Sun Apr 13 01:58:38 KST 2025

NAME                         READY   STATUS    RESTARTS   AGE
vaultdemo-685f97956f-5xn9d   1/1     Running   0          28s

  password: d0YtdzJMSXZocngxbnppLUU1SFo=
  username: di1rdWJlcm5ldC1kZXYtcG9zdC1JNmVsSmxXRHdvY3ZUa0NKUXdtVy0xNzQ0NDc3MTIw
Sun Apr 13 01:59:08 KST 2025

# 실제 postgresql 에 사용자 정보 확인 (계속 추가되고 있음)
# kubectl exec -it -n postgres postgres-postgresql-0 -- sh -c "PGPASSWORD=secret-pass psql -U postgres -h localhost -c '\du'"
                                                  List of roles
                      Role name                      |                         Attributes
-----------------------------------------------------+------------------------------------------------------------
 postgres                                            | Superuser, Create role, Create DB, Replication, Bypass RLS
 v-kubernet-dev-post-1UGLywrLiJTZaaEf8miA-1744476863 | Password valid until 2025-04-12 16:55:28+00
 v-kubernet-dev-post-6UvLI5FPiUF9fMY7QNEp-1744476652 | Password valid until 2025-04-12 16:51:57+00
 v-kubernet-dev-post-7mJgWsGrFamOFToYkZLX-1744476819 | Password valid until 2025-04-12 16:54:44+00
 v-kubernet-dev-post-9heCLQZOM1vEr5F61aC2-1744476733 | Password valid until 2025-04-12 16:53:18+00
 v-kubernet-dev-post-BTeXCNTCA2sRfAXUs84e-1744476390 | Password valid until 2025-04-12 16:47:35+00
 v-kubernet-dev-post-BiTwN5EhluVL7vqbnMng-1744476390 | Password valid until 2025-04-12 16:47:35+00
 v-kubernet-dev-post-F23WnObMJvf1b3EXjMX8-1744476909 | Password valid until 2025-04-12 16:56:14+00
 v-kubernet-dev-post-F66ZR0rV2R9yaoelOtBr-1744476566 | Password valid until 2025-04-12 16:50:31+00
 v-kubernet-dev-post-I6elJlWDwocvTkCJQwmW-1744477120 | Password valid until 2025-04-12 16:59:45+00
 v-kubernet-dev-post-Jh15Ui20lrcxuFo8yH1b-1744476432 | Password valid until 2025-04-12 16:48:17+00
 v-kubernet-dev-post-T8gKAH6Z2h7hPJeDpVs6-1744476521 | Password valid until 2025-04-12 16:49:46+00
...


# 로그 확인
kubectl stern -n demo-ns -l app=vaultdemo
...

kubectl stern -n vault vault-0
...
vault-0 vault 2025-04-10T08:32:09.484Z [INFO]  expiration: revoked lease: lease_id=demo-db/creds/dev-postgres/Ph1sg8efnqssOU5FVIuAqhMW
vault-0 vault 2025-04-10T08:32:54.229Z [INFO]  expiration: revoked lease: lease_id=demo-db/creds/dev-postgres/XKkHZ65ZYLeILvo8GqWfE7F3
vault-0 vault 2025-04-10T08:33:36.804Z [INFO]  expiration: revoked lease: lease_id=demo-db/creds/dev-postgres/rk1KUFsV7SjZPNFsCqCHGZVx
...

 

실습을 마무리하고 kind 클러스터를 삭제합니다.

# 클러스터 삭제
kind delete cluster --name myk8s

# 확인
docker ps
cat ~/.kube/config

 

 

마무리

쿠버네티스 환경에서 Setcret을 사용하기 위해서 Vault를 활용하는 방안을 살펴보았습니다. 실습은 간략한 Static Secret과 Dynamic Secret을 예제로 살펴보았지만, Vault를 사용해 Secret 관리를 위임하는 다양한 사례가 있을 수 있습니다.

 

Vault를 활용하는 방식에서 Sidecar injector나 CSI Driver를 사용하는 방식은 간단하지만 기존 워크로드의 변경이 있어야 하기 때문에 다소 불편할 수 있습니다. 한편 VSO를 사용하는 방법은 사용 방식은 복잡하지만, 개발자 입장에서는 기존 쿠버네티스의 Secret을 사용하는 인터페이스를 그대로 사용할 수 있다는 장점이 있었습니다.

 

그럼 이번 포스트를 마무리 하겠습니다.

이번 포스트에서는 Kubernetes 환경에서 애플리케이션 배포를 위한 CI(Continous Intergration)/CD(Continous Deployment) 구성을 예제를 통해서 살펴보겠습니다.

 

쿠버네티스를 사용하는 환경은 소소 코드를 코드 리파지터리에 반영하면, 이를 통해서 컨테이너 이미지를 빌드하고, 신규 컨테이너 이미지를 바탕으로 쿠버네티스에 워크로드가 배포가 이뤄집니다.

 

이 과정을 한땀 한땀 개발자의 PC에서 진행한다는 것도 어려운 일이고, 한편으로는 잦은 코드 변경에 따른 반복 작업으로 오히려 애플리케이션 개발 보다는 배포를 진행하는데 더 많은 시간을 소요할 수 있습니다. 또 각 절차에서 사용자가 개입하게 되면 오히려 실수에 의해 배포 문제로 서비스 이슈가 생긴다면 더 큰일입니다.

 

본 포스트에서 Jenkins를 통한 CI와 Argo CD를 통한 CD를 구성해보면서, CI/CD 과정을 어떻게 간결하고 자동화를 할 수 있는지 아이디어를 얻어가셨으면 좋겠습니다.

 

목차

  1. 실습 환경 구성
  2. Jenkins를 통한 CI 구성
  3. Argo CD를 통한 CD 구성

 

1. 실습 환경 구성

먼저 실습 환경은 아래와 같은 플로우를 완성하는데 목표를 두고 있습니다.

 

개발자가 코드를 개발팀 리파지터리에 배포하면, Jenkins가 CI 역할로 코드를 fetch하고 컨테이너 이미지를 빌드해 컨테이너 레지스트리(Docker Hub)로 업로드(Push)를 하고, 변경 결과를 DevOps팀의 소스 리파지터리에 반영합니다.

 

CD를 담당하는 Argo CD는 DevOps팀의 리파지터리의 변경 사항을 전달 받아, 대상 쿠버네티스가 Desired State와 일치하는지를 확인해 상태를 Sycn하게 됩니다. 이때, 신규 업로드된 컨테이너 이미지를 다운로드(Pull)해 신규 배포가 이뤄집니다.

 

본 실습에서 사용하는 각 구성요소는 아래와 같습니다.

 

실습에서 쿠버네티스 환경은 kind를 통하여 간단하게 구성하도록 하겠습니다.

Gogs, Jenkins은 docker compose로 구성하고, Argo CD는 쿠버네티스에 in-cluster 방식으로 설치하겠습니다.

 

Docker Hub 설정

먼저 컨테이너 이미지 저장소로 사용할 Docker Hub에 dev-app 리파지터리를 생성하고, 토큰을 발급하도록 하겠습니다.

 

토큰은 우측 상단 계정에서 Account settings에서 Personal access tokens를 통해서 생성합니다.

 

이때 Access permissions는 Read, Write, Delete로 선택합니다.

생성된 토큰을 잘 기록해 둡니다.

 

Kubernetes 환경 구성

먼저 실습을 위해 Windows 환경의 WSL을 구성하고, kind로 쿠버네티스를 생성하겠습니다.

# 클러스터 배포 전 확인
mkdir cicd-labs
cd ~/cicd-labs

# WSL2의 eth0 IP를 지정
ip -br -c a

MyIP=<각자 자신의 WSL2의 eth0 IP>
MyIP=172.28.157.42

# cicd-labs 디렉터리에서 아래 파일 작성
cat > kind-3node.yaml <<EOF
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
networking:
  apiServerAddress: "$MyIP"
nodes:
- role: control-plane
  extraPortMappings:
  - containerPort: 30000
    hostPort: 30000
  - containerPort: 30001
    hostPort: 30001
  - containerPort: 30002
    hostPort: 30002
  - containerPort: 30003
    hostPort: 30003
- role: worker
- role: worker
EOF
kind create cluster --config kind-3node.yaml --name myk8s --image kindest/node:v1.32.2

# 확인
kind get nodes --name myk8s
kind get nodes --name myk8s
myk8s-control-plane
myk8s-worker
myk8s-worker2

kubectl get no
NAME                  STATUS     ROLES           AGE   VERSION
myk8s-control-plane   Ready      control-plane   36s   v1.32.2
myk8s-worker          NotReady   <none>          19s   v1.32.2
myk8s-worker2         NotReady   <none>          19s   v1.32.2


# k8s api 주소 확인 
kubectl cluster-info
Kubernetes control plane is running at https://172.28.157.42:33215
CoreDNS is running at https://172.28.157.42:33215/api/v1/namespaces/kube-system/services/kube-dns:dns/proxy

To further debug and diagnose cluster problems, use 'kubectl cluster-info dump'.

 

해당 쿠버네티스 환경에 앞서 생성한 Docker Hub에 생성된 이미지를 가져오기 위해서 docker-registry 시크릿을 생성합니다.

# k8s secret : 도커 자격증명 설정 
kubectl get secret -A  # 생성 시 타입 지정

DHUSER=<도커 허브 계정>
DHPASS=<도커 허브 암호 혹은 토큰>
echo $DHUSER $DHPASS

kubectl create secret docker-registry dockerhub-secret \
  --docker-server=https://index.docker.io/v1/ \
  --docker-username=$DHUSER \
  --docker-password=$DHPASS

 

Gogs, Jenkins 설치

앞선 과정에서 kind로 클러스터를 설치하면 docker network에 kind라는 Bridge가 생성됩니다.

이 kind 네트워크를 활용해 Gogs, Jenkins를 아래와 같이 설치합니다. 참고로 이 실습에서는 쿠버네티스, Gogs, Jenkins가 모두 생성된 kind 네트워크를 사용하며, 노출된 IP를 WSL의 eth0로 설정하여 서로간 통신이 가능하도록 설정합니다.

# 작업 디렉토리로 이동
cd cicd-labs

# kind 설치를 먼저 진행하여 docker network(kind) 생성 후 아래 Jenkins,gogs 생성해야 합니다.
# docker network 확인 : kind 를 사용
docker network ls
...
d91da96d2114   kind      bridge    local
...

# 
cat <<EOT > docker-compose.yaml
services:

  jenkins:
    container_name: jenkins
    image: jenkins/jenkins
    restart: unless-stopped
    networks:
      - kind
    ports:
      - "8080:8080"
      - "50000:50000"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - jenkins_home:/var/jenkins_home

  gogs:
    container_name: gogs
    image: gogs/gogs
    restart: unless-stopped
    networks:
      - kind
    ports:
      - "10022:22"
      - "3000:3000"
    volumes:
      - gogs-data:/data

volumes:
  jenkins_home:
  gogs-data:

networks:
  kind:
    external: true
EOT


# 배포
docker compose up -d
[+] Running 21/21
 ✔ gogs Pulled                                                                                                                                                                        12.3s
 ✔ jenkins Pulled                                                                                                                                                                     33.5s

[+] Running 4/4
 ✔ Volume "cicd-labs_jenkins_home"  Created                                                                                                                                            0.0s
 ✔ Volume "cicd-labs_gogs-data"     Created                                                                                                                                            0.0s
 ✔ Container gogs                   Started                                                                                                                                            2.8s
 ✔ Container jenkins                Started

docker compose ps
NAME      IMAGE             COMMAND                  SERVICE   CREATED          STATUS                    PORTS
gogs      gogs/gogs         "/app/gogs/docker/st…"   gogs      58 seconds ago   Up 55 seconds (healthy)   0.0.0.0:3000->3000/tcp, :::3000->3000/tcp, 0.0.0.0:10022->22/tcp, [::]:10022->22/tcp
jenkins   jenkins/jenkins   "/usr/bin/tini -- /u…"   jenkins   58 seconds ago   Up 55 seconds             0.0.0.0:8080->8080/tcp, :::8080->8080/tcp, 0.0.0.0:50000->50000/tcp, :::50000->50000/tcp

 

Gogs 초기 설정

설치된 gogs를 초기화 하기 위해서 브라우저에서 아래와 같이 접근합니다.

http://127.0.0.1:3000/install 

 

초기 설정을 위한 아래와 같은 화면이 표시됩니다.

 

초기 설정에서 아래와 같은 정보를 설정하였습니다.

  • Database: SQLite3
  • Application URL: http://<WSL의 eth0 IP>:3000/
  • Default Branch: main
  • Admin Account Settings: Username, Password, Admin Email 입력

이후 [Gogs 설치하기]를 통해서 설치를 진행하고 화면이 전환되면 관리자 계정을 통해서 접속합니다.

앞서 설명드린 개발팀 Repository와 DevOps팀 Repository를 생성하고, 이후 인증에 활용할 Token 생성을 진행합니다. (참고로 번역이 이상한 부분이 있을 수 있어 하단의 언어 설정을 영어로 변경하고 진행하시기 바랍니다)

 

리파지터리 생성

실습에 필요한 2개의 리파지터리를 생성하겠습니다.

 

[개발팀 리파지터리]

  • Repository Name: dev-app
  • Visibility: Private
  • .gitignore: Python
  • Readme: Default로 두고, initialize this repository with selected files and template 체크

[DevOps팀 리파지터리]

  • Repository Name: ops-deploy
  • Visibility: Private
  • .gitignore: Python
  • Readme: Default로 두고, initialize this repository with selected files and template 체크

 

생성을 완료하면 아래와 같은 환경이 생성됩니다.

 

토큰 생성

로컬 PC에서 git을 통해 Gogs로 접근하기 위해서 토큰을 생성하겠습니다.

Gogs의 우측 상단 계정을 눌러서 Your Settings>Application>Generate New Token을 통해서 아래와 같이 토큰을 생성하고, 생성된 Token 값을 기록해 둡니다.

 

이제 기본 코드를 작성하여 개발팀 리파지터리(dev-app)에 코드를 Push하겠습니다.

TOKEN=<각자 Gogs Token>
TOKEN=8cdf5569aedd230503abea67b0794b4d1e931c10 

MyIP=<각자 자신의 PC IP> # Windows (WSL2) 사용자는 자신의 WSL2 Ubuntu eth0 IP 입력 할 것!
MyIP=172.28.157.42

git clone http://devops:$TOKEN@$MyIP:3000/devops/dev-app.git
Cloning into 'dev-app'...
...

cd dev-app

# git 초기 설정
git --no-pager config --local --list
git config --local user.name "devops"
git config --local user.email "a@a.com"
git config --local init.defaultBranch main
git config --local credential.helper store
git --no-pager config --local --list

# 정보 확인
git --no-pager branch
* main
git remote -v
origin  http://devops:8cdf5569aedd230503abea67b0794b4d1e931c10@172.28.157.42:3000/devops/dev-app.git (fetch)
origin  http://devops:8cdf5569aedd230503abea67b0794b4d1e931c10@172.28.157.42:3000/devops/dev-app.git (push)

# server.py 파일 작성
cat > server.py <<EOF
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from datetime import datetime
import socket

class RequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        match self.path:
            case '/':
                now = datetime.now()
                hostname = socket.gethostname()
                response_string = now.strftime("The time is %-I:%M:%S %p, VERSION 0.0.1\n")
                response_string += f"Server hostname: {hostname}\n"                
                self.respond_with(200, response_string)
            case '/healthz':
                self.respond_with(200, "Healthy")
            case _:
                self.respond_with(404, "Not Found")

    def respond_with(self, status_code: int, content: str) -> None:
        self.send_response(status_code)
        self.send_header('Content-type', 'text/plain')
        self.end_headers()
        self.wfile.write(bytes(content, "utf-8")) 

def startServer():
    try:
        server = ThreadingHTTPServer(('', 80), RequestHandler)
        print("Listening on " + ":".join(map(str, server.server_address)))
        server.serve_forever()
    except KeyboardInterrupt:
        server.shutdown()

if __name__== "__main__":
    startServer()
EOF


# (참고) python 실행 확인: 아래와 같이 /와 /healthz 에 대해서 응답하는 간단한 웹 서버
python3 server.py
Listening on 0.0.0.0:80
127.0.0.1 - - [29/Mar/2025 23:56:19] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [29/Mar/2025 23:56:27] "GET /healthz HTTP/1.1" 200 -


# Dockerfile 생성
cat > Dockerfile <<EOF
FROM python:3.12
ENV PYTHONUNBUFFERED 1
COPY . /app
WORKDIR /app 
CMD python3 server.py
EOF


# VERSION 파일 생성
echo "0.0.1" > VERSION

# 결과 파일 확인
tree
.
├── Dockerfile
├── README.md
├── VERSION
└── server.py

# remote에 push 진행
git status
git add .
git commit -m "Add dev-app"
git push -u origin main
...

 

작업을 마치면 아래와 같이 파일이 반영된 것을 확인할 수 있습니다.

 

Jenkins 초기 설정

생성된 Jenkins 컨테이너에서 초기 패스워드를 확인하고 접속합니다.

# 작업 디렉토리로 이동
cd cicd-labs

# Jenkins 초기 암호 확인
docker compose exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword
cf7605f7b5ff45349b65e9fc682ab5ca

# Jenkins 웹 접속 > 초기 암호 입력 > Plugin 설치 > admin 계정 정보 입력 
# > Jenkins URL에 WSL의 eth0 입력
웹 브라우저에서 http://127.0.0.1:8080 접속 

# (참고) 로그 확인 : 플러그인 설치 과정 확인
docker compose logs jenkins -f

 

이때 실습 과정에서는 Jenkins 에서 docker build를 수행하기 때문에 jenkins 내부에 docker 를 설치하겠습니다.

# Jenkins 컨테이너 내부에 도커 실행 파일 설치
docker compose exec --privileged -u root jenkins bash
-----------------------------------------------------
id

curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  tee /etc/apt/sources.list.d/docker.list > /dev/null
apt-get update && apt install docker-ce-cli curl tree jq yq -y

# 확인 (아래 명령이 정상 수행되어야 함)
docker info
docker ps
which docker

# Jenkins 컨테이너 내부에서 root가 아닌 jenkins 유저도 docker를 실행할 수 있도록 권한을 부여
groupadd -g 1001 -f docker  # Windows WSL2(Container) >> cat /etc/group 에서 docker 그룹ID를 지정

chgrp docker /var/run/docker.sock
ls -l /var/run/docker.sock
usermod -aG docker jenkins
cat /etc/group | grep docker
docker:x:1001:jenkins

exit
--------------------------------------------

# Jenkins 컨테이너 재기동으로 위 설정 내용을 Jenkins app 에도 적용 필요
docker compose restart jenkins
[+] Restarting 1/1
 ✔ Container jenkins  Started     

# jenkins user로 docker 명령 실행 확인
docker compose exec jenkins id
uid=1000(jenkins) gid=1000(jenkins) groups=1000(jenkins),1001(docker)
docker compose exec jenkins docker info
docker compose exec jenkins docker ps

 

이후 Jenkins를 웹 화면으로 접속하여 해당 실습에서 사용할 플러그인을 설치합니다.

좌측 Jenkins 관리 > Plugins로 이동하여 Pipeline Stage View, Docker Pipeline, Gogs 를 각 설치합니다.

 

예를 들어, Available plugins에서 아래와 같이 검색하고 선택한 뒤 Install 진행합니다.

 

마지막으로 Jenkins에서 자격 증명 설정하겠습니다. 생성하는 자격 증명은 Jenkins에서 Gogs, Docker Hub, Kubernetes에 대해 접근에 사용되는 인증 정보를 담고 있습니다.

 

다시 좌측 메뉴의 Jenkins 관리 > Credentials로 이동합니다. 아래 Domains에서 global을 선택합니다.

 

Add Credentials를 통해서 각 자격 증명을 생성합니다.

1) Gogs 자격증명 ( gogs-crd)

  • Kind : Username with password
  • Username : devops
  • Password : *<토큰>*
  • ID : gogs-crd

2) 도커 허브 자격증명 (dockerhub-crd)

  • Kind : Username with password
  • Username : *<도커 계정명>*
  • Password : *<토큰>*
  • ID : dockerhub-crd

3) 쿠버네티스(kind) 자격증명 (k8s-crd)

  • Kind : Secret file
  • File : *<kubeconfig 파일>*
  • ID: k8s-crd

최종 아래와 같이 자격 증명이 생성되었습니다.

Argo CD 초기 설정

아래와 같이 설치된 kind 클러스터에 Argo CD를 배포합니다.

# 네임스페이스 생성 및 파라미터 파일 작성
cd cicd-labs

kubectl create ns argocd
cat <<EOF > argocd-values.yaml
dex:
  enabled: false

server:
  service:
    type: NodePort
    nodePortHttps: 30002
  extraArgs:
    - --insecure  # HTTPS 대신 HTTP 사용
EOF

# 설치
helm repo add argo https://argoproj.github.io/argo-helm
helm install argocd argo/argo-cd --version 7.8.13 -f argocd-values.yaml --namespace argocd # 7.7.10

# 확인
kubectl get pod,svc -n argocd
NAME                                                   READY   STATUS    RESTARTS   AGE
pod/argocd-application-controller-0                    1/1     Running   0          3m51s
pod/argocd-applicationset-controller-cccb64dc8-wsd7w   1/1     Running   0          3m51s
pod/argocd-notifications-controller-7cd4d88cd4-4s789   1/1     Running   0          3m51s
pod/argocd-redis-6c5698fc46-njwtf                      1/1     Running   0          3m51s
pod/argocd-repo-server-5f6c4f4cf4-d4twk                1/1     Running   0          3m51s
pod/argocd-server-7cb958f5fb-str77                     1/1     Running   0          3m51s

NAME                                       TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)                      AGE
service/argocd-applicationset-controller   ClusterIP   10.96.90.65    <none>        7000/TCP                     3m52s
service/argocd-redis                       ClusterIP   10.96.155.55   <none>        6379/TCP                     3m52s
service/argocd-repo-server                 ClusterIP   10.96.3.228    <none>        8081/TCP                     3m52s
service/argocd-server                      NodePort    10.96.84.116   <none>        80:30080/TCP,443:30002/TCP   3m52s

kubectl get crd | grep argo
applications.argoproj.io      2025-03-29T15:40:20Z
applicationsets.argoproj.io   2025-03-29T15:40:20Z
appprojects.argoproj.io       2025-03-29T15:40:20Z

# 최초 접속 암호 확인
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d ;echo
LM991sRTVwk7xTPu

 

웹 브라우저에서 http://127.0.0.1:30002 접속하고 admin에 확인된 암호로 접속합니다.

 

접근 후 Settings>Repositories에서 CONNECT REPO를 통해 앞서 생성한 Gogs의 ops-deploy 리파지터리를 연결합니다.

 

아래와 같이 연결정보를 입력합니다. 아래 password는 앞서 Gogs에서 생성한 Token을 사용하시면 됩니다.

 

아래와 같이 정상적으로 연결이 완료되어야 합니다.

 

여기까지 Docker Hub에 리파지터리를 생성하고, 이후 쿠버네티스, Gogs, Jenkins, Argo CD 를 설치하고 초기 설정을 진행하였습니다.

 

 

2. Jenkins를 통한 CI 구성

아래 Jenkins 화면을 통해서 기본적인 jenkins의 용어를 살펴보겠습니다.

 

Item이나 빌드라는 용어가 확인됩니다.

Jenkins에서 작업의 기본 단위를 Item이라고 합니다. 이를 Project, Job, Pipeline 등으로 표현하기도 합니다.

 

Item에는 아래와 같은 지시 사항을 포함합니다.

1) Trigger: 작업을 수행하는 시점 (작업 수행 Task가 언제 시작될지를 지시)

2) Build step: 작업을 구성하는 단계별 Task를 단계별 step으로 구성할 수 있습니다.

3) Post-build action: Task가 완료된 후 수행할 명령

 

이때 Jenkins에서는 '빌드'라는 용어로 해당 작업의 특정 실행 버전을 가집니다. 하나의 작업이 여러 번 실행된다고 할 때, 실행될 때마다 고유 빌드 번호가 부여됩니다. 작업 실행 중에 생성된 아티팩트, 콘솔 로그 등 특정 실행 버전과 관련된 세부 정보가 해당 빌드 번호로 저장됩니다.

 

수동으로 빌드하는 Item 생성

이제 Jenkins에서 CI를 수행하기 위해서 웹 화면의 [+ 새로운 Item]을 눌러 Item을 생성하겠습니다.

  • Item Name: pipeline-ci
  • Item type: Pipeline
  • pipeline script:
pipeline {
    agent any
    environment {
        DOCKER_IMAGE = '<자신의 도커 허브 계정>/dev-app' // Docker 이미지 이름
    }
    stages {
        stage('Checkout') {
            steps {
                 git branch: 'main',
                 url: 'http://<자신의 집 IP>:3000/devops/dev-app.git',  // Git에서 코드 체크아웃
                 credentialsId: 'gogs-crd'  // Credentials ID
            }
        }
        stage('Read VERSION') {
            steps {
                script {
                    // VERSION 파일 읽기
                    def version = readFile('VERSION').trim()
                    echo "Version found: ${version}"
                    // 환경 변수 설정
                    env.DOCKER_TAG = version
                }
            }
        }
        stage('Docker Build and Push') {
            steps {
                script {
                    docker.withRegistry('https://index.docker.io/v1/', 'dockerhub-crd') {
                        // DOCKER_TAG 사용
                        def appImage = docker.build("${DOCKER_IMAGE}:${DOCKER_TAG}")
                        appImage.push()
                        appImage.push("latest")
                    }
                }
            }
        }
    }
    post {
        success {
            echo "Docker image ${DOCKER_IMAGE}:${DOCKER_TAG} has been built and pushed successfully!"
        }
        failure {
            echo "Pipeline failed. Please check the logs."
        }
    }
}

 

이 과정은 크게 environment, stages, post로 나눠집니다. enviroment는 환경 변수를 정의한 것으로 이해할 수 있으며, post는 앞서 설명한 Post-build action를 정의하였습니다.

 

stage 단계를 다시 CHECKOUT > READ VERSION > Docker Build and Push 단계로, 지정된 git을 checkout하고 VERSION 파일을 읽어 DOCKER_TAG에 사용할 버전 정보로 사용하면서, 마지막으로 docker build와 push를 수행합니다.

 

또 앞서 초기 설정에서 생성한 자격 증명을 credentialsId로 참조하는 것을 알 수 있습니다.

 

위 스크립트를 수정하여 하단의 Pipleline의 Pipeline script에 입력하고 저장합니다.

 

이렇게 생성된 Item에서 [지금 빌드]를 수행하면 해당 Pipeline을 수동으로 수행할 수 있습니다.

 

Docker Hub에서도 새로 업로드된 이미지가 확인됩니다.

 

테스트로 디플로이먼트를 생성해보고, 정상적으로 파드가 실행되는지 확인합니다.

cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 2
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
        image: docker.io/$DHUSER/dev-app:0.0.1
        livenessProbe:
          initialDelaySeconds: 30
          periodSeconds: 30
          httpGet:
            path: /healthz
            port: 80
            scheme: HTTP
          timeoutSeconds: 5
          failureThreshold: 3
          successThreshold: 1
      imagePullSecrets:
      - name: dockerhub-secret
EOF

kubectl get po -w
NAME                          READY   STATUS              RESTARTS   AGE
timeserver-565559b4bf-pbd2q   0/1     ContainerCreating   0          14s
timeserver-565559b4bf-sd76g   0/1     ContainerCreating   0          14s
timeserver-565559b4bf-pbd2q   1/1     Running             0          62s
timeserver-565559b4bf-sd76g   1/1     Running             0          63s

kubectl get po -owide
NAME                          READY   STATUS    RESTARTS   AGE    IP           NODE            NOMINATED NODE   READINESS GATES
timeserver-565559b4bf-pbd2q   1/1     Running   0          110s   10.244.1.5   myk8s-worker2   <none>           <none>
timeserver-565559b4bf-sd76g   1/1     Running   0          110s   10.244.2.6   myk8s-worker    <none>           <none>


# 접속 테스트
kubectl run curl-pod --image=curlimages/curl:latest --command -- sh -c "while true; do sleep 3600; done"

kubectl exec -it curl-pod -- curl 10.244.1.5
The time is 4:57:46 PM, VERSION 0.0.1
Server hostname: timeserver-565559b4bf-pbd2q

 

수동 빌드를 통해서 생성된 이미지가 정상적으로 동작합니다.

 

 

자동 빌드 수행되는 Item 생성

이제 Jenkins의 Item에서 빌드가 자동으로 수행되도록, Gogs의 개발팀 리파지터리에 변경이 발생하면 Webhook을 통해서 Jenkins의 Item을 트리거 하도록 설정하겠습니다.

 

먼저 Jenkins에서 새로운 Item을 생성하고, 아래와 같이 pipeline script를 입력합니다.

  • Item name: SCM-Pipeline
  • Item type: Pipeline
  • GitHub project: http://172.28.157.42:3000/devops/dev-app (Gogs 리파지터리)
  • Gogs Webhook>Use Gogs secret: 임의로 설정
  • Triggers>Build when a change is pushed to Gogs 체크
  • Pipeline: Pipeline script from SCM 으로 설정해 해당 리파지터리의 Jenkinsfile을 사용하도록 합니다.

 

참고로 아래는 현재 실습 환경 구성 때문에 수행하는 내용으로 Gogs에서 Webhook을 동일한 IP로 수행하기 위해서 아래와 같이 설정합니다.

# gogs 컨테이너의 /data/gogs/conf/app.ini 파일 수정
[security]
INSTALL_LOCK = true
SECRET_KEY   = j2xaUPQcbAEwpIu
LOCAL_NETWORK_ALLOWLIST = 172.28.157.42 # WSL2 Ubuntu eth0 IP

 

안탁깝게도 gogs 이미지에 shell이 포함되어 있지 않아서 docker exec로는 수행이 어렵습니다. vscode의 Docker extension을 통해서 파일을 수정합니다.

 

이후 아래 명령으로 gogs 재시작 하시면 됩니다.

docker compose restart gogs

 

이제 Gogs의 Settings>Webhooks 에서 아래와 같이 webhook을 추가합니다.

  • Palyload URL: http://**:8080/gogs-webhook/?job=SCM-Pipeline/
  • Secret: 임의로 설정 (Gogs와 Jenkins간 동일한 Secret을 세팅해 서로 신뢰하도록 해야함)

 

마지막으로 해당 리파지터리에 실제로 Jenkinsfile을 작성해 Push하고, 설정된 Webhook을 통해서 정상 수행되는지 살펴보겠습니다.

# Jenkinsfile 빈 파일 작성
touch Jenkinsfile

# VERSION 파일 : 0.0.3 수정
# server.py 파일 : 0.0.3 수정

 

Jenkinsfile에 아래를 작성합니다.

pipeline {
    agent any
    environment {
        DOCKER_IMAGE = '<자신의 도커 허브 계정>/dev-app' // Docker 이미지 이름
    }
    stages {
        stage('Checkout') {
            steps {
                 git branch: 'main',
                 url: 'http://<자신의 집 IP>:3000/devops/dev-app.git',  // Git에서 코드 체크아웃
                 credentialsId: 'gogs-crd'  // Credentials ID
            }
        }
        stage('Read VERSION') {
            steps {
                script {
                    // VERSION 파일 읽기
                    def version = readFile('VERSION').trim()
                    echo "Version found: ${version}"
                    // 환경 변수 설정
                    env.DOCKER_TAG = version
                }
            }
        }
        stage('Docker Build and Push') {
            steps {
                script {
                    docker.withRegistry('https://index.docker.io/v1/', 'dockerhub-crd') {
                        // DOCKER_TAG 사용
                        def appImage = docker.build("${DOCKER_IMAGE}:${DOCKER_TAG}")
                        appImage.push()
                        appImage.push("latest")
                    }
                }
            }
        }
    }
    post {
        success {
            echo "Docker image ${DOCKER_IMAGE}:${DOCKER_TAG} has been built and pushed successfully!"
        }
        failure {
            echo "Pipeline failed. Please check the logs."
        }
    }
}

 

해당 파일을 push하여 Job이 수행되는지 확인합니다.

git add . && git commit -m "VERSION $(cat VERSION) Changed" && git push -u origin main

 

정상 수행되는 것으로 확인됩니다.

 

빌드된 이미지도 정상적으로 업로드되었습니다.

 

이로써 Jenkins를 통해서 Gogs의 개발팀 리파지터리에 변경 사항이 발생하면 자동으로 CI가 수행되도록 구성이 완료되었습니다.

 

현재 dev-app의 파일 구조는 아래와 같습니다.

tree
.
├── Dockerfile
├── Jenkinsfile
├── README.md
├── VERSION
└── server.py

 

Jenkinsfile을 개발팀 리파지터리에서 관리해야할 필요가 없다면, 이는 Jenkins의 Item 생성 시 Pipeline script from SCM 설정에서 다른 리파지터리를 참조하는 것도 좋을 것 같습니다.

 

 

3. Argo CD를 통한 CD 구성

앞서 CI 테스트에서는 수동으로 쿠버네티스 환경에 디플로이먼트를 배포했습니다. 이 과정을 Jenkins를 통해 CD 구성을 할 수도 있습니다.

예를 들어, 아래와 같이 stages에 k8s deployment blue version 단계를 수행하는 것과 같습니다.

pipeline {
    agent any

    environment {
        KUBECONFIG = credentials('k8s-crd')
    }

    stages {
        stage('Checkout') {
            steps {
                 git branch: 'main',
                 url: 'http://<자신의 집 IP>:3000/devops/dev-app.git',  // Git에서 코드 체크아웃
                 credentialsId: 'gogs-crd'  // Credentials ID
            }
        }

        stage('container image build') {
            steps {
                echo "container image build" // 생략
            }
        }

        stage('container image upload') {
            steps {
                echo "container image upload" // 생략
            }
        }

        stage('k8s deployment blue version') {
            steps {
                sh "kubectl apply -f ./deploy/echo-server-blue.yaml --kubeconfig $KUBECONFIG"
                sh "kubectl apply -f ./deploy/echo-server-service.yaml --kubeconfig $KUBECONFIG"
            }
        }

        stage('approve green version') {
            steps {
                input message: 'approve green version', ok: "Yes"
            }
        }

        stage('k8s deployment green version') {
            steps {
                sh "kubectl apply -f ./deploy/echo-server-green.yaml --kubeconfig $KUBECONFIG"
            }
        }

        stage('approve version switching') {
            steps {
                script {
                    returnValue = input message: 'Green switching?', ok: "Yes", parameters: [booleanParam(defaultValue: true, name: 'IS_SWITCHED')]
                    if (returnValue) {
                        sh "kubectl patch svc echo-server-service -p '{\"spec\": {\"selector\": {\"version\": \"green\"}}}' --kubeconfig $KUBECONFIG"
                    }
                }
            }
        }

        stage('Blue Rollback') {
            steps {
                script {
                    returnValue = input message: 'Blue Rollback?', parameters: [choice(choices: ['done', 'rollback'], name: 'IS_ROLLBACk')]
                    if (returnValue == "done") {
                        sh "kubectl delete -f ./deploy/echo-server-blue.yaml --kubeconfig $KUBECONFIG"
                    }
                    if (returnValue == "rollback") {
                        sh "kubectl patch svc echo-server-service -p '{\"spec\": {\"selector\": {\"version\": \"blue\"}}}' --kubeconfig $KUBECONFIG"
                    }
                }
            }
        }
    }
}

 

다만 이러한 과정은 Jenkins에 kubectl 같은 바이너리가 있어야 하거나, 혹은 다른 plugin을 사용해야 하는 불편함도 있을 수 있습니다.

또 결국에는 각 리소스에 대한 yaml 파일을 개발팀 리파지터리에 포함하다보니, 하나의 리파지터리에 파일을 관리하는 역할이 분리되지 않는 문제도 있을 수 있습니다.

 

다른 관점으로는 사용자가 임의로 클러스터의 오브젝트를 컨트롤 하는 상황을 배제하고 싶은 경우도 있습니다.

예를 들어, 배포는 끝났고 운영 중의 상태이지만, 사용자가 디플로이와 같은 오브젝트를 변경한다면 이것은 배포 시점의 상태와는 다른 상태가 됩니다.

즉, GitOps 관점에서 선언적인 상태를 Git 리파지터리에 정의(Desired Manifest)하고, 운영 중인 상태(Live Manifest)를 항상 유지하도록 하는 방식이 필요합니다.

이러한 방식을 Argo CD를 통해서 구현할 수 있습니다.

 

먼저 Gogs의 ops-deploy에서 Settings>Webhooks에서 Webhook을 추가합니다.

  • Palyload URL: http://:30002/api/webhook

 

이후 Gogs의 DevOps팀 리파지터리에 실습에 필요한 파일을 작성하여 Push합니다.

cd cicd-labs

TOKEN=<>
MyIP=172.28.157.42
git clone http://devops:$TOKEN@$MyIP:3000/devops/ops-deploy.git
cd ops-deploy

# git 기본 설정
git --no-pager config --local --list
git config --local user.name "devops"
git config --local user.email "a@a.com"
git config --local init.defaultBranch main
git config --local credential.helper store
git --no-pager config --local --list
cat .git/config

# git 확인
git --no-pager branch
 -v* main

git remote -v
origin  http://devops:8cdf5569aedd230503abea67b0794b4d1e931c10@172.28.157.42:3000/devops/ops-deploy.git (fetch)
origin  http://devops:8cdf5569aedd230503abea67b0794b4d1e931c10@172.28.157.42:3000/devops/ops-deploy.git (push)


# 폴더 생성
mkdir dev-app

# 도커 계정 정보
DHUSER=<도커 허브 계정>

# 버전 정보 
VERSION=0.0.1

# VERSION, yaml 파일 생성
cat > dev-app/VERSION <<EOF
$VERSION
EOF

cat > dev-app/timeserver.yaml <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
  name: timeserver
spec:
  replicas: 2
  selector:
    matchLabels:
      pod: timeserver-pod
  template:
    metadata:
      labels:
        pod: timeserver-pod
    spec:
      containers:
      - name: timeserver-container
        image: docker.io/$DHUSER/dev-app:$VERSION
        livenessProbe:
          initialDelaySeconds: 30
          periodSeconds: 30
          httpGet:
            path: /healthz
            port: 80
            scheme: HTTP
          timeoutSeconds: 5
          failureThreshold: 3
          successThreshold: 1
      imagePullSecrets:
      - name: dockerhub-secret
EOF

cat > dev-app/service.yaml <<EOF
apiVersion: v1
kind: Service
metadata:
  name: timeserver
spec:
  selector:
    pod: timeserver-pod
  ports:
  - port: 80
    targetPort: 80
    protocol: TCP
    nodePort: 30000
  type: NodePort
EOF

# Git Push
git add . && git commit -m "Add dev-app deployment yaml" && git push -u origin main

 

Argo CD는 Application이라는 CRD를 통해서 쿠버네티스 클러스터에 배포할 선언적 설정과 이를 동기화 하는 방법을 정의합니다.

아래와 같이 ops-deploy를 바라보는 Application을 생성합니다.

# Application 생성
cat <<EOF | kubectl apply -f -
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: timeserver
  namespace: argocd
  finalizers:
  - resources-finalizer.argocd.argoproj.io
spec:
  project: default
  source:
    path: dev-app
    repoURL: http://$MyIP:3000/devops/ops-deploy
    targetRevision: HEAD
  syncPolicy:
    automated:
      prune: true
    syncOptions:
    - CreateNamespace=true
  destination:
    namespace: default
    server: https://kubernetes.default.svc
EOF

 

Application 을 생성하면 바로 Argo CD에서 ops-deploy의 yaml을 바탕으로 sync를 하는 것을 알 수 잇습니다.

 

아래와 같이 명령으로 정상 실행되었는지 확인할 수 있습니다.

# 확인
kubectl get applications -n argocd timeserver
NAME         SYNC STATUS   HEALTH STATUS
timeserver   Synced        Healthy

# 서비스 테스트
curl http://127.0.0.1:30000
The time is 6:40:06 PM, VERSION 0.0.1
Server hostname: timeserver-565559b4bf-sd76g
curl http://127.0.0.1:30000/healthz
Healthy

 

조금 더 개선해보기

여기서 더 나아가, 개발팀 리파지터리에 반영이 되면 변경 내용이 DevOps팀 리파지터리에 반영되고, 이 변경을 통해서 쿠버네티스 클러스터에 Sync가 되도록 최종 반영해 보겠습니다.

 

 

개발팀 리파지터리(dev-app)의 Jenkinsfile을 아래와 같이 수정합니다.

기존 Jenkins pipeline에서 ops-deploy Checkout를 통해 ops-deploy를 checkout하고, 변경된 VERSION 정보를 반영해 ops-deploy version update push 과정에서 ops-deploy 로 push하는 것을 확인할 수 있습니다.

pipeline {
    agent any
    environment {
        DOCKER_IMAGE = '<자신의 도커 허브 계정>/dev-app' // Docker 이미지 이름
        GOGSCRD = credentials('gogs-crd')
    }
    stages {
        stage('dev-app Checkout') {
            steps {
                 git branch: 'main',
                 url: 'http://<자신의 집 IP>:3000/devops/dev-app.git',  // Git에서 코드 체크아웃
                 credentialsId: 'gogs-crd'  // Credentials ID
            }
        }
        stage('Read VERSION') {
            steps {
                script {
                    // VERSION 파일 읽기
                    def version = readFile('VERSION').trim()
                    echo "Version found: ${version}"
                    // 환경 변수 설정
                    env.DOCKER_TAG = version
                }
            }
        }
        stage('Docker Build and Push') {
            steps {
                script {
                    docker.withRegistry('https://index.docker.io/v1/', 'dockerhub-crd') {
                        // DOCKER_TAG 사용
                        def appImage = docker.build("${DOCKER_IMAGE}:${DOCKER_TAG}")
                        appImage.push()
                        appImage.push("latest")
                    }
                }
            }
        }
        stage('ops-deploy Checkout') {
            steps {
                 git branch: 'main',
                 url: 'http://<자신의 집 IP>:3000/devops/ops-deploy.git',  // Git에서 코드 체크아웃
                 credentialsId: 'gogs-crd'  // Credentials ID
            }
        }
        stage('ops-deploy version update push') {
            steps {
                sh '''
                OLDVER=$(cat dev-app/VERSION)
                NEWVER=$(echo ${DOCKER_TAG})
                sed -i "s/$OLDVER/$NEWVER/" dev-app/timeserver.yaml
                sed -i "s/$OLDVER/$NEWVER/" dev-app/VERSION
                git add ./dev-app
                git config user.name "devops"
                git config user.email "a@a.com"
                git commit -m "version update ${DOCKER_TAG}"
                git push http://${GOGSCRD_USR}:${GOGSCRD_PSW}@<자신의 집 IP>:3000/devops/ops-deploy.git
                '''
            }
        }
    }
    post {
        success {
            echo "Docker image ${DOCKER_IMAGE}:${DOCKER_TAG} has been built and pushed successfully!"
        }
        failure {
            echo "Pipeline failed. Please check the logs."
        }
    }
}

 

개발팀 코드 배포는 Jenkins의 Job을 트리거하고, Jenkins Pipeline을 통해 컨테이너 이미지 빌드 작업을 수행하고, 다시 ops-deploy에 버전 변경 내용을 반영 시켜, 최종 쿠버네티스 환경에 Sync 되도록 Argo CD가 동작합니다.

 

이제 아래와 같이 VERSION 정보까지 수정하고 push를 진행합니다.

# VERSION 파일 수정 : 0.0.3
# server.py 파일 수정 : 0.0.3

# git push : VERSION, server.py, Jenkinsfile
git add . && git commit -m "VERSION $(cat VERSION) Changed" && git push -u origin main

 

테스트 과정에서 몇 가지 에러가 발생하였지만, 최종 잘 반영되는 것으로 확인됩니다.

 

ArgoCD에서도 Sync를 통해서 다른 ReplicaSet이 생성되는 것을 확인할 수 있습니다.

 

이것으로 실습을 마무리하겠습니다.

 

 

마무리

실습을 통해 Jenkins를 통한 CI와 Argo CD를 통한 CD를 구성해보았습니다.

쿠버네티스 환경에서 CI/CD 과정을 어떻게 간결하고 자동화를 할 수 있는지 대략적인 아이디어를 얻어가셨으면 좋겠습니다.

CNI란?

CNI(Container Network Interface)는 CNCF(Cloud Native Computing Foundation)의 프로젝트로 Specification과 리눅스 컨테이너의 네트워크 인터페이스를 구성하기 위한 plugin을 작성하기 위한 라이브러리로 구성됩니다.

CNI는 컨테이너의 네트워크 연결성과 컨테이너가 삭제되었을 때 할당된 리소스를 제거하는 역할에 집중합니다.

참고: https://github.com/containernetworking/cni

 

보통 Kubernetes에 어떤 CNI를 쓰느냐라고 얘기를 하면 의미가 통하기는 하지만, 실제로 calico, cilium, flannel 등은 CNI plugin이라고 할 수 있습니다.

Kubernetes 에서 CNI Plugin의 동작은 간략히 아래와 같이 이뤄집니다.

  1. Kubelet이 Container Runtime에 컨테이너 생성을 요청
  2. Container Runtime이 컨테이너의 Network Namespace를 생성
  3. Container Runtime이 CNI 설정과 환경변수를 표준 입력으로 CNI Plugin 호출
  4. CNI Plugin이 컨테이너의 네트워크 인터페이스를 구성하고, IP를 할당하고, 호스트 네트워크 간의 veth pair 를 생성
  5. CNI Plugin이 호스트 네트워크 네임스페이스와 컨테이너 네트워크 네임스페이스에 라우팅을 구성

 

오래되긴 했지만 CNI와 CNI Plugin에 대해 잘 설명한 영상이 있습니다.

https://www.youtube.com/watch?v=4E_l-B988Ek&t=1341s

 

 

실습

간단히 영상의 실습을 따라해보겠습니다.

먼저 go가 설치된 환경에서 아래와 같이 샘플 CNI plugin을 가져와 빌드를 진행합니다. 빌드가 끝나면 bin 폴더에 바이너리들이 위치합니다.

root@jumpVM:~# git clone https://github.com/containernetworking/plugins.git
Cloning into 'plugins'...
remote: Enumerating objects: 19825, done.
remote: Counting objects: 100% (268/268), done.
remote: Compressing objects: 100% (180/180), done.
remote: Total 19825 (delta 151), reused 86 (delta 86), pack-reused 19557 (from 2)
Receiving objects: 100% (19825/19825), 16.41 MiB | 19.77 MiB/s, done.
Resolving deltas: 100% (11214/11214), done.
root@jumpVM:~# cd plugins/
root@jumpVM:~/plugins# ls
CONTRIBUTING.md  README.md         go.mod       plugins
DCO              RELEASING.md      go.sum       test_linux.sh
LICENSE          build_linux.sh    integration  test_windows.sh
OWNERS.md        build_windows.sh  pkg          vendor
root@jumpVM:~/plugins# ./build_linux.sh
Building plugins
  bandwidth
  firewall
  portmap
  sbr
  tuning
  vrf
  bridge
  dummy
  host-device
  ipvlan
  loopback
  macvlan
  ptp
  tap
  vlan
  dhcp
  host-local
  static
root@jumpVM:~/plugins# ls
CONTRIBUTING.md  README.md       build_windows.sh  pkg              vendor
DCO              RELEASING.md    go.mod            plugins
LICENSE          bin             go.sum            test_linux.sh
OWNERS.md        build_linux.sh  integration       test_windows.sh
root@jumpVM:~/plugins# cd bin
root@jumpVM:~/plugins/bin# ls
bandwidth  dummy        host-local  macvlan  sbr     tuning
bridge     firewall     ipvlan      portmap  static  vlan
dhcp       host-device  loopback    ptp      tap     vrf

 

그리고 3개의 세션을 만들어서 아래의 명령을 실행합니다.

## 좌측 세션 (demons 생성)
$ sudo ip netns add demons
## 우측 상단 세션 (host ns의 ip/route 정보 확인)
$ watch -d -n 1 'ip a; echo ""; ip route'
## 우측 하단 세션 (domons의 ip/route 정보 확인)
$ watch -d -n 1 'ip netns exec demons ip a; echo ""; ip netns exec demons ip route;'

최초 상태는 아래와 같습니다.

 

컨테이너와 호스트간에 veth 디바이스를 생성해주는 ptp plugin을 사용합니다.

https://www.cni.dev/plugins/current/main/ptp/

 

ptp 바이너리를 실행하면 version을 알 수 없다고 합니다. CNI는 단순히 필요한 정보를 환경 변수로, 그리고 spec을 표준 입력으로 전달합니다. CNI_COMMAND로 VERSION을 넣고 다시 바이너리를 실행하면 제공하는 버전을 알려줍니다.

root@jumpVM:~/plugins/bin# ./ptp
CNI ptp plugin version unknown
CNI protocol versions supported: 0.1.0, 0.2.0, 0.3.0, 0.3.1, 0.4.0, 1.0.0, 1.1.0
root@jumpVM:~/plugins/bin# CNI_COMMAND=VERSION ./ptp
{"cniVersion":"1.1.0","supportedVersions":["0.1.0","0.2.0","0.3.0","0.3.1","0.4.0","1.0.0","1.1.0"]}

 

아래와 같이 표준 입력으로 전달할 CNI 설정도 준비합니다. CNI plugin에는 IPAM과 컨테이너 네트워크를 구성하는 역할을 하는 plugin으로 나뉩니다. 여기에서는 ptp와 ipam으로 host-local을 사용했습니다.

{
    "cniVersion": "0.3.1",
    "name": "demonet",
    "type": "ptp", ## CNI binary
    "ipam": {
        "type": "host-local", ## CNI binary for IPAM
        "subnet": "192.168.0.0/24"
    }
}

 

이제 실제로 ADD 명령을 전달합니다. 하지만 여러가지 변수들이 지정되지 않아 에러가 발생하였습니다.

root@jumpVM:~/plugins/bin# CNI_COMMAND=ADD ./ptp < config
{
    "code": 4,
    "msg": "required env variables [CNI_CONTAINERID,CNI_NETNS,CNI_IFNAME,CNI_PATH] missing"
}

 

해당 변수들까지 환경 변수로 지정하고 명령을 다시 수행합니다.

$ CNI_COMMAND=ADD CNI_CONTAINERID=1234 CNI_NETNS=/var/run/netns/demons CNI_IFNAME=domoeth0 CNI_PATH=/root/plugins/bin ./ptp < config

 

명령 수행 결과로 생성된 interface에 대한 정보와 IPAM에서 전달된 ip 정보를 확인할 수 있으며, 우측 세션을 보면 새로운 veth pair와 라우팅이 추가되었음을 알 수 있습니다.

 

마지막으로 DEL 명령으로 테스트 구성을 삭제합니다.

CNI_COMMAND=DEL CNI_CONTAINERID=1234 CNI_NETNS=/var/run/netns/demons CNI_IFNAME=domoeth0 CNI_PATH=/root/plugins/bin ./ptp < config  

이렇게 샘플 CNI plugin을 실행해 봄으로서 앞서 설명한 과정을 이해할 수 있으며, Container Runtime이 어떤 방식으로 CNI plugin을 호출하는지를 이해할 수 있습니다. 실제로 ADD, DEL 과 같은 요청을 합니다.

containerd 를 사용하는 윈도우 노드 추가

지난 '쿠버네티스 윈도우 워커 노드 추가(with Calico CNI)' 에서 Docker EE 를 사용하여 Windows 워커 노드를 추가했습니다. Docker EE 가 deprecated 됨에 따라 containerd 를 활용할 필요가 있어서 추가로 containerd 를 이용해 Windows 워커노드를 추가하는 절차를 기록했습니다.

 

아래 'containerd 시작하기' 문서를 참고하여 containerd 를 설치합니다.

https://github.com/containerd/containerd/blob/main/docs/getting-started.md#installing-containerd-on-windows

PS C:\Users\Administrator> $Version="1.6.4"
PS C:\Users\Administrator> curl.exe -L https://github.com/containerd/containerd/releases/download/v$Version/containerd-$Version-windows-amd64.tar.gz -o containerd-windows-amd64.tar.gz
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
100 30.0M  100 30.0M    0     0  1393k      0  0:00:22  0:00:22 --:--:-- 1630k
PS C:\Users\Administrator> tar.exe xvf .\containerd-windows-amd64.tar.gz
x bin/
x bin/containerd.exe
x bin/containerd-shim-runhcs-v1.exe
x bin/containerd-stress.exe
x bin/ctr.exe
PS C:\Users\Administrator> Copy-Item -Path ".\bin\" -Destination "$Env:ProgramFiles\containerd" -Recurse -Force
PS C:\Users\Administrator> cd $Env:ProgramFiles\containerd\
PS C:\Program Files\containerd> .\containerd.exe config default | Out-File config.toml -Encoding ascii
PS C:\Program Files\containerd> Get-Content config.toml
disabled_plugins = []
imports = []
oom_score = 0
plugin_dir = ""
required_plugins = []
root = "C:\\ProgramData\\containerd\\root"
state = "C:\\ProgramData\\containerd\\state"
temp = ""
version = 2

[cgroup]
  path = ""

[debug]
  address = ""
  format = ""
  gid = 0
  level = ""
  uid = 0

[grpc]
  address = "\\\\.\\pipe\\containerd-containerd"
  gid = 0
  max_recv_message_size = 16777216
  max_send_message_size = 16777216
  tcp_address = ""
  tcp_tls_ca = ""
  tcp_tls_cert = ""
  tcp_tls_key = ""
  uid = 0

[metrics]
  address = ""
  grpc_histogram = false

[plugins]

  [plugins."io.containerd.gc.v1.scheduler"]
    deletion_threshold = 0
    mutation_threshold = 100
    pause_threshold = 0.02
    schedule_delay = "0s"
    startup_delay = "100ms"

  [plugins."io.containerd.grpc.v1.cri"]
    device_ownership_from_security_context = false
    disable_apparmor = false
    disable_cgroup = false
    disable_hugetlb_controller = false
    disable_proc_mount = false
    disable_tcp_service = true
    enable_selinux = false
    enable_tls_streaming = false
    enable_unprivileged_icmp = false
    enable_unprivileged_ports = false
    ignore_image_defined_volumes = false
    max_concurrent_downloads = 3
    max_container_log_line_size = 16384
    netns_mounts_under_state_dir = false
    restrict_oom_score_adj = false
    sandbox_image = "k8s.gcr.io/pause:3.6"
    selinux_category_range = 0
    stats_collect_period = 10
    stream_idle_timeout = "4h0m0s"
    stream_server_address = "127.0.0.1"
    stream_server_port = "0"
    systemd_cgroup = false
    tolerate_missing_hugetlb_controller = false
    unset_seccomp_profile = ""

    [plugins."io.containerd.grpc.v1.cri".cni]
      bin_dir = "C:\\Program Files\\containerd\\cni\\bin"
      conf_dir = "C:\\Program Files\\containerd\\cni\\conf"
      conf_template = ""
      ip_pref = ""
      max_conf_num = 1

    [plugins."io.containerd.grpc.v1.cri".containerd]
      default_runtime_name = "runhcs-wcow-process"
      disable_snapshot_annotations = false
      discard_unpacked_layers = false
      ignore_rdt_not_enabled_errors = false
      no_pivot = false
      snapshotter = "windows"

      [plugins."io.containerd.grpc.v1.cri".containerd.default_runtime]
        base_runtime_spec = ""
        cni_conf_dir = ""
        cni_max_conf_num = 0
        container_annotations = []
        pod_annotations = []
        privileged_without_host_devices = false
        runtime_engine = ""
        runtime_path = ""
        runtime_root = ""
        runtime_type = ""

        [plugins."io.containerd.grpc.v1.cri".containerd.default_runtime.options]

      [plugins."io.containerd.grpc.v1.cri".containerd.runtimes]

        [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runhcs-wcow-process]
          base_runtime_spec = ""
          cni_conf_dir = ""
          cni_max_conf_num = 0
          container_annotations = []
          pod_annotations = []
          privileged_without_host_devices = false
          runtime_engine = ""
          runtime_path = ""
          runtime_root = ""
          runtime_type = "io.containerd.runhcs.v1"

          [plugins."io.containerd.grpc.v1.cri".containerd.runtimes.runhcs-wcow-process.options]

      [plugins."io.containerd.grpc.v1.cri".containerd.untrusted_workload_runtime]
        base_runtime_spec = ""
        cni_conf_dir = ""
        cni_max_conf_num = 0
        container_annotations = []
        pod_annotations = []
        privileged_without_host_devices = false
        runtime_engine = ""
        runtime_path = ""
        runtime_root = ""
        runtime_type = ""

        [plugins."io.containerd.grpc.v1.cri".containerd.untrusted_workload_runtime.options]

    [plugins."io.containerd.grpc.v1.cri".image_decryption]
      key_model = "node"

    [plugins."io.containerd.grpc.v1.cri".registry]
      config_path = ""

      [plugins."io.containerd.grpc.v1.cri".registry.auths]

      [plugins."io.containerd.grpc.v1.cri".registry.configs]

      [plugins."io.containerd.grpc.v1.cri".registry.headers]

      [plugins."io.containerd.grpc.v1.cri".registry.mirrors]

    [plugins."io.containerd.grpc.v1.cri".x509_key_pair_streaming]
      tls_cert_file = ""
      tls_key_file = ""

  [plugins."io.containerd.internal.v1.opt"]
    path = "C:\\ProgramData\\containerd\\root\\opt"

  [plugins."io.containerd.internal.v1.restart"]
    interval = "10s"

  [plugins."io.containerd.internal.v1.tracing"]
    sampling_ratio = 1.0
    service_name = "containerd"

  [plugins."io.containerd.metadata.v1.bolt"]
    content_sharing_policy = "shared"

  [plugins."io.containerd.runtime.v2.task"]
    platforms = ["windows/amd64", "linux/amd64"]
    sched_core = false

  [plugins."io.containerd.service.v1.diff-service"]
    default = ["windows", "windows-lcow"]

  [plugins."io.containerd.service.v1.tasks-service"]
    rdt_config_file = ""

  [plugins."io.containerd.tracing.processor.v1.otlp"]
    endpoint = ""
    insecure = false
    protocol = ""

[proxy_plugins]

[stream_processors]

  [stream_processors."io.containerd.ocicrypt.decoder.v1.tar"]
    accepts = ["application/vnd.oci.image.layer.v1.tar+encrypted"]
    args = ["--decryption-keys-path", "C:\\Program Files\\containerd\\ocicrypt\\keys"]
    env = ["OCICRYPT_KEYPROVIDER_CONFIG=C:\\Program Files\\containerd\\ocicrypt\\ocicrypt_keyprovider.conf"]
    path = "ctd-decoder"
    returns = "application/vnd.oci.image.layer.v1.tar"

  [stream_processors."io.containerd.ocicrypt.decoder.v1.tar.gzip"]
    accepts = ["application/vnd.oci.image.layer.v1.tar+gzip+encrypted"]
    args = ["--decryption-keys-path", "C:\\Program Files\\containerd\\ocicrypt\\keys"]
    env = ["OCICRYPT_KEYPROVIDER_CONFIG=C:\\Program Files\\containerd\\ocicrypt\\ocicrypt_keyprovider.conf"]
    path = "ctd-decoder"
    returns = "application/vnd.oci.image.layer.v1.tar+gzip"

[timeouts]
  "io.containerd.timeout.bolt.open" = "0s"
  "io.containerd.timeout.shim.cleanup" = "5s"
  "io.containerd.timeout.shim.load" = "5s"
  "io.containerd.timeout.shim.shutdown" = "3s"
  "io.containerd.timeout.task.state" = "2s"

[ttrpc]
  address = ""
  gid = 0
  uid = 0
PS C:\Program Files\containerd> .\containerd.exe --register-service
PS C:\Program Files\containerd> Start-Service containerd
PS C:\Program Files\containerd> Get-Service containerd

Status   Name               DisplayName
------   ----               -----------
Running  containerd         containerd

containerd 가 윈도우의 서비스 형태로 실행됩니다.

docker 가 설치되지 않기 때문에 별도의 CLI를 설치합니다. crictl 명령을 수행해보면 각각의 컨테이너 런타임의 endpont를 찾기 때문에, 아래를 참고하여 user profile 쪽에 crictl.config을 생성했습니다.

https://github.com/kubernetes-sigs/cri-tools/blob/master/docs/crictl.md

PS C:\Users\Administrator> $VERSION="v1.24.2"
PS C:\Users\Administrator> curl.exe -L https://github.com/kubernetes-sigs/cri-tools/releases/download/$VERSION/crictl-$VERSION-windows-amd64.tar.gz -o crictl-windows-amd64.tar.gz
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
100 14.1M  100 14.1M    0     0  1275k      0  0:00:11  0:00:11 --:--:-- 1705k
PS C:\Users\Administrator> tar.exe xvf .\crictl-windows-amd64.tar.gz -C $ENV:WINDIR\system32
x crictl.exe
PS C:\Users\Administrator> crictl ps
time="2022-07-12T14:35:37+09:00" level=warning msg="runtime connect using default endpoints: [npipe:////./pipe/dockershim npipe:////./pipe/containerd-containerd npipe:////./pipe/cri-dockerd]. As the default settings are now deprecated, you should set the endpoint instead."
time="2022-07-12T14:35:37+09:00" level=error msg="unable to determine runtime API version: rpc error: code = Unavailable desc = connection error: desc = \"transport: Error while dialing open //./pipe/dockershim: The system cannot find the file specified.\""
time="2022-07-12T14:35:37+09:00" level=warning msg="image connect using default endpoints: [npipe:////./pipe/dockershim npipe:////./pipe/containerd-containerd npipe:////./pipe/cri-dockerd]. As the default settings are now deprecated, you should set the endpoint instead."
time="2022-07-12T14:35:37+09:00" level=error msg="unable to determine image API version: rpc error: code = Unavailable desc = connection error: desc = \"transport: Error while dialing open //./pipe/dockershim: The system cannot find the file specified.\""
CONTAINER           IMAGE               CREATED             STATE               NAME                ATTEMPT             POD ID              POD

PS C:\Users\Administrator> mkdir $Env:UserProfile\.crictl


    Directory: C:\Users\Administrator


Mode                LastWriteTime         Length Name
----                -------------         ------ ----
d-----     2022-07-12   오후 2:40                .crictl


PS C:\Users\Administrator> notepad $Env:UserProfile\.crictl\crictl.yaml
PS C:\Users\Administrator> type $Env:UserProfile\.crictl\crictl.yaml
runtime-endpoint: npipe:\\\\.\\pipe\\containerd-containerd
image-endpoint: npipe:\\\\.\\pipe\\containerd-containerd
timeout: 2
debug: true
pull-image-on-create: false
PS C:\Users\Administrator> crictl ps
time="2022-07-12T14:43:19+09:00" level=debug msg="get runtime connection"
time="2022-07-12T14:43:19+09:00" level=debug msg="get image connection"
time="2022-07-12T14:43:19+09:00" level=debug msg="ListContainerResponse: []"
CONTAINER           IMAGE               CREATED             STATE               NAME                ATTEMPT             POD ID              POD

Calico 를 설치하려고 진행하니 아래와 같이 에러가 발생합니다.

PS C:\Users\Administrator> mkdir c:\k


    Directory: C:\


Mode                LastWriteTime         Length Name
----                -------------         ------ ----
d-----     2022-07-12   오후 2:51                k


PS C:\Users\Administrator> scp root@172.16.3.170:~/.kube/config c:\k\
root@172.16.3.170's password:
config                                                                                                                                                                                                          100% 5640     5.5KB/s   00:00
PS C:\Users\Administrator> Invoke-WebRequest https://projectcalico.docs.tigera.io/scripts/install-calico-windows.ps1 -OutFile c:\install-calico-windows.ps1
PS C:\Users\Administrator> c:\install-calico-windows.ps1 -KubeVersion 1.22.6 -ServiceCidr 10.96.0.0/12 -DNSServerIPs 10.96.0.10
WARNING: The names of some imported commands from the module 'helper' include unapproved verbs that might make them less discoverable. To find the commands with unapproved verbs, run the Import-Module command again with the Verbose
parameter. For a list of approved verbs, type Get-Verb.
c:\calico-windows.zip not found, downloading Calico for Windows release...
Downloaded [https://github.com/projectcalico/calico/releases/download/v3.23.2//calico-windows-v3.23.2.zip] => [c:\calico-windows.zip]
C:\install-calico-windows.ps1 : The term 'Get-HnsNetwork' is not recognized as the name of a cmdlet, function, script file, or operable program. Check the spelling of the name, or if a path was included, verify that the path is correct and t
ry again.
At line:1 char:1
+ c:\install-calico-windows.ps1 -KubeVersion 1.22.6 -ServiceCidr 10.96. ...
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : ObjectNotFound: (Get-HnsNetwork:String) [install-calico-windows.ps1], CommandNotFoundException
    + FullyQualifiedErrorId : CommandNotFoundException,install-calico-windows.ps1

Docker EE를 사용할 때와 다르게, containerd 를 설치하는 것 자체만으로 Windows 의 컨테이너 feature를 설치하지 않기 때문에 HNS와 같은 서비스 및 기타 cmdlet이 설치되어 있지 않습니다.

The term 'Get-HnsNetwork' is not recognized as the name of a cmdlet, function, script file, or operable program.

 

윈도우 서버에서 containers fature를 먼서 설치합니다. (재시작으로 수행결과가 없지만 아래와 같이 진행하면 됩니다)

PS C:\Users\Administrator> Install-WindowsFeature -Name containers
PS C:\Users\Administrator> Restart-Computer -Force

재시작후 다시 확인해보면 Host Network Service 가 조회됩니다.

PS C:\Users\Administrator> Get-Service hns

Status   Name               DisplayName
------   ----               -----------
Stopped  hns                Host Network Service

containerd 로 변경한 이후 한 가지 에러가 더 발생합니다.

PS C:\Users\Administrator> c:\install-calico-windows.ps1 -KubeVersion 1.22.6 -ServiceCidr 10.96.0.0/12 -DNSServerIPs 10.96.0.10
WARNING: The names of some imported commands from the module 'helper' include unapproved verbs that might make them less discoverable. To find the commands with unapproved verbs, run the Import-Module command again with the Verbose
parameter. For a list of approved verbs, type Get-Verb.
Unzip Calico for Windows release...
Creating CNI directory
<생략>
Validating configuration...
CNI binary directory C:\Program Files\containerd\cni\bin doesn't exist.  Please create it and ensure kubelet is configured with matching --cni-bin-dir.
At C:\CalicoWindows\libs\calico\calico.psm1:35 char:13
+             throw "CNI binary directory $env:CNI_BIN_DIR doesn't exis ...
+             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    + CategoryInfo          : OperationStopped: (CNI binary dire... --cni-bin-dir.:String) [], RuntimeException
    + FullyQualifiedErrorId : CNI binary directory C:\Program Files\containerd\cni\bin doesn't exist.  Please create it and ensure kubelet is configured with matching --cni-bin-dir.

containerd로 변경되면서 cni\bin 위치를 제대로 생성하지 않고, 참조하는 것 같습니다만.. 이 부분은 설치 스크립트를 더 분석해야 확인가능 할 것 같습니다.

일단 해당 디렉터리를 수동으로 생성해주고 시작합니다.

PS C:\Program Files\containerd\cni\conf> mkdir "C:\Program Files\containerd\cni\bin"


    Directory: C:\Program Files\containerd\cni


Mode                LastWriteTime         Length Name
----                -------------         ------ ----
d-----     2022-07-12   오후 3:51                bin


PS C:\Program Files\containerd\cni> c:\install-calico-windows.ps1 -KubeVersion 1.22.6 -ServiceCidr 10.96.0.0/12 -DNSServerIPs 10.96.0.10
WARNING: The names of some imported commands from the module 'helper' include unapproved verbs that might make them less discoverable. To find the commands with unapproved verbs, run the Import-Module command again with the Verbose
parameter. For a list of approved verbs, type Get-Verb.
Unzip Calico for Windows release...
Creating CNI directory
Downloading Windows Kubernetes scripts
[DownloadFile] File c:\k\hns.psm1 already exists.
WARNING: The names of some imported commands from the module 'hns' include unapproved verbs that might make them less discoverable. To find the commands with unapproved verbs, run the Import-Module command again with the Verbose
parameter. For a list of approved verbs, type Get-Verb.
Downloaded [https://dl.k8s.io/v1.22.6/kubernetes-node-windows-amd64.tar.gz] => [C:\Users\Administrator\AppData\Local\Temp\2\tmp3B38.tar.gz]
Setup Calico for Windows...
Error from server (NotFound): namespaces "calico-system" not found
Calico running in kube-system namespace
Backend networking is vxlan

Start Calico for Windows install...

Setting environment variables if not set...
Environment variable KUBE_NETWORK is already set: Calico.*
Environment variable CALICO_NETWORKING_BACKEND is already set: vxlan
Environment variable K8S_SERVICE_CIDR is already set: 10.96.0.0/12
Environment variable DNS_NAME_SERVERS is already set: 10.96.0.10
Environment variable DNS_SEARCH is already set: svc.cluster.local
Environment variable CALICO_DATASTORE_TYPE is already set: kubernetes
Environment variable KUBECONFIG is already set: c:\k\config
Environment variable ETCD_ENDPOINTS is not set. Setting it to the default value:
Environment variable ETCD_KEY_FILE is not set. Setting it to the default value:
Environment variable ETCD_CERT_FILE is not set. Setting it to the default value:
Environment variable ETCD_CA_CERT_FILE is not set. Setting it to the default value:
Environment variable CNI_BIN_DIR is already set: C:\Program Files\containerd\cni\bin
Environment variable CNI_CONF_DIR is already set: C:\Program Files\containerd\cni\conf
Environment variable CNI_CONF_FILENAME is already set: 10-calico.conf
Environment variable CNI_IPAM_TYPE is already set: calico-ipam
Environment variable VXLAN_VNI is already set: 4096
Environment variable VXLAN_MAC_PREFIX is already set: 0E-2A
Environment variable VXLAN_ADAPTER is not set. Setting it to the default value:
Environment variable NODENAME is already set: k8s-ww2
Environment variable CALICO_K8S_NODE_REF is already set: k8s-ww2
Environment variable STARTUP_VALID_IP_TIMEOUT is already set: 90
Environment variable IP is already set: autodetect
Environment variable CALICO_LOG_DIR is already set: C:\CalicoWindows\logs
Environment variable FELIX_LOGSEVERITYFILE is already set: none
Environment variable FELIX_LOGSEVERITYSYS is already set: none
Validating configuration...
Installing node startup service...


    Hive: HKEY_LOCAL_MACHINE\Software


Name                           Property
----                           --------
Tigera


    Hive: HKEY_LOCAL_MACHINE\Software\Tigera


Name                           Property
----                           --------
Calico
Service "CalicoNode" installed successfully!
Set parameter "AppParameters" for service "CalicoNode".
Set parameter "AppDirectory" for service "CalicoNode".
Set parameter "DisplayName" for service "CalicoNode".
Set parameter "Description" for service "CalicoNode".
Set parameter "Start" for service "CalicoNode".
Reset parameter "ObjectName" for service "CalicoNode" to its default.
Set parameter "Type" for service "CalicoNode".
Reset parameter "AppThrottle" for service "CalicoNode" to its default.
Creating log directory.

PSPath            : Microsoft.PowerShell.Core\FileSystem::C:\CalicoWindows\logs
PSParentPath      : Microsoft.PowerShell.Core\FileSystem::C:\CalicoWindows
PSChildName       : logs
PSDrive           : C
PSProvider        : Microsoft.PowerShell.Core\FileSystem
PSIsContainer     : True
Name              : logs
FullName          : C:\CalicoWindows\logs
Parent            : CalicoWindows
Exists            : True
Root              : C:\
Extension         :
CreationTime      : 2022-07-12 오후 3:53:55
CreationTimeUtc   : 2022-07-12 오전 6:53:55
LastAccessTime    : 2022-07-12 오후 3:53:55
LastAccessTimeUtc : 2022-07-12 오전 6:53:55
LastWriteTime     : 2022-07-12 오후 3:53:55
LastWriteTimeUtc  : 2022-07-12 오전 6:53:55
Attributes        : Directory
Mode              : d-----
BaseName          : logs
Target            : {}
LinkType          :

Set parameter "AppStdout" for service "CalicoNode".
Set parameter "AppStderr" for service "CalicoNode".
Set parameter "AppRotateFiles" for service "CalicoNode".
Set parameter "AppRotateOnline" for service "CalicoNode".
Set parameter "AppRotateSeconds" for service "CalicoNode".
Set parameter "AppRotateBytes" for service "CalicoNode".
Done installing startup service.
Installing Felix service...
Service "CalicoFelix" installed successfully!
Set parameter "AppParameters" for service "CalicoFelix".
Set parameter "AppDirectory" for service "CalicoFelix".
Set parameter "DependOnService" for service "CalicoFelix".
Set parameter "DisplayName" for service "CalicoFelix".
Set parameter "Description" for service "CalicoFelix".
Set parameter "Start" for service "CalicoFelix".
Reset parameter "ObjectName" for service "CalicoFelix" to its default.
Set parameter "Type" for service "CalicoFelix".
Reset parameter "AppThrottle" for service "CalicoFelix" to its default.
Set parameter "AppStdout" for service "CalicoFelix".
Set parameter "AppStderr" for service "CalicoFelix".
Set parameter "AppRotateFiles" for service "CalicoFelix".
Set parameter "AppRotateOnline" for service "CalicoFelix".
Set parameter "AppRotateSeconds" for service "CalicoFelix".
Set parameter "AppRotateBytes" for service "CalicoFelix".
Done installing Felix service.
Copying CNI binaries to C:\Program Files\containerd\cni\bin
Writing CNI configuration to C:\Program Files\containerd\cni\conf\10-calico.conf.
Wrote CNI configuration.

Calico for Windows installed

Starting Calico...
This may take several seconds if the vSwitch needs to be created.
Waiting for Calico initialisation to finish...
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-07-12 오후 3:09:43
Calico initialisation finished.
Done, the Calico services are running:

Status      : Running
Name        : CalicoFelix
DisplayName : Calico Windows Agent


Status      : Running
Name        : CalicoNode
DisplayName : Calico Windows Startup


Caption                 :
Description             : Enable kubectl exec and log
ElementName             : kubectl exec 10250
InstanceID              : KubectlExec10250
CommonName              :
PolicyKeywords          :
Enabled                 : True
PolicyDecisionStrategy  : 2
PolicyRoles             :
ConditionListType       : 3
CreationClassName       : MSFT|FW|FirewallRule|KubectlExec10250
ExecutionStrategy       : 2
Mandatory               :
PolicyRuleName          :
Priority                :
RuleUsage               :
SequencedActions        : 3
SystemCreationClassName :
SystemName              :
Action                  : Allow
Direction               : Inbound
DisplayGroup            :
DisplayName             : kubectl exec 10250
EdgeTraversalPolicy     : Block
EnforcementStatus       : NotApplicable
LocalOnlyMapping        : False
LooseSourceMapping      : False
Owner                   :
Platforms               : {}
PolicyStoreSource       : PersistentStore
PolicyStoreSourceType   : Local
PrimaryStatus           : OK
Profiles                : 0
RuleGroup               :
Status                  : The rule was parsed successfully from the store. (65536)
StatusCode              : 65536
PSComputerName          :
Name                    : KubectlExec10250
ID                      : KubectlExec10250
Group                   :
Profile                 : Any
Platform                : {}
LSM                     : False

Calico Node와 Calico Felix 가 정상 실행되었습니다.

PS C:\Program Files\containerd\cni\bin> Get-Service -Name CalicoNode

Status   Name               DisplayName
------   ----               -----------
Running  CalicoNode         Calico Windows Startup


PS C:\Program Files\containerd\cni\bin> Get-Service -Name CalicoFelix

Status   Name               DisplayName
------   ----               -----------
Running  CalicoFelix        Calico Windows Agent

추가 스크립트를 수행하여 kubelet과 kube-proxy 를 설치하고 서비스를 실행합니다.

PS C:\Program Files\containerd\cni\bin> C:\CalicoWindows\kubernetes\install-kube-services.ps1
Installing kubelet service...
Service "kubelet" installed successfully!
Set parameter "AppParameters" for service "kubelet".
Set parameter "AppDirectory" for service "kubelet".
Set parameter "DisplayName" for service "kubelet".
Set parameter "Description" for service "kubelet".
Set parameter "Start" for service "kubelet".
Reset parameter "ObjectName" for service "kubelet" to its default.
Set parameter "Type" for service "kubelet".
Reset parameter "AppThrottle" for service "kubelet" to its default.
Set parameter "AppStdout" for service "kubelet".
Set parameter "AppStderr" for service "kubelet".
Set parameter "AppRotateFiles" for service "kubelet".
Set parameter "AppRotateOnline" for service "kubelet".
Set parameter "AppRotateSeconds" for service "kubelet".
Set parameter "AppRotateBytes" for service "kubelet".
Done installing kubelet service.
Installing kube-proxy service...
Service "kube-proxy" installed successfully!
Set parameter "AppParameters" for service "kube-proxy".
Set parameter "AppDirectory" for service "kube-proxy".
Set parameter "DisplayName" for service "kube-proxy".
Set parameter "Description" for service "kube-proxy".
Set parameter "Start" for service "kube-proxy".
Reset parameter "ObjectName" for service "kube-proxy" to its default.
Set parameter "Type" for service "kube-proxy".
Reset parameter "AppThrottle" for service "kube-proxy" to its default.
Set parameter "AppStdout" for service "kube-proxy".
Set parameter "AppStderr" for service "kube-proxy".
Set parameter "AppRotateFiles" for service "kube-proxy".
Set parameter "AppRotateOnline" for service "kube-proxy".
Set parameter "AppRotateSeconds" for service "kube-proxy".
Set parameter "AppRotateBytes" for service "kube-proxy".
Done installing kube-proxy service.
PS C:\Program Files\containerd\cni\bin> Get-Service kubelet

Status   Name               DisplayName
------   ----               -----------
Stopped  kubelet            kubelet service


PS C:\Program Files\containerd\cni\bin> Get-Service kube-proxy

Status   Name               DisplayName
------   ----               -----------
Stopped  kube-proxy         kube-proxy service

PS C:\Program Files\containerd\cni\bin> Start-Service kubelet
PS C:\Program Files\containerd\cni\bin> start-Service kube-proxy
PS C:\Program Files\containerd\cni\bin> Get-Service kubelet

Status   Name               DisplayName
------   ----               -----------
Running  kubelet            kubelet service


PS C:\Program Files\containerd\cni\bin> Get-Service kube-proxy

Status   Name               DisplayName
------   ----               -----------
Running  kube-proxy         kube-proxy service

kubelet과 kube-proxy가 실행되면 워커 노드가 Join 된 것으로 확인됩니다. 기존에 조인되었던 k8s-ww 서버가 다운된 상태라, 해당 노드에 스케줄되었던 pod가 신규로 조인된 서버로 스케줄링이 되었습니다.

root@k8s-m:~# kubectl get no
NAME     STATUS     ROLES                  AGE    VERSION
k8s-lw   Ready      <none>                 120d   v1.22.6
k8s-m    Ready      control-plane,master   120d   v1.22.6
k8s-ww   NotReady   <none>                 120d   v1.22.6
root@k8s-m:~# kubectl get no
NAME      STATUS     ROLES                  AGE    VERSION
k8s-lw    Ready      <none>                 120d   v1.22.6
k8s-m     Ready      control-plane,master   120d   v1.22.6
k8s-ww    NotReady   <none>                 120d   v1.22.6
k8s-ww2   Ready      <none>                 49s    v1.22.6
root@k8s-m:~# kubectl get po -owide
NAME                     READY   STATUS              RESTARTS         AGE     IP              NODE      NOMINATED NODE   READINESS GATES
iis-7dfbf869dd-4472b     0/1     ContainerCreating   0                5h46m   <none>          k8s-ww2   <none>           <none>
iis-7dfbf869dd-brqrc     1/1     Terminating         0                120d    192.168.208.5   k8s-ww    <none>           <none>
netshoot                 1/1     Running             11 (6h51m ago)   120d    192.168.114.5   k8s-lw    <none>           <none>
nginx-868547d6bf-kv858   1/1     Running             1 (6h51m ago)    120d    192.168.114.4   k8s-lw    <none>           <none>
root@k8s-m:~# kubectl get no
NAME      STATUS     ROLES                  AGE    VERSION
k8s-lw    Ready      <none>                 120d   v1.22.6
k8s-m     Ready      control-plane,master   120d   v1.22.6
k8s-ww    NotReady   <none>                 120d   v1.22.6
k8s-ww2   Ready      <none>                 85m    v1.22.6
root@k8s-m:~# kubectl get po -owide
NAME                     READY   STATUS        RESTARTS      AGE     IP              NODE      NOMINATED NODE   READINESS GATES
iis-7dfbf869dd-4472b     1/1     Running       0             7h11m   192.168.112.4   k8s-ww2   <none>           <none>
iis-7dfbf869dd-brqrc     1/1     Terminating   0             120d    192.168.208.5   k8s-ww    <none>           <none>
netshoot                 1/1     Running       11 (8h ago)   120d    192.168.114.5   k8s-lw    <none>           <none>
nginx-868547d6bf-kv858   1/1     Running       1 (8h ago)    120d    192.168.114.4   k8s-lw    <none>           <none>

윈도우 워커노드에서도 crictl 로 컨테이너를 확인할 수 있습니다.

C:\Users\Administrator>crictl ps
time="2022-07-12T22:45:46+09:00" level=debug msg="get runtime connection"
time="2022-07-12T22:45:46+09:00" level=debug msg="get image connection"
time="2022-07-12T22:45:46+09:00" level=debug msg="ListContainerResponse: [&Container{Id:077ab4df6b9972af7806c017a7e85e3b437475de5f3a8c9316c983240af73af7,PodSandboxId:f994462e02b028c6cf9e0f38eab984bf0158734db64aaf4379c326224fe53c87,Metadata:&ContainerMetadata{Name:iis,Attempt:0,},Image:&ImageSpec{Image:sha256:cf88a43a7460e1a84be0a24ee22042f73069346710907e702dadb1a7e8a39eaf,Annotations:map[string]string{},},ImageRef:sha256:cf88a43a7460e1a84be0a24ee22042f73069346710907e702dadb1a7e8a39eaf,State:CONTAINER_RUNNING,CreatedAt:1657614998696454400,Labels:map[string]string{io.kubernetes.container.name: iis,io.kubernetes.pod.name: iis-7dfbf869dd-4472b,io.kubernetes.pod.namespace: default,io.kubernetes.pod.uid: 8aecf61b-2e61-4f55-80a0-551b146b4cc6,},Annotations:map[string]string{io.kubernetes.container.hash: 42b06ae0,io.kubernetes.container.restartCount: 0,io.kubernetes.container.terminationMessagePath: /dev/termination-log,io.kubernetes.container.terminationMessagePolicy: File,io.kubernetes.pod.terminationGracePeriod: 30,},}]"
CONTAINER           IMAGE               CREATED             STATE               NAME                ATTEMPT             POD ID              POD
077ab4df6b997       cf88a43a7460e       5 hours ago         Running             iis                 0                   f994462e02b02       iis-7dfbf869dd-4472b

calico 에서 제공하는 스크립트를 통해 워커노드 조인이 진행되어 과정이 많이 생략되어 있습니다. 상세한 과정을 이해하기위해서는 스크립트를 전반적으로 살펴봐야 할 것 같습다.

현재 calico 의 QuickStart 가이드에는 'Install Calico for Windows using HostProcess containers' 라는 방식을 추가로 제공하고 있습니다. 2022/7/12일 현재 GA가 안된 상태이긴 한데, 이후 이과정도 한번 살펴보겠습니다.

들어가며

쿠버네티스는 버전 1.14 부터 윈도우 워커 노드를 프로덕션 레벨로 지원하기 시작했습니다.

Kubernetes 1.14: Production-level support for Windows Nodes, Kubectl Updates, Persistent Local Volumes GA
https://kubernetes.io/blog/2019/03/25/kubernetes-1-14-release-announcement/

Azure-CNI, OVN-Kubernetes, Flannel 을 윈도우 워커노드를 지원하는 CNI로 소개하였으며, 현재 kubernetes.io 의 공식 문서에는 flannel을 기준으로 윈도우 워커 노드를 추가하는 가이드를 제공하고 있습니다. 테스트나 일반적인 환경이라면 flannel 로 윈도우 워커 노드를 구성할 수 있습니다.

다만 Network Policy 를 사용해야하는 요건이 있다면 flannel이 아닌 다른 옵션을 찾아봐야 합니다. 다행히 Calico 프로젝트에서 2020년 9월 Calico for Windows 를 오픈 소스로 발표함에 따라 오픈 소스 Calico 를 CNI로 사용할 수 있는 옵션이 생겼습니다.

Tigera Announces Open-Source Calico for Windows and Collaboration with Microsoft
https://kubernetes.io/blog/2019/03/25/kubernetes-1-14-release-announcement/#production-level-support-for-windows-nodes

본 포스트에서는 Calico CNI 로 쿠버네티스에 윈도우 워커 노드를 추가하는 과정과 Calico CNI 에 대해서 다뤄보고자 합니다. 쿠버네티스가 구성되어 있다는 전제 하에 윈도우 워커노드를 위한 구성 과정의 ②, ④, ⑤ 번 과정을 중심으로 작성하였습니다.

윈도우 워커노드 구성 과정

① 리눅스 컨트롤 플레인 구성 ② Calico CNI 구성 및 윈도우 워커 노드를 위한 설정 변경 ③ 리눅스 워커 노드 추가 ④ 윈도우 워커노드 추가 ⑤ 워크로드 테스트

 

테스트 환경

윈도우 워커 노드를 구성하기 위해서 리눅스 컨트롤 플레인이 있어야 합니다. 쿠버네티스 윈도우 지원에서 윈도우 노드는 컨트롤 플레인의 역할을 할 수 없으며, 한편으로 쿠버네티스 에코시스템의 다양한 에드온(Addon)을 사용하기 위해 리눅스 워커 노드를 같이 사용하는 것이 권장됩니다.

 

구성도

 

Caclio 에서 윈도우 워커 노드를 위한 설정 변경

먼저 Calico 에서 윈도우 워커 노드 사용을 하는데 있어 제약사항은 아래 Requirements 문서를 확인할 필요가 있습니다.

https://projectcalico.docs.tigera.io/getting-started/windows-calico/kubernetes/requirements

 

오버레이 방식을 VXLAN 모드 변경

Calico 의 overlay 모드를 사용할 때 유의사항은 윈도우 워커 노드에서는 IPIP 모드를 지원하지 않기 때문에 VXLAN 으로사용해야 한다는 점입니다.

아래 명령을 통해 IPIP 에서 VXLAN 방식로 변경할 수 있습니다.

root@k8s-m:~# calicoctl get ippool default-ipv4-ippool -o wide
NAME                  CIDR             NAT    IPIPMODE   VXLANMODE   DISABLED   DISABLEBGPEXPORT   SELECTOR
default-ipv4-ippool   192.168.0.0/16   true   Always     Never       false      false              all()
root@k8s-m:~# calicoctl get ippool default-ipv4-ippool -o yaml | sed -e "s/ipipMode: Always/ipipMode: Never/" | calicoctl apply -f -
Successfully applied 1 'IPPool' resource(s)
root@k8s-m:~# calicoctl get ippool default-ipv4-ippool -o yaml | sed -e "s/vxlanMode: Never/vxlanMode: Always/" | calicoctl apply -f -
Successfully applied 1 'IPPool' resource(s)
root@k8s-m:~# calicoctl get ippool default-ipv4-ippool -o wide
NAME                  CIDR             NAT    IPIPMODE   VXLANMODE   DISABLED   DISABLEBGPEXPORT   SELECTOR
default-ipv4-ippool   192.168.0.0/16   true   Never      Always      false      false              all()

# 위의 절차만으로는 윈도우 워커 노드 추가 시 에러가 발생해서 아래의 절차를 추가하였음
root@k8s-m:~# kubectl get felixconfigurations.crd.projectcalico.org default  -o yaml -n kube-system > felixconfig.yaml
root@k8s-m:~# cp felixconfig.yaml felixconfig.yaml.org
root@k8s-m:~# vi felixconfig.yaml
root@k8s-m:~# diff felixconfig.yaml felixconfig.yaml.org
13c13
<   ipipEnabled: false
---
>   ipipEnabled: true
root@k8s-m:~# kubectl apply -f felixconfig.yaml
Warning: resource felixconfigurations/default is missing the kubectl.kubernetes.io/last-applied-configuration annotation which is required by kubectl apply. kubectl apply should only be used on resources created declaratively by either kubectl create --save-config or kubectl apply. The missing annotation will be patched automatically.
felixconfiguration.crd.projectcalico.org/default configured

 

참고로 온프레미스 환경에서 사용가능한 Calico 의 네트워크 옵션을 간략하게 소개합니다.

non-overlay 모드

Pod IP가 외부로 직접 통신하는 방식입니다. Pod IP는 Calico의 Bird에서 BGP 통하여 서로 전파하며, 네트워크 장비와 직접 BGP Peer 를 맺어 외부에서 Pod IP로 직접 통신 가능하게 만들 수 있습니다. 만약 네트워크 장비와 BGP Peering이 어렵다면 L2 네트워크라는 전제하에 클러스터 내부에서만 BGP 로 Pod IP를 전파해 사용할 수도 있습니다.

overlay 모드

Pod IP를 encapsulation 하는 오버레이 모드가 지원됩니다. 오버레이 기법에는 IPIP 와 VXLAN 이 있습니다. 먼저 IPIP 방식은 Outer IP(IPIP) 로 encapsulation 해서 패킷을 전송하는 방식입니다.

VXLAN 방식은 VXLAN 기법으로 encapsulation 해서 패킷을 전송하는 방식입니다. 다만 아래 그림과 같이 BGP 라우팅을 사용하지 않는 방법입니다. (Bird 를 사용하지 않고, Felix 가 etcd를 통해 다른 노드의 Pod CIDR 정보를 가져와서 정보를 업데이트 합니다)

cross-subnet 모드

라우팅이 필요없는 구간에서는 non-overlay 모드로 통신하고, 라우팅이 필요한 구간에서는 overlay 모드로 통신하는 특별한 모드입니다. non-overlay 모드를 통해 L2 구간에서는 오버헤드가 없는 통신을 하면서 필요할 때는overlay 모드를 사용합니다. 단 윈도우 워커 노드 환경에서는 cross-subnet 이 지원되지 않습니다.

 

IPAM 옵션 수정

Calico에서는 IP Borrowing 이라는 방식으로 노드의 IP가 부족하면 다른 노드에서 빌려오는 옵션이 있습니다. 다만 윈도우 워커 노드는 IP Borrowing 매커니즘을 지원하지 않기 때문에 아래와 같이 옵션을 수정해야 합니다.

root@k8s-m:~# calicoctl ipam configure --strictaffinity=true
Successfully set StrictAffinity to: true

 

윈도우 워커 노드 추가

1) 사전 작업

업데이트 설치

kubernetes.io 의 윈도우 노드 추가에 대한 공식 문서를 보면 윈도우 워커 노드에서 VXLAN/오버레이를 사용하기 위해서는 사전에 KB4489899를 설치해야 합니다. (참고로 KB4489899 는 현재 제공되지 않고 있습니다. 윈도우 서버 2019 의 업데이트는 누적되기 때문에 해당 업데이트 이후의 업데이트를 설치하면 됩니다 )

추가로 사전에 Docker 나 containerD를 설치해야하는데 절차는 아래의 문서를 참고하기 바랍니다.

Docker EE 설치

https://docs.microsoft.com/ko-kr/virtualization/windowscontainers/quick-start/set-up-environment?tabs=Windows-Server#install-docker

ConatinerD 설치

https://kubernetes.io/ko/docs/setup/production-environment/container-runtimes/#containerd

마이크로소프트는 2021년 9월 부터 Docker EE 빌드를 생성하지 않는다고 발표했습니다. 윈도우 서버의 컨테이너 런타임은 containerd 가 더 적절한 선택일 수 있습니다. 다만 여기서는 리눅스 노드와 동일한 환경을 구성하고자 도커를 설치했습니다.

참고로 Docker EE는 쿠버네티스 1.14 에서 stable 되었고, ContainerD 는 쿠버네티스 1.20에서 stable 되었습니다.
https://kubernetes.io/ko/docs/setup/production-environment/windows/intro-windows-in-kubernetes/#%EC%BB%A8%ED%85%8C%EC%9D%B4%EB%84%88-%EB%9F%B0%ED%83%80%EC%9E%84

 

2) Calico 컴포넌트 설치

아래부터는 Calico 공식 문서의 Quickstart 를 참고하여 실행하였습니다.

# Prepare the directory for Kubernetes files on Windows node
PS C:\Users\Administrator> mkdir c:\k


    Directory: C:\


Mode                LastWriteTime         Length Name
----                -------------         ------ ----
d-----     2022-03-14   오전 1:55                k

# Copy the Kubernetes kubeconfig file from the master node
PS C:\Users\Administrator> scp root@172.16.3.170:~/.kube/config c:\k\
root@172.16.3.170's password:
config                                                                                100% 5640   352.6KB/s   00:00

# Download the PowerShell script, install-calico-windows.ps1
PS C:\Users\Administrator> Invoke-WebRequest https://projectcalico.docs.tigera.io/scripts/install-calico-windows.ps1 -OutFile c:\install-calico-windows.ps1

# Install Calico for Windows for your datastore with using the default parameters
# The PowerShell script downloads Calico for Windows release binary, Kubernetes binaries, Windows utilities files, configures Calico for Windows, and starts the Calico service.
PS C:\Users\Administrator> c:\install-calico-windows.ps1 -KubeVersion 1.22.6 -ServiceCidr 10.96.0.0/12 -DNSServerIPs 10.96.0.10
WARNING: The names of some imported commands from the module 'helper' include unapproved verbs that might make them
less discoverable. To find the commands with unapproved verbs, run the Import-Module command again with the Verbose
parameter. For a list of approved verbs, type Get-Verb.
Creating CNI directory
<생략>
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-03-14 오전 1:48:58
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-03-14 오전 1:48:58
Waiting for Calico initialisation to finish...StoredLastBootTime , CurrentLastBootTime 2022-03-14 오전 1:48:58
Calico initialisation finished.
Done, the Calico services are running:

Status      : Running
Name        : CalicoFelix
DisplayName : Calico Windows Agent


Status      : Running
Name        : CalicoNode
DisplayName : Calico Windows Startup


Calico for Windows Started

CalicoFelix 와 CalicoNode 가 서비스로 실행되었습니다.

 

3) 쿠버네티스 컴포넌트 설치

위에서 c:\install-calico-windows.ps1 를 실행하면 C:\CalicoWindows 에 install-kube-services.ps1 파일이 생성됩니다. 이 파워쉘 스크립트를 실행하면 kubelet 과 kube-proxy 가 서비스로 설치됩니다.

마지막으로 Start-Service 로 각 서비스를 실행합니다.

PS C:\Users\Administrator> C:\CalicoWindows\kubernetes\install-kube-services.ps1
the param is
Installing kubelet service...
Service "kubelet" installed successfully!
Set parameter "AppParameters" for service "kubelet".
Set parameter "AppDirectory" for service "kubelet".
Set parameter "DisplayName" for service "kubelet".
Set parameter "Description" for service "kubelet".
Set parameter "Start" for service "kubelet".
Reset parameter "ObjectName" for service "kubelet" to its default.
Set parameter "Type" for service "kubelet".
Reset parameter "AppThrottle" for service "kubelet" to its default.
Set parameter "AppStdout" for service "kubelet".
Set parameter "AppStderr" for service "kubelet".
Set parameter "AppRotateFiles" for service "kubelet".
Set parameter "AppRotateOnline" for service "kubelet".
Set parameter "AppRotateSeconds" for service "kubelet".
Set parameter "AppRotateBytes" for service "kubelet".
Done installing kubelet service.
Installing kube-proxy service...
Service "kube-proxy" installed successfully!
Set parameter "AppParameters" for service "kube-proxy".
Set parameter "AppDirectory" for service "kube-proxy".
Set parameter "DisplayName" for service "kube-proxy".
Set parameter "Description" for service "kube-proxy".
Set parameter "Start" for service "kube-proxy".
Reset parameter "ObjectName" for service "kube-proxy" to its default.
Set parameter "Type" for service "kube-proxy".
Reset parameter "AppThrottle" for service "kube-proxy" to its default.
Set parameter "AppStdout" for service "kube-proxy".
Set parameter "AppStderr" for service "kube-proxy".
Set parameter "AppRotateFiles" for service "kube-proxy".
Set parameter "AppRotateOnline" for service "kube-proxy".
Set parameter "AppRotateSeconds" for service "kube-proxy".
Set parameter "AppRotateBytes" for service "kube-proxy".
Done installing kube-proxy service.
PS C:\Users\Administrator> Get-Service -Name kubelet

Status   Name               DisplayName
------   ----               -----------
Stopped  kubelet            kubelet service


PS C:\Users\Administrator> Get-Service -Name kube-proxy

Status   Name               DisplayName
------   ----               -----------
Stopped  kube-proxy         kube-proxy service


PS C:\Users\Administrator> Start-Service -Name kubelet
PS C:\Users\Administrator> Start-Service -Name kube-proxy
PS C:\Users\Administrator> Get-Service -Name kubelet

Status   Name               DisplayName
------   ----               -----------
Running  kubelet            kubelet service


PS C:\Users\Administrator> Get-Service -Name kube-proxy

Status   Name               DisplayName
------   ----               -----------
Running  kube-proxy         kube-proxy service

kubelet 과 kube-proxy를 실행하고 잠시 후에 컨트롤 플레인에서 확인해보면 아래와 같이 윈도우 워커 노드가 확인됩니다.

root@k8s-m:~# kubectl get no -owide
NAME     STATUS   ROLES                  AGE   VERSION   INTERNAL-IP    EXTERNAL-IP   OS-IMAGE                                    KERNEL-VERSION     CONTAINER-RUNTIME
k8s-lw   Ready    <none>                 87m   v1.22.6   172.16.3.171   <none>        Ubuntu 20.04.2 LTS                          5.4.0-77-generic   docker://20.10.13
k8s-m    Ready    control-plane,master   89m   v1.22.6   172.16.3.170   <none>        Ubuntu 20.04.2 LTS                          5.4.0-77-generic   docker://20.10.13
k8s-ww   Ready    <none>                 80s   v1.22.6   172.16.3.172   <none>        Windows Server 2019 Datacenter Evaluation   10.0.17763.1999    docker://20.10.9

리눅스 워커 노드를 kubeadm 으로 조인하는 것과 다르게 해당 스크립트를 수행하는 것만으로 윈도우 워커 노드가 추가됩니다.

 

워크로드 테스트

워크로드 테스트 샘플 애플리케이션은 아래 Calico 의 github 에서 참고 했습니다.

https://github.com/tigera-solutions/install-calico-for-windows

 

애플리케이션 디플로이

root@k8s-m:~/app# ls
netshoot.yml  stack-iis.yml  stack-nginx.yml
root@k8s-m:~/app# kubectl apply -f ./
pod/netshoot created
deployment.apps/iis created
service/iis-svc created
deployment.apps/nginx created
service/nginx-svc created
root@k8s-m:~/app# kubectl get po -w
NAME                     READY   STATUS              RESTARTS   AGE
iis-7dfbf869dd-24zzx     0/1     ContainerCreating   0          8s
netshoot                 0/1     ContainerCreating   0          8s
nginx-868547d6bf-kv858   0/1     ContainerCreating   0          8s
iis-7dfbf869dd-24zzx     0/1     ContainerCreating   0          11s
netshoot                 1/1     Running             0          43s
nginx-868547d6bf-kv858   1/1     Running             0          51s
c
root@k8s-m:~# kubectl exec -it netshoot -- bash
bash-5.1# nslookup nginx-svc
Server:         10.96.0.10
Address:        10.96.0.10#53

Name:   nginx-svc.default.svc.cluster.local
Address: 10.103.180.29

bash-5.1# nslookup iis-svc
Server:         10.96.0.10
Address:        10.96.0.10#53

Name:   iis-svc.default.svc.cluster.local
Address: 10.101.147.244

bash-5.1# curl -Is http://nginx-svc | grep -i http
HTTP/1.1 200 OK
bash-5.1# curl -Is http://iis-svc | grep -i http
HTTP/1.1 200 OK
bash-5.1# exit
exit

윈도우 워커 노드에 IIS 를 리눅스 워커 노드에 Nginx 를 배포합니다. (윈도우 컨테이너 이미지는 사이즈가 크기 때문에 Pulling 에 오랜 시간이 걸립니다)

netshoot 파드에서 각 서비스가 정상 호출됩니다.

 

Network Policy 적용

아래 allow-nginx-ingress-from-iis.yaml 를 생성하여 IIS 파드만 Nginx 파드를 호출할 수 있도록 합니다.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-nginx-ingress-from-iis
  namespace: default
spec:
  podSelector:
    matchLabels:
      run: nginx
  policyTypes:
  - Ingress
  ingress:
  - from:
    - podSelector:
        matchLabels:
          run: iis
    ports:
    - protocol: TCP
      port: 80
---

IIS 파드는 nginx-svc 호출이 가능하고, netshoot 파드는 불가합니다.

# netshoot -> nginx : 성공 (사전)
root@k8s-m:~/app# kubectl exec -t netshoot -- sh -c 'SVC=nginx-svc; curl -m 5 -sI http://$SVC 2>/dev/null | grep -i http'
HTTP/1.1 200 OK

# netpol 생성
root@k8s-m:~/app# kubectl apply -f k8s.allow-nginx-ingress-from-iis.yaml
networkpolicy.networking.k8s.io/allow-nginx-ingress-from-iis created


# iis -> nginx : 성공

root@k8s-m:~/app# IIS_POD=$(kubectl get pod -l run=iis -o jsonpath='{.items[*].metadata.name}')
root@k8s-m:~/app# kubectl exec -t $IIS_POD -- powershell -command 'iwr -UseBasicParsing  -TimeoutSec 5 http://nginx-svc'


StatusCode        : 200
StatusDescription : OK
Content           : <!DOCTYPE html>
                    <html>
                    <head>
                    <title>Welcome to nginx!</title>
                    <style>
                    html { color-scheme: light dark; }
                    body { width: 35em; margin: 0 auto;
                    font-family: Tahoma, Verdana, Arial, sans-serif; }
                    </style...
RawContent        : HTTP/1.1 200 OK
                    Connection: keep-alive
                    Accept-Ranges: bytes
                    Content-Length: 615
                    Content-Type: text/html
                    Date: Sun, 13 Mar 2022 18:47:39 GMT
                    ETag: "61f0168e-267"
                    Last-Modified: Tue, 25 Jan 2022 ...
Forms             :
Headers           : {[Connection, keep-alive], [Accept-Ranges, bytes],
                    [Content-Length, 615], [Content-Type, text/html]...}
Images            : {}
InputFields       : {}
Links             : {@{outerHTML=<a href="http://nginx.org/">nginx.org</a>;
                    tagName=A; href=http://nginx.org/}, @{outerHTML=<a
                    href="http://nginx.com/">nginx.com</a>; tagName=A;
                    href=http://nginx.com/}}
ParsedHtml        :
RawContentLength  : 615


# netshoot -> nginx : 실패
root@k8s-m:~/app# kubectl exec -t netshoot -- sh -c 'SVC=nginx-svc; curl -m 5 -sI http://$SVC 2>/dev/null | grep -i http'
command terminated with exit code 1

 

마치며

이 포스트는 Kubernetes Korea Group 에서 1~3월 초까지 진행한 쿠버네티스 네트워크 스터디(KANS, Kubernetes Advanced Networking Study) 의 졸업 과제의 일환으로 작성하였습니다. 해당 스터디에서 다룬 쿠버네티스 네트워크와 CNI에 대한 이해를 바탕으로 원할한 테스트가 가능했습니다.

긴 글 읽어주셔서 감사하며 작성된 내용에 오류가 있다면 언제든지 알려주십시오.

 

참고

Intro to windows in kubernetes

https://kubernetes.io/docs/setup/production-environment/windows/intro-windows-in-kubernetes/#windows-os-version-support

윈도우 노드 추가

https://kubernetes.io/ko/docs/tasks/administer-cluster/kubeadm/adding-windows-nodes/

윈도우 노드 - 컨테이너 런타임 설치

https://kubernetes.io/ko/docs/tasks/administer-cluster/kubeadm/adding-windows-nodes/#%EC%9C%88%EB%8F%84%EC%9A%B0-%EC%9B%8C%EC%BB%A4-%EB%85%B8%EB%93%9C-%EC%A1%B0%EC%9D%B8-joining

Calico 네트워크 모드 선택

https://projectcalico.docs.tigera.io/networking/determine-best-networking#on-prem

Windows Calico QuickStart

https://projectcalico.docs.tigera.io/getting-started/windows-calico/quickstart

Windows Calico 관련 참고 영상

https://tigera.wistia.com/medias/gvc1f5132d

https://www.youtube.com/watch?v=DMKS43POa5s

 

이번 포스트에서는 수동으로 Kubernetes 클러스터 업그레이드 하는 방법을 살펴보고자 합니다.

kubernetes.io 공식 가이드는 맨 하단 참고의 링크를 확인하시기 바랍니다.

 

테스트 환경 정보

## Ubuntu 버전
root@k8s-m0:~# cat /etc/os-release | grep PRETTY
PRETTY_NAME="Ubuntu 20.04.3 LTS"
root@k8s-m0:~# uname -a
Linux k8s-m0 5.4.0-96-generic #109-Ubuntu SMP Wed Jan 12 16:49:16 UTC 2022 x86_64 x86_64 x86_64 GNU/Linux

## 3 node HA Control plane 과 1 대의 worker 
root@k8s-m0:~# kubectl get no
NAME     STATUS   ROLES                  AGE     VERSION
k8s-m0   Ready    control-plane,master   68m     v1.21.7
k8s-m1   Ready    control-plane,master   43m     v1.21.7
k8s-m2   Ready    control-plane,master   20m     v1.21.7
k8s-w1   Ready    <none>                 5m27s   v1.21.7

## 주요 바이너리의 버전 확인
root@k8s-m0:~# kubeadm version
kubeadm version: &version.Info{Major:"1", Minor:"21", GitVersion:"v1.21.7", GitCommit:"1f86634ff08f37e54e8bfcd86bc90b61c98f84d4", GitTreeState:"clean", BuildDate:"2021-11-17T14:40:08Z", GoVersion:"go1.16.10", Compiler:"gc", Platform:"linux/amd64"}
root@k8s-m0:~# kubelet --version
Kubernetes v1.21.7
root@k8s-m0:~# kubectl version
Client Version: version.Info{Major:"1", Minor:"21", GitVersion:"v1.21.7", GitCommit:"1f86634ff08f37e54e8bfcd86bc90b61c98f84d4", GitTreeState:"clean", BuildDate:"2021-11-17T14:41:19Z", GoVersion:"go1.16.10", Compiler:"gc", Platform:"linux/amd64"}
Server Version: version.Info{Major:"1", Minor:"21", GitVersion:"v1.21.7", GitCommit:"1f86634ff08f37e54e8bfcd86bc90b61c98f84d4", GitTreeState:"clean", BuildDate:"2021-11-17T14:35:38Z", GoVersion:"go1.16.10", Compiler:"gc", Platform:"linux/amd64"}

 

Kubernetes 업그레이드 절차 요약

  1. primary control plane 을 업그레이드 한다.
  2. additional control plane 을 업그레이드 한다.
  3. worker 노드를 업그레이드 한다.

 

업그레이드 가능 버전 확인

먼저 apt update 를 수행하고 apt-cache 에서 업그레이드 가능한 버전을 확인해 봅니다.

테스트에서는 가장 최신 버전인 1.23.3 을 사용합니다.

root@k8s-m0:~# apt update
Get:1 http://security.ubuntu.com/ubuntu focal-security InRelease [114 kB]
Hit:2 https://download.docker.com/linux/ubuntu focal InRelease
Hit:3 http://archive.ubuntu.com/ubuntu focal InRelease
Get:4 http://archive.ubuntu.com/ubuntu focal-updates InRelease [114 kB]
Get:6 http://archive.ubuntu.com/ubuntu focal-backports InRelease [108 kB]
Get:5 https://packages.cloud.google.com/apt kubernetes-xenial InRelease [9383 B]
Fetched 345 kB in 23s (14.8 kB/s)
Reading package lists... Done
Building dependency tree
Reading state information... Done
19 packages can be upgraded. Run 'apt list --upgradable' to see them.
root@k8s-m0:~# apt-cache madison kubeadm
   kubeadm |  1.23.3-00 | https://apt.kubernetes.io kubernetes-xenial/main amd64 Packages
   kubeadm |  1.23.2-00 | https://apt.kubernetes.io kubernetes-xenial/main amd64 Packages
   kubeadm |  1.23.1-00 | https://apt.kubernetes.io kubernetes-xenial/main amd64 Packages
   kubeadm |  1.23.0-00 | https://apt.kubernetes.io kubernetes-xenial/main amd64 Packages
   kubeadm |  1.22.6-00 | https://apt.kubernetes.io kubernetes-xenial/main amd64 Packages
   <생략>

 

이제 primary control plane 을 먼저 업그레이드 해보겠습니다.

 

Primary Control Plane 업그레이드

프라이머리 컨트롤 플레인의 업그레이드 절차는 간단히 아래와 같습니다.

1. kubeadm 바이너리 업그레이드

2. kubeadm upgrade plan 으로 사전 확인

3. kubeadm upgrade apply 로 업그레이드 (이때 kubectl version 으로 확인한 Server Version 이 업그레이드 됩니다.)

4. 노드 drain

5. kubectl, kubelet 바이너리 업그레이드

6. kubelet daemon-reload, restart

7. 노드 uncordon

 

수행 결과를 살펴 보겠습니다.

# 1. kubeadm 바이너리 업그레이드
root@k8s-m0:~# apt-mark unhold kubeadm && apt-get update && apt-get install -y kubeadm=1.22.3-00 && apt-mark hold kubeadm
Canceled hold on kubeadm.
Hit:1 https://download.docker.com/linux/ubuntu focal InRelease
<생략>
Fetched 336 kB in 24s (14.2 kB/s)
Reading package lists... Done
Reading package lists... Done
Building dependency tree
Reading state information... Done
The following packages will be upgraded:
  kubeadm
1 upgraded, 0 newly installed, 0 to remove and 18 not upgraded.
Need to get 8712 kB of archives.
After this operation, 971 kB of additional disk space will be used.
Get:1 https://packages.cloud.google.com/apt kubernetes-xenial/main amd64 kubeadm amd64 1.22.3-00 [8712 kB]
Fetched 8712 kB in 23s (372 kB/s)
(Reading database ... 64135 files and directories currently installed.)
Preparing to unpack .../kubeadm_1.22.3-00_amd64.deb ...
Unpacking kubeadm (1.22.3-00) over (1.21.7-00) ...
Setting up kubeadm (1.22.3-00) ...
kubeadm set on hold.
root@k8s-m0:~#
[참고] apt-mark unhold 와 hold 의 의미
업그레이드 전 apt-mark showhold 를 수행해보면 kubeadm, kubectl, kubelet 은 자동 업데이트, 제거가 되지 않도록 보류(hold) 상태인 것을 알 수 있습니다. 바이너리를 업데이트 하기 위해 unhold -> 패키지 업데이트 -> hold 를 수행하는 것을 알 수 있습니다.

 

# 2. kubeadm upgrade plan 으로 사전 확인
root@k8s-m0:~# kubeadm upgrade plan
[upgrade/config] Making sure the configuration is correct:
[upgrade/config] Reading configuration from the cluster...
[upgrade/config] FYI: You can look at this config file with 'kubectl -n kube-system get cm kubeadm-config -o yaml'
[preflight] Running pre-flight checks.
[upgrade] Running cluster health checks
[upgrade] Fetching available versions to upgrade to
[upgrade/versions] Cluster version: v1.21.7
[upgrade/versions] kubeadm version: v1.22.3
W0205 10:07:29.475001   87282 version.go:103] could not fetch a Kubernetes version from the internet: unable to get URL "https://dl.k8s.io/release/stable.txt": Get "https://storage.googleapis.com/kubernetes-release/release/stable.txt": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
W0205 10:07:29.476348   87282 version.go:104] falling back to the local client version: v1.22.3
[upgrade/versions] Target version: v1.22.3
W0205 10:07:39.796859   87282 version.go:103] could not fetch a Kubernetes version from the internet: unable to get URL "https://dl.k8s.io/release/stable-1.21.txt": Get "https://storage.googleapis.com/kubernetes-release/release/stable-1.21.txt": context deadline exceeded (Client.Timeout exceeded while awaiting headers)
W0205 10:07:39.797158   87282 version.go:104] falling back to the local client version: v1.22.3
[upgrade/versions] Latest version in the v1.21 series: v1.22.3

Components that must be upgraded manually after you have upgraded the control plane with 'kubeadm upgrade apply':
COMPONENT   CURRENT       TARGET
kubelet     4 x v1.21.7   v1.22.3

Upgrade to the latest version in the v1.21 series:

COMPONENT                 CURRENT    TARGET
kube-apiserver            v1.21.7    v1.22.3
kube-controller-manager   v1.21.7    v1.22.3
kube-scheduler            v1.21.7    v1.22.3
kube-proxy                v1.21.7    v1.22.3
CoreDNS                   v1.8.0     v1.8.4
etcd                      3.4.13-0   3.5.0-0

You can now apply the upgrade by executing the following command:

        kubeadm upgrade apply v1.22.3

_____________________________________________________________________


The table below shows the current state of component configs as understood by this version of kubeadm.
Configs that have a "yes" mark in the "MANUAL UPGRADE REQUIRED" column require manual config upgrade or
resetting to kubeadm defaults before a successful upgrade can be performed. The version to manually
upgrade to is denoted in the "PREFERRED VERSION" column.

API GROUP                 CURRENT VERSION   PREFERRED VERSION   MANUAL UPGRADE REQUIRED
kubeproxy.config.k8s.io   v1alpha1          v1alpha1            no
kubelet.config.k8s.io     v1beta1           v1beta1             no
_____________________________________________________________________

 

# 3. kubeadm upgrade apply 로 업그레이드 (이때 kubectl version 으로 확인한 Server Version 이 업그레이드 됩니다.)
# [upgrade/confirm] Are you sure you want to proceed with the upgrade? [y/N]: y  ## <--- y 입력

root@k8s-m0:~# kubeadm upgrade apply v1.22.3
[upgrade/config] Making sure the configuration is correct:
[upgrade/config] Reading configuration from the cluster...
[upgrade/config] FYI: You can look at this config file with 'kubectl -n kube-system get cm kubeadm-config -o yaml'
[preflight] Running pre-flight checks.
[upgrade] Running cluster health checks
[upgrade/version] You have chosen to change the cluster version to "v1.22.3"
[upgrade/versions] Cluster version: v1.21.7
[upgrade/versions] kubeadm version: v1.22.3
[upgrade/confirm] Are you sure you want to proceed with the upgrade? [y/N]: y
[upgrade/prepull] Pulling images required for setting up a Kubernetes cluster
[upgrade/prepull] This might take a minute or two, depending on the speed of your internet connection
[upgrade/prepull] You can also perform this action in beforehand using 'kubeadm config images pull'
[upgrade/apply] Upgrading your Static Pod-hosted control plane to version "v1.22.3"...
Static pod: kube-apiserver-k8s-m0 hash: d8693cdc9fb5dd3fefac2a158d5685e8
Static pod: kube-controller-manager-k8s-m0 hash: c3b44cc1fd7121c166d49b72c6a30be6
Static pod: kube-scheduler-k8s-m0 hash: 341d83fb10c50335ea71f49ea327305c
[upgrade/etcd] Upgrading to TLS for etcd
Static pod: etcd-k8s-m0 hash: 39ec3d381c0aa337ca22b0433ae46cf4
[upgrade/staticpods] Preparing for "etcd" upgrade
[upgrade/staticpods] Renewing etcd-server certificate
[upgrade/staticpods] Renewing etcd-peer certificate
[upgrade/staticpods] Renewing etcd-healthcheck-client certificate
[upgrade/staticpods] Moved new manifest to "/etc/kubernetes/manifests/etcd.yaml" and backed up old manifest to "/etc/kubernetes/tmp/kubeadm-backup-manifests-2022-02-05-10-21-19/etcd.yaml"
[upgrade/staticpods] Waiting for the kubelet to restart the component
[upgrade/staticpods] This might take a minute or longer depending on the component/version gap (timeout 5m0s)
Static pod: etcd-k8s-m0 hash: 39ec3d381c0aa337ca22b0433ae46cf4
<생략>
Static pod: etcd-k8s-m0 hash: 39ec3d381c0aa337ca22b0433ae46cf4
Static pod: etcd-k8s-m0 hash: a6bfdd1cf0b0bfcde91c69798c3c2f61
[apiclient] Found 3 Pods for label selector component=etcd
[upgrade/staticpods] Component "etcd" upgraded successfully!
[upgrade/etcd] Waiting for etcd to become available
[upgrade/staticpods] Writing new Static Pod manifests to "/etc/kubernetes/tmp/kubeadm-upgraded-manifests139287456"
[upgrade/staticpods] Preparing for "kube-apiserver" upgrade
[upgrade/staticpods] Renewing apiserver certificate
[upgrade/staticpods] Renewing apiserver-kubelet-client certificate
[upgrade/staticpods] Renewing front-proxy-client certificate
[upgrade/staticpods] Renewing apiserver-etcd-client certificate
[upgrade/staticpods] Moved new manifest to "/etc/kubernetes/manifests/kube-apiserver.yaml" and backed up old manifest to "/etc/kubernetes/tmp/kubeadm-backup-manifests-2022-02-05-10-21-19/kube-apiserver.yaml"
[upgrade/staticpods] Waiting for the kubelet to restart the component
[upgrade/staticpods] This might take a minute or longer depending on the component/version gap (timeout 5m0s)
Static pod: kube-apiserver-k8s-m0 hash: d8693cdc9fb5dd3fefac2a158d5685e8
<생략>
Static pod: kube-apiserver-k8s-m0 hash: d8693cdc9fb5dd3fefac2a158d5685e8
Static pod: kube-apiserver-k8s-m0 hash: dff5e288e0f790320cd77e7e1a9aa3db
[apiclient] Found 3 Pods for label selector component=kube-apiserver
[upgrade/staticpods] Component "kube-apiserver" upgraded successfully!
[upgrade/staticpods] Preparing for "kube-controller-manager" upgrade
[upgrade/staticpods] Renewing controller-manager.conf certificate
[upgrade/staticpods] Moved new manifest to "/etc/kubernetes/manifests/kube-controller-manager.yaml" and backed up old manifest to "/etc/kubernetes/tmp/kubeadm-backup-manifests-2022-02-05-10-21-19/kube-controller-manager.yaml"
[upgrade/staticpods] Waiting for the kubelet to restart the component
[upgrade/staticpods] This might take a minute or longer depending on the component/version gap (timeout 5m0s)
Static pod: kube-controller-manager-k8s-m0 hash: c3b44cc1fd7121c166d49b72c6a30be6
<생략>
Static pod: kube-controller-manager-k8s-m0 hash: c3b44cc1fd7121c166d49b72c6a30be6
Static pod: kube-controller-manager-k8s-m0 hash: aa3b83543a7ff5ed8c12c460f2ca4750
[apiclient] Found 3 Pods for label selector component=kube-controller-manager
[upgrade/staticpods] Component "kube-controller-manager" upgraded successfully!
[upgrade/staticpods] Preparing for "kube-scheduler" upgrade
[upgrade/staticpods] Renewing scheduler.conf certificate
[upgrade/staticpods] Moved new manifest to "/etc/kubernetes/manifests/kube-scheduler.yaml" and backed up old manifest to "/etc/kubernetes/tmp/kubeadm-backup-manifests-2022-02-05-10-21-19/kube-scheduler.yaml"
[upgrade/staticpods] Waiting for the kubelet to restart the component
[upgrade/staticpods] This might take a minute or longer depending on the component/version gap (timeout 5m0s)
Static pod: kube-scheduler-k8s-m0 hash: 341d83fb10c50335ea71f49ea327305c
<생략>
Static pod: kube-scheduler-k8s-m0 hash: 341d83fb10c50335ea71f49ea327305c
Static pod: kube-scheduler-k8s-m0 hash: 3d3feecdbe65a70d9d6a0cae39754db9
[apiclient] Found 3 Pods for label selector component=kube-scheduler
[upgrade/staticpods] Component "kube-scheduler" upgraded successfully!
[upgrade/postupgrade] Applying label node-role.kubernetes.io/control-plane='' to Nodes with label node-role.kubernetes.io/master='' (deprecated)
[upload-config] Storing the configuration used in ConfigMap "kubeadm-config" in the "kube-system" Namespace
[kubelet] Creating a ConfigMap "kubelet-config-1.22" in namespace kube-system with the configuration for the kubelets in the cluster
[kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml"
[bootstrap-token] configured RBAC rules to allow Node Bootstrap tokens to get nodes
[bootstrap-token] configured RBAC rules to allow Node Bootstrap tokens to post CSRs in order for nodes to get long term certificate credentials
[bootstrap-token] configured RBAC rules to allow the csrapprover controller automatically approve CSRs from a Node Bootstrap Token
[bootstrap-token] configured RBAC rules to allow certificate rotation for all node client certificates in the cluster
[addons] Applied essential addon: CoreDNS
[endpoint] WARNING: port specified in controlPlaneEndpoint overrides bindPort in the controlplane address
[addons] Applied essential addon: kube-proxy

[upgrade/successful] SUCCESS! Your cluster was upgraded to "v1.22.3". Enjoy!

[upgrade/kubelet] Now that your control plane is upgraded, please proceed with upgrading your kubelets if you haven't already done so.

# 버전 확인 (primary control palne 만 업그레이드가 되었으므로, Server Version이 업그레이드 되지 않은 노드가 결과를 보낼 수 있음)
root@k8s-m0:~# kubectl version
Client Version: version.Info{Major:"1", Minor:"21", GitVersion:"v1.21.7", GitCommit:"1f86634ff08f37e54e8bfcd86bc90b61c98f84d4", GitTreeState:"clean", BuildDate:"2021-11-17T14:41:19Z", GoVersion:"go1.16.10", Compiler:"gc", Platform:"linux/amd64"}
Server Version: version.Info{Major:"1", Minor:"22", GitVersion:"v1.22.3", GitCommit:"c92036820499fedefec0f847e2054d824aea6cd1", GitTreeState:"clean", BuildDate:"2021-10-27T18:35:25Z", GoVersion:"go1.16.9", Compiler:"gc", Platform:"linux/amd64"}

 

[참고] kubeadm upgrade 를 수행하면 관련된 인증서를 자동으로 리뉴얼 합니다. 만약 인증서 리뉴얼을 제외하고자 한다면 --certificate-renewal=false 옵션을 추가해서 실행해야 합니다.

 

# 4. 노드 drain
root@k8s-m0:~# kubectl drain k8s-m0 --ignore-daemonsets
node/k8s-m0 cordoned
WARNING: ignoring DaemonSet-managed Pods: kube-system/kube-flannel-ds-589g7, kube-system/kube-proxy-hqmll
node/k8s-m0 drained
root@k8s-m0:~# kubectl get no
NAME     STATUS                     ROLES                  AGE     VERSION
k8s-m0   Ready,SchedulingDisabled   control-plane,master   3h48m   v1.21.7
k8s-m1   Ready                      control-plane,master   3h23m   v1.21.7
k8s-m2   Ready                      control-plane,master   3h      v1.21.7
k8s-w1   Ready                      <none>                 165m    v1.21.7

 

[참고] kubelet 과 kubectl 을 업그레이드 하기 전에는 노드를 drain 시켜 노드가 파드 스케줄링을 받지 않도록 하고, 실행 중인 워크로드를 퇴출(evict)합니다.

 

# 5. kubectl, kubelet 바이너리 업그레이드
root@k8s-m0:~# apt-mark unhold kubelet kubectl && apt-get update && apt-get install -y kubelet=1.22.3-00 kubectl=1.22.3-00 && apt-mark hold kubelet kubectl
Canceled hold on kubelet.
Canceled hold on kubectl.
Hit:1 https://download.docker.com/linux/ubuntu focal InRelease
Hit:2 http://archive.ubuntu.com/ubuntu focal InRelease
Get:3 http://security.ubuntu.com/ubuntu focal-security InRelease [114 kB]
Get:5 http://archive.ubuntu.com/ubuntu focal-updates InRelease [114 kB]
Get:6 http://archive.ubuntu.com/ubuntu focal-backports InRelease [108 kB]
Get:4 https://packages.cloud.google.com/apt kubernetes-xenial InRelease [9383 B]
Fetched 345 kB in 24s (14.5 kB/s)
Reading package lists... Done
Reading package lists... Done
Building dependency tree
Reading state information... Done
The following packages will be upgraded:
  kubectl kubelet
2 upgraded, 0 newly installed, 0 to remove and 17 not upgraded.
Need to get 28.2 MB of archives.
After this operation, 3028 kB of additional disk space will be used.
Get:1 https://packages.cloud.google.com/apt kubernetes-xenial/main amd64 kubectl amd64 1.22.3-00 [9039 kB]
Get:2 https://packages.cloud.google.com/apt kubernetes-xenial/main amd64 kubelet amd64 1.22.3-00 [19.1 MB]
Fetched 28.2 MB in 28s (999 kB/s)
(Reading database ... 64135 files and directories currently installed.)
Preparing to unpack .../kubectl_1.22.3-00_amd64.deb ...
Unpacking kubectl (1.22.3-00) over (1.21.7-00) ...
Preparing to unpack .../kubelet_1.22.3-00_amd64.deb ...
Unpacking kubelet (1.22.3-00) over (1.21.7-00) ...
Setting up kubectl (1.22.3-00) ...
Setting up kubelet (1.22.3-00) ...
kubelet set on hold.
kubectl set on hold.


# 6. kubelet daemon-reload, restart
root@k8s-m0:~# systemctl daemon-reload && systemctl restart kubelet

# 7. 노드 uncordon
root@k8s-m0:~# kubectl uncordon k8s-m0
node/k8s-m0 uncordoned
root@k8s-m0:~# kubectl get no
NAME     STATUS   ROLES                  AGE     VERSION
k8s-m0   Ready    control-plane,master   4h16m   v1.22.3
k8s-m1   Ready    control-plane,master   3h51m   v1.21.7
k8s-m2   Ready    control-plane,master   3h28m   v1.21.7
k8s-w1   Ready    <none>                 3h13m   v1.21.7

프라이머리 컨트롤 플레인이 v1.22.3 으로 업그레이드 되었습니다.

 

[참고] kubeadm upgrade 명령은 static pod 매니페스트를 업그레이드 한다. 기존 매니페스트와 비교해보면 아래와 같이 버전 정보가 변경된 것을 알 수 있다. 
root@k8s-m0:~/manifests_bk# diff kube-scheduler.yaml /etc/kubernetes/manifests/kube-scheduler.yaml
20c20
< image: k8s.gcr.io/kube-scheduler:v1.21.7
---
> image: k8s.gcr.io/kube-scheduler:v1.22.3

 

이제 나머지 컨트롤 플레인도 업그레이드를 진행하겠습니다.

 

Additional Control Plane 업그레이드

추가 컨트롤 플레인의 업그레이드 절차는 아래와 같습니다.

1. kubeadm 바이너리 업그레이드

2. kubeadm upgrade node 로 업그레이드 (이 부분이 kubeadm upgrade apply 에서  kubeadm upgrade node 로 다름)

3. 노드 drain

4. kubectl, kubelet 바이너리 업그레이드

5. kubelet daemon-reload, restart

6. 노드 uncordon

 

수행 결과를 살펴 보겠습니다.

# 1. kubeadm 바이너리 업그레이드
root@k8s-m1:~# apt-mark unhold kubeadm && apt-get update && apt-get install -y kubeadm=1.22.3-00 && apt-mark hold kubeadm
Canceled hold on kubeadm.
Hit:1 https://download.docker.com/linux/ubuntu focal InRelease
Get:2 http://security.ubuntu.com/ubuntu focal-security InRelease [114 kB]
Hit:3 http://archive.ubuntu.com/ubuntu focal InRelease
Get:4 http://archive.ubuntu.com/ubuntu focal-updates InRelease [114 kB]
Get:6 http://archive.ubuntu.com/ubuntu focal-backports InRelease [108 kB]
Get:5 https://packages.cloud.google.com/apt kubernetes-xenial InRelease [9383 B]
Fetched 345 kB in 23s (14.8 kB/s)
Reading package lists... Done
Reading package lists... Done
Building dependency tree
Reading state information... Done
The following packages will be upgraded:
  kubeadm
1 upgraded, 0 newly installed, 0 to remove and 18 not upgraded.
Need to get 8712 kB of archives.
After this operation, 971 kB of additional disk space will be used.
Get:1 https://packages.cloud.google.com/apt kubernetes-xenial/main amd64 kubeadm amd64 1.22.3-00 [8712 kB]
Fetched 8712 kB in 24s (366 kB/s)
(Reading database ... 64135 files and directories currently installed.)
Preparing to unpack .../kubeadm_1.22.3-00_amd64.deb ...
Unpacking kubeadm (1.22.3-00) over (1.21.7-00) ...
Setting up kubeadm (1.22.3-00) ...
kubeadm set on hold.


# 2. kubeadm upgrade node 로 업그레이드 (이 부분이 kubeadm upgrade apply 에서  kubeadm upgrade node 로 다름)
(admin-k8s:default) root@k8s-m1:~# kubeadm upgrade node
[upgrade] Reading configuration from the cluster...
[upgrade] FYI: You can look at this config file with 'kubectl -n kube-system get cm kubeadm-config -o yaml'
[preflight] Running pre-flight checks
[preflight] Pulling images required for setting up a Kubernetes cluster
[preflight] This might take a minute or two, depending on the speed of your internet connection
[preflight] You can also perform this action in beforehand using 'kubeadm config images pull'
[upgrade] Upgrading your Static Pod-hosted control plane instance to version "v1.22.3"...
Static pod: kube-apiserver-k8s-m1 hash: 121c5f0b29db118a71b4afb666436a28
Static pod: kube-controller-manager-k8s-m1 hash: 6c9d416769ffcfda2b9b457c2278465b
Static pod: kube-scheduler-k8s-m1 hash: 5b40ca2fc4b25745dd3e0a5fbb7cd7f1
[upgrade/etcd] Upgrading to TLS for etcd
Static pod: etcd-k8s-m1 hash: 52933cc98a0e0db8d2740d5d66b76a50
[upgrade/staticpods] Preparing for "etcd" upgrade
[upgrade/staticpods] Renewing etcd-server certificate
[upgrade/staticpods] Renewing etcd-peer certificate
[upgrade/staticpods] Renewing etcd-healthcheck-client certificate
[upgrade/staticpods] Moved new manifest to "/etc/kubernetes/manifests/etcd.yaml" and backed up old manifest to "/etc/kubernetes/tmp/kubeadm-backup-manifests-2022-02-05-12-09-38/etcd.yaml"
[upgrade/staticpods] Waiting for the kubelet to restart the component
[upgrade/staticpods] This might take a minute or longer depending on the component/version gap (timeout 5m0s)
Static pod: etcd-k8s-m1 hash: 52933cc98a0e0db8d2740d5d66b76a50
Static pod: etcd-k8s-m1 hash: 52933cc98a0e0db8d2740d5d66b76a50
<생략>
Static pod: etcd-k8s-m1 hash: 52933cc98a0e0db8d2740d5d66b76a50
Static pod: etcd-k8s-m1 hash: 52933cc98a0e0db8d2740d5d66b76a50
Static pod: etcd-k8s-m1 hash: 572bcedba634f1d0a840c50caf2c2c89
[apiclient] Found 3 Pods for label selector component=etcd
[upgrade/staticpods] Component "etcd" upgraded successfully!
[upgrade/etcd] Waiting for etcd to become available
[upgrade/staticpods] Writing new Static Pod manifests to "/etc/kubernetes/tmp/kubeadm-upgraded-manifests160626215"
[upgrade/staticpods] Preparing for "kube-apiserver" upgrade
[upgrade/staticpods] Renewing apiserver certificate
[upgrade/staticpods] Renewing apiserver-kubelet-client certificate
[upgrade/staticpods] Renewing front-proxy-client certificate
[upgrade/staticpods] Renewing apiserver-etcd-client certificate
[upgrade/staticpods] Moved new manifest to "/etc/kubernetes/manifests/kube-apiserver.yaml" and backed up old manifest to "/etc/kubernetes/tmp/kubeadm-backup-manifests-2022-02-05-12-09-38/kube-apiserver.yaml"
[upgrade/staticpods] Waiting for the kubelet to restart the component
[upgrade/staticpods] This might take a minute or longer depending on the component/version gap (timeout 5m0s)
Static pod: kube-apiserver-k8s-m1 hash: 121c5f0b29db118a71b4afb666436a28
Static pod: kube-apiserver-k8s-m1 hash: 121c5f0b29db118a71b4afb666436a28
<생략>
Static pod: kube-apiserver-k8s-m1 hash: 121c5f0b29db118a71b4afb666436a28
Static pod: kube-apiserver-k8s-m1 hash: 121c5f0b29db118a71b4afb666436a28
Static pod: kube-apiserver-k8s-m1 hash: ff13ada2027cc50e6a9db21ef2dd0678
[apiclient] Found 3 Pods for label selector component=kube-apiserver
[upgrade/staticpods] Component "kube-apiserver" upgraded successfully!
[upgrade/staticpods] Preparing for "kube-controller-manager" upgrade
[upgrade/staticpods] Renewing controller-manager.conf certificate
[upgrade/staticpods] Moved new manifest to "/etc/kubernetes/manifests/kube-controller-manager.yaml" and backed up old manifest to "/etc/kubernetes/tmp/kubeadm-backup-manifests-2022-02-05-12-09-38/kube-controller-manager.yaml"
[upgrade/staticpods] Waiting for the kubelet to restart the component
[upgrade/staticpods] This might take a minute or longer depending on the component/version gap (timeout 5m0s)
Static pod: kube-controller-manager-k8s-m1 hash: 6c9d416769ffcfda2b9b457c2278465b
Static pod: kube-controller-manager-k8s-m1 hash: 6c9d416769ffcfda2b9b457c2278465b
<생략>
Static pod: kube-controller-manager-k8s-m1 hash: 6c9d416769ffcfda2b9b457c2278465b
Static pod: kube-controller-manager-k8s-m1 hash: 6c9d416769ffcfda2b9b457c2278465b
Static pod: kube-controller-manager-k8s-m1 hash: 9894d6b6867df03a62434e48ca7456c3
[apiclient] Found 3 Pods for label selector component=kube-controller-manager
[upgrade/staticpods] Component "kube-controller-manager" upgraded successfully!
[upgrade/staticpods] Preparing for "kube-scheduler" upgrade
[upgrade/staticpods] Renewing scheduler.conf certificate
[upgrade/staticpods] Moved new manifest to "/etc/kubernetes/manifests/kube-scheduler.yaml" and backed up old manifest to "/etc/kubernetes/tmp/kubeadm-backup-manifests-2022-02-05-12-09-38/kube-scheduler.yaml"
[upgrade/staticpods] Waiting for the kubelet to restart the component
[upgrade/staticpods] This might take a minute or longer depending on the component/version gap (timeout 5m0s)
Static pod: kube-scheduler-k8s-m1 hash: 5b40ca2fc4b25745dd3e0a5fbb7cd7f1
Static pod: kube-scheduler-k8s-m1 hash: 5b40ca2fc4b25745dd3e0a5fbb7cd7f1
<생략>
Static pod: kube-scheduler-k8s-m1 hash: 5b40ca2fc4b25745dd3e0a5fbb7cd7f1
Static pod: kube-scheduler-k8s-m1 hash: 5b40ca2fc4b25745dd3e0a5fbb7cd7f1
Static pod: kube-scheduler-k8s-m1 hash: 6e1d9a4253856a2c13140b36a8811869
[apiclient] Found 3 Pods for label selector component=kube-scheduler
[apiclient] Error getting Pods with label selector "component=kube-scheduler" [Get "https://192.168.100.100:16443/api/v1/namespaces/kube-system/pods?labelSelector=component%3Dkube-scheduler": context deadline exceeded (Client.Timeout exceeded while awaiting headers)]
[apiclient] Error getting Pods with label selector "component=kube-scheduler" [Get "https://192.168.100.100:16443/api/v1/namespaces/kube-system/pods?labelSelector=component%3Dkube-scheduler": net/http: request canceled (Client.Timeout exceeded while awaiting headers)]
[apiclient] Error getting Pods with label selector "component=kube-scheduler" [Get "https://192.168.100.100:16443/api/v1/namespaces/kube-system/pods?labelSelector=component%3Dkube-scheduler": net/http: request canceled (Client.Timeout exceeded while awaiting headers)]
[apiclient] Error getting Pods with label selector "component=kube-scheduler" [Get "https://192.168.100.100:16443/api/v1/namespaces/kube-system/pods?labelSelector=component%3Dkube-scheduler": net/http: request canceled (Client.Timeout exceeded while awaiting headers)]
[upgrade/staticpods] Component "kube-scheduler" upgraded successfully!
[upgrade] The control plane instance for this node was successfully updated!
[kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml"
[upgrade] The configuration for this node was successfully updated!
[upgrade] Now you should go ahead and upgrade the kubelet package using your package manager.


# 3. 노드 drain
root@k8s-m1:~# kubectl drain k8s-m1 --ignore-daemonsets
node/k8s-m1 cordoned
WARNING: ignoring DaemonSet-managed Pods: kube-system/kube-flannel-ds-p2r67, kube-system/kube-proxy-xvrqf
evicting pod kube-system/coredns-78fcd69978-njbnw
pod/coredns-78fcd69978-njbnw evicted
node/k8s-m1 evicted


# 4. kubectl, kubelet 바이너리 업그레이드
root@k8s-m1:~# apt-mark unhold kubelet kubectl && apt-get update && apt-get install -y kubelet=1.22.3-00 kubectl=1.22.3-00 && apt-mark hold kubelet kubectl
Canceled hold on kubelet.
Canceled hold on kubectl.
Hit:1 https://download.docker.com/linux/ubuntu focal InRelease
Get:2 http://security.ubuntu.com/ubuntu focal-security InRelease [114 kB]
Hit:4 http://archive.ubuntu.com/ubuntu focal InRelease
Get:5 http://archive.ubuntu.com/ubuntu focal-updates InRelease [114 kB]
Get:6 http://archive.ubuntu.com/ubuntu focal-backports InRelease [108 kB]
Hit:3 https://packages.cloud.google.com/apt kubernetes-xenial InRelease
Fetched 336 kB in 23s (14.9 kB/s)
Reading package lists... Done
Reading package lists... Done
Building dependency tree
Reading state information... Done
The following packages will be upgraded:
  kubectl kubelet
2 upgraded, 0 newly installed, 0 to remove and 17 not upgraded.
Need to get 28.2 MB of archives.
After this operation, 3028 kB of additional disk space will be used.
Get:1 https://packages.cloud.google.com/apt kubernetes-xenial/main amd64 kubectl amd64 1.22.3-00 [9039 kB]
Get:2 https://packages.cloud.google.com/apt kubernetes-xenial/main amd64 kubelet amd64 1.22.3-00 [19.1 MB]
Fetched 28.2 MB in 29s (970 kB/s)
(Reading database ... 64135 files and directories currently installed.)
Preparing to unpack .../kubectl_1.22.3-00_amd64.deb ...
Unpacking kubectl (1.22.3-00) over (1.21.7-00) ...
Preparing to unpack .../kubelet_1.22.3-00_amd64.deb ...
Unpacking kubelet (1.22.3-00) over (1.21.7-00) ...
Setting up kubectl (1.22.3-00) ...
Setting up kubelet (1.22.3-00) ...
kubelet set on hold.
kubectl set on hold.


# 5. kubelet daemon-reload, restart
root@k8s-m1:~# systemctl daemon-reload && systemctl restart kubelet


# 6. 노드 uncordon
root@k8s-m1:~# kubectl uncordon k8s-m1
node/k8s-m1 uncordoned
root@k8s-m1:~# kubectl get no
NAME     STATUS   ROLES                  AGE     VERSION
k8s-m0   Ready    control-plane,master   4h58m   v1.22.3
k8s-m1   Ready    control-plane,master   4h34m   v1.22.3
k8s-m2   Ready    control-plane,master   4h10m   v1.22.3
k8s-w1   Ready    <none>                 3h55m   v1.21.7

 

 

Worker 노드 업그레이드

워커 노드 업그레이드 절차는 추가 컨트롤 플레인의 업그레이드 절차와 동일합니다.

수행 결과를 살펴 보겠습니다.

 

root@k8s-w1:~# apt-mark unhold kubeadm && apt-get update && apt-get install -y kubeadm=1.22.3-00 && apt-mark hold kubeadm
Canceled hold on kubeadm.
Get:1 http://security.ubuntu.com/ubuntu focal-security InRelease [114 kB]
Hit:2 http://archive.ubuntu.com/ubuntu focal InRelease
Hit:3 https://download.docker.com/linux/ubuntu focal InRelease
Get:4 http://archive.ubuntu.com/ubuntu focal-updates InRelease [114 kB]
Get:6 http://archive.ubuntu.com/ubuntu focal-backports InRelease [108 kB]
Get:5 https://packages.cloud.google.com/apt kubernetes-xenial InRelease [9383 B]
Fetched 345 kB in 23s (15.2 kB/s)
Reading package lists... Done
Reading package lists... Done
Building dependency tree
Reading state information... Done
The following packages will be upgraded:
  kubeadm
1 upgraded, 0 newly installed, 0 to remove and 18 not upgraded.
Need to get 8712 kB of archives.
After this operation, 971 kB of additional disk space will be used.
Get:1 https://packages.cloud.google.com/apt kubernetes-xenial/main amd64 kubeadm amd64 1.22.3-00 [8712 kB]
Fetched 8712 kB in 22s (390 kB/s)
(Reading database ... 63715 files and directories currently installed.)
Preparing to unpack .../kubeadm_1.22.3-00_amd64.deb ...
Unpacking kubeadm (1.22.3-00) over (1.21.7-00) ...
Setting up kubeadm (1.22.3-00) ...
kubeadm set on hold.
root@k8s-w1:~# kubeadm upgrade node
[upgrade] Reading configuration from the cluster...
[upgrade] FYI: You can look at this config file with 'kubectl -n kube-system get cm kubeadm-config -o yaml'
[preflight] Running pre-flight checks
[preflight] Skipping prepull. Not a control plane node.
[upgrade] Skipping phase. Not a control plane node.
[kubelet-start] Writing kubelet configuration to file "/var/lib/kubelet/config.yaml"
[upgrade] The configuration for this node was successfully updated!
[upgrade] Now you should go ahead and upgrade the kubelet package using your package manager.

kubeadm upgrade node 작업은 컨트롤 플레인의 static pod의 image pulling 이 없기 때문에 빨리 끝납니다.

아래의 노드 drain 은 컨트롤 플레인에서 수행합니다.

# Control Plane 에서 수행
root@k8s-m0:~# kubectl drain k8s-w1 --ignore-daemonsets
node/k8s-w1 cordoned
WARNING: ignoring DaemonSet-managed Pods: kube-system/kube-flannel-ds-4jqg8, kube-system/kube-proxy-rhzmv
evicting pod kube-system/coredns-78fcd69978-nwf2f
evicting pod kube-system/coredns-78fcd69978-j6p7g
pod/coredns-78fcd69978-nwf2f evicted
pod/coredns-78fcd69978-j6p7g evicted
node/k8s-w1 evicted
root@k8s-m0:~# kubectl get no
NAME     STATUS                     ROLES                  AGE     VERSION
k8s-m0   Ready                      control-plane,master   5h2m    v1.22.3
k8s-m1   Ready                      control-plane,master   4h38m   v1.22.3
k8s-m2   Ready                      control-plane,master   4h14m   v1.22.3
k8s-w1   Ready,SchedulingDisabled   <none>                 3h59m   v1.21.7

이어서 워커노드에서 나머지 업그레이드 작업을 수행합니다.

root@k8s-w1:~# apt-mark unhold kubelet kubectl && apt-get update && apt-get install -y kubelet=1.22.3-00 kubectl=1.22.3-00 && apt-mark hold kubelet kubectl

Canceled hold on kubelet.
Canceled hold on kubectl.
Hit:1 https://download.docker.com/linux/ubuntu focal InRelease
Hit:2 http://archive.ubuntu.com/ubuntu focal InRelease
Get:3 http://security.ubuntu.com/ubuntu focal-security InRelease [114 kB]
Get:5 http://archive.ubuntu.com/ubuntu focal-updates InRelease [114 kB]
Get:6 http://archive.ubuntu.com/ubuntu focal-backports InRelease [108 kB]
Hit:4 https://packages.cloud.google.com/apt kubernetes-xenial InRelease
Fetched 336 kB in 22s (15.0 kB/s)
Reading package lists... Done
Reading package lists... Done
Building dependency tree
Reading state information... Done
The following packages will be upgraded:
  kubectl kubelet
2 upgraded, 0 newly installed, 0 to remove and 17 not upgraded.
Need to get 28.2 MB of archives.
After this operation, 3028 kB of additional disk space will be used.
Get:1 https://packages.cloud.google.com/apt kubernetes-xenial/main amd64 kubectl amd64 1.22.3-00 [9039 kB]
Get:2 https://packages.cloud.google.com/apt kubernetes-xenial/main amd64 kubelet amd64 1.22.3-00 [19.1 MB]
Fetched 28.2 MB in 25s (1105 kB/s)
(Reading database ... 63715 files and directories currently installed.)
Preparing to unpack .../kubectl_1.22.3-00_amd64.deb ...
Unpacking kubectl (1.22.3-00) over (1.21.7-00) ...
Preparing to unpack .../kubelet_1.22.3-00_amd64.deb ...
Unpacking kubelet (1.22.3-00) over (1.21.7-00) ...
Setting up kubectl (1.22.3-00) ...
Setting up kubelet (1.22.3-00) ...
kubelet set on hold.
kubectl set on hold.
root@k8s-w1:~# systemctl daemon-reload && systemctl restart kubelet
root@k8s-w1:~#

마지막으로 노드 uncordon 작업을 컨트롤 플레인에서 수행합니다.

# Control Plane 에서 수행
root@k8s-m0:~# kubectl uncordon k8s-w1
node/k8s-w1 uncordoned
root@k8s-m0:~# kubectl get no
NAME     STATUS   ROLES                  AGE     VERSION
k8s-m0   Ready    control-plane,master   5h7m    v1.22.3
k8s-m1   Ready    control-plane,master   4h42m   v1.22.3
k8s-m2   Ready    control-plane,master   4h18m   v1.22.3
k8s-w1   Ready    <none>                 4h4m    v1.22.3

이로써 Kubernetes 클러스터의 전체 노드 업그레이드가 완료 되었습니다.

 

Kubernetes 업그레이드 확인

업그레이드를 마치고 최종 상태를 확인하였습니다.

root@k8s-m0:~# kubectl get no
NAME     STATUS   ROLES                  AGE     VERSION
k8s-m0   Ready    control-plane,master   5h8m    v1.22.3
k8s-m1   Ready    control-plane,master   4h44m   v1.22.3
k8s-m2   Ready    control-plane,master   4h20m   v1.22.3
k8s-w1   Ready    <none>                 4h5m    v1.22.3
root@k8s-m0:~# kubectl version
Client Version: version.Info{Major:"1", Minor:"22", GitVersion:"v1.22.3", GitCommit:"c92036820499fedefec0f847e2054d824aea6cd1", GitTreeState:"clean", BuildDate:"2021-10-27T18:41:28Z", GoVersion:"go1.16.9", Compiler:"gc", Platform:"linux/amd64"}
Server Version: version.Info{Major:"1", Minor:"22", GitVersion:"v1.22.3", GitCommit:"c92036820499fedefec0f847e2054d824aea6cd1", GitTreeState:"clean", BuildDate:"2021-10-27T18:35:25Z", GoVersion:"go1.16.9", Compiler:"gc", Platform:"linux/amd64"}
root@k8s-m0:~# kubeadm version
kubeadm version: &version.Info{Major:"1", Minor:"22", GitVersion:"v1.22.3", GitCommit:"c92036820499fedefec0f847e2054d824aea6cd1", GitTreeState:"clean", BuildDate:"2021-10-27T18:40:11Z", GoVersion:"go1.16.9", Compiler:"gc", Platform:"linux/amd64"}
root@k8s-m0:~# kubelet --version
Kubernetes v1.22.3

 

참고

https://kubernetes.io/ko/docs/tasks/administer-cluster/kubeadm/kubeadm-upgrade/

https://wnw1005.tistory.com/363

+ Recent posts