기록은 기억의 연장선

더 많은 것을 기억하기 위해 기록합니다


  • Home

  • Tags

  • Categories

  • Archives

  • Search

nextstep 교육과정 및 멘토링 후기

Posted on 2020-11-02 | In life | Comments: 5 Comments

nextstep 교욱과정

작년 3월, 그러니까 1년 반전 쯤… 이쪽 업계에서는 유명하신 박재성(자바지기)님이 하시는 교육과정을 들은적이 있었다.
교육과정명은 클린코드를 위한 TDD, 리팩토링 with Java 이었는데, 과정은 대충 이러했다.

매주 금요일 3시간의 수업을 진행한다(총 5주)

매주 금요일 오후 7시 반에 강남역에 모여서 수업을 진행한다.
수업 내용은 대부분 OOP, TDD, refactoring 에 대한 내용이었고, 가끔씩 재성님의 인생 철학(!)을 얘기해주시기도 하신다.
수업 내용은 아주 유익하고, 인생 철학 또한 아주 유익하다 ㅋㅋㅋ

수업과 별개로 과제를 진행한다

이 과제가 교육과정의 핵심이다.
프로그래밍 언어로 자동차 경주 게임, 로또, 사다리 게임 등을 직접 구현하는 과제이다(출력은 콘솔로 한다).
단순히 구현하는 것이 아니라, 교육과정 중에 배운 OOP, TDD, refactoring 을 열심히 사용(?)하여 구현해야 한다.
그리고 각 과제마다, 담당 코드 리뷰어들이 있다.
(리뷰어는 이미 이 과정을 수료하신 분들로 구성되어 있다.)
하나의 과제는 여러 step 들로 구성되어 있으며, step 별 요구사항을 완료하여 PR 을 보내면 리뷰어가 리뷰를 해주고, approve 가 되면 다음 step 으로 넘어가게 되는 구조이다.
이렇게 총 5개(1개는 optional)의 과제를 완료하면 교육과정을 수료할 수 있게 된다.

어렵다 어려워

매번 서비스 메서드에 모든 로직을 다 떄려박던 나같은 트랜잭션 스크립터(?)에게 이 과제는 매우 어려웠었다.
지금까지 내가 해오던 코딩과는 너무 달랐기 때문이었다.
어떤 기준으로 객체를 설계해야할지, 객체끼리는 어떻게 메시지를 주고 받아야할지, 테스트는 제대로 짜고 있는지…
그야말로 혼란스러웠다.
몇시간동안 한줄도 못 짜고 머리를 싸매며 고통스러워 했었던적도 한두번이 아니었던 것 같다.

그래도 매일 고민하고, 리뷰어님들의 아주아주 정성스러운 피드백들을 받다보니 시간이 지날수록 조금씩 나아지는 내 모습을 발견할 수 있었다.
코드리뷰

그저 빛…

결과적으로 5주 동안 필수 과제 4개를 전부 완료하고, 한 단계 더 성장한 모습으로 이 과정을 수료할 수 있었다.

이제는 실전

회사로 돌아왔다(교육과정 듣는다고 회사를 안간것은 아니었지만…).
회사 코드를 봤다.
트랜잭션 스크립트 패턴이 많고, 테스트도 많이 없다. 개선해야 할 부분이 많아 보인다.
하지만 코드 자체가 워낙 양이 많고 복잡하며, 다른 코드나 시스템끼리 복잡하게 의존되는 부분이 많아서 어디서부터 어떻게 시작해야할지 정하기가 힘들었다.
그래도 훈련소까지 갔다왔는데… 그냥 포기할수는 없었다.

기존에 존재하던 코드들에 대해 테스트를 작성하기 시작했고, 테스트 작성이 완료되고나니 조금씩 리팩토링 하는것이 가능했다.
그렇게 하나의 프로젝트를 전반적으로 리팩토링했다.
굉장히 뿌듯했다.

새로운 프로젝트를 들어갔을때에도, 클린코드에 대한 끈을 놓지 않으려고 했다.
항상 테스트를 작성했으며, 객체 설계에 대해 매번 고민하고 작성했다.

그렇게 1년 정도 실전에서 훈련한 결과, 확실히 코드 자체가 작년에 비해 많이 달라진것을 느끼고 있다.
아직 갈 길은 많이 멀었지만, 작년에 이 과정을 듣지 못했다면 아직까지 나는 얽히고 섥힌 코드들을 유지보수하느라 고통받고 있지 않았을까 싶다.


nextstep 멘토링

이 교육에는 수업 이후에 멘토링을 해주는 과정이 추가적으로 포함되어 있다.
멘토링은 이 과정을 수료한 수강자에 한해서 모두 제공된다.

단순히 수업을 듣는다고 수료가 되는것이 아니라, 필수과제 4개의 과제를 모두 제출해 통과해야만 수료가 된다.

멘토링에서 해주는 내용은 아래와 같다.

  • 이력서 검토 및 피드백
  • 온라인, 오프라인 모의 면접
  • 추천

어쩌다보니 나도 이번에 이직 시기가 되어서, 멘토링을 신청하게 되었다.

이력서 검토 및 피드백

일단 자유 포멧으로 이력서를 작성해 전달해주면, 이력서를 전체적으로 점검하고 피드백을 작성하여 전달해주신다.
이력서 피드백

이런식으로 전달해주신다. 매우 좋다!!!

전달받은 피드백을 적용해서 이력서를 다시 전달해드리면 또 추가적으로 피드백을 주시게 되고, 이런식으로 몇번 왔다갔다 하면서 이력서를 전체적으로 다듬어가는 과정이다.
이 과정에서 확실히 처음에 비해 이력서가 매우 나아지는 과정을 경험했다.

온라인 모의 면접

처음에는 그냥 화상 회의라고 하셔서 부담없이 들어갔는데… 온라인 모의 면접이었다😅
이력서를 기반으로 꼼꼼하게 질문해주시고, 이 질문이 어떤 의도였는지, 내 대답이 어땠으면 좋았을지에 대해 바로 바로 피드백 해주신다.

그리고 다음 오프라인 면접 날짜를 잡고, 그 날짜까지 추가로 더 준비해오면 좋을 것들에 대해 숙제(!)를 내주신다.

개인적으로 피드백 해주시는 내용들이 도움이 많이 되었고, 내 스스로 내가 한 일들에 대해 제대로 정리되어 있지 않았다는 사실을 깨달을 수 있었다.

오프라인 모의 면접

온라인 면접 후, 딱 1주일 뒤에 오프라인 모의 면접을 진행했다.
오프라인 모의 면접은 잠실역에 있는 우형 사무실에서 진행했다.
총 1시간 정도 진행되며, 온라인 면접처럼 바로 피드백을 주지 않고 실제 면접과 동일한 분위기로 모의 면접을 진행하였다.
그리고 오프라인인 만큼 내가 수행했던 프로젝트에 대해 화이트보드에 그려가며 설명하는 과정들이 많았다.

확실히 모의면접을 진행하고나니 자신감도 붙고, 앞으로 어떤 부분들을 더 준비해야할지 더 잘 알게 되었다.

추천

이후에는 추천이다.
이력서, 모의면접 과정을 다 진행하고 난 후에 통과가 되면 nextstep 쪽에서 연계된 기업들에 한해 추천을 해준다.
단순히 내 이력서만 전달해주시는 것이 아니라, 교육과정과 멘토링 진행할 때의 내 모습을 기반으로 직접 추천서를 작성해서 같이 전달해주신다.

그래서 결과는?

모의면접 진행이 끝난 후 실제 이력서를 내고 면접을 진행하였고, 최종적으로 원하던 기업에 합격하여 다음주에 입사하게 된다.
면접 과정에서 nextstep 에서 받았던 멘토링 과정이 도움이 많이 되었다.

평소에 갈증이 많은 개발자라면 nextstep 의 교육 과정을 들어보는 것을 추천한다.

sql and or 순서

Posted on 2019-10-11 | Edited on 2020-11-02 | In db | Comments: 0 Comments

다들 알다시피 SQL 에서 소괄호 없이 AND OR 을 사용하면 AND -> OR 의 순서로 처리된다
그렇다면 아래와 같은 쿼리는 어떻게 처리되는걸까?

1
2
3
select count(*) 
from employees
where year(hire_date) = '1998' or year(hire_date) = '1999' and gender = 'M';

이 쿼리는 and 조건 먼저 실행되고 그다음 or 조건이 실행된다
이 쿼리는 아래의 두 쿼리를 합친것과 동일하다

1
2
3
4
5
select count(*) from employees 
where year(hire_date) = '1999' and gender = 'M';

select count(*) from employees
where year(hire_date) = '1998';

결론은 그냥 알아보기 너무 힘드니까 입닥치고 괄호로 감싸주자

Read more »

[java] 쓰레드 기본

Posted on 2019-07-21 | Edited on 2020-11-02 | In java | Comments: 0 Comments

기본적으로 서버 프로그램의 경우 많은 동시에 많은 사용자의 요청을 처리해야 하므로 멀티 쓰레드로 동작한다
우리가 매번 사용하는 톰캣 또한 사용자의 요청을 모두 쓰레드가 처리하는, 멀티 쓰레드 구조이다
그래므로 쓰레드에 대한 지식은 필수이다
라고 말했지만 나는 겉핥기밖에 모르는 것 같아서 다시 기초부터 공부하고 있고, 이를 정리하고자 한다

쓰레드에 대한 기본 지식은 구글에 검색하면 아주 아주 잘 설명된 글들이 많으니 그것을 참조하면 되고, 여기서는 자바에서 쓰레드를 사용하는 법에 대해 정리하겠다

쓰레드 구현

자바에서 쓰레드를 구현하는 방법은 Thread 클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법이 있다
자바에서는 상속이 비싼 행위이기 때문에 보통은 Runnable 인터페이스를 구현하여 쓰레드를 구현한다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MyThread1 extends Thread {
@Override
public void run() {
/* 쓰레드에서 실행할 내용 */
}
}

class MyThread2 implements Runnable {
@Override
public void run() {
/* 쓰레드에서 실행할 내용 */
}
}

class Main {
public static void main(String[] args) {
MyThread1 myThread1 = new MyThread1(); // Thread 상속 시
Thread myThread2 = new Thread(new MyThread2()); // Runnable 구현시

// 쓰레드 실행
myThread1.start();
myThread2.start();
}
}

Thread 클래스를 상속받은 경우 바로 인스턴스를 생성하면 되고, Runnable 인터페이스를 구현한 경우 Thread 클래스의 인자로 넘겨주면 된다
그리고 start() 메서드를 호출해서 쓰레드를 실행시키고 있음을 볼 수 있다

참고로 한번 사용한(start()가 이미 호출된) 쓰레드는 재사용 할 수 없다
두 번 이상 호출 시 IllegalThreadStateException이 발생한다

앞서 우리가 구현했던 run 메서드는 단순히 수행할 태스크만을 작성하는 부분이고, 실제로 쓰레드를 실행시키려면 위와 같이 start 메서드를 통해서 실행해야 한다
start 메서드 호출 시 쓰레드가 작업을 실행하는데 필요한 호출 스택을 생성하고 run 메서드를 실행하게 된다

