티스토리 뷰
도커 이미지와 컨테이너
도커 엔진에서 사용하는 기본 단위는 이미지와 컨테이너이며, 이 두가지가 도커 엔진의 핵심입니다.
도커 이미지
이미지는 컨테이너를 생성할 때 필요한 요소이며, 가상 머신을 생성할 때 사용하는 iso파일과 비슷한 개념입니다. 이미지는 바이너리 파일로 존재하며, 컨테이너를 생성하고 실행할 때 읽기 전용으로 사용됩니다.
구조는 다음과 같습니다.
저장소이름/이미지이름:<태그>
ex) golf/jenkins:1.12.4 jenkins:latest
- 저장소 : 이미지가 저장된 저장소를 의미, 저장소가 명시되어있지 않으면 보통 Docker hub에 저장된 공식 이미지입니다.
- 이미지 이름 : 이미지가 어떤 역할을 하는지 식별하기 위해 만드는 경우가 많습니다. 예를들어 jenkins가 이름이면 해당 이미지는 보통 jenkins의 역할을 수행하게 됩니다.
- 태그 : 이미지 버전 관리 혹은 리비전 관리에 사용됩니다. 태그를 생략하면 이미지의 태그를 latest로 인식합니다.
도커 컨테이너
도커 이미지는 Jenkins 뿐만 아니라 MySQL, Redis, Kafka 등 여러가지 종류가 있습니다. 이러한 이미지로 컨테이너를 생성하면 해당 이미지의 목적에 맞는 파일이 들어있는 파일 시스템과 격리된 시스템 자원 및 네트워크를 사용할 수 있는 독립된 공간이 생성됩니다.
컨테이너는 이미지를 읽기 전용으로 사용하되 이미지에서 변경된 사항만 컨테이너 계층에 저장하므로 컨테이너에서 무엇을 하던지 이미지는 영향을 받지 않습니다. 또한 생성된 각 컨테이너는 각기 독립된 파일 시스템을 제공 받으며 호스트와 분리돼 있으므로 특정 컨테이너에서 어떤 애플리케이션을 설치하거나 삭제해도 다른 컨테이너와 호스트는 변화가 없습니다.
컨테이너 생성
docker run -i -t ubunu:14.04 명령어로 간단하게 컨테이너를 생성할 수 있습니다. 이 때 docker run 명령어에 -i -t 옵션 (상호 입 출력을 가능하게 합니다.) 마지막으로 이미지 명과 태그를 명시하면 ubuntu 컨테이너가 실행됩니다.
옵션은 다음과 같이 존재합니다.
- -i : 상호 입출력
- -t : tty를 활용해서 배시 셸 사용
- --name : 컨테이너 이름 지정(유용)
- -p : 포트 바인딩
- -d : detach 모드로 백그라운드 실행(입출력 없는 상태로 실행, 포그라운드로 실행)
- -e : 컨테이너 내부 환경 변수 설정(docs 참조)
- --link : IP가 아닌 컨테이너 별명으로 컨테이너끼리 접근하는 기술,(타겟_컨테이너/타겟_별칭)
- 현재는 deprecated -> 도커 브릿지를 이용
이렇게 만들어진 컨테이너는 docker exec -it <컨테이너 명> bash 명령어로 서버내에 접근할 수 있으며 ls 명령어 등을 이용하여 서버 내부를 확인하실 수 있습니다.
컨테이너를 외부에 노출
컨테이너는 가상 머신과 마찬가지로 가상 IP 주소를 할당 받습니다. 기본적으로 도커는 컨테이너에 172.17.0.x IP를 순차적으로 할당합니다. 컨테이너를 생성한 후 접속하여 ifconfig로 네트워크 인터페이스를 확인할 수 있습니다.
하지만 컨테이너에 아무런 설정을 하지 않으면 이 컨테이너는 외부에서 접근할 수가 없고 설치된 호스트에서만 접근할 수 있는데 만약 외부에 컨테이너의 애플리케이션을 노출하려면 eth0의 IP와 호스트 IP 포트를 바인딩 해야합니다.
docker run -i -t --name my-jenkins -p 8000:8080 jenkins:latest
-p 옵션은 컨테이너의 포트를 호스트의 포트와 바인딩하여 연결할 수 있게 설정합니다. 이때 내부에 여러 개의 포트가 있다면 -p를 여러번 사용하여 포트 바인딩을 해줍시다.
원리는 다음과 같습니다.
호스트 IP 8000번으로 접근 → 8000번 포트는 컨테이너의 8080 포트로 포워딩 → 서비스 접근
도커 볼륨
도커로 띄운 mysql 이미지가 있다고 가정해봅니다. 이 때 구조는 다음과 같이 나타나집니다.
이미 생성된 이미지는 변경되지 않으며, 컨테이너 계층에 원래 이미지에서 변경된 파일 시스템 등을 저장합니다. 워드프레스에서 쓴 로그인 정보나 게시글 등과 같아 데이터베이스를 운용하면서 쌓이는 데이터가 저장됩니다.
하지만 이 구조는 치명적인 단점이 있습니다. mysql 컨테이너가 삭제되면 컨테이너 계층에 있는 데이터베이스의 정보도 같이 삭제가 된다는 것입니다. 또한 복구도 할 수 없습니다.
도커는 이러한 단점을 극복하기 위해 영속적으로 보관할 수 있는 방법을 몇가지 제시하는데 그 중 가장 쉽게 활용할 수 있는 방법이 도커 볼륨을 이용하는 것입니다.
볼륨을 활용하는 방법은 여러가지가 있습니다. 호스트와 볼륨을 공유할 수도 있고, 볼륨 컨테이너를 활용할 수도 있으며, 도커가 관리하는 볼륨을 생성할 수도 있습니다.
그럼 첫 번째 방법 부터 확인해 봅시다.
docker run -d \
-- name my-mysql \
-e MYSQL_ROOT_PASSWORD=password \
-e MYSQL_DATABASE=member \
-v /home/my-mysql_db:/var/lib/mysql \
mysql:5.7
docker run -d \
-e MY_MYSQL_DB_PASSWORD=password \
-- name my-mysql_hostvolume \
-- link my-mysql_hostvolume:mysql \
-p 80 \
my-mysql
my-mysql 컨테이너에 -p 옵션으로 컨테이너의 80포트를 외부에 노출했으므로 docker ps 명령어에서 확인한 my-mysql_hostvolume 컨테이너의 호스트 포트로 my-mysql 컨테이너에 접속할 수 있습니다.
-v 옵션을 이용하여 실행 시 볼륨을 추가해줬고 그 값은 /home/my-mysql_db:/var/lib/mysql로 설정한 것입니다. 이는 호스트의 /home/my-mysql_db와 컨테이너의 /var/lib/mysql를 공유한다는 듯입니다.
즉, [호스트의 공유 디렉터리]:[컨테이너의 공유 디렉터리] 형태로 보시면 좋습니다.
이렇게 되면 컨테이너가 삭제되더라도 볼륨에 계속 남아있기 때문에 로컬만 데이터가 남아있다면 언제든 데이터를 재사용할 수 있습니다.
볼륨 컨테이너
볼륨을 사용하는 두 번째 방법은 -v 옵션으로 볼륨을 사용하는 컨테이너를 다른 컨테이너와 공유하는 것입니다. 컨테이너를 생성할 때 —volume-from 옵션을 설정하면 -v 또는 —volume 옵션을 적용한 컨테이너의 볼륨 디렉토리를 공유할 수 있습니다.
이는 -v로 생성된 볼륨을 다른 컨테이너에서 공유하는건데 관계도를 보면 다음과 같습니다.
여러 개의 컨테이너가 동일한 컨테이너에 —volumes-from 옵션을 사용함으로써 볼륨을 공유할 수 있습니다. 이러한 구조를 이용하여 호스트에서 볼륨만 공유하고 별도의 역할을 담당하지 않는 일명 볼륨 컨테이너로서 활용하는 것도 가능합니다.
도커 볼륨
볼륨을 활용하는 세 번째 방법은 도커 볼륨입니다. 다음 명령어를 사용하여 생성이 가능합니다.
docker volume create name golfvolume
이 때 도커 자체에서 제공하는 볼륨 기능을 사용하여 데이터를 보존할 수 있습니다.
구조를 보면 다음과 같습니다.
볼륨은 디렉터리 하나에 상응하는 단위로서 도커 엔진에서 관리합니다. 도커 볼륨도 호스트 볼륨 공유와 마찬가지로 호스트에 저장함으로써 데이터를 보존하지만 파일이 실제로 어디에 저장되는지 사용자는 알 필요가 없습니다.
하지만 알아야 하는경우 docker inspect --type volume golfvolume 명령어를 이용하여 실제로 어디에 저장되는지 알 수 있습니다.
도커 네트워크
도커 네트워크 구조
이전에 확인했듯이 컨테이너 내부에서 ifconfig를 통해 네트워크 인터페이스에 eth0와 lo 네트워크 인터페이스가 있는 것을 확인했습니다. 이 내부 IP는 도커가 설치된 호스트, 즉 내부 망에서만 쓸 수 있는 IP이므로 외부와 연결될 필요가 있습니다. 도커는 각 컨테이너에 외부와의 네트워크를 제공하기 위해 컨테이너마다 가상 네트워크 인터페이스를 호스트에 생성 하며 이 인터페이스의 이름은 veth로 시작합니다. veth 인터페이스는 사용자가 직접 생성할 필요는 없으며 컨테이너가 생성될 때 도커 엔진이 자동으로 생성합니다.
- eth0 : 공인 IP 또는 내부 IP가 할당되어 외부와 통신할 수 있는 호스트의 네트워크 인터페이스
- veth0 : 컨테이너 시작시 생성되며, 각 컨테이너의 eth0과 연결
- docker0 : 브리지, 각 veth 인터페이스와 바인딩돼 호스트의 eth0 인터페이스를 이어주는 역할
정리하면
- 컨테이너 eth0 <-> 호스트의 veth.. 연결
- 호스트의 veth.. <-> 호스트의 docker0 브리지와 바인딩
- 호스트의 docker0 브리지와 바인딩 <-> 호스트의 eth0
brctl show docker0하면 실제로 veth가 바인딩 되었는지 확인 가능하다.
도커 네트워크 기능
컨테이너를 생성하면 기본적으로 docker0 브리지를 통해 외부와 통신할 수 있는 환경을 사용할 수 있지만 사용자의 선택에 따라 여러 네트워크 드라이버를 쓸 수도 있습니다.
도커가 자체적으로 제공하는 대표적인 네트워크로는 브리지, 호스트, 논, 컨테이너, 오버레이가 있습니다.
브리지 네트워크
docker0 브리지와 비슷하게 브리지 네트워크는 docker0이 아닌 사용자 정의 브리지를 새로 생성해 각 컨테이너에 연결하는 네트워크 구조입니다. 컨테이너는 연결된 브리지를 통해 외부와 통신이 가능합니다.
도커 브리지를 생성해 보겠습니다.
docker network create --driver bridge golfbridge
그 후 컨테이너가 golfbridge 네트워크를 사용하게 해보겠습니다.
docker run -i -t --name golf_container \
--net golfbridge \
ubuntu:14:04
컨테이너 내부에 접속해서 ifconfig 명령어를 통해 새로운 IP 대역이 할당된 것을 확인해볼 수 있습니다.
이렇게 생성된 사용자 정의 네트워크는 docker network disconnect 또는 connect 명령어를 이용하여 유동적으로 붙이고 뗄 수 있습니다. (단, 논 네트워크, 호스트 네트워크 같은 특별한 모드에선 불가)
호스트 네트워크
네트워크를 호스트로 설정하면 호스트의 네트워크 환경을 그대로 사용할 수 있습니다.
docker run -i -t --name network_host \
--net host \
ubuntu:14:04
위 명령어를 통해 호스트를 설정한 컨테이너의 내부에서 네트워크 환경을 확인하면 호스트와 같은 것을 알 수 있습니다. 호스트 머신에서 설정한 호스트 네임도 컨테이너가 물려받아 호스트 이름으로 컨테이너의 호스트 이름도 동일하게 설정됩니다.
논 네트워크
말 그대로 아무런 네트워크를 쓰지 않는 것입니다.
docker run -i -t --name network_none \
--net none \
ubuntu:14:04
위 명령어로 외부와 단절된 컨테이너를 생성할 수 있습니다.
컨테이너 네트워크
— net 옵션으로 container를 입력하면 다른 컨테이너의 네트워크 네임 스페이스 환경을 공유할 수 있습니다.
docker run -i -t -d --name network_container_1 ubuntu:14.04
docker run -i -t -d --name network_container_2 \
--net container:network_container_1 \
ubuntu:14.04
위 명령어로 컨테이너를 공유할 수 있으며 이렇게 공유된 컨테이너는 내부 IP를 새로 할당받지 않으며 호스트에 veth로 시작하는 가상 네트워크 인터페이스도 생성되지 않습니다. network_container_2 컨테이너의 네트워크 설정은 전부 network_container_1과 동일해집니다.
브리지 네트워크와 —net-alias
브리지 타입의 네트워크와 run 명령어의 —net-alias 옵션을 함께 쓰면 특정 호스트 이름으로 컨테이너 여러 개에 접근할 수 있습니다.
docker run -i -t -d --name network_alias_container1 \
--net mybridge \
--net-alias alicek106
docker run -i -t -d --name network_alias_container2 \
--net mybridge \
--net-alias alicek106 \
ubuntu:14.04
docker run -i -t -d --name network_alias_container3 \
--net mybridge \
--net-alias alicek106 \
ubuntu:14.04
예를 들어 컨테이너 IP 주소가 172.18.0.3고 두 번째, 세 번째 컨테이너는 각각 172.18.0.4, 172.18.0.5 일 것입니다. 세 개의 컨테이너에 접근할 컨테이너를 생성한 뒤 alicek106이라는 호스트 이름으로 ping 요청을 전송해봅시다.
docker run -i -t --name network_alias_ping \
--net mybridge \
ubuntu:14.04
ping -c 1 alicek106
PING alicek106 (172.18.0.5) 56(84) bytes of data.
# ... anything
ping -c 1 alicek106
PING alicek106 (172.18.0.3) bytes of data.
# ....
컨테이너 3개의 IP로 각각 ping이 전송되는 것을 확인할 수 있는데 이유는 라운드 로빈 방식으로 요청이 되기 때문입니다. 이것이 가능한 이유는 가능한 이유는 도커엔진에 내장된 DNS가 alicek106이라는 호스트 이름을 —net-alias 옵션으로 aliceK106을 설정한 컨테이너로 변환하기 때문입니다.
도커의 DNS는 호스트 이름으로 유동적인 컨테이너를 찾을 때 주로 사용됩니다. 가장 대표적으로 —link옵션입니다. 이는 컨테이너의 IP가 변경 돼도 별명으로 컨테이너를 찾을 수 있게 DNS에 의해 자동으로 관리됩니다. 단 이경우는 디폴트 브리지 네트워크의 컨테이너 DNS라는 점이 다릅니다.
--net-alias 비슷한 원리도커는 기본 브리지 네트워크가 아닌 사용자가 정의한 브리지 네트워크에 사용된 내장 DNS 서버를 가집니다.
그리고 별칭을 사용한 컨테이너의 IP는 DNS 서버에 별칭 호스트 이름으로 등록됩니다.
dig라는 도구를 사용하여 호스트 이름을 검색하면 아래와 같이 나옵니다.
dig alicek106
alicek106. 600 IN A 172.18.0.5
alicek106. 600 IN A 172.18.0.3
alicek106. 600 IN A 172.18.0.4
IP의 리스트 순서가 모두 다르게 반복해서 입력된다는 것을 알 수 있습니다.
MacVLAN 네트워크
호스트의 네트워크 인터페이스 카드를 가상화해 물리 네트워크 환경을 컨테이너에게 동일하게 제공합니다.
위 처럼 공유기, 라우터, 스위치와 같은 네트워크 장비에 두 대의 서버가 연결돼 있고, 각서버는 192.168.0.0/24 대역에서 IP를 동적으로 할당받는다고 가정하면 MacVLAN을 사용하면 각 컨테이너에 192.168.0.0/24 대역의 IP를 할당할 수 있습니다. 따라서 MacVLAN을 사용하는 컨테이너들과 동일한 IP 대역을 사용하는 서버 및 컨테이너들은 서로 통신이 가능합니다.
테스트를 해보면
공유기의 네트워크 정보 : 192.168.0.0/24
서버 1(node 1) : 192.168.0.50
서버 2(node 2) : 192.168.0.51
# 서버1
docker network create -d macvlan --subnet=192.168.0.0/24 \
--ip-range=192.168.0.64/28 --gateway=192.168.0.1 \
-o macvlan_mode=bridge -o parent=eth0 my_macvlan
# 서버2
docker network create -d macvlan --subnet=192.168.0.0/24 \
--ip-range=192.168.0.128/28 --gateway=192.168.0.1 \
-o macvlan_mode=bridge -o parent=eth0 my_macvlan
-d : 네트워크 드라이버로 macvlan을 사용한다는 것을 명시(--drive와 동일)
--subnet : 컨테이너가 사용할 네트워크 정보를 입력한다. 여기서는 네트워크 장비의 IP 대역 기본 설정을 그대로 따릅니다.
--ip-ranage :
- MacVLAN 을 생성하는 호스트에서 사용할 컨테이너의 IP 범위를 입력한다.
- node01과 node02의 IP 범위가 겹처 동일한 IP의 컨테이너가 각가 생성된다면 정상 동작 안될수 있으므로 겹치지 않게 설정합니다.
--gateway : 네트워크에 설정된 게이트웨이를 입력한다. 여기서는 네트워크 장비의 기본 설정을 그대로 따른다.
-o : 네트워크의 추가적인 옵션을 설정합니다.
- 이 두 서버는 동일한 대역대의 네트워크를 가지게 됩니다.
컨테이너 로깅
json-file 로그
컨테이너 내부에서 어떤 일이 발생하는지 로그를 통해 운영을 하면서도 알 수 있어야합니다.
docker logs {containername} 명령어를 이용해서 로그 정보를 볼 수 있습니다. 예를들어서 Spring application이나 MySQL이 성공적으로 실행됐는지를 확인하기 위해서 이 명령어로 로그를 확인하여 알 수 있습니다.
또한 docker logs -f {containername} 명령어를 통해 tail 명령어 처럼 최신 로그들을 계속 업데이트 하며 볼 수 있습니다.
syslog 로그
컨테이너는 JSON 뿐 아니라 syslog로 보내 저장하도록 설정할 수 있습니다.
docker run -d --name syslog_container \
--log-driver=syslog \
ubuntu:14.04 \
echo syslogtest
다음 명령어를 활용하여 syslog를 쌓을 수 있는 컨테이너를 생성할 수 있으며 syslog 로깅 드라이버는 기본적으로 로컬호스트의 syslog에 저장하므로 운영체제에 따라 syslog 파일의 위치를 파악하여 확인할 수 있습니다.
tail /usr/bin/syslog
...
Warning: -w flag has no effect with -sort flag
Warning: -w flag not supported for a set of one or more files
Warning: directory "%s" is not an ASL data store
-w flag not supported for a set of one or more files
Warning: -w flag has no effect with -x export flag
export file open failed: %s
can't allocate memory - exiting
ut_typecom.apple.system.logger.messageWarning: -w flag cannot be used when querying syslogd directly
export file write failed: %s
<?xml version="1.0" encoding="UTF-8"?>
http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array>
</array>
</plist>
syslog can only read one legacy format database
can't combine legacy and non-legacy databases in a single search
data store file %s open failed: %s
data store file %s open failed
...
syslog에 기록된 예시입니다. syslog를 원격 서버에 설치하면 로그 옵션을 추가해 로그 정보를 원격 서버로 보낼 수 있습니다.
fluented 로깅
각종 로그를 수집하고 저장할 수 있는 기능을 제공하는 오픈소스 도구로서, 도커 엔진의 컨테이너의 로그를 fluented를 통해 저장할 수 있도록 플러그인을 공식적으로 제공합니다. fluented는 데이터 포맷으로 JSON을 사용하기 때문에 쉽게 사용할 수 있고 여러 저장소에 저장할 수 있는 장점이 있습니다.
컨테이너 할당 제한
컨테이너를 생성하는 run, create 명령어에서 컨테이너의 자원 할당량을 조정하도록 옵션을 입력할 수 있습니다. 아무런 조치를 해놓지 않는다면 한 컨테이너가 자원을 독점할 수 있고 이로 인해 다른 컨테이너 뿐만 아니라 호스트 자체의 동작에 영향을 받을 수 있습니다.
컨테이너 메모리 제한
docker run —memory를 지정해 컨테이너의 메모리를 제한할 수 있습니다. 입력할 수 있는 단위는 MB, GB이며, 최소 메모리는 4MB 입니다.
docker run -d \
--memory="1g"
--name memory_1g \
nginx
위 명령어로 컨테이너 메모리를 1GB로 제한하였습니다.
컨테이너 CPU 제한
—cpu-shares
이 옵션은 컨테이너를 가중치를 설정해 해당 컨테이너가 CPU를 상대적으로 얼마나 사용할 수 있는지르 나타냅니다. 즉 컨테이너에 CPU를 한 개씩 할당하는 방식이 아닌, 시스템에 존재하는 CPU를 비중만큼 나눠 쓸것인지 명시하는 옵션입니다.
docker run -i -t --name cpu_share \
--cpu-shares 1024 \
ubuntu:14.04
아무런 설정을 하지 않았을때 컨테이너가 가지는 값은 1024로, 이는 CPU 할당에서 1의 비중을 뜻합니다.
—cpuset-cpu
호스트 CPU가 여러 개 있을 때 —cpuset-cpus 옵션을 지정해 컨테이너가 특정 CPU만 사용하도록 설정할 수 있습니다. CPU 집중적인 작업이 필요하다면 여러 개의 CPU를 사용하도록 설정해 작업을 적절하게 분배하는 것이 좋습니다.
docker run -d --name cpuset_2 \
--cpuset-cpus=2 \
alicek106/stress \
stress --cpu 1
위 명령어는 컨테이너가 3번째 CPU만 사용하도록 설정합니다.
—cpu-period, —cpu-quota
컨테이너의 CFS 주기는 기본적으로 100ms로 설정되지만 run 명령어의 옵션 중 —cpu-period와 —cpu-quota로 이 주기를 변경할 수 있습니다.
docker run -d --name quota_1_4 \
--cpu-period=100000 \
--cpu-quota=25000 \
alicek106/stress \
stress --cpu 1
—cpu-period의 값은 기본적으로 100000이며, 이는 100ms를 뜻합니다. —cpu-quota는 —cpu-period에 설정된 시간 중 CPU 스케줄링에 얼마나 할당할 것인지를 설정합니다.
—cpus
—cpu-period, —cpu-quota와 동일한 기능을 하지만 좀 더 직관적으로 CPU의 개수를 직접 지정한다는 점에서 다릅니다. 예를 들어 —cpus 옵션에 0.5를 설정하면 —cpu-period=100000 또는 —cpu-quota=50000과 동일하게 컨테이너의 CPU를 제한할 수 있습니다.
docker run -d --name cpus_container \
--cpus=0.5 \
alicek106/stress \
stress --cpu 1
Block I/O 제한
컨테이너를 생성할 때 아무런 옵션을 설정하지 않으면 컨테이너 내부에서 파일을 읽고 쓰는 대역폭에 제한이 설정되지 않습니다. 하나의 컨테이너가 Block I/O를 과도하게 사용하지 않게 설정하려면 run 명령어에서 —device-write-bps, —device-read-bps, —device-write-iops, —device-read-iops 옵션을 지정해 블록 입출력을 제한할 수 있습니다. 단 Direct I/O의 경우에만 제한되며, Buffered I/O는 제한되지 않습니다.
'docker' 카테고리의 다른 글
[Docker swarm으로 고가용성 서버 구축하기 - 2] Docker swarm 배포하기 (0) | 2023.08.05 |
---|---|
[Docker swarm으로 고가용성 서버 구축하기 - 1] 클러스터와 부하분산 (0) | 2023.07.30 |
[Docker 4주차] Docker Swarm (2) | 2023.02.18 |
[Docker 3주차] Docker Engine 2부 (1) | 2023.02.18 |
docker란? (0) | 2022.06.17 |
- Total
- Today
- Yesterday
- 면접준비
- 개발
- 취업
- thread
- Kotlin
- 취업준비
- 개발자
- java
- Redis
- 프로그래밍
- 프로젝트
- DB
- 백엔드
- 면접 준비
- 면접
- 코드
- 게시판
- 코딩
- docker
- CS
- swarm
- 인터뷰
- MySQL
- Spring
- 동시성
- 취준
- 자바
- IT
- JPA
- DevOps
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |