Chapter 12

롤링 업데이트와 앱 설정

  • 12.1 롤링 업데이트와 롤백
  • 12.2 Docker의 명령어와 인수
  • 12.3 Kubernetes의 명령어와 인수
  • 12.4 환경 변수 설정
  • 12.5 ConfigMap
  • 12.6 Secret
  • 12.7 미사용 시 Secret 암호화

배포를 업데이트할 때 다운타임 없이 롤링으로 교체하는 전략부터, Docker와 Kubernetes에서 컨테이너 명령어와 인수를 다루는 법, 환경 변수를 설정하는 세 가지 방식(직접 지정, ConfigMap, Secret), 그리고 etcd에 저장된 Secret을 실제로 암호화하는 방법까지 애플리케이션 라이프사이클 전반을 다뤄.

쿠버네티스 배포에서 애플리케이션을 업데이트할 때 핵심은 롤링 업데이트가 기본 전략이라는 거야. 한꺼번에 전부 내렸다가 올리는 게 아니라, 하나씩 교체하면서 다운타임 없이 업그레이드하는 방식이거든.

배포를 처음 만들면 롤아웃이 트리거되고, 이때 리비전 1이 생겨. 그 뒤에 컨테이너 이미지를 새 버전으로 바꾸면 또 롤아웃이 트리거되면서 리비전 2가 만들어지는 식이야. 이렇게 리비전을 기록해두기 때문에 문제가 생기면 이전 버전으로 롤백할 수 있어.

롤아웃 상태를 확인하려면 kubectl rollout status deployment/<배포이름> 명령을 쓰면 되고, 리비전 이력을 보려면 kubectl rollout history deployment/<배포이름>을 쓰면 돼.

배포 전략은 두 가지가 있어. 첫 번째는 Recreate 전략인데, 기존 인스턴스를 전부 삭제한 다음 새 버전을 전부 올리는 거야. 당연히 중간에 서비스가 끊기겠지? 그래서 이건 기본 전략이 아니야. 두 번째가 Rolling Update인데, 이전 버전을 하나 내리고 새 버전을 하나 올리고, 또 하나 내리고 하나 올리는 식으로 점진적으로 교체하는 거야. 전략을 따로 지정하지 않으면 자동으로 롤링 업데이트가 적용돼.

실제로 업데이트하는 방법은 두 가지야. 배포 정의 파일(YAML)을 직접 수정한 다음 kubectl apply -f 명령을 실행하는 방법이 있고, kubectl set image deployment/<배포이름> <컨테이너이름>=<새이미지> 명령으로 이미지만 바꾸는 방법도 있어. 다만 두 번째 방법을 쓰면 정의 파일과 실제 상태가 달라지니까 주의해야 해.

kubectl describe deployment로 배포 상세 정보를 보면 두 전략의 차이를 이벤트에서 확인할 수 있어. Recreate는 이전 레플리카셋이 먼저 0으로 축소되고 나서 새 레플리카셋이 확장되는 반면, Rolling Update는 이전 레플리카셋이 하나씩 줄어들면서 동시에 새 레플리카셋이 하나씩 늘어나거든.

내부적으로 어떻게 동작하냐면, 배포가 처음 만들어지면 레플리카셋을 자동 생성하고 거기에 파드를 만들잖아. 업그레이드가 일어나면 새로운 레플리카셋이 하나 더 생기고, 거기에 새 버전 파드가 하나씩 올라가면서 이전 레플리카셋의 파드가 하나씩 내려가는 거야. kubectl get replicasets를 실행해보면 이전 레플리카셋에 파드 0개, 새 레플리카셋에 파드 5개 이런 식으로 보여.

업그레이드한 다음에 문제가 발견되면 kubectl rollout undo deployment/<배포이름> 명령으로 롤백할 수 있어. 이러면 새 레플리카셋의 파드가 제거되고 이전 레플리카셋의 파드가 다시 올라와. 롤백 전후로 kubectl get replicasets를 비교해보면 파드 수가 정확히 뒤바뀐 걸 확인할 수 있어.

정리하면 주요 명령어는 이래. kubectl create로 배포 생성, kubectl get deployments로 목록 확인, kubectl applykubectl set image로 업데이트, kubectl rollout status로 롤아웃 상태 확인, kubectl rollout undo로 롤백.

이제 배포 업데이트를 넘어서, 컨테이너가 시작할 때 실행하는 명령어를 어떻게 제어하는지 Docker 레벨부터 살펴보자.