!(thread call stack)[https://joont92.github.io/temp/thread-call-stack.jpg]

위는 메인 메서드에서 2개의 쓰레드를 실행시켰을 때의 모습이다
보다시피 각 쓰레드들은 모두 별개의 작업이고, 스케줄링의 대상이 된다
스케줄러가 정한 실행순서에 따라 각 쓰레드들을 돌면서 연산을 수행하게 될 것이고, 수행이 끝난 쓰레드들은 호출스택이 비워지면서 먼저 종료가 될 것이다

CPU는 기본적으로 쓰레드를 기반으로 스케줄링을 한다

확인 필요(+ JVM 쓰레드 스케줄러)
즉, 메인 메서드가 수행을 마쳤다 하더라도 쓰레드가 남아있다면 프로그램은 종료되지 않음을 뜻한다
실행중인 사용자 쓰레드가 하나도 없을 경우 프로그램은 종료된다

그리고 언급했다시피 각각의 쓰레드들은 각각 별개의 작업흐름이기 때문에 한 쓰레드에서 예외가 발생해도 다른 쓰레드의 실행에는 영향을 미치지 않게 된다

싱글 쓰레드와 멀티 쓰레드

방금 아주 간단하게 멀티 쓰레드를 구성해봤는데,
사실 단순히 CPU만 사용해서 계산하는 작업을 수행할 경우 위와 같이 멀티쓰레드로 작업을 수행하는것이 더 비효율적이다
쓰레드간 context switching 비용이 발생하기 때문이다

하지만 쓰레드 내에서 연산 이외의 작업을 수행할 경우(CPU 이외의 자원을 사용할 경우), 멀티 쓰레드 프로세스가 훨씬 효율적이다
예를 들면 파일이나 네트워크 I/O 작업등이 있게 되는데, 특정 쓰레드가 이러한 작업을 수행하고 있을 경우 CPU는 이를 기다리지 않고 다른 쓰레드의 작업을 수행하면 되기 때문이다
만약 싱글 쓰레드였다면 I/O 작업이 끝날 때 까지 CPU가 대기해야 했을 것이다

쓰레드 우선순위

쓰레드는 우선순위(priority)라는 속성(멤버변수)을 가지고 있고, 이 우선순위에 따라 스케줄러가 할당하는 시간이 달라진다
쓰레드의 우선순위가 같다면 CPU는 각 쓰레드에게 거의 같은 양의 시간을 할당하지만, 우선순위가 다르다면 CPU는 우선순위가 높은 쓰레드에게 더 많은 작업시간을 할당한다
즉 쓰레드가 수행하는 작업의 중요도에 따라 쓰레드의 우선순위를 다르게 지정하여 특정 쓰레드가 더 많은 작업시간을 갖도록 처리할 수 있다
(예를 들면 채팅을 처리하는 쓰레드는 쓰레드는 파일을 전송하는 쓰레드보다 우선순위가 높아야한다)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
System.out.print("1");
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 100; i++) {
System.out.print("2");
}
});
thread1.setPriority(10);
thread2.setPriority(1);

thread1.start();
thread2.start();

// 출력 : 222222222222222222222222222222222222222222222222...........1111111111111111111111111111111111111...............

쓰레드가 가질 수 있는 우선순위의 범위는 1~10까지 이며, 숫자가 높을수록(작을수록) 우선순위가 높다
참고로 이 우선순위 값은 절대적인 것이 아니라 상대적인 값이다
값이 1정도 차이나는 경우에는 별 차이가 없지만, 2 이상 차이가 나면 실행시간에 많은 차이가 발생하게 된다

쓰레드의 우선순위는 따로 지정해주지 않으면 쓰레드를 생성한 쓰레드로부터 상속을 받게 된다(main 쓰레드는 우선순위가 5이다)

데몬 쓰레드

쓰레드는 사용자 쓰레드와 데몬 쓰레드 2종류가 있다
지금까지 언급했던 것은 전부 사용자 쓰레드이고, 데몬 쓰레드의 경우 일반 쓰레드의 작업을 돕는 보조적인 역할을 수행하는 쓰레드를 말한다
보조 역할을 하는 쓰레드이므로 일반 쓰레드가 모두 종료되면 데몬 쓰레드 또한 강제적으로 종료된다(더이상 필요없기 때문에)
데몬 쓰레드의 예로는 가비지 컬렉터, 자동저장, 화면 자동갱신 등이 있다

쓰레드를 데몬 쓰레드로 생성시키고 싶다면 아래와 같이 setDaemon() 메서드만 실행시켜주면 된다

1
2
3
4
Thread thread = new Thread(new MyThread());
thread.setDaemon(true); // start 전에 해줘야함

thread.start();

이 외에도 데몬 쓰레드가 생성한 쓰레드도 자동으로 데몬 쓰레드가 된다

데몬 쓰레드는 기본적으로 무한루프와 조건문을 이용해서 작성된다
(예시에서 봤듯이 가비지 컬렉터, 자동저장 등은 계속해서 상태를 체크해야하는 작업이다)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class AutoSaveDaemonThread implements Runnable {
@Override
public void run() {
while(true) {
try {
Thread.sleep(3 * 1000);
} catch(InterruptedException e) {
// do nothing
}

if(autoSave) {
autoSave();
}
}
}
}

JVM은 기본적으로 가비지 컬렉션, 이벤트처리, 그래픽처리와 같이 프로그램이 실행되는데 필요한 보조작업을 수행하는 데몬 쓰레드들을 자동적으로 실행시킨다

쓰레드 실행제어(Welcome To Hell!)

앞서 쓰레드의 우선순위로 쓰레드간 실행을 제어하긴 했지만, 사실 이것만으로는 부족하다
효율적인 멀티쓰레드 프로그램을 만들기 위해서는 보다 정교한 스케줄링을 통해 주어진 자원을 여러 쓰레드가 낭비없이 잘 사용하도록 프로그래밍 해야한다
이를 위해 자바에서는 쓰레드를 컨트롤 할 수 있는 기능을 제공하는데, 이를 알기전에 먼저 쓰레드의 상태에 대해 알고 있어야한다

thread states

  • NEW

    쓰레드가 생성되고 아직 start()가 호출되지 않은 상태

  • RUNNABLE

    start()를 호출했다고 바로 실행되는 것이 아니라, 큐의 구조로 된 실행 대기열에 저장되어 대기하다가 자기 차례가 되면 실행한다

  • RUNNING

    실행대기열에 있던 쓰레드가 자기 차례가 되어 실행중인 상태

  • WAITING

    실행중인 쓰레드가 특정 메서드(sleep(), wait(), join() 등)에 의해 일시정지 상태가 된 것을 말한다
    지정된 일시정지 시간이 다 되거나 특정 메서드(notify(), interrupt()) 호출이 발생하면 다시 RUNNABLE 상태로 돌아가 실행대기열에 저장된다

  • BLOCKED

    ?

  • TERMINATED

    쓰레드의 작업이 종료된 상태를 말한다
    작업이 종료된 쓰레드는 소멸되므로 사실상 쓰레드가 이 상태로 있는것은 아니다

아래는 쓰레드의 상태를 제어하는 주요 메서드들에 대한 설명이다

start

위에서 언급한 것처럼, 쓰레드를 실행시키는 메서드이다
바로 RUNNING 상태가 되지는 않고 RUNNABLE 상태로 되었다가 자기 차례가 되면 RUNNING 상태가 된다

join

지정한 쓰레드의 작업이 끝날 때 까지 기다리는 메서드이다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Thread thread1 = new Thread(new MyThread());
Thread thread2 = new Thread(new MyThread());

try {
thread1.start();
thread1.join(); // 1

thread2.start();
thread2.join(); // 2
} catch(InterruptedException e) {
// do nothing
}

System.out.println("job ended"); // 3

join() 메서드 실행 시, 기다리는 대상은 해당 쓰레드를 실행한 쓰레드이다
thread1, thread2를 실행한 쓰레드를 메인 쓰레드라고 가정한다면,

  1. main thread는 thread1 을 실행하고, thread1 의 작업이 끝날 때 까지 기다린다

메서드의 인자로 밀리초나 나노초를 줘서 특정 시간 동안만 기다리게도 설정할 수 있다

  1. thread1의 작업이 끝난 뒤 mani thread는 thread2 를 실행하고, thread2 의 작업이 끝날 때 까지 기다린다
  2. thread2의 작업이 끝나고 나면 main thread의 마지막 부분을 실행하고, main thread는 종료된다

보다시피 join 메서드는 한 쓰레드의 작업 중간에 다른 쓰레드의 작업이 필요할 경우 유용하게 사용할 수 있다

sleep

지정한 시간동안 작업을 일시정지 상태로 들어가는 메서드이다
static 메서드로 제공되며(인스턴스 메서드는 deprecated 되었다), 이 메서드를 호출한 쓰레드가 지정한 시간만큼 일시정시 상태가 된다

1
2
3
4
5
6
7
8
Thread thread = new Thread(new MyThread());
thread.start();

try {
Thread.sleep(5000);
} catch(InterruptedException e) {
//
}

5000 millisecond 이기 때문에 5초간 WAITTING 상태가 된다
5초가 지난 후 다시 RUNNABLE 상태가 되어 실행대기열에 저장된다

yield

RUNNABLE 상태로 들어가면서 다른 쓰레드에게 작업을 양보하는 메서드이다
이 또한 static 메서드로 제공되며, 이 메서드를 호출한 쓰레드가 RUNNABLE 상태로 들어가게 된다

다른 메서드들과의 차이점은 WAITING 상태로 들어가지 않고 바로 RUNNABLE 로 들어간다는 점이다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class MyThread implements Runnable {
@Override
public void run() {
// 1000번 loop
for(long i = 0; i < 1000; i++) {
}
System.out.println("sub thread has been terminated");
}
}

class Main {
public void static main(String[] args) {
Thread thread = new Thread(new MyThread());
thread.start();

Thread.yield();
System.out.println("main thread has been terminated");
}
}

원래라면 main thread 종료 문구 -> sub thread 종료 문구 의 순서로 출력되어야 하지만,
main thread 에서 yield 로 다른 쓰레드에게 실행을 양보함으로써 sub thread가 먼저 종료됨을 볼 수 있다

쓰레드의 실행을 양보하고 RUNNABLE 상태로 들어간 것이기 때문에,
sub thread의 작업 시간이 CPU 스케줄링에서 쓰레드에 할당하는 작업 시간보다 길 경우, main thread 종료 문구가 먼저 출력될 것이다
WAITING 상태로 들어가는 join 과는 다르다

suspend, resume, stop

이 3개의 메서드는 쓰레드를 교착상태(dead-lock)에 빠트릴 수 있어 deprecated 되었다
그렇다고 사용 못하는 것은 아니고, 아래와 같이 작성하여 구현할 수 있다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class MyThread implements Runnable {
private boolean suspended = false;
private boolean stopped = false;

private Thread thread;

public MyThread(String name) {
thread = new Thread(this, name);
}

@Override
public void run() {
while(!stopped) {
if(!suspended) {
try {
//
} catch(InterruptedException e) {
System.out.println(name + " interrupted");
}
} else {
Thread.yield();
}
}
}

public void suspend() {
suspended = true;
thread.interrupt();
}

public void resume() {
suspended = false;
}

public void stop() {
stopped = true;
thread.interrupt();
}

public void start() {
thread.start();
}
}

stopped, suspended flag 값으로 기존의 suspend(), stop() 메서드를 구현하였다
게다가 suspend 상태일때는 Thread.yield() 를 발생시켜 불필요한 while 문을 돌지 않도록 하였으며,
stop()과 suspend() 시에 interrupt()를 발생시켜 thread가 WAITING 상태라면 깨워서 RUNNABLE 상태로 가도록 설정하였다
(interrupt() 시에 WAITING 상태가 아니라면 아무일도 일어나지 않는다)

