개발/Developing

Docker와 Kubernetes를 이용하여 Deploy하기

lazykuna 2022. 5. 7. 12:02

예전에 빠르게 On-boarding하려고 일주일간 삽질하면서 “Hello World!” 웹서버를 Deploy 했던 적이 있는데, 그 때 정리했던 내용을 적당히 추려서 포스팅으로 남겨둡니다.

당시 Docker과 kubernetes를 이용하여 실제 작동하는 skeleton 서비스를 직접 만들어 보는 것만이 이 플랫폼들을 제일 빠르게 파악할 수 있는 방법이라고 생각해서, 일단 무작정 만들고 봤었던 기억이 있네요.

사실 k8s 아키텍쳐 및 동작 방식에 대해서 공부했으면 진작 알 수 있는 내용들이 많지만, 여기서는 일단 코드 위주로 내용을 적어 놓습니다.

1. Docker로 이미지 생성

익히 해오던 대로 적절한 코드, 적절한 베이스 이미지를 고르고(여기서는 가벼운 alpine) Dockerfilemulti-stage 로 빌드합니다.

// main.go
package main

import fmt

func main() {
    fmt.Println("Hello World!")
}
# Dockerfile

# prepare for go compile
FROM golang:1.15 as builder
WORKDIR /go/src
COPY go.mod go.mod
COPY go.sum go.sum
RUN go mod download

# copy source
COPY . .

# build
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o main .

# run
FROM alpine:latest
RUN mkdir /app
COPY --from=builder /go/src/main /app/main

# run
WORKDIR /app/
CMD ["/app/main"]
# Bash

# build docker image
> docker build -t username/hello_world:latest .

# push docker image to public repository
# (you can fetch your image using `pull` after it pushed)
> docker push username/hello_world
  • 경량화된 컨네이너 이미지에 executable을 올리는 것이기 때문에, 의존성을 dynamic link로 하면 안 되고 static link로 컴파일 해야 합니다. golang에서는 그러한 옵션이 CGO_ENABLED=0 입니다.
    • M1 같은데에서 빌드하는 경우 GOARCH 도 필요합니다. 아시죠...? 아키텍쳐가 다릅니다
  • Dockerfile의 Multi-stage 빌드 시, FROM 으로 새 베이스 이미지를 설정하는 순간 output으로 나올 immutable 이미지가 새 이미지로 바뀝니다. 이에 따라 기존 이미지의 파일을 접근하려면 “이름"을 알고 있어야 하고, 그게 바로 맨 첫번째 이미지 FROM 설정할 때 builder 이름을 붙여준 이유입니다.
  • docker build 수행시 -t 옵션을 사용하여 이미지 태그명을 붙여주는데, 여기에서는 public repo에 올릴 것이라는 것을 상정했기 때문에 username/reponame:version 꼴로 태그명을 붙여줍니다.

여기까지 완료했다면 이제 우리가 만든 docker image는 public repo에 올라간 상태고, 다른 시스템에서 충분히 접근이 가능한 상태가 되었습니다!

  • 혹시 모르니 docker run 으로 만든 이미지가 정상적으로 수행되는지 한 번 확인은 해 둡시다.

2. Kubernetes에 Docker image를 Commit하기

이제 위에서 만든 이미지를 public repo에 올렸으므로 kubernetes가 접근할 수 있기 때문에, 그대로 실행할 수 있습니다.

  • 주의점: 앞에서 만든 이미지는 print 하고 바로 exit 되므로 pod이 생성되자마자 바로 종료되기 때문에, 쿠버네티스가 계속해서 pod을 띄우려고 하여 리소스 낭비가 심하게 되므로, 저 이미지를 그대로 올리면 안되고 sleep() 라도 걸어두어야 합니다!!

그전에 먼저 kubectl을 k8s 서버와 연동되도록 설정해 둡시다. 로컬일 수도 있고 AWS일 수도 있겠지만 적당히 설정해 주고...

이제 Deployment를 만들어서 docker image를 쿠버네티스가 실행하도록 할 것입니다. 여기서 몇 가지 개념을 정리하면,

  • Pod: docker 의 container에 대응하는 개념으로, 컨테이너가 실제로 돌아가는 환경 및 프로세스를 일컫습니다
  • Deployment: Pod들을 지칭하는 단위로, pod들의 관리(replica 유지 등)을 수행합니다.

따라서 deployment를 만들어야 pod를 만들 수 있습니다. 굳이 이렇게 복잡하게 생긴 이유는 아시다시피 실제 서비스를 위한 장치들이 이것저것 붙어있기 때문에...

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
spec:
  selector:
    matchLabels:
      app: helloworld
  replicas: 1
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: helloworld
        image: username/hello_world:latest
        ports:
        - containerPort: 80