Docker 컨테이너에서 명령어가 어떻게 동작하는지 이해하는 게 핵심이야. 컨테이너는 가상 머신처럼 OS를 호스팅하는 게 아니라, 특정 프로세스를 실행하기 위한 거거든. 그래서 그 프로세스가 끝나면 컨테이너도 종료돼.

docker run ubuntu를 실행하면 컨테이너가 바로 종료되는 걸 볼 수 있어. 왜냐하면 우분투 이미지의 Dockerfile을 보면 기본 명령(CMD)이 bash로 되어 있거든. bash는 터미널에서 입력을 받는 셸인데, Docker는 기본적으로 컨테이너에 터미널을 연결하지 않아. 그래서 bash가 터미널을 못 찾고 바로 종료되고, 컨테이너도 같이 죽는 거야. 반면에 nginx 이미지는 CMD가 nginx 명령이고, MySQL 이미지는 mysqld 명령이니까 계속 실행되는 거지.

컨테이너 시작 시 다른 명령을 실행하고 싶으면 docker run ubuntu sleep 5 이런 식으로 명령을 뒤에 붙이면 돼. 이러면 이미지에 정의된 기본 CMD를 덮어쓰는 거야.

이걸 영구적으로 만들고 싶으면 자기만의 이미지를 만들면 돼. Dockerfile에서 CMD를 지정하는 방법은 셸 형식(CMD sleep 5)과 JSON 배열 형식(CMD ["sleep", "5"])이 있는데, JSON 배열로 쓸 때는 첫 번째 요소가 반드시 실행 파일이어야 하고, 명령과 매개변수를 별도 요소로 분리해야 해. CMD ["sleep 5"] 이렇게 합치면 안 된다는 거야.

FROM ubuntu
CMD ["sleep", "5"]

이렇게 만들고 docker build -t ubuntu-sleeper .로 빌드한 다음 docker run ubuntu-sleeper만 하면 항상 5초 자고 종료돼.

그런데 잠자기 시간을 바꾸고 싶으면? docker run ubuntu-sleeper sleep 10처럼 할 수 있지만, 이미지 이름이 이미 "sleeper"인데 sleep 명령을 또 쓰는 건 좀 이상하잖아. docker run ubuntu-sleeper 10만 하고 싶은 거지.

바로 여기서 ENTRYPOINT가 등장해. ENTRYPOINT는 컨테이너 시작 시 실행할 프로그램을 지정하고, 명령줄에서 전달하는 값은 거기에 추가돼. CMD와의 차이가 뭐냐면, CMD는 명령줄 매개변수가 들어오면 CMD 전체가 대체되는 반면, ENTRYPOINT는 명령줄 매개변수가 뒤에 붙는 거야.

FROM ubuntu
ENTRYPOINT ["sleep"]
CMD ["5"]

이렇게 ENTRYPOINT와 CMD를 함께 쓰면, 명령줄에 값을 안 주면 sleep 5가 실행되고, docker run ubuntu-sleeper 10을 하면 CMD가 덮어써져서 sleep 10이 실행돼. 기본값도 있고 덮어쓰기도 가능한 구조가 되는 거야. 이때 ENTRYPOINT와 CMD 둘 다 반드시 JSON 배열 형식으로 지정해야 해.

만약 ENTRYPOINT 자체를 런타임에 바꾸고 싶다면 docker run --entrypoint sleep2.0 ubuntu-sleeper 10처럼 --entrypoint 옵션을 쓰면 돼. 그러면 시작 시 실행되는 명령이 sleep2.0 10이 되는 거야.

Docker에서의 ENTRYPOINT와 CMD 개념을 이해했으니, 이걸 쿠버네티스 파드 정의에서는 어떻게 표현하는지 보자.

쿠버네티스 파드에서 명령어와 인수를 지정하는 방법은 Docker의 ENTRYPOINT, CMD와 직접 대응되는데, 이 매핑을 정확히 이해하는 게 핵심이야.

이전 강의에서 만든 ubuntu-sleeper 이미지를 파드로 실행한다고 해보자. 기본적으로 5초 동안 sleep한 후 종료돼. 만약 10초로 바꾸고 싶으면 파드 정의 파일에서 args 속성을 쓰면 돼.

apiVersion: v1
kind: Pod
metadata:
  name: ubuntu-sleeper-pod