쓰레드의 동기화

멀티 쓰레드 환경의 경우 여러 쓰레드가 같은 프로세스 내의 자원을 공유하기 때문에, 서로의 작업에 영향을 줄 수 있다
자바의 경우, 힙 영역의 경우 JVM 프로세스가 공유하는 영역이고, 스택 영역의 경우 각 쓰레드마다 별개로 가지는 영역이다
그러므로 우리는 항상 힙 영역의 데이터를 사용할 경우 주의를 기울여야 한다
아래는 힙 영역의 데이터를 공유함으로써 문제가 발생하는 것에 대한 예시이다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Account {
int amount;

public Account(int amount) {
this.amount = amount;
}

public void withdraw(int money) {
if(amount >= money) {
amount-=money;
}
}
}

class MyThread implements Runnable {
private Account account;

public MyThread(Account account) {
this.account = account;
}

@Override
public void run() {
account.withdraw(1000);
}
}

class Main {
public void static main(String[] args) {
Account account = new Account(1000);

Thread thread1 = new Thread(new MyThread(account));
Thread thread2 = new Thread(new MyThread(account));

thread1.start();
thread2.start();
}
}

잔고 1000원의 계좌를 생성하고, 1000원을 출금하는 쓰레드를 2개 생성하여 동시에 실행시켰다

결과로 항상 0원이 나올것이라 생각했지만, 돌려보면 -1000원이 나올수도 있다
(위 정도의 작업에서는 거의 무조건 0원이 나오므로, if 문 안에 Thread.sleep 을 조금 걸고 테스트해보면 확인할 수 있다)

이는 thread1과 thread2가 account 객체를 공유하기 때문에 발생하는 문제이다
예를 들면 thread1이 if문을 통과하고 아직 amount를 차감하지 않은 상태에서, thread2가 if문을 통과하는 경우가 될 것이다
이런 상황이 발생하면 결국 두 쓰레드 모두 amount 값을 차감하게 되고, 우리는 음수 결과값을 받아보게 되는 것이다

쓰레드 프로그램 작성시에 스택내의 변수만을 사용하여 위와 같은 상황을 안 만들면 되지 않느냐고 생각할 수 있지만,
로직을 처리하다 보면 결국에는 위와 같이 공용 변수에 접근하는 순간이 발생하므로, 위와 같은 현상은 피할 수 없다

그러므로 위 withdraw 같은 메서드들은 하나의 쓰레드에서 접근하고 있을 경우 다른 쓰레드에서 접근하지 못하도록 락을 걸어 데이터가 틀어지는 것을 방지해야 하는데, 이를 동기화라고 한다
자바에서는 snychronized 키워드를 사용해 이를 구현할 수 있고, 방법은 아래와 같이 메서드에 synchronized 를 선언하는 방법과, synchronized block을 선언하는 방법이 있다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* synchronized method
**/
public synchronized void withdraw(int money) {
if(amount >= money) {
amount-=money;
}
}

/**
* synchronized block
**/
public void withdraw(int money) {
synchronized(this) {
if(amount >= money) {
amount-=money;
}
}
}

그리고 이런식의 동기화 블록을 사용해서 프로그램을 작성시에는 교착상태(dead-lock)을 항상 주의해줘야 한다
만약 thread가 락을 건 상태에서 정지되거나 종료된다면 이를 기다리는 다른 쓰레드들이 전부 데드락에 빠지기 떄문이다
이같은 이유로 suspend(), stop() 메서드등이 deprecated 되었다

wait, notify

한 쓰레드가 객체에 lock을 걸 경우, 이 객체를 사용하려는 다른 쓰레드들은 lock 이 풀릴떄까지 무작정 같이 기다려야하는 비효율이 발생하게 된다
이 같은 상황을 방지하기 위해 나온 메서드가 wait()과 notify()이다

wait()과 notify(), notifyAll()은 Object 클래스에 정의된 메서드이므로 모든 객체에서 호출이 가능하며, synchronized 블록 내에서만 사용할 수 있다
wait()이 호출되면 쓰레드는 이때까지 자기가 걸고있던 모든 락을 다 풀고, WAITING 상태로 전환되면서 wait()이 호출된 객체의 대기실에서 대기하게 된다
그러다가 다른 쓰레드에 의해 해당 객체의 notify()나 notifyAll()이 호출되면, 객체의 대기실에서 나와 다시 RUNNABLE 상태로 들어가게 된다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Account {
int amount;

public Account(int amount) {
this.amount = amount;
}

public synchronized void withdraw(int money) {
while(amount < money) { // 1
try {
wait(); // 2
} catch(InterruptedException e) { }
}

amount -= money;
}

public synchronized void deposit(int money) {
balance += money;
notifyAll(); // 3
}
}
  1. 전달받은 금액을 출금하기 위해 메서드에 락을 걸고 들어왔으나, 가지고 있는 잔고보다 출금하려는 금액이 더 많은 상황이다
  2. 그러므로 출금하지 못하고, Account 객체의 wait()를 호출한다
  • withdraw 에 걸고 있는 락이 풀리게 된다
  • 쓰레드는 WAITING 상태로 들어가게 된다
  • 쓰레드는 Account 객체 인스턴스의 대기실(waiting room)에서 기다리게 된다
  1. deposit 메서드가 호출되며 금액이 채워지고, notifyAll()을 호출한다
  • Account 객체 인스턴스의 대기실에 있는 모든 쓰레드들을 깨운다
  • 깨어난 쓰레드는 2번의 위치에서 다시 흐름을 이어가게 되는데, while 문이므로 다시 출금 가능한지 검사하게 된다

    if문 대신 while문을 사용한 이유이다

nofiy() 대신 notifyAll() 을 사용하나 사실 별 차이는 없지만,
notify()를 사용해서 한개의 쓰레드만 꺠울 경우 어떤 쓰레드가 꺠워질지 알수 없고,
이로인해 우선순위가 높은 쓰레드가 계속 pool에 머물게 될 수 도 있으므로, notifyAll()을 통해 모든 쓰레드를 꺠워서 스케줄러에 의해 처리되도록 해주는 것이 좋다

참고 :

  • 남궁성, 『Java의 정석 2nd Edition』, 도우출판(2010)

[kubernetes] 배포 전략

Posted on 2019-07-13 | Edited on 2020-11-02 | In kubernetes | Comments: 4 Comments

쿠버네티스는 2가지 방법으로 무중단 배포를 수행할 수 있다

롤링 업데이트

Deployment 속성중에 .specs.strategy.type 값을 통해 Pod 교체전략을 지정할 수 있는데, 여기서 RollingUpdate 를 사용하는 방법이다
RollingUpdate는 우리가 잘 알다시피, Pod를 하나씩 죽이고 새로 띄우면서 순차적으로 교체하는 방법이다

RollingUpdate, Recreate 2개의 값이 존재하며 Recreate의 경우 기존 파드를 모두 삭제한 다음 새로운 파드를 생성하는 방법이다(이 방식은 무중단이 아니다)
기본값은 RollingUpdate 이다

RollingUpdate를 테스트하기 위해 먼저 deployment 와 service를 정의한다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apiVersion: apps/v1
kind: Deployment
metadata:
name : test-deployment
spec:
replicas: 4
selector:
matchLabels:
app: test-pod
template:
metadata:
labels:
app: test-pod
spec:
containers:
- name: test-pod
image: joont92/echo-version:1.0 # 단순히 요청을 받으면 version을 리턴해주는 서버이다
ports:
- containerPort: 8080
1
2
3
4
5
6
7
8
9
10
apiVersion: v1
kind: Service
metadata:
name: test-service
spec:
ports:
- port: 80
targetPort: 8080
selector:
app: test-pod

deployment의 기본 속성이 RollingUpdate 이므로 따로 RollingUpdate 부분을 명시하지 않았다

이제 다른 Pod에서 위 서비스명으로 호출하면 1.0 이라는 버전을 리턴해주게 된다
이를 2.0을 리턴해주는 이미지로 바꿀건데, RollingUpdate 를 이용하여 바꿔보도록 할 것이다

먼저 위의 서비스 명으로 호출하는 Pod를 하나 만들어야한다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
apiVersion: v1
kind: Pod
metadata:
name: update-checker
spec:
containers:
- name: update-checker
image: joont92/update-chekcer:latest # curl 이 깔려있는 alpine 리눅스
command:
- sh
- -c
- |
while true
do
echo "[`date`] curl -s http://test-service/"
sleep 1
done

이 Pod가 뜨게되면 1초마다 test-service를 호출하게 되고, test-service 에서 사용하는 Pod 가 받아서 0.1.0 이라는 값을 리턴해주게 될 것이다

1
2
3
[Sat Jul 13 14:08:27 UTC 2019] APP_VERSION=0.1.0
[Sat Jul 13 14:08:28 UTC 2019] APP_VERSION=0.1.0
...

이제 Deployment 내 Pod의 이미지를 바꿔서 적용시켜보겠다
먼저 변경할 내용을 작성하고,

1
2
3
4
5
6
spec:
template:
spec:
containers:
- name: echo-version
image: joont92/echo-version:0.2.0 # 버전 변경

기존의 Deployment 에 적용한다

1
$ kubectl patch deplyment test-deployment -p "$(cat patch-deployment.yaml)"

참고로 아래처럼 할수도 있다

1
$ kubectl set image deployment test-deployment test-pod=joont92/echo-version:0.2.0

변경사항을 적용하자, 컨테이너가 하나씩 삭제되고 생성되는 것을 볼 수 있다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
$ kubectl get pod -l app:test-pod -w
NAME READY STATUS RESTARTS AGE
test-deployment-6b8d4f7967-9gwxl 0/1 Terminating 0 46s
test-deployment-6b8d4f7967-f9nlf 1/1 Running 0 48s
test-deployment-6b8d4f7967-knrws 1/1 Running 0 48s
test-deployment-6b8d4f7967-wkd4l 0/1 Terminating 0 46s
test-deployment-86658bfcfd-4kf2z 1/1 Running 0 3s
test-deployment-86658bfcfd-ckxvx 0/1 ContainerCreating 0 1s
test-deployment-86658bfcfd-d4znq 0/1 ContainerCreating 0 3s
test-deployment-86658bfcfd-d4znq 1/1 Running 0 4s
test-deployment-6b8d4f7967-knrws 1/1 Terminating 0 49s
test-deployment-86658bfcfd-s96t6 0/1 Pending 0 0s
test-deployment-86658bfcfd-s96t6 0/1 Pending 0 0s
test-deployment-86658bfcfd-s96t6 0/1 ContainerCreating 0 1s
test-deployment-6b8d4f7967-wkd4l 0/1 Terminating 0 49s
test-deployment-6b8d4f7967-wkd4l 0/1 Terminating 0 49s
test-deployment-86658bfcfd-ckxvx 1/1 Running 0 4s
test-deployment-6b8d4f7967-f9nlf 1/1 Terminating 0 51s
test-deployment-6b8d4f7967-knrws 0/1 Terminating 0 52s
test-deployment-6b8d4f7967-knrws 0/1 Terminating 0 52s
test-deployment-6b8d4f7967-knrws 0/1 Terminating 0 52s
test-deployment-86658bfcfd-s96t6 1/1 Running 0 4s
test-deployment-6b8d4f7967-f9nlf 0/1 Terminating 0 54s
test-deployment-6b8d4f7967-f9nlf 0/1 Terminating 0 54s
test-deployment-6b8d4f7967-f9nlf 0/1 Terminating 0 54s
test-deployment-6b8d4f7967-9gwxl 0/1 Terminating 0 53s
test-deployment-6b8d4f7967-9gwxl 0/1 Terminating 0 53s