yaml에 대해서도 몇 가지 설명을 덧붙이면,

  • replica: pod을 몇 개 만들 것이고, 유지할 것인가에 대한 설정입니다.
  • selector.matchLabels: pod에 붙일 label을 결정합니다. 여기서는 label이 app: helloworld 그 자체가 됩니다. 여러개를 붙일 수도 있고요. 당연히, 이후에 이 deployment들과 연관된 pod들을 직접 찾을 수 있도록 하기 위해 사용하는 속성입니다.
  • template.metadata.labels: deploy에 붙일 label입니다. 설명은 위와 동일
  • template.spec: deploy가 실행할 container에 대한 설명입니다. 세부설명 길게 안 써도 뭔지 대충 아실 듯..?

이제 이걸 바로 실행하면, pod가 생성된 것을 바로 확인할 수 있습니다!

> kubectl apply -f deployment.yaml

# do check is deployment / pod is really there
> kubectl get deployments
> kubectl get pods --show-labels

추가) kubectl create로 인자 주고 yaml 파일 가져오기

kubectl create deployment my-dep --image=nginx --dry-run=client -oyaml

dry-run을 통해 yaml로 정보를 빼올 수 있습니다.

잠깐 짚고 넘어가기 — kubectl create와 kubectl apply와의 차이라면, apply는 알아서 현재 환경과 대조해서 create/update/delete 작업을 해 줍니다.

추가) namespace 개념 및 생성해보기

혼자서 EKS를 사용하는 경우라면 상관이 없겠지만, 여럿이서 사용하는 경우라면 독립된 서비스 영역이 필요할 수 있습니다. (혹은 quota 설정이라던가...)

이럴 때 namespace를 지정하여 사용할 수 있습니다.

cat > namespace.yaml << EOF
apiVersion: v1
kind: Namespace
metadata:
name: prd-api
EOF

아래와 같이 yml을 만들고 kubectl apply하여 namespace를 지정 가능하며, 이후 나오는 모든 명령어에 -n {{namespace}} 를 붙이면 해당 namespace에 대해 작업이 수행됩니다. 아무것도 지정하지 않으면 default namespace가 사용됩니다.

... 아니면 kubectl configdefault namespace 설정해 줄 수도 있습니다.

추가) 이미지 업데이트하기

# refresh image
> kubectl set image deployment/nginx-deployment nginx=nginx:1.16.1

# restart
> kubectl rollout status deployment/nginx-deployment

3. Kubernetes service, ingress 생성하여 외부로 서비스를 공개하기

pod이 만들어 진 것을 확인할 수는 있지만 실제 서비스로 접근할 수는 없습니다. kubernetes가 외부에서 들어온 패킷을 deploy에 전달해 주는 방법을 모르기 때문에, 그 “방법" 을 정의해 주어야 합니다. 그 수단이 바로 Service입니다.

왜 이렇게 복잡하게 하냐면 역시 또 “실제 운용에서의 비즈니스"의 문제입니다. pod에 균등하게 트래픽 전달도 해야 하고 장애시에는 트래픽 보내지 말아야 하고 ..정도의 일을 합니다.

일단 차치하고 빠르게 Service 객체를 만들어 봅니다.

apiVersion: v1
kind: Service
metadata:
  name: hello-world-svc # service의 이름
spec:
  selector:
    app: hello_world # container의 pod label
  ports:
    - protocol: TCP
      port: 80 # service out port
      targetPort: 8080 # service in port (application port in container)

물론 여전히 안 됩니다. http://<kubeserver-ip> 같은 주소로 접속해도 아무 효과가 없는데, 이유는 kube 서버가 외부 패킷을 어떻게 포워딩할지에 대한 정의가 없어서입니다.

이를 위해서 ingress라는 객체가 또 필요합니다! 다만 특이하게도 ingress는 ingress container이 실제 요청을 처리하게 됩니다. 즉 따라서 아래 두 가지 요소가 준비되어있어야 합니다.

  • kubenetes ingress 객체
  • ingress container (ingress-nginx)
# ingress-nginx
apiVersion: v1
kind: Service
metadata:
  name: nginx-ingress
  namespace: nginx-ingress
spec:
  type: NodePort
  ports:
  - port: 80
    targetPort: 80
    protocol: TCP
    name: http
  - port: 443
    targetPort: 443
    protocol: TCP
    name: https
  selector:
    app: nginx-ingress