spec:
  containers:
    - name: ubuntu-sleeper
      image: ubuntu-sleeper
      args: ["10"]

Docker 세계에서 docker run ubuntu-sleeper 10이라고 하면 뒤에 붙는 "10"이 CMD를 덮어쓰잖아. 파드 정의 파일의 args가 바로 그 역할을 해. Dockerfile의 CMD 명령어를 재정의하는 거야.

그럼 ENTRYPOINT 자체를 바꾸고 싶다면? Docker에서는 --entrypoint 옵션을 썼지. 파드 정의 파일에서는 command 필드가 그 역할을 해.

spec:
  containers:
    - name: ubuntu-sleeper
      image: ubuntu-sleeper
      command: ["sleep2.0"]
      args: ["10"]

여기서 헷갈리기 쉬운 부분이 있어. 이름이 좀 직관적이지 않거든. 파드의 command 필드가 Dockerfile의 ENTRYPOINT를 재정의하고, 파드의 args 필드가 Dockerfile의 CMD를 재정의해. "command"라는 이름 때문에 CMD를 재정의할 것 같지만 실제로는 ENTRYPOINT를 재정의하는 거야. 이 매핑을 꼭 기억해둬.

정리하면, Dockerfile에 ENTRYPOINT와 CMD 두 개가 있고, 파드 정의에 command와 args 두 개가 있는데, command는 ENTRYPOINT에 대응하고 args는 CMD에 대응해.

컨테이너에 명령어를 넘기는 것 외에, 실행 중인 애플리케이션에 설정 값을 전달하는 방법도 중요한데, 그게 바로 환경 변수야.

쿠버네티스에서 환경 변수를 설정하는 방법은 크게 세 가지야. 가장 기본적인 건 파드 정의 파일에 직접 키-값 쌍으로 넣는 거고, 나머지 두 개는 ConfigMap과 Secret을 사용하는 거야.

파드 정의 파일에서 환경 변수를 직접 지정하려면 컨테이너 스펙 안에 env 속성을 써. env는 배열이라서 각 항목이 대시(-)로 시작하고, 항목마다 namevalue가 있어.

spec:
  containers:
    - name: my-app
      image: my-app
      env:
        - name: APP_COLOR
          value: blue
        - name: APP_MODE
          value: prod

이렇게 하면 컨테이너 안에서 APP_COLOR라는 환경 변수에 blue라는 값이 들어가게 돼. Docker에서 -e 옵션으로 환경 변수 넘기는 것과 같은 거야.

그런데 값을 직접 쓰는 대신 ConfigMap이나 Secret에서 가져올 수도 있어. 이 경우에는 value 대신 valueFrom을 쓰고 거기서 configMapKeyRef나 secretKeyRef를 지정하는 거야. ConfigMap은 일반 설정 데이터를 담고, Secret은 비밀번호 같은 민감한 데이터를 담는 데 쓰여.

환경 변수를 직접 넣는 건 간단하지만, 설정이 많아지면 관리가 힘들어져. 여러 파드에서 같은 설정을 쓸 때 일일이 복사하는 것도 번거롭고. 이럴 때 ConfigMap으로 한곳에서 관리하면 편해.

ConfigMap은 파드 정의 파일에 흩어져 있는 환경 변수들을 한곳에서 관리할 수 있게 해주는 쿠버네티스 오브젝트야. 키-값 쌍 형태로 설정 데이터를 저장하고, 파드를 만들 때 그 데이터를 환경 변수로 주입하는 방식이거든.

ConfigMap 작업은 두 단계야. 먼저 ConfigMap을 만들고, 그다음 파드에 주입해.

ConfigMap을 만드는 방법은 명령형과 선언형이 있어. 명령형은 kubectl create configmap 명령으로 바로 만드는 거야.

kubectl create configmap app-config --from-literal=APP_COLOR=blue --from-literal=APP_MODE=prod

--from-literal 옵션으로 키-값 쌍을 직접 지정하는 건데, 설정이 많아지면 좀 복잡해져. 그럴 때는 --from-file 옵션으로 파일에서 읽어올 수도 있어.

선언형은 정의 파일을 만드는 거야.

apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  APP_COLOR: blue
  APP_MODE: prod

일반적인 쿠버네티스 오브젝트 구조와 비슷한데, spec 대신 data가 있다는 점이 달라. kubectl create -f <파일명>으로 생성하면 돼.