롤링 업데이트 동작 제어

Deployment의 .specs.strategy 속성을 설정하여 롤링 업데이트의 동작을 제어할 수 있다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: apps/v1
kind: Deployment
metadata:
name : test-deployment
spec:
replicas: 4
strategy: # 요기
type: RollingUpdate
rollingUpdate:
maxUnvailable: 3
maxSurge: 4
selector:
matchLabels:
app: test-pod
template:
metadata:
labels:
app: test-pod
spec:
containers:
- name: test-pod
image: joont92/echo-version:0.1.0
ports:
- containerPort: 8080

maxUnvaliable은 롤링 업데이트시 동시에 삭제할 수 있는 파드의 최대 개수를 의미하고, maxSurge는 동시에 생성될 수 있는 파드의 최대 개수를 의미한다
(둘 다 기본값은 replicas 값의 25%이다)
이 값을 적절히 수정하면 RollingUpdate 시간을 단축할 수 있으나, 갑자기 한 Pod에 부하가 몰리거나 서버의 리소스가 잠깐 급증하는 사이드이팩트도 있으니 잘 설정해줘야 한다

블루-그린

롤링 업데이트는 강력하지만, 구버전과 새버전이 공존하는 시간이 발생한다는 단점이 있다
이 문제를 해결하기 위해 사용되는게 이 블루-그린 배포 인데, 이는 서버를 새버전과 구버전으로 2세트를 마련하고, 이를 한꺼번에 교체하는 방법이다

기존에 아래와 같이 Service, Deployment가 생성되어 있었다고 치자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: apps/v1
kind: Deployment
metadata:
name : test-deployment-blue
spec:
replicas: 4
selector:
matchLabels:
app: test-pod
color: blue
template:
metadata:
labels:
app: test-pod
color: blue
spec:
containers:
- name: test-pod
image: joont92/echo-version:0.1.0 # 구버전
ports:
- containerPort: 8080
1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: Service
metadata:
name: test-service
spec:
ports:
- port: 80
targetPort: 8080
selector:
app: test-pod
color: blue

blue-green 배포를 하기 위해, 새로운 버전이 적용된 Deployment 세트를 하나 더 준비한다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: apps/v1
kind: Deployment
metadata:
name : test-deployment-green
spec:
replicas: 4
selector:
matchLabels:
app: test-pod
color: green
template:
metadata:
labels:
app: test-pod
color: green
spec:
containers:
- name: test-pod
image: joont92/echo-version:0.2.0 # 신버전
ports:
- containerPort: 8080

아래가 블루-그린 배포를 하기 위한 핵심 부분이다

1
2
3
spec:
selector:
color: green
1
$ kubectl patch service test-service -p "$(cat patch-service.yaml)"

test-service의 label selector가 업데이트 됨으로써, test-service는 새로 배포된 Pod 들만을 바라보게 변경되었다
이처럼 구버전과 신버전이 공존하는 텀이 없이 바로 신버전으로 전환할 수 있다(!!)
게다가 만약 새로 배포된 버전에 문제가 발생한다면, 다시 test-service의 label을 blue로 돌려줌으로써 쉽게 롤백도 가능하다

기존에 물려있던 트래픽에 대해서는… 롤링배포와 블루그린 배포에서 차이점이 뭘까?

참고 :

  • 야마다 아키노리, 『도커/쿠버네티스를 활용한 컨테이너 개발 실전 입문』, 심효섭 옮김, 위키북스(2019)

[kubernetes] kubectl context

Posted on 2019-07-03 | Edited on 2020-11-02 | In kubernetes | Comments: 0 Comments

쿠버네티스 클러스터를 관리하는 cli 도구인 kubectl에는 환경을 바꿔가며 클러스터를 관리할 수 있는 context 라는 기능?이 존재한다
예를 들어 내 로컬 pc에 설치된 쿠버네티스 클러스터용 context 를 사용하면 kubectl 명령으로 내 로컬 쿠버네티스 클러스터를 컨트롤 할 수 있게되며,
GCP에 있는 쿠버네티스 클러스터용 context를 사용하면 kubectl로 GCP 쿠버네티스 클러스터를 컨트롤 할 수 있게 되는 것이다

설정

context 는 kubectl 을 깔면 생성되는 파일인 ~/.kube/config 파일에서 설정할 수 있다
아래는 내 PC의 config 파일이다(보기쉽게 조금 수정했다)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
apiVersion: v1
clusters:
- cluster:
insecure-skip-tls-verify: true
server: https://localhost:6443
name: local-cluster
- cluster:
certificate-authority-data: ~~~~
server: https://xxx.xxx.xxx.xxx
name: gcp-cluster

users:
- name: local-user
user:
blah blah
- name: gcp-user
user:
blah blah

contexts:
- context:
cluster: gcp-cluster
user: gcp-user
name: gcp-context
- context:
cluster: local-cluster
user: local-user
name: local-context

current-context: local-context

kind: Config
preferences: {}

크게 clusters, users, contexts 가 있다

  • clusters

    말 그대로 쿠버네티스 클러스터의 정보이다
    내 PC에 설치된 쿠버네티스 클러스터와, GCP에 설치된 쿠버네티스 클러스터가 있음을 볼 수 있다
    각 클러스터의 이름을 local-cluster, gcp-cluster로 수정해서 알아보기 쉽게 해놓았다
    처음 클러스터 생성하면 조금 복잡?한 이름으로 생성되는데, 위처럼 자신이 알아보기 쉽게 바꿔주는 것이 좋다

  • users

    클러스터에 접근할 유저의 정보이다
    각 환경마다 필요한 값들이 다르다
    이 또한 알아보기 쉽게 local-user, gcp-user 로 수정해놓았다

  • context

    cluster와 user를 조합해서 생성된 값이다
    cluster의 속성값으로는 위에서 작성한 cluster의 name을 지정했고, user의 속성값 또한 위에서 작성한 user의 name을 지정했다
    local-context는 local-user 정보로 local-cluster에 접근하는 하나의 set가 되는 것이다

  • current-context

    현재 사용하는 context 를 지정하는 부분이다
    현재 local-context 를 사용하라고 설정해놓았으므로, 터미널에서 kubectl 명령을 입력하면 로컬 쿠버네티스 클러스터를 관리하게 된다

context 조회 및 변경(feat. kubectx)

가장 단순한 방법으로는 ~/.kube/config 파일을 컨트롤하여 context를 조회하거나 수정하는 방법이 있고,
그 다음 방법으로는 kubectl config 명령을 이용하는 방법이 있다

1
2
3
4
5
6
7
8
9
# gcp-context 로 변경
$ kubectl config use-context gcp-context

# context 조회
$ kubectl config get-contexts

CURRENT NAME CLUSTER AUTHINFO NAMESPACE
* gcp-context gcp-cluster gcp-user
local-context local-cluster local-user

하지만 이것보다 kubectx 라는 더 최적화된 도구가 있다

github : https://github.com/ahmetb/kubectx

간단하게 brew 로 설치할 수 있다
아래는 간단한 사용법이다

1
2
3
4
5
6
7
8
9
10
# context 조회
$ kubectl
gcp-context # 노란색으로 표시
local-context

# local-context 로 변경
$ kubectx local-context

# 이전 context로 돌아가기
$ kubectx -

[docker] network 구조

Posted on 2019-06-29 | Edited on 2020-11-02 | In docker | Comments: 0 Comments

도커를 공부하면서 네트워크 부분에서 알게된 부분은 아래와 같았다

  • 도커 컨테이너를 띄울때마다 매번 동일한 네트워크 대역의 IP가 할당된다
  • 각 컨테이너들은 서로 통신이 가능하다
  • docker-compose로 컨테이너를 띄우면 다른 네트워크 대역의 IP가 할당된다

이런것을 알게되다 보니 도커의 네트워크 구성이 궁금해졌고, 찾아가며 공부한 내용을 정리하고자 한다
(참고에 있는 글들에 훨씬 잘 설명되어있으니 그걸 읽는게 더 낫다… 난 그냥 개인 정리용…ㅎ)


veth interface, NET namespace

도커의 네트워크 구조를 이해하기 위해선 먼저 리눅스의 NET namespace와 veth interface에 대해 알아야한다

  • veth interface

    간단히 말해 랜카드에 연결된 실제 네트워크 인터페이스가 아니라, 가상으로 생성한 네트워크 인터페이스이다
    일반적인 네트워크 인터페이스와는 달리 패킷을 전달받으면, 자신에게 연결된 다른 네트워크 인터페이스로 패킷을 보내주는 식으로 동작하기 때문에 항상 쌍(pair)로 생성해줘야 한다

  • NET namespace

    리눅스 격리 기술인 namespace 중 네트워크와 관련된 부분을 말한다
    네트워크 인터페이스를 각각 다른 namespace에 할당함으로써 서로가 서로를 모르게끔 설정할 수 있다