---
# Ingress
apiVersion: v1
kind: Ingress
metadata:
  name: hello-world-ing
spec:
  rules:
  - host: yourhostname.com
    http:
      paths:
      - backend:
          serviceName: hello-world-svc
          servicePort: 80

여기까지 하면 비로소 외부에서 서비스 접근을 할 수가 있게 됩니다.

참고) Service type as LoadBalancer

서비스 타입을 LoadBalancer로 하면 로드밸런서가 ingress 역할을 대신 해 주는 방식으로 해서, 바로 외부에서 접근이 가능하게 됩니다.

(물론 서비스 프로바이더가 이를 지원해야 합니다)

참고) ingress 건너뛰고 바로 pod과 통신하기

일단 급한대로 ingress 없이 (혹은 무시하고) 통신이 하고 싶다면, kubectl 로 라우팅 경로를 뚫어줄 수 있습니다~

# port -- local : container

kubectl port-forward deployment/hello-world 80:8080

or

kubectl port-forward service/hello-world-svc 80:8080

4. TroubleShoot & Tips

몇 가지 겪었던 문제점들이나 팁을 정리해 놓습니다.

서비스 연결이 안 돼요

몇 가지 단계를 거쳐서 확인해 봐야 합니다.

  1. 서비스 정상 작동 확인: port-forward로 deploy, service를 일단 확인해 봅시다
  2. service.yaml 내용 확인: 세팅이 이상하게 되어 있을 수도 있음
  3. ingress 확인: 확률이 매우 낮지만, 관련 세팅이 빠진게 있는지 한번 더 체크

Dangling된 로컬 이미지 및 컨테이너 날리는 방법?

docker container prune
docker image prune

image 같은 경우, 아마 Running container이 없고 tag가 없는 경우(새 image로 tag를 덮어쓴 경우) 가 prune 대상일 겁니다.

Kubernetes에서의 로그를 확인하고 싶어요

kubectl logs -f -l name=hello-world --all-containers

# -f: 실시간 streaming log
# -l: log를 확인할 대상 pod을 label로 지정
# --all-containers: (label 조건이 맞는) 모든 pod들에 대한 로그 확인

부하에 따라 동적으로 node를 생성할 순 없나요?

HPA (Horizontal Pod Autoscaler)을 사용하여 만들 수 있습니다.

일단 skeleton 서비스를 만드는 데에는 불필요한 내용이라 제외했다. 나중에 기회가 되면 별도 포스트로 다뤄보고 싶다.

자세한 건 k8s의 공식 문서 참조. 테스트는 loader.io와 같은 방식으로 부하를 주어 테스트 할 수 있다는 듯.

  • AWS ELB와 같은 외부 서비스를 이용하는 것도 한 방법입니다. (LoadBalancer 객체로 연동 가능)

쿠버네티스의 리소스를 편하게 모니터링 할 순 없을까요?

prometheus나 grafana를 주로 씁니다.

아주 간단하게 확인하려면, kubectl top podkubectl top node 명령어를 사용 가능합니다. CLI 기반이라면 k9s 도 있고요.

추가: Automation

이 모든 과정을 Ansible을 통해서 자동화 시켜봅시다.

물론 bash로도 가능하겠지만, ansible이 보다 더 유연하고(docker대신 pipeline으로서의 기능이 많아 자동화 측면에서 훨씬 좋습니다. 여기서는 아주 간단한 예시만 남겨둡니다. (작동 보장 못 함)

cat update.yml
---
- hosts: build docker image and push
  gather_facts: no
  tasks:
    - name: build container image
      docker_image:
        name: hello_world
                tag: 1.01
        path: ~/proj/hello_world
                push: yes

        - name: apply kubernetes config
          kubernetes.core.k8s:
            state: present
            src: ~/proj/deployment.yaml

이렇게 만들어진 스크립트를 Jenkins 등의 파이프라인에 올려두고 쓸 수도 있겠죠?

도보시오

  • Kubernetes 네트워크 및 Routing에 대해서
    • 쿠버네티스 내부의 가상IP와 ingress 기반의 라우팅 등등에 대해 세부적으로 어떻게 동작하는지 알면 좋을지도요..?
    • 조금 특이사항을 적어놓자면 Service의 라우팅이 주목할 점입니다. 별도의 네트워크 인터페이스 같은걸 이용하지 않고, 커널의 기능(netfilter)을 이용하여 패킷을 후킹하는 구조로 되어 있습니다. 아무래도 패킷 후킹을 통해 클러스터 상태 파악도 겸사겸사 할 겸 이렇게 만들었을까요?

출처