용도별로 여러 개의 ConfigMap을 만들 수 있어. 예를 들어 하나는 앱 설정용, 하나는 MySQL 설정용, 하나는 Redis 설정용 이런 식으로. 나중에 파드에 연결할 때 이름으로 참조하니까 이름을 잘 지어놓는 게 중요해.

ConfigMap을 확인하려면 kubectl get configmaps, 상세 정보는 kubectl describe configmaps <이름>을 쓰면 돼.

이제 두 번째 단계, 파드에 주입하는 방법이야. 파드 정의 파일의 컨테이너에 envFrom 속성을 추가해.

spec:
  containers:
    - name: my-app
      image: my-app
      envFrom:
        - configMapRef:
            name: app-config

이렇게 하면 app-config ConfigMap에 있는 모든 키-값 쌍이 환경 변수로 주입돼.

ConfigMap 데이터를 주입하는 방법은 사실 여러 가지야. 방금 본 것처럼 전체를 envFrom으로 넣을 수도 있고, 특정 키만 단일 환경 변수로 넣을 수도 있고, 볼륨을 이용해서 파일로 넣을 수도 있어.

ConfigMap이 일반 설정 데이터를 담는다면, 비밀번호나 키 같은 민감한 데이터는 어떻게 관리해야 할까? 바로 Secret을 쓰면 돼.

Secret은 비밀번호나 키 같은 민감한 데이터를 저장하기 위한 쿠버네티스 오브젝트야. ConfigMap이랑 비슷한데, 데이터가 인코딩된 형태로 저장된다는 게 다르거든.

예를 들어 MySQL 데이터베이스에 연결하는 웹 애플리케이션이 있다고 하자. 호스트명이나 사용자 이름은 ConfigMap에 넣어도 괜찮지만, 비밀번호를 일반 텍스트로 ConfigMap에 넣는 건 적절하지 않잖아. 이럴 때 Secret을 쓰는 거야.

Secret도 ConfigMap처럼 두 단계야. 먼저 만들고, 파드에 주입해.

명령형으로 만들려면 kubectl create secret generic 명령을 써.

kubectl create secret generic app-secret --from-literal=DB_HOST=mysql --from-literal=DB_PASSWORD=paswrd

--from-literal로 키-값을 직접 넣거나, --from-file로 파일에서 읽어올 수 있어.

선언형으로 만들 때는 정의 파일을 쓰는데, 여기서 주의할 점이 있어. 데이터를 일반 텍스트가 아니라 base64로 인코딩해서 넣어야 해.

apiVersion: v1
kind: Secret
metadata:
  name: app-secret
data:
  DB_HOST: bXlzcWw=
  DB_PASSWORD: cGFzd3Jk

리눅스에서 base64 인코딩은 echo -n 'mysql' | base64로 하면 되고, 디코딩은 echo -n 'bXlzcWw=' | base64 --decode로 하면 돼.

Secret을 확인하려면 kubectl get secrets를 쓰면 되고, kubectl describe secret <이름>은 속성은 보여주지만 값은 숨겨. 값까지 보려면 kubectl get secret <이름> -o yaml을 써야 해. 그러면 base64로 인코딩된 값을 볼 수 있어.

이제 파드에 주입하는 방법이야. 환경 변수로 넣으려면 envFrom을 써.

spec:
  containers:
    - name: my-app
      image: my-app
      envFrom:
        - secretRef:
            name: app-secret

이렇게 하면 Secret의 모든 데이터가 환경 변수로 들어가. 단일 환경 변수로 특정 키만 넣을 수도 있고, 볼륨으로 마운트할 수도 있어. 볼륨으로 마운트하면 Secret의 각 속성이 파일로 만들어지는데, 예를 들어 DB_PASSWORD 파일 안에 비밀번호가 들어있는 식이야. Secret에 속성이 3개면 파일도 3개 생겨.

Secret을 쓴다고 해서 데이터가 안전하게 암호화되는 건 아니야. base64 인코딩은 누구나 디코딩할 수 있거든. etcd에 저장된 Secret 데이터를 실제로 암호화하려면 별도의 설정이 필요해.

이 데모의 핵심은 etcd에 저장되는 Secret 데이터가 기본적으로 암호화되지 않는다는 거야. base64 인코딩은 암호화가 아니거든. 누구나 디코딩할 수 있어. 그래서 etcd에 접근할 수 있는 사람이면 모든 Secret을 볼 수 있다는 문제가 있고, 이걸 해결하려면 **미사용 시 암호화(Encryption at Rest)**를 활성화해야 해.