(자세한 내용은 https://bluese05.tistory.com/28 를 참조한다… 100배 설명이 더 잘되어 있다)

도커 네트워크 구조

도커는 위에서 언급한 veth interface와 NET namespace 를 사용해 네트워크를 구성한다
그림으로 보면 아래와 같다

docker-network

  1. 생성되는 도커 컨테이너는 namespace 로 격리되고, 그 상태에서 통신을 위한 네트워크 인터페이스(eth0)를 할당받는다
  2. host PC의 veth interface 가 생성되고 도커 컨테이너 내의 eth0 과 연결한다

    컨테이너의 네트워크 격리를 달성하기 위해 선택한 방법인 것 같다

  3. host PC의 veth interface 는 docker0 이라는 다른 veth interface 와 연결된다
  4. 이 과정이 컨테이너가 추가될 때 마다 반복된다

여기서 나오는 docker0은 docker 실행 시 자동으로 생성되는 가상 브릿지 이다
컨테이너가 생성될 때 마다 가상 인터페이스가 생성되고, 이 브릿지에 바인딩 되는 형태라고 보면 된다
즉, 모든 컨테이너는 외부로 통신할 때 이 docker0 브릿지를 무조건 거쳐가야 되는 것이다
이러한 특징 때문에 도커 컨테이너끼리 서로 통신이 가능한 것이다

docker0 브릿지에 할당된 ip 대역에 맞춰 컨테이너들도 ip가 할당된다
e.g. docker0 = 172.17.0.1/16, container1 = 172.17.0.2, container2 = 172.17.0.3 …
IP는 veth가 가지는걸까, eth0 이 가지는걸까?(가상 네트워크 인터페이스에 대한 이해가 조금 더 필요하다)

아래는 컨테이너 2개를 띄웠을 떄의 모습이다

1
2
3
4
5
6
7
8
9
10
11
12
$ ip link ls

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 08:00:27:d8:72:d9 brd ff:ff:ff:ff:ff:ff
3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
link/ether 02:42:c0:e5:8c:93 brd ff:ff:ff:ff:ff:ff
4: vethf3bf38b@if34: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP mode DEFAULT group default
link/ether 02:13:50:cf:5c:48 brd ff:ff:ff:ff:ff:ff link-netnsid 1
5: veth1680438@if36: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP mode DEFAULT group default
link/ether f2:b2:f7:eb:a5:55 brd ff:ff:ff:ff:ff:ff link-netnsid 2

그리고 아래는 브릿지에 연결되어 있는 veth interface 를 조회한 모습이다

1
2
3
4
$ brctl show docker0
bridge name bridge id STP enabled interfaces
docker0 8000.0242c0e58c93 no veth1680438
vethf3bf38b

참고로 mac이나 window에서는 이 docker0 브릿지나 veth interface 들을 볼 수 없다
VM 안으로 감쳐줘 있기 때문이다

사실 docker container의 네트워크 모드는 총 4개이다

bridge, host, container, none 으로 총 4개가 존재한다
위에서 살펴본 구조가 bridge 네트워크로 생성한 container의 동작방식이며, default 값이다
kubernetes의 pod을 생성할때는 container 모드를 사용한다
자세한 내용은 https://bluese05.tistory.com/38를 참조한다

docker-compose 로 띄우면 다른 네트워크 대역을 가진다

docker-compose 를 공부할 때 docker-compose 로 띄운 컨테이너들은 모두 같은 네트워크에 속한다는 말이 있었고, 실제로도 그랬다
단독으로 띄운 도커 컨테이너와 docker-compose 를 통해 띄운 도커 컨테이너들을 들어가서 ip를 확인해보면 각자 네트워크 대역대가 다름을 알 수 있다

이는 docker-compose 로 컨테이너를 띄우면 compose 로 묶은 범위에 맞춰 브릿지를 하나 더 생성하기 때문이다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 이렇게 docker-compose로 컨테이너를 띄우고
$ docker-compose -f docker-compose.yml up
# 네트워크 인터페이스를 확인해보면
$ ip link ls

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP mode DEFAULT group default qlen 1000
link/ether 08:00:27:d8:72:d9 brd ff:ff:ff:ff:ff:ff
# docker0 브릿지와 아까 띄워놓은 컨테이너들
3: docker0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
link/ether 02:42:c0:e5:8c:93 brd ff:ff:ff:ff:ff:ff
4: vethf3bf38b@if34: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP mode DEFAULT group default
link/ether 02:13:50:cf:5c:48 brd ff:ff:ff:ff:ff:ff link-netnsid 1
5: veth1680438@if36: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master docker0 state UP mode DEFAULT group default
link/ether f2:b2:f7:eb:a5:55 brd ff:ff:ff:ff:ff:ff link-netnsid 2
# 여기서부터 docker-compose로 띄운 부분
# bridge가 하나 더 생겼다!
6: br-776e18676383: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP mode DEFAULT group default
link/ether 02:42:e0:f3:47:6b brd ff:ff:ff:ff:ff:ff
7: vethc059d77@if39: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master br-776e18676383 state UP mode DEFAULT group default
link/ether 0a:f7:37:0c:cd:64 brd ff:ff:ff:ff:ff:ff link-netnsid 3
8: veth0fea37d@if41: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue master br-776e18676383 state UP mode DEFAULT group default
link/ether b2:95:86:71:c6:43 brd ff:ff:ff:ff:ff:ff link-netnsid 4

이런 상태이므로 물론 docker-compose로 띄운 컨테이너와 일반 컨테이너간의 통신은 불가능하다(서로 경유하는 브릿지가 다르므로)

외부와 통신은 어떻게 할까?

우리는 컨테이너를 띄울 때 아래와 같이 포트포워딩을 설정하여 외부에 컨테이너를 공개할 수 있다(+expose)

1
2
3
4
5
6
# 포트포워딩 설정하여 컨테이너 생성
$ docker container run -p 8080:80 nginx
# 포트 listen 확인
$ netstat -nlp | grep 8080

tcp6 0 0 :::8080 :::* LISTEN 26113/docker-proxy-

근데 보다시피 docker-proxy 라는 프로세스가 해당 포트를 listen 하고 있음을 볼 수 있다
이는 간단히 docker host 로 들어온 요청을 해당하는 컨테이너로 넘기는 역할만을 수행하는 프로세스이다
컨테이너에 포트포워딩이나 expose를 설정했을 경우 같이 생성되는 프로세스이다

그렇지만 중요한것은, 실제로 포트포워딩을 이 docker-proxy가 담당하는 것이 아니라, host PC iptables 에서 관리한다는 점이다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ iptables -t nat -L -n

Chain PREROUTING (policy ACCEPT)
target prot opt source destination
DOCKER all -- 0.0.0.0/0 0.0.0.0/0 ADDRTYPE match dst-type LOCAL

Chain INPUT (policy ACCEPT)
target prot opt source destination

Chain OUTPUT (policy ACCEPT)
target prot opt source destination
DOCKER all -- 0.0.0.0/0 !127.0.0.0/8 ADDRTYPE match dst-type LOCAL

Chain POSTROUTING (policy ACCEPT)
target prot opt source destination
MASQUERADE all -- 172.17.0.0/16 0.0.0.0/0
MASQUERADE tcp -- 172.17.0.4 172.17.0.4 tcp dpt:80

Chain DOCKER (2 references)
target prot opt source destination
RETURN all -- 0.0.0.0/0 0.0.0.0/0
DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.4:80

보다시피 모든 요청을 DOCKER Chain 으로 넘기고, DOCKER Chain 에서는 DNAT를 통해 포트포워딩을 해주고 있음을 볼 수 있다
(이 iptables 룰은 docker daemon이 자동으로 한다)

docker-proxy 는 iptables가 어떠한 이유로 NAT를 사용하지 못하게 될 경우 사용된다고 한다

참고 :

  • NET namespace, veth interface : https://bluese05.tistory.com/28
  • 도커 네트워크 구조 : https://bluese05.tistory.com/15
  • 도커 네트워크 외부 통신 : https://bluese05.tistory.com/53

[kubernetes] 주요 개념

Posted on 2019-06-21 | Edited on 2020-11-02 | In kubernetes | Comments: 0 Comments

kubernetes란?

구글이 2014년에 발표한 컨테이너 오케스트레이션 도구이다

컨테이너 오케스트레이션 도구란 많은 수의 컨테이너를 협조적으로 연동시키기 위한 통합 시스템을 말한다

도커 등장 이래로 많은 오케스트레이션 도구가 나왔지만(mesos, ECS, Swarm 등),
쿠버네티스가 가장 강력한 끝판왕으로 등장함에 따라 현재는 사실상 표준이 된 상태이다
많은 클라우드 플랫폼에서도 쿠버네티스를 연동하여 사용할 수 있는 기술을 제공한다

GCP는 GKE, AWS는 EKS, 애저는 AKS

로컬에서 kubernetes 띄우기

예전에는 로컬에서 kubernetes를 띄우려면 minikube 를 이용해야 했는데
minikube는 기존에 도커를 위해 띄워진 VM에 쿠버네티스를 띄우는 것이 아니라 새로운 VM(dockerd)를 띄우는 방식이므로 조금 까다로운 면이 있었다

하지만 요즘에는 윈도우/macOS용 도커에서 쿠버네티스 통합 기능을 제공해주므로 이를 사용하여 간단하게 쿠버네티스를 구축할 수 있다
대신 minikube 에 자동으로 설치되어 있는 대시보드 같은것은 추가로 설치해줘야 하는 불편함은 있다

mac에서 kubernetes 설치는 kubernetes 탭의 Enable Kubernetes만 클릭해주면 된다
mac kubernetes 설치

그리고 쿠버네티스 API를 실행하기 위한 명령행 도구인 kubectl도 설치해준다
https://kubernetes.io/docs/tasks/tools/install-kubectl/

kubernetes 주요 개념

쿠버네티스 내에는 매우 다양한 리소스들이 존재하고, 이 리소스들이 클러스터내에서 서로 연동하고 협조하면서 컨테이너 시스템을 구성하는 형태이다

하나의 쿠버네티스 환경 자체를 클러스터라고 부른다

노드

쿠버네티스 내에 떠있는 호스트들(가상머신이나 물리적 서버머신)이다
이중에서도 마스터 노드와 일반 노드들로 나뉘는데, 마스터 노드에는 쿠버네티스 클러스 전체를 관리하기 위한 관리 컴포넌트들이 자리하는 곳이고, 이 관리 컴포넌트들에 의해 일반 노드들로 컨테이너들이 오케스트레이션 되는 구조이다

마스터 노드에 들어가는 관리 컴포넌트들의 종류는 아래와 같다

  • kube-apiserver
  • kube-scheduler
  • kube-controller-manager
  • etcd

마스터 노드에는 이 관리 컴포넌트 외에 다른 컴포넌트(Pod)들은 들어갈 수 없다


여기까지가 기본적인 개념이고, 이제부터 쿠버네티스의 주요 리소스에 대해 설명하겠다
아래는 간단한 설명이며, 상세한 설명은 조대협님의 블로그를 보는 것이 좋을 것 같다

오브젝트

쿠버네티스에서 가장 중요한 부분은 오브젝트라는 개념인데, 이 오브젝트는 크게 기본 오브젝트와 컨트롤러로 나뉜다
기본 오브젝트는 리소스들의 가장 기본적인 구성 단위이며, 컨트롤러는 이 기본 오브젝트들을 생성하고 관리하는 기능을 가진 애들을 말한다

기본 오브젝트의 종류는 아래와 같고

  • 파드
  • 서비스
  • 볼륨
  • 네임스페이스

컨트롤러의 종류는 아래와 같다

  • 레플리카 셋
  • 디플로이먼트
  • 스테이트풀 셋
  • 데몬 셋
  • 잡

참고로 아래에서 설명하겠지만, 이 오브젝트들은 모두 논리적인 단위이다
각 노드들에 생성된 수많은 도커 컨테이너, 네트워크 인터페이스들을 쿠버네티스가 논리적인 단위로 묶어서 Pod, Service, namespace 등의 개념으로 제공하게 되는 것이다

이 오브젝트에 중요한 속성이 있는데, 바로 스펙과 상태이다
스펙은 우리가 직접 설정파일 같은 것으로 작성해서 전달해줘야 하는것으로써, 오브젝트가 어떤 상태가 되어야 한다고 작성한 것을 말한다
아래는 스펙의 간단한 예제이다

1
2
3
4
5
6
7
8
9
10
11
apiVersion: v1
kind: Pod
metadata:
name: myapp-pod
labels:
app: myapp
spec:
containers:
- name: myapp-container
image: busybox # 이미지는 로컬을 참조하지 않고 항상 registry에서 땡겨오는 것 같다
command: ['sh', '-c', 'echo Hello Kubernetes! && sleep 3600']

busybox 이미지를 가진 container가 하나 들어가있는 pod를 띄워야한다고 스펙에 명시했다
이 스펙을 보고 쿠버네티스는 오브젝트를 생성하게 될 것이며, 이 오브젝트에 대한 상태는 쿠버네티스에 의해 제공되게 된다
그리고 쿠버네티스는 이 오브젝트의 상태가 우리가 원한 상태와 일치하도록 계속 관리해주는 역할을 수행하게된다

원하는 상태를 직접 명시하지는 않았지만, 아마도 RUNNING 을 말하는거겠지…

기본 오브젝트 - Pod

쿠버네티스의 가장 기본적인 배포 단위(컨테이너)이다
우리가 알고있는 도커 컨테이너와는 조금 다른게, 하나의 Pod는 하나 이상의 컨테이너를 포함할 수 있는 구조이다
즉 웹서버를 구성한다고 할 때 nginx pod, spring-boot pod, mysql pod 를 각각 띄워야 하는 것이 아니라 이 컨테이너들을 모두 하나의 pod에 넣을 수 있다는 의미이다

위의 오브젝트의 스펙을 설명하는 부분에서 Pod의 설정파일 예시를 작성했는데, 보다시피 spec에 containers 로 여러 컨테이너를 받을 수 있게 되어있다

하나의 Pod는 하나의 노드에만 배치될 수 있다
Pod 내의 컨테이너가 각각 다른 노드에 배치될 수 없다

이러한 특징 때문에,
쿠버네티스 내에서 Pod를 띄울경우 내부의 컨테이너가 다 떠야 Pod 의 상태가 RUNNING 으로 표시되고(e.g. 2/2),
Pod 에 접근하고자 할 경우 -c 옵션으로 접근할 컨테이너를 지정해줘야 한다

1
$ kubectl exec -it myapp-pod /bin/bash -c myapp-container

그리고 추가로,
Pod에는 관심사를 합칠 수 있다는 장점(컨테이너들을 여러개 묶어서 배포할 수 있으므로) 외에도 추가적인 장점이 존재한다

첫째로, Pod 내의 컨테이너들은 서로 IP와 Port를 공유한다
기존에 docker-compose로 띄웠던 컨테이너들이 서로 이름으로 참조했던 방식이 아닌, 서로 localhost로 통신할 수 있는 방식이다
즉, 1개의 Pod 내에 만약 spring-boot, mysql이 있다고 한다면 각자 서로를 localhost:8080, localhost:3306 으로 참조할수 있게 되는 것이다

각자의 IP를 가지는 컨테이너들이 어떻게 localhost로 통신할 수 있을까?

둘째로, Pod 내부의 모든 컨테이너가 공유하는 볼륨을 설정할 수 있다
즉 Pod 내부의 모든 컨테이너는 그 볼륨에 접근할 수 있고, 그 컨테이너끼리 데이터를 공유하는 것을 허용하게 되는 것이다

기본 오브젝트 - 서비스

아래에서 다시 설명하겠지만, Pod의 경우 kubernetes 에서 가장 작은 단위의 리소스라 삭제되거나 추가되는 행위가 잦은 리소스이다(scalable)
문제는 매번 삭제되고 추가될 때 마다 IP가 랜덤하게 새로 부여된다는 것이다
이러한 상황에서는 고정된 엔드포인트로 호출하는 것이 어려워진다
또한 Pod의 경우 보통 1개로 운영하지 않고, 여러개의 Pod을 띄워서 로드밸런싱을 제공해줘야 한다
즉, 이러한 역할을 해주는 리소스가 Pod들 앞단에 하나 더 존재해야하는데, 서비스가 이러한 역할을 한다
서비스는 지정된 IP로 생성이 가능하고, 여러 Pod를 묶어서 로드 밸런싱이 가능하며, 고유한 DNS 이름을 가질 수 있다

서비스가 Pod 들을 묶을 때는 레이블과 셀렉터 라는 개념을 사용하는데, 아래의 스펙을 보면 직관적으로 이해할 수 있을 것이다

1
2
3
4
5
6
7
8
9
10
apiVersion: v1
kind: Service
metadata:
name: my-service
spec:
selector:
app: myapp
ports:
- protocol: TCP
port: 80

보다시피 selector 로 app: myapp 이라고 선언해놓았는데, 이는 app: myapp 이라는 레이블을 가진 Pod 들을 선택해서 my-service 라는 서비스로 묶겠다는 의미이다

그렇다면 label은 어떻게 설정하는가?
위의 Pod 를 설정할 때 metadata에 label: myapp 이라는 부분을 봤을 것이다

1
2
3
4
5
6
7
apiVersion: v1
kind: Pod
metadata:
name: myapp-pod
labels:
app: myapp
...

이 부분이 리소스에 label을 설정하는 부분이다
label은 여러개 설정할 수 있다

이제 이렇게 서비스를 정의했으니 이 서비스를 통해 Pod 에 접근할 수 있어야 할 것이다
이는 서비스를 생성할 때 지정하는 타입에 따라 방식이 나뉜다
쿠버네티스에서 지원하는 서비스의 타입들은 아래와 같다

  • ClusterIp
    디폴트 설정으로, 서비스에 내부 IP(Cluster IP)를 할당한다
    그러므로 클러스터 내에서는 접근이 가능하지만, 클러스터 외부에서는 접근이 불가능하다
    클러스터 내의 컨테이너로 들어간 뒤 curl http://my-service[:80] 를 호출하면 myapp 레이블을 가진 pod의 80포트로 연결될 것이다(targetPort 지정가능, 지정하지 않을 시 port와 동일하게 설정)

  • NodePort
    Cluster IP 로 접근가능하면서 모든 노드의 IP와 포트를 통해서도 접근이 가능하게 된다

    1
    2
    3
    4
    5
    6
    7
    8
    9
    ....
    spec:
    selector:
    app: myapp
    type: NodePort
    ports:
    - protocol: TCP
    port: 80
    nodePort: 31111

    클러스터 내에서 <내부IP>:<포트>으로도 접속 가능하고, 외부에서 <NodeIP>:<NodePort>로도 접근 가능하다

    모든 Node의 포트가 열리고, 해당 서비스로 연결된다
    30000 ~ 32767 까지 사용 가능
    노드의 IP가 바뀔 수 있는 부분을 처리해줘야함
    보통 ingress랑 같이 씀
    ingress랑 같이 쓰면 해결됨
    api gateway 붙이기 싫을 때 ingress를 쓴다?

  • LoadBalancer
    보통 클라우드 서비스에서만 설정 가능한 방식으로, 외부 IP를 가지고 있는 로드밸런서를 할당한다
    외부 IP를 가지고 있기 때문에 외부에서 접근이 가능하다
    클라우드 서비스 내에서 외부 IP가 할당된 로드밸런서를 생성하고 서비스에 연결시키는 구조
    서비스 자체에 로드밸런싱 기능이 있는데, 왜 굳이 로드밸런서를 할당시키는지?

  • ExternalName
    외부 서비스를 쿠버네티스 내부에서 호출하고자 할 떄 사용할 수 있다
    클러스터 내의 Pod들이 클러스터 밖에 있는 서비스(예를 들면 RDS)를 호출하려면 NAT 설정 등 복잡한 설정이 필요한데, 서비스를 externalName 타입으로 설정하면 이를 간단하게 해결 가능하다

    1
    2
    3
    4
    5
    6
    7
    8
    9
    apiVersion: v1
    kind: Service
    metadata:
    name: my-service-for-rds
    spec:
    selector:
    app: myproxy
    type: ExternalName
    externalName: xxxx-rds.amazonaws.com

    이렇게 설정하면 클러스터 내의 Pod 들이 이 서비스를 호출할 경우 xxxx-rds.amazoneaws.com 으로 포워딩해주게 된다(일종의 프록시 역할)

기본 오브젝트 - 볼륨

도커로 볼륨을 연결할때를 생각해보면, 볼륨은 도커 컨테이너가 생성된 호스트에 위치해야 했다
하지만 쿠버네티스의 특성상 Pod가 여러 호스트(노드)를 교차하면서 배포되므로, 이러면 너무 번거롭다
쿠버네티스는 이를 해결하기 위해 PersistentVolume, PersistentVolumeClaim 이라는 것을 제공한다

간단히 말해 PersistentVolume은 쿠버네티스에 지정한 물리 디스크이고, PersistentVolumeClaim은 그 PersistentVolume과 Pod를 연결할 수 있게 해주는 개념이다
자세한 내용은 https://bcho.tistory.com/1259 를 참고한다

PersistentVolume 외에도 쿠버네티스는 다양한 볼륨을 지원한다
https://kubernetes.io/docs/concepts/storage/volumes/

기본 오브젝트 - 네임스페이스

쿠버네티스 클러스터내의 논리적인 분리단위이다
네임스페이스별로 리소스들을 나눠서 관리할 수 있고, 접근권한, 리소스 할당량 등을 설정할 수 있다


컨트롤러 - ReplicaSet

알다시피 어느정도 규모가 되는 어플리케이션을 구축하려면 하나의 Pod로는 안되고, Pod를 여러개 실행해 가용성을 확보해야 한다
이럴때 사용하는 것이 RelicaSet이다
ReplicaSet는 똑같은 정의를 갖는 Pod를 여러개 생성하고 관리하기 위한 리소스이다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: frontend
spec:
replicas: 3
selector:
matchLabels:
tier: frontend
template:
metadata:
labels:
tier: frontend
spec:
containers:
- name: php-redis
image: gcr.io/google_samples/gb-frontend:v3

보다시피 크게 replicas, selector, template 3가지의 파트로 구성된다

  • replicas
    ReplicaSet에 의해 관리될 Pod의 개수이다
    설정된 값보다 Pod의 수가 적으면 추가로 띄우고, Pod의 수가 더 많으면 남는 Pod를 삭제한다
  • selector
    ReplicaSet으로 관리할 Pod를 선택한다
    현재는 label을 기반으로 select 하고 있다

    service 의 selector 와 문법이 달라서 혼동될 수 있는데, 그냥 지원되지 않는 것이라고 한다(deployment 는 됨)
    https://medium.com/@zwhitchcox/matchlabels-labels-and-selectors-explained-in-detail-for-beginners-d421bdd05362

  • template
    Pod를 추가로 띄울 때 어떻게 만들지에 대한 Pod 정보를 정의해놓은 부분이다
    새로 생성된 Pod도 selector에 의해 선택되어야 하므로 selector의 label과 동일하게 맞춰줘야 한다

아래는 ReplicaSet에 대해 조금 주의(?)할 부분들이다

  • ReplicaSet 생성 시 label이 일치하면 기존에 떠있는 Pod 들도 같이 ReplicaSet로 묶이는데, 이 Pod들이 template에 있는 Pod의 형태와 않더라도 삭제되지 않음에 주의해야 한다

    e.g. 기존에 app: reverse-proxy 레이블의 apache Pod가 떠있는 상태에서,
    selector = app: reverse-proxy, template = nginx의 ReplicaSet을 생성하면 기존의 apache Pod는 삭제되지않고 같은 ReplicaSet이 된다

  • selector 가 Pod 들을 묶는 기준이니까, 기존에 ReplicaSet가 띄워진 상태에서 selector 를 바꾸게 되면 기존의 Pod 들은 삭제되지 않고 남아있게 되나?

    ReplicaSet으로 떠있는 상태에서 selector를 바꾸게 되면 문법적으로 오류가 발생한다
    ReplicaSet에 대한 메타정보(떠있는 Pod들과 매핑 등)이 마스터 노드 어딘가에… 저장되어서 그것으로 판단하는 것 같다

컨트롤러 - Deployment

ReplicaSet 보다 상위에 있는 개념으로 ReplicaSet 배포의 기본 단위가 되는 리소스이다
아래와 같은 관계이다

Deploymemnt, ReplicaSet

쿠버네티스는 이 Deployment를 단위로 애플리케이션을 배포한다
실제 운영에서는 ReplicaSet을 직접 다루기보다는 Deployment를 통해 배포하는 경우가 대부분이다

설정은 ReplicaSet와 거의 동일하게 작성한다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
label:
app: frontend
spec:
replicas: 3
selector:
matchLabels:
tier: frontend
template:
metadata:
labels:
tier: frontend
spec:
containers:
- name: php-redis
image: gcr.io/google_samples/gb-frontend:v3

Deployment의 특징은 리비전을 사용해 배포를 관리할 수 있다는 점이다

Deployment를 생성한 뒤 리비전을 확인해본다

1
2
3
4
5
6
$ kubectl apply -f deployment.yml --record # 어떤 kubectl을 실행했는지 남기기 위함
$ kubectl rollout history deployment frontend

deployment.extensions/fronend
REVISION CHANGE-CAUSE
1 kubectl apply --filename=deployment.yaml --record=true

결과로는 REVISION=1 값이 출력됨을 볼 수 있다

이 리비전값은 아래와 같은 특성이 있다

  • replicas 의 값을 바꿔도 리비전값이 올라가진 않는다
  • replicaSet 컨테이너의 이미지를 바꾸고 적용하면 리비전값이 올라간다

리비전을 올리기 위해 아래와 같이 컨테이너 이미지를 바꾸고

1
2
3
containers:
- name: php-redis
image: gcr.io/google_samples/gb-frontend:v4

deployment를 다시 적용하면 리비전값이 올라감을 볼 수 있다

1
2
3
4
5
6
$ kubectl rollout history deployment echo

deployment.extensions/frontend
REVISION CHANGE-CAUSE
1 kubectl apply --filename=deployment.yaml --record=true
2 kubectl apply --filename=deployment.yaml --record=true
1
2
3
4
5
6
7
8
9
$ kubectl get pods --selector app=frontend

NAME READY STATUS RESTARTS AGE
frontend-6bfffbcf9f-n42dh 1/1 Running 0 1m
frontend-6bfffbcf9f-tssb9 1/1 Running 0 1m
frontend-6bfffbcf9f-vfmcj 1/1 Running 0 1m
frontend-8556ddbfb9-zpfrg 0/1 Terminating 0 3m
frontend-8556ddbfb9-9p7ld 0/1 Terminating 0 3m
frontend-8556ddbfb9-x5kvb 0/1 Terminating 0 3m

이렇게 리비전으로 관리하게 됨으로써 Deployment를 롤백이 가능하게 된다

$ kubectl rollout undo deployment frontend

Pod를 확인해보면 바로 직전 리비전으로 롤백되고 있음을 볼 수 있다(바로 직전만 가능한 듯 하다)
롤백되면 리비전이 다시 1로 돌아가는 것이 아니라, 3으로 올라간다
리비전은 최대 10까지 가능한 것 같다

참고 :

  • 야마다 아키노리, 『도커/쿠버네티스를 활용한 컨테이너 개발 실전 입문』, 심효섭 옮김, 위키북스(2019)
  • 쿠버네티스 개념 이해 https://bcho.tistory.com/1256?category=731548
  • 쿠버네티스 서비스 https://bcho.tistory.com/1262

[docker] volume container 추가하기

Posted on 2019-06-16 | Edited on 2020-11-02 | In docker | Comments: 0 Comments

volume container란?

일반적으로 docker container는 컨테이너 내부에 데이터를 관리하므로, 컨테이너가 파기되면 데이터가 모두 날라가게 된다
이는 mysql 같은 데이터 스토리지를 사용할 경우 위험하게 되는데, 이를 방지하기 위해 따로 볼륨을 설정해서 데이터를 저장해줘야 한다
호스트OS 디렉토리를 마운트시켜서 데이터를 관리할 수도 있지만, 호스트쪽 디렉토리에 의존이 생기고 만약 이 디렉토리의 데이터를 잘못 손대면 애플리케이션에 부정적 영향을 미칠 수 있기 때문에 이 방식은 사용하지 않는것이 좋다

그래서 이에 대한 대안으로 추천되는것이 볼륨 컨테이너이다
볼륨 컨테이너는 말 그대로 데이터를 저장하는 것이 목적인 컨테이너이다
기본적으로 우리는 Dockerfile 작성 시 아래와 같이 볼륨을 설정할 수 있다

1
2
3
4
5
FROM mysql

VOLUME /var/lib/mysql

# ...

위처럼 작성하게 되면 컨테이너 내의 /var/lib/mysql 디렉터리가 호스트 PC의 /var/lib/docker/volumes/${volume_name}/_data에 마운트 된다
(볼륨 이름은 임의의 해쉬값으로 생성된다)

참고로 mac이나 windows의 경우 /var/lib/docker/volumes 디렉토리가 없는데,
이는 mac이나 windows의 경우 docker를 바로 실행할 수 없으므로 VM을 하나 띄운 뒤, docker를 실행하기 때문이다
즉, /var/lib/docker/volumes 디렉토리는 mac과 docker 사이에 띄워진 VM 내에 감춰져있다
참고 : https://forums.docker.com/t/var-lib-docker-does-not-exist-on-host/18314

volume container는 볼륨의 이러한 특징을 사용한 것이다
컨테이너 자체를 볼륨을 관리하는 애로 만들어서 캡슐화하고, 이를 다른 컨테이너의 볼륨에 매핑해서 결합을 느슨하게 하는 것이다

만약 아래와 같은 볼륨 컨테이너를 작성하고,

1
2
3
4
FROM busybox # 최소한의 운영체제 기능만 제공

VOLUME /var/lib/mysql
VOLUME /var/log

/var/lib/mysql, /var/log 디렉토리에 대한 볼륨 2개가 생성되어 각각 /var/lib/docker/volumes/${volume_name}/_data 에 마운트 된다

빌드하고 컨테이너로 띄운 뒤,

1
2
$ docker image build -t volume_container:latest .
$ docker container run -d volume_container:latest

참고로 볼륨 컨테이너를 띄우면 바로 종료되는데, 이렇게 종료된 컨테이너를 사용해도 상관없다

--volumes-from 옵션으로 다른 컨테이너에 연결한다면

1
$ docker container run --volumes-from volume_container mysql:5.7

아래와 같은 형태가 되는 것이다

1
2
mysql container -> volume_container -> /var/lib/docker/volumes/${/var/lib/mysql's volume_name}/_data
-> /var/lib/docker/volumes/${/var/log's volume_name}/_data

docker-compose.yml 에 volume container 추가

위의 방식처럼 컨테이너를 직접 만들고 다른 컨테이너 실행 시 --volumes-from 속성으로 연결해주는 방법도 있지만, docker-compose를 사용 시 좀 더 간단한 방법을 제공해준다
아래처럼 작성하면 된다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
version: "3"
services:
test_database:
image: mysql:5.7
environment:
MYSQL_DATABASE: test_db
MYSQL_ROOT_PASSWORD: root
MYSQL_ROOT_HOST: '%'
ports:
- 3306:3306
volumes:
- test_volume:/var/lib/mysql

test_application:
build: .
expose:
- 8080
depends_on:
- test_database

volumes:
test_volume:

보다시피 test_volume 이라는 볼륨을 생성하고, 사용하는 쪽에서 ${volume_name}:${mount를 원하는 디렉토리} 의 형태로 지정해주면 된다
docker-compose 설정파일 v2 의 형태로 보면 좀 더 직관적으로 이해가 갈 것이다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
version: "2"
services:
test_database:
image: mysql:5.7
environment:
MYSQL_DATABASE: test_db
MYSQL_ROOT_PASSWORD: root
MYSQL_ROOT_HOST: '%'
ports:
- 3306:3306
volumes:
- test_volume

test_application:
build: .
expose:
- 8080
depends_on:
- test_database

test_volume:
image: busybox
volumes:
- /var/lib/mysql
- /var/log

v3의 경우 컨테이너를 따로 생성하지 않아도 된다는 장점이 있다

볼륨 컨테이너는 충분히 좋은 기능이지만, 그래도 범위가 같은 도커 호스트 안이라는 사실은 변하지 않는다
이렇듯 데이터 이식 면에서는 아직 개선할 부분이 많이 남아있다

참고 :

  • https://darkrasid.github.io/docker/container/volume/2017/05/10/docker-volumes.html
  • https://stackoverflow.com/questions/45494746/docker-compose-volumes-from-usage-example
Read more »

[docker] docker-compose로 nginx + spring-boot + mysql 구성하기

Posted on 2019-06-16 | Edited on 2020-11-02 | In docker | Comments: 0 Comments

알다시피 일반적으로 시스템은 단일 어플리케이션 만으로 구성되지 않는다
다른 어플리케이션 서버나 미들웨어 등과 서로 통신하며 하나의 시스템이 구성된다
이번에는 가장 일반적인 구조인 리버스 프록시(nginx) + 어플리케이션 서버(spring-boot) + 데이터 스토어(mysql) 를 구성해보겠다

각각의 구성요소는 서로 의존관계가 있다
서로 통신할 수 있어야하고 각 구성요소가 올라오는 순서도 맞춰줘야 한다
물론 도커로 다 설정할 수 있긴하지만, 사람이 매번 수작업으로 맞춰줘야 하기 때문에 번거롭고 실수하기도 쉽다
그래서 이런 컨테이너의 실행을 한번에 관리할 수 있게 해주는 docker-compose를 사용할 것이다

사전 구성

spring boot로 간단히 사용자를 저장하고, 조회하는 로직을 작성한다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// TestController.java
@RequiredArgsConstructor
@RequestMapping("/users")
@Controller
public class TestController {
private final UserRepository userRepository;

@PostMapping
public ResponseEntity<Void> createUser(@RequestBody UserRequest request) {
User user = userRepository.save(request.toEntity());

return ResponseEntity.created(URI.create("/users/" + user.getId())).build();
}

@GetMapping
public ResponseEntity<List<User>> getUsers() {
return ResponseEntity.ok(
userRepository.findAll()
);
}

@GetMapping("/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
return ResponseEntity.ok(
userRepository.findById(id)
.orElseThrow(IllegalStateException::new)
);
}
}

// UserRequest.java
@Getter
@Setter
public class UserRequest {
private String name;

private Integer age;

public User toEntity() {
return new User(name, age);
}
}

// User.java
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "user")
public class User {
@Getter
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "id")
private Long id;

@Getter
@Column(name = "name")
private String name;

@Getter
@Column(name = "age")
private Integer age;

public User(String name, Integer age) {
this.name = name;
this.age = age;
}
}

// UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {
}

보다시피 user 를 조회하고 저장하는 기능을 가진 간단한 API 서버이다

이를 도커 컨테이너로 띄우기 위해 Dockerfile을 작성한다

1
2
3
4
5
6
FROM openjdk:8-jdk

COPY ./test-application /test-application
WORKDIR /test-application

CMD ["./gradlew", "bootRun"]

test-application은 spring-boot 어플리케이션이 있는 디렉토리이다
간단하게 spring-boot 어플리케이션이 있는 디렉토리 전체를 컨테이너로 복사한 뒤, ./gradlew bootRun으로 어플리케이션을 실행시킨다

spring-boot, mysql 띄우기(feat. docker-compose.yml)

이제 위 파일을 docker image로 만들어주면 되는데, 알다시피 저 Dockerfile을 사용해 docker image를 만들어봐야 사용하지 못한다
db가 없기 때문이다
mysql을 docker container 로 띄운 뒤 spring-boot 를 docker container로 띄우면 되긴 하지만, 번거로우므로 이를 같이 해줄 수 있는 docker-compose 설정 파일을 작성한다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
version: "3"
services:
test_database:
# 컨테이너 이름을 주고 싶다면 작성한다
# container_name: test_database
image: mysql:5.7
environment:
MYSQL_DATABASE: test_db
MYSQL_ROOT_PASSWORD: root
MYSQL_ROOT_HOST: '%'
ports:
- 3306:3306