먼저 Secret을 하나 만들어보자. kubectl create secret generic my-secret --from-literal=key1=supersecret으로 생성한 다음, etcd에서 직접 확인해보면 그 비밀 값이 그대로 보여. etcdctl 명령으로 확인할 수 있는데, 인증을 위한 CA 인증서를 전달해야 하고, 경로는 /registry/secrets/default/my-secret 형식이야. hexdump로 출력해보면 저장된 데이터 안에 평문 비밀이 그대로 들어있는 걸 확인할 수 있어.

암호화가 이미 활성화되어 있는지 확인하려면 kube-apiserver 프로세스에서 --encryption-provider-config 옵션이 있는지 봐. ps aux | grep kube-apiserver로 확인하거나, kubeadm 환경이면 /etc/kubernetes/manifests/kube-apiserver.yaml 파일을 보면 돼. 이 옵션이 없으면 미사용 시 암호화가 비활성화 상태인 거야.

암호화를 활성화하려면 **암호화 구성 파일(EncryptionConfiguration)**을 만들어야 해. 이 파일에서 어떤 리소스를 암호화할지, 어떤 암호화 알고리즘을 쓸지 정의해. 파드나 배포 같은 건 굳이 암호화할 필요 없고, Secret만 하면 돼.

apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
      - secrets
    providers:
      - aescbc:
          keys:
            - name: key1
              secret: <32바이트 무작위 키를 base64로 인코딩한 값>
      - identity: {}

provider 목록에서 순서가 중요해. 첫 번째 provider가 암호화에 사용돼. identity provider는 "암호화 안 함"을 의미하니까, 이게 첫 번째에 있으면 암호화가 안 되는 거야. aescbc 같은 실제 암호화 알고리즘을 첫 번째에 놓아야 해. identity를 아래에 두는 이유는 이전에 암호화 없이 저장된 데이터도 읽을 수 있게 하기 위해서야.

32바이트 무작위 키는 head -c 32 /dev/urandom | base64로 생성할 수 있어.

이 구성 파일을 만들었으면 kube-apiserver에 연결해야 해. /etc/kubernetes/manifests/kube-apiserver.yaml을 편집해서 --encryption-provider-config 옵션을 추가하고, 구성 파일이 있는 로컬 디렉토리를 볼륨과 볼륨 마운트로 파드 안에 연결해줘. 예를 들어 /etc/kubernetes/enc/ 디렉토리에 파일을 넣고, 이 경로를 hostPath 볼륨으로 마운트하는 거야.

파일을 저장하면 kube-apiserver가 자동으로 재시작돼. crictl podskubectl get pods -n kube-system으로 다시 올라왔는지 확인하고, ps aux | grep encryption으로 옵션이 적용됐는지 봐.

중요한 점은 암호화 활성화 이후에 새로 만드는 Secret만 암호화된다는 거야. 기존에 있던 Secret은 그대로야. 기존 Secret도 암호화하려면 kubectl get secrets --all-namespaces -o json | kubectl replace -f - 명령으로 동일한 데이터로 업데이트해줘야 해. 이렇게 하면 기존 Secret도 새 암호화 알고리즘으로 다시 저장돼.

그리고 절대로 Secret 정의 파일을 GitHub 같은 곳에 푸시하면 안 돼. base64는 누구나 디코딩할 수 있으니까.


정리

12장 읽고 기억할 거 세 가지:

  1. 배포 업데이트의 기본 전략은 Rolling Update이고, 문제가 생기면 kubectl rollout undo로 이전 리비전의 레플리카셋으로 롤백할 수 있다. Kubernetes 파드에서 command는 Docker의 ENTRYPOINT를, args는 CMD를 재정의한다는 매핑을 헷갈리지 말 것.
  2. 환경 변수는 직접 env로 넣거나, ConfigMap(일반 설정)과 Secret(민감 데이터)에서 envFrom으로 주입할 수 있다. ConfigMap은 data에 평문, Secret은 base64 인코딩 값을 넣는다.
  3. Secret의 base64 인코딩은 암호화가 아니다. etcd에 저장된 Secret을 실제로 보호하려면 EncryptionConfiguration을 만들어 kube-apiserver에 --encryption-provider-config로 연결하고, 기존 Secret도 replace 명령으로 다시 저장해야 한다.