test_application:
build: .
ports:
- 8080:8080
depends_on:
- test_database

mysql과 spring-boot 컨테이너 2개를 띄워주는 docker-compose.yml 파일이다
mysql의 경우 이미지명을 지정해 레지스트리에서 땡겨오도록 했고, spring-boot의 경우 현 위치에 있는 Dockerfile을 참조하여 만든 image를 컨테이너로 띄우게끔 했다
(docker-compose.yml과 spring-boot Dockerfile은 같은 위치에 있다)
여기서 중요한 것은 depends_on 속성인데, 이렇게 해놓으면 mysql 컨테이너가 다 뜬 다음 spring-boot 컨테이너가 뜨게끔 설정된다

이제 spring-boot에서 mysql을 바라볼 수 있도록 application.properties에 접속 정보를 작성한다

1
2
3
4
5
6
7
8
9
spring.datasource.url=jdbc:mysql://test_database:3306/test_db?useSSL=false
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=com.mysql.jdbc.Driver

spring.jpa.hibernate.ddl-auto=update
spring.jpa.database-platform=org.hibernate.dialect.MySQL5Dialect
spring.jpa.generate-ddl=true
spring.jpa.show-sql=true

여기서 특별한 부분은 datasource url로 ip 대신 test_database 라고 준 부분이다
이는 docker-compose.yml에 써놓은 mysql의 서비스명과 동일한데,
이는 docker-compose.yml 내에 작성한 컨테이너들은 모두 같은 네트워크 대역으로 묶어서 생성하기 때문이다

compose 단위로 새로운 네트워크 대역으로 생성한다
그냥 단일 도커들만 실행하면 기본 네트워크 대역에 할당된다

compose 없이 컨테이너 각각 띄워보고 들어가서 ip 확인해보면 대역대가 같다
하지만 compose 로 생성한 컨테이너는 대역대가 다르다

이제 작성이 끝났으니, docker-compose 로 컨테이너들을 실행시켜보자

1
$ docker-compose up

docker-compose는 기본적으로 명령을 실행한 위치에 있는 docker-compose.yml 파일을 참조하여 실행한다
만약 다른 경로에 있거나 다른 파일명을 사용하고 싶을 경우 -f 옵션으로 docker-compose 파일을 지정해주면 된다

mysql과 spring-boot가 차례대로 실행되는것을 볼 수 있다

-d 옵션을 주면 백그라운드로 실행시킬 수 있다

postman으로 localhost:8080으로 API를 호출해보면, 잘 동작함을 볼 수 있다

nginx 추가

이제 리버스 프록시인 nginx를 추가해보자
docker-compose.yml 파일을 수정한다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
version: "3"
services:
test_web:
image: nginx
ports:
- 80:80
volumes:
- ./nginx/conf.d:/etc/nginx/conf.d
depends_on:
- test_application

test_database:
image: mysql:5.7
environment:
MYSQL_DATABASE: test_db
MYSQL_ROOT_PASSWORD: root
MYSQL_ROOT_HOST: '%'
ports:
- 3306:3306

test_application:
build: .
expose:
- 8080
depends_on:
- test_database

spring-boot 컨테이너가 뜬 다음에 레지스트리에서 nginx를 땡겨와서 컨테이너로 띄우게끔 했다
nginx 를 작성한 부분에 volumes 라는 부분이 보이는데, 이는 호스트의 nginx/conf.d 폴더를 컨테이너의 /etc/nginx/conf.d 폴더로 마운트 해주겠다는 의미이다
이렇게 작성한 이유는 호스트쪽에 작성해놓은 nginx 설정 파일을 nginx 컨테이너가 뜨면서 읽게하기 위함이다

nginx는 /etc/nginx/conf.d 내에 들어있는 모든 .conf 파일을 include 한다

아래는 conf.d 안에 작성한 app.conf 파일이다

1
2
3
4
5
6
7
8
9
10
11
server {
listen 80;
access_log off;

location / {
proxy_pass http://test_application:8080;
proxy_set_header Host $host:$server_port;
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}

이제 작성이 끝났으니, docker-compose를 down 했다가 다시 up 한다
앞에 리버스 프록시를 두었으니 url localhost로 변경했을때 잘 작동함을 볼 수 있다

참고로 test_application에 대한 포트포워딩 설정이었던 ports가 expose로 바뀌었는데, 이는 컨테이너 내부에서만 해당 포트를 인식하게끔 하는 속성이다
즉, 다른 컨테이너에서는 8080으로 통신이 가능하지만, 외부에서는 8080으로 더이상 접근할 수 없다

github url : https://github.com/joont92/docker-study/tree/master/step02

참고 :

  • https://github.com/hellokoding/hellokoding-courses/blob/master/docker-examples/dockercompose-springboot-mysql-nginx/docker-compose.yaml
Read more »

[docker] container 다루기

Posted on 2019-06-09 | Edited on 2020-11-02 | In docker | Comments: 0 Comments

빌드한 도커 이미지를 실행시키면 도커 컨테이너가 된다
언급했듯이 도커 이미지는 하나의 템플릿이므로, 하나의 도커 이미지로 여러개의 도커 컨테이너를 만들 수 있다

도커 컨테이너 생명주기

도커 컨테이너는 도커 이미지를 실행시킨 것이기 때문에 상태를 가지고 있다

  • 실행 중

    ENTRYPOINT, CMD에 있는 어플리케이션이 실행된 상태를 말한다
    명령행 도구 등의 컨테이너는 이 상태가 길게 유지되지 않는다

  • 정지

    실행 중 상태에 있는 컨테이너를 명시적으로 종료하거나, 컨테이너에서 실행된 어플리케이션이 정상/오류를 막론하고 종료된 경우의 상태를 말한다
    정지되어도 그 상태 그대로 디스크에 남아있기 때문에(docker container ls 로 확인 가능) 다시 실행할 수 있다

  • 파기

    정지된 컨테이너를 명시적으로 삭제하면 파기 상태가 된다(docker container ls 로 확인 불가능)
    파기된 컨테이너는 다시 정지된 컨테이너와 같은 상태로 돌아갈 수 없으므로 주의해서 실행해야 한다


아래는 도커 컨테이너를 관리하면서 자주 사용하는 명령어들과 그에 대한 간단한 설명이다
추가적인 명령어나 옵션이 궁금하다면 https://docs.docker.com/engine/reference/commandline/container/ 를 참조한다

docker container run

1
2
$ docker container run [options] 이미지명[:태그명] [명령] [명령인자..]
$ docker container run [options] 이미지ID [명령] [명령인자..]

도커 이미지로부터 컨테이너를 생성+실행 하는 명령이다

유용한 옵션이 많으니 활용하면 좋다

  • -d : 백그라운드로 실행

  • -p : 포트포워딩

    -p 9000:8080 == 호스트 포트 9000번을 컨테이너 포트 8080으로 포워딩

  • –name : 컨테이너에 이름 부여

    매번 컨테이너ID를 조회하는 것이 번거로우므로 이름을 지정할 수 있다
    컨테이너 이름은 중복될 수 없기 때문에 개발환경 외에는 잘 사용되지 않는다(운영은 많은수의 컨테이너를 추가/삭제 하므로)

  • -i, -t : -i는 표준 입력을 받을지 여부이고(파이프라이닝, 키보드 입력), -t는 가상터미널을 제공할지 여부이다

    1
    $ docker container run -it ubuntu:16.04

    보통 위처럼 -it를 같이 사용하고, 이러면 컨테이너에 쉘에 직접 접근할 수 있게 된다(가상 터미널로 표준 입력을 하는것이 되므로)

    -t 없이 -i 만 사용해도 의미가 있다(파이프라이닝으로 입력하는 방법이 있으므로)
    -i 없이 -t 만 사용하는건 의미가 없다(입력을 받지 못하는 상태라 가상터미널을 열어봐야)
    -d와 -it를 같이 사용할 수 없다(백그라운드라 입력을 대기하거나 가상터미널을 제공하는것이 불가능하다)

  • –rm : 컨테이너를 종료할 때 컨테이너를 파기하는 옵션

    같은 이름으로 컨테이너를 실행시킬 수 없기 때문에 이 옵션을 사용해주는것이 좋다
    보통 --name 과 같이 사용한다

컨테이너에 할당할 자원도 어느정도 제어할 수 있다 https://jungwoon.github.io/docker/2019/01/13/Docker-6/

docker container ls

1
$ docker container ls [options]

컨테이너의 목록을 보여줌

  • -a : 종료된 컨테이너의 목록도 같이 보여줌(원래는 실행 중인 컨테이너의 목록만 보여줌)

  • -q : 컨테이너 ID만 추출함

    1
    $ docker container stop $(docker container ls -q)

    처럼 사용할 수 있음

  • –filter : 컨테이너 목록 필터링

    1
    2
    $ docker container ls --filter "name=container_name" # 컨테이너명
    $ docker container ls --filter "ancestor=image_name" # 이미지명

docker container stop

1
$ docker container stop 컨테이너ID_또는_컨테이너명

실행중인 컨테이너를 종료할 떄 사용한다

docker container restart

1
$ docker container restart 컨테이너ID_또는_컨테이너명

정지한 컨테이너를 재시작할 때 사용한다

start 와 차이가 뭘까?

docker container rm

1
$ docker container rm 컨테이너ID_또는_컨테이너명

정지된 컨테이너를 완전히 파기할 떄 사용한다
실행중인 컨테이너를 강제로 삭제하고 싶다면 -f 옵션을 사용한다

docker container logs

1
$ docker container logs [options] 컨테이너ID_또는_컨테이너명

컨테이너의 표준 출력으로 출력된 내용을 보여준다
-f 옵션을 주면 새로 출력되는 표준 출력을 계속 볼 수 있다

보통은 이 내용을 수집해 웹 브라우저나 명령행 도구를 통해 보여주므로 이 명령을 사용할 일은 많이 없다

docker container exec

1
$ docker container exec [options] 컨테이너ID_또는_컨테이너명 컨테이너에서_실행할_명령

실행 중인 컨테이너에서 원하는 명령을 실행할 수 있다

1
2
$ docker container exec ubuntu_docker pwd
$ docker container exec ubuntu_docker ls

아예 컨테이너로 접속하고 싶다면 아래와 같이 입력하면 된다

1
$ docker container exec -it ubuntu_docker sh

docker container cp

1
2
$ docker container cp [options] 컨테이너ID_또는_컨테이너명:원본파일 호스트_대상파일
$ docker container cp [options] 호스트_원본파일 컨테이너ID_또는_컨테이너명:대상파일

실행중인 컨테이너에 파일을 복사하거나 복사해 올 때 사용한다

Dockerfile의 CP는 이미지 빌드시에 호스트 -> 컨테이너 방향으로의 복사만 가능하다

1
$ docker container cp test_file ubuntu_docker:/tmp/test_file

참고 :

  • 야마다 아키노리, 『도커/쿠버네티스를 활용한 컨테이너 개발 실전 입문』, 심효섭 옮김, 위키북스(2019)
Read more »
12…19

JunYoung Park

182 posts
18 categories
344 tags
RSS
© 2020 JunYoung Park
Powered by Hexo v3.6.0
|
Theme – NexT.Muse v7.1.0