Computer >> 컴퓨터 >  >> 소프트웨어 >> 가상 머신

Docker 컨테이너 네트워킹 완벽 가이드: 호스트 연결부터 컨테이너 링크까지

이번 시간에는 Docker의 매력을 더 깊이 탐구해 보겠습니다. 지금까지 두 번의 자습서를 진행했습니다. 첫 번째는 기술 전반에 대한 아주 상세한 소개로, 서비스 실행 방법과 포트 노출, Dockerfile을 활용한 이미지 커밋과 빌드 등 다양한 팁을 다뤘습니다. 이어서 supervisord를 init 스크립트 및 systemd의 대체재로 사용하는 방법도 살펴보았습니다.

오늘은 네트워킹에 대해 배워보겠습니다. 컨테이너에 접속하는 방법, 생성된 인스턴스 내부에서 호스트에 접근하는 방법, 그리고 무엇보다 중요한 것은 사전에 토폴로지를 전혀 알지 못하는 상황에서 한 컨테이너에서 다른 컨테이너로 연결하는 방법입니다. 꽤 흥미로운 주제가 될 것입니다. 이전 두 가이드와 마찬가지로 단계별로 진행하며 모든 내용을 자세히 설명하겠습니다.

목차

  1. 환경 설정
  2. 호스트에서 컨테이너로의 네트워킹
    1. 요약
  3. 컨테이너에서 호스트로의 네트워킹
  4. 컨테이너 간 네트워킹
  5. 컨테이너 링크!
    1. hosts 파일
    2. 서버 이름으로 접속하기
  6. 추가 학습 자료
  7. 결론

환경 설정

먼저 여러 개의 컨테이너를 생성해 보겠습니다. 지난 테스트에서 만든 이미지가 이미 있으므로 처음부터 시작할 필요가 없습니다. 세 개의 컨테이너를 실행할 것이며, 각각 SSH와 Apache 서비스를 구동합니다. 여기서는 네트워킹 부분에 집중하고자 하므로 보안 측면은 무시하겠습니다.

지난번과 달리 이번에 새롭게 할 일은 식별을 쉽게 하기 위해 컨테이너에 이름을 지정하는 것입니다. --name 옵션을 사용하면 됩니다. 예를 들면 다음과 같습니다:

docker run -d -ti -p 22 -p 80 --name net3 image-4:latest

세 인스턴스의 IP 주소도 확인해 보겠습니다:

[root@localhost ~]# docker inspect net1 | grep -i ipaddr
"IPAddress": "172.17.0.5",

[root@localhost ~]# docker inspect net2 | grep -i ipaddr
"IPAddress": "172.17.0.6",

[root@localhost ~]# docker inspect net3 | grep -i ipaddr
"IPAddress": "172.17.0.7",

이것이 테스트를 위한 초기 구성입니다. 동일한 호스트에서 실행되는 세 개의 컨테이너가 있고, 각자 서비스와 노출된 서비스 포트를 가지고 있습니다. 이제 본격적으로 흥미로운 작업을 시작할 수 있습니다.

호스트에서 컨테이너로의 네트워킹

이것은 비교적 쉬운 부분이며, 솔직히 말하면 이전 실습에서 이미 해본 적이 있습니다. SSH로 컨테이너에 접속했고, telnet으로 포트 테스트도 해봤습니다. 물론 netcat(nc)도 잘 작동합니다(보안 경보는 잠시 꺼두시죠). 연결 방법은 두 가지가 있습니다. 첫째, 이전에 했던 것처럼 IP 주소를 사용해 컨테이너에 직접 접근하는 방법입니다.

ssh 172.17.0.5
The authenticity of host '172.17.0.5 (172.17.0.5)' can't be established. ECDSA key fingerprint is
00:4b:de:91:60:e5:22:cc:f7:89:01:19:3e:61:cb:ea.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added '172.17.0.5' (ECDSA) to the list of known hosts.
root@172.17.0.5's password:

둘째, 컨테이너 내부의 서비스 포트에 대응하는 호스트 매핑 포트에 접근하는 방법입니다. docker ps 출력에서 PORTS 정보만 살펴보면:

0.0.0.0:49162->22/tcp, 0.0.0.0:49161->80/tcp   net1
0.0.0.0:49163->22/tcp, 0.0.0.0:49164->80/tcp net2
0.0.0.0:49165->22/tcp, 0.0.0.0:49166->80/tcp net3

위 예제에서 net1이라는 컨테이너의 경우 포트 22가 호스트의 49162번 포트로 매핑되어 있습니다. 이는 기본적으로 172.17.42.1/16 네트워크가 할당되는 docker0 인터페이스를 의미합니다. 실제로 확인해 보면:

telnet 172.17.42.1 49163
Trying 172.17.42.1...
Connected to 172.17.42.1.
Escape character is '^]'.
SSH-2.0-OpenSSH_6.6.1

그런데 왜 더 혼란스럽고 기억하기 어려운 두 번째 방법을 사용해야 할까요? 답은 이렇습니다. 동적 매핑 대신 미리 정해진 호스트 포트를 지정하면 컨테이너 네트워크의 토폴로지를 명확하게 파악할 수 있습니다. 또한 컨테이너를 호스트 외부에서 접근 가능하게 하려면 IP 포워딩과 NAT가 필요한데, docker0 인터페이스를 통신에 사용하면 더 적고 단순한 iptables 규칙으로 처리할 수 있습니다.

iptables -L 명령을 실행하고 DOCKER 체인을 확인하면 이를 검증할 수 있습니다. -p 옵션과 함께 컨테이너를 실행할 때마다 새 규칙이 자동으로 추가됩니다.

Chain DOCKER (1 references)
target prot opt source destination
ACCEPT tcp -- anywhere 172.17.0.5 tcp dpt:http
ACCEPT tcp -- anywhere 172.17.0.5 tcp dpt:ssh
ACCEPT tcp -- anywhere 172.17.0.6 tcp dpt:ssh
ACCEPT tcp -- anywhere 172.17.0.6 tcp dpt:http
ACCEPT tcp -- anywhere 172.17.0.7 tcp dpt:ssh
ACCEPT tcp -- anywhere 172.17.0.7 tcp dpt:http
ACCEPT tcp -- anywhere 172.17.0.8 tcp dpt:ssh
ACCEPT tcp -- anywhere 172.17.0.8 tcp dpt:http

반면 보안 제약이 없다면 동적 방식이 상당히 유용할 수 있습니다. 컨테이너를 시작할 때 포트 매핑을 미리 알 필요가 없기 때문입니다. docker ps 출력을 파싱하여 실시간으로 정보를 얻을 수 있습니다. 다만 서비스 운영 시에는 고정 IP 주소와 알려진 포트를 설정하는 것이 유용한 경우가 많습니다.

요약

다소 혼란스러울 수 있으니 간단히 정리해 보겠습니다. 모든 Docker 호스트에는 docker0 또는 유사한 이름의 브리지가 실행되고 있으며, 일반적으로 /16 서브넷을 사용합니다. 이름, 서브넷, 심지어 네트워크 자체도 변경할 수 있습니다. 그 뒤에는 각자 고유한 사설 IP 주소와 하나 이상의 노출된 포트를 가진 수많은 컨테이너가 존재할 수 있습니다.

이러한 컨테이너와 해당 포트에 연결하려면 몇 가지 선택지가 있습니다. 직접 연결할 수도 있지만, 그 경우 각 컨테이너마다 IP 포워딩, NAT, 방화벽 규칙이 필요하고 IP 주소도 알아야 합니다.

호스트 인터페이스(docker0)와 호스트 포트 방식으로 연결하고 싶다면 컨테이너가 어떻게 실행되는지 알 필요가 없습니다. 호스트 포트와 컨테이너 포트의 매핑 관계만 알면 됩니다. 즉, 여러 호스트 노드 간에 컨테이너에 접근 가능하게 하려면 호스트 인터페이스(docker0)만 다른 호스트에 보이게 하면 됩니다. 이렇게 하면 IP 포워딩, NAT, 방화벽 규칙이 단순해집니다. 예를 들면:

0.0.0.0:30001->22/tcp net1
0.0.0.0:30007->22/tcp net4
0.0.0.0:30008->22/tcp net5

위 예제는 세 개의 서로 다른 컨테이너의 SSH에 접속할 수 있게 해줍니다. 일대일 매핑, 즉 동일한 호스트 포트와 컨테이너 포트(예: 22:22)를 사용하면 사실상 하나의 컨테이너에서 하나의 서비스만 리스닝하는 것으로 제한됩니다. 이는 집의 공유기와 같은 원리입니다. 일대일 매핑을 사용하려면 컨테이너마다 다른 포트를 사용해야 하는데, 서비스가 예측 가능한 포트에서 실행되기를 기대하는 입장에서는 혼란스러울 수 있습니다.

위 예제에서 보듯이 세 개의 컨테이너가 SSH를 사용할 수 있습니다. 컨테이너 내부 관점에서는 항상 22번 포트입니다. 외부에서는 포트 번호가 컨테이너와 서비스를 동시에 식별합니다. 그리고 이것이 내부에서 무슨 일이 벌어지는지 신경 쓰지 않아도 되는 유일하게 알아야 할 정보가 됩니다.

즉, 스크립트 로직 등을 활용해 컨테이너 생성 시 호스트 포트를 동적으로 할당하고, 그 정보를 활용해 서비스 포트를 식별할 수 있습니다. 이렇게 하면 호스트-컨테이너 네트워킹이 단순하고 우아한 작업이 됩니다. 게다가 컨테이너 토폴로지를 사전에 알지 못해도 호스트 간 연결도 가능합니다.

컨테이너에서 호스트로의 네트워킹

이번에는 좀 더 흥미로운 부분입니다. 컨테이너는 장기적인 목적으로 운영될 수 있으므로 한동안 계속 가동 상태를 유지해야 할 수 있습니다. 그렇다면 업그레이드, 패치, hosts 파일 업데이트 등 컨테이너 유지보수가 필요할 수 있습니다. Chef, Puppet, Cfengine 같은 구성 관리 도구를 사용하고 있다면 그 부분도 고려해야 합니다.

먼저 던져야 할 큰 질문은, 컨테이너가 외부 세계와 통신할 수 있는가? 입니다. 답은 그렇습니다. 소개 가이드에서 공식 저장소에서 CentOS 업데이트를 직접 다운로드했으므로 이 부분이 잘 작동한다는 것을 알고 있습니다. 그렇다면 docker0로 ping은 어떨까요?

두 번째 질문은, docker0 인터페이스에 접근한 후 그 호스트 포트에 연결할 수 있는가? 입니다. 잠시 필요한 정보가 컨테이너 내부에 주어졌다고 가정해 봅시다.

telnet 172.17.42.1 49162
Trying 172.17.42.1...
telnet: connect to address 172.17.42.1: No route to host

이것은 작동하지 않습니다. 호스트에 IP 포워딩을 구성하지 않았기 때문입니다. 게다가 필요한 트래픽을 허용하는 방화벽 규칙도 필요합니다. 여기서 조금 복잡해지는데, iptables에 익숙하지 않다면 다소 어려움을 겪을 수 있습니다.

echo 1 > /proc/sys/net/ipv4/ip_forward

그리고 iptables 규칙입니다(호스트에서 실행, 다소 느슨한 규칙임을 유의하세요):

iptables -t filter -A FORWARD -d 172.17.0.0/16 \
-o docker0 -j ACCEPT
iptables -t filter -A FORWARD -s 172.17.0.0/16 \
-i docker0 -j ACCEPT
iptables -t filter -A FORWARD -i docker0 -o docker0 -j ACCEPT
iptables -t nat -A POSTROUTING -s 172.17.0.0/16 \
! -d 172.17.0.0/16 -p tcp -j MASQUERADE --to-ports 1016-65535 \
iptables -t nat -A POSTROUTING -s 172.17.0.0/16
! -d 172.17.0.0/16 -p udp -j MASQUERADE --to-ports 1016-65535 \
iptables -t nat -A POSTROUTING -s 172.17.0.0/16
! -d 172.17.0.0/16 -j MASQUERADE

가장 간단한 대안은 방화벽을 끄거나 전부 허용하는 것입니다. systemctl stop firewalld, iptables -F 또는 이에 상응하는 명령을 실행하면 됩니다. 추가 보안이 필요한 일부 시나리오에서는 불가능할 수 있지만, 이는 앞서 논의한 첫 번째 사용 사례와도 연관됩니다. 이제 모든 컨테이너와 그에 연관된 서비스마다 수동으로 규칙을 만들어야 한다고 상상해 보세요.

적절한 네트워킹 규칙이 갖춰지면 호스트 포트에 연결할 수 있으며, 이는 사실상 다른 컨테이너와 통신하는 것을 의미합니다. 물론 특정 IP 주소나 포트가 반드시 다른 컨테이너를 대상으로 해야 하는 것은 아닙니다. 나아가 호스트의 다른 네트워크 장치, 심지어 원격 호스트에도 연결할 수 있습니다.

컨테이너 간 네트워킹

David Bowie의 노래 "Ashes to Ashes"처럼 말이죠, 물론 그만큼 멋지지는 않지만요. 이것이 가장 유용한 시나리오로 보입니다. 역시 두 가지 선택지가 있습니다. 다른 컨테이너에 직접 연결을 시도하거나, docker0의 동적으로 매핑된 포트를 사용하는 것입니다. 우리는 약간 편법을 쓰겠습니다. 호스트와 호스트 포트를 컨테이너로 들어가는 게이트웨이로 사용하는 것입니다. 첫 번째 방법은 꽤 간단합니다. net1에서 net2와 net3로 ping을 시도해 보겠습니다.

아무 문제 없이 작동합니다. 하지만 문제는, 우리가 이 IP 주소들을 외부 세계에서 알고 있다는 점입니다! 컨테이너 내부에서 이 정보를 얻은 것이 아닙니다. 실제로 보안상의 이유와 베이스 컨테이너가 상대적으로 작은 이미지라는 사실 때문에 기본적으로 좋은 네트워킹 도구를 사용할 수 없습니다. ifconfig 유틸리티조차 없습니다. 게다가 컨테이너 내부에서는 iptables를 조작할 수도 없습니다:

iptables -L
iptables v1.4.21: can't initialize iptables table `filter': Permission denied (you must be root)
Perhaps iptables or your kernel needs to be upgraded.

따라서 docker0에 접근해 호스트 포트에 연결하는 두 번째 방법은 사실상 실행 가능하지 않을 수 있습니다. 컨테이너 내부에서는 호스트 방화벽 규칙을 조작할 수 없기 때문입니다. 이것이 바로 세 번째 방법을 논의하는 이유입니다.

컨테이너 링크!

이를 올바르게 수행하는 방법은 --link 기능을 사용하는 것입니다. 즉, 새 인스턴스를 생성하면서 기존 컨테이너에 연결(link)하는 것입니다. 링크 기능은 연결된 컨테이너의 호스트명과 IP 주소를 새 인스턴스의 환경 변수와 /etc/hosts 파일에 삽입합니다. 다소 혼란스럽게 들릴 수 있으니 예를 들어 설명하겠습니다:

docker run -d -ti -p 80 --name web image-4:latest
8e689d8ae3ef43eeab3bfa9cf523e1cc6658dae5fc665f8cbf889db17db46c26

docker inspect web | grep -i ipaddr
"IPAddress": "172.17.0.9",

nc -w1 -v 172.17.0.9 80
Ncat: Version 6.40 ( https://nmap.org/ncat )
Ncat: Connected to 172.17.0.9:80.

웹 서버 역할을 하는 컨테이너가 실행 중입니다. 이제 클라이언트가 이 서버에 접근할 수 있기를 원합니다. 토폴로지에 대한 사전 지식이 없을 수 있으므로 IP 주소 대신 서버 이름을 사용하고자 합니다. 게다가 이름은 네트워크 레이아웃 변경을 용이하게 해주므로 유용합니다. 실제로 클라이언트 컨테이너를 생성하고 웹 서버에 연결해 보겠습니다:

docker run -ti --name client --link web:web image-4:latest /bin/bash

여기서 하는 것은 source:target 형태의 링크 생성입니다. 즉, web이라는 이름의 서버(컨테이너)가 client라는 새 컨테이너 내부에서 web으로 보이게 됩니다. 컨테이너를 생성하고 접속한 후 BASH 셸에서 env 명령을 실행하여 환경을 확인할 수 있습니다:

WEB_PORT_80_TCP_PORT=80
WEB_PORT_80_TCP=tcp://172.17.0.9:80
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PWD=/
WEB_PORT_22_TCP_ADDR=172.17.0.9
container_uuid=558a9db0-6d1e-d9d2-b62d-a8dbfbad0b2d
WEB_PORT_22_TCP=tcp://172.17.0.9:22
SHLVL=1
HOME=/root
WEB_NAME=/client/web
WEB_PORT_80_TCP_PROTO=tcp
WEB_PORT_80_TCP_ADDR=172.17.0.9
LESSOPEN=||/usr/bin/lesspipe.sh %s
WEB_PORT=tcp://172.17.0.9:22
_=/usr/bin/env

동일한 이름 매핑은 혼란스러울 수 있으므로 다른 매핑을 시도해 보겠습니다:

docker run -ti --name client --link web:dedoimedo image-4:latest /bin/bash

그러면 다음과 같은 결과를 얻습니다.

선택적으로, 빠른 작업만 수행하면 되는 컨테이너를 생성하는 경우 run --rm 옵션 사용을 고려할 수 있습니다. 단기 작업의 경우 디스크에 컨테이너 파일을 유지할 이유가 없으며, Docker가 자동으로 정리하도록 할 수 있습니다. 공식 문서를 읽어보면:

기본적으로 컨테이너의 파일 시스템은 컨테이너가 종료된 후에도 유지됩니다. 이는 최종 상태를 검사할 수 있어 디버깅이 훨씬 쉬워지고 기본적으로 모든 데이터를 보존합니다. 하지만 단기 포그라운드 프로세스를 실행하는 경우 이러한 컨테이너 파일 시스템이 상당히 쌓일 수 있습니다. 대신 컨테이너 종료 시 Docker가 자동으로 컨테이너를 정리하고 파일 시스템을 제거하기를 원한다면 --rm 플래그를 추가하면 됩니다.
docker run --rm -ti --name client --link web:dedoimedo image-4:latest /bin/bash

hosts 파일

링크 기능의 두 번째 좋은 점은 새 컨테이너의 /etc/hosts 파일에 필요한 이름들이 자동으로 채워진다는 것입니다. IPv6 항목을 제외하면 아래와 같은 형태입니다:

[root@e1e92f6918dd /]# cat /etc/hosts
172.17.0.11 e1e92f6918dd
127.0.0.1 localhost
172.17.0.9 dedoimedo 8e689d8ae3ef web

서버 이름으로 접속하기

호스트명 확인과 환경 설정이 갖춰졌으므로, 이제 어디서든 네트워킹을 하는 것처럼 간단해집니다. 정말 단순하고 기억하기 쉬우며, 무엇보다 스크립트로 자동화할 수 있습니다.

추가 학습 자료

아직 재미가 부족하시다면 다음 자료도 참고해 보세요:

Docker 고급 네트워킹 가이드(Docker advanced networking guide)

결론

여기까지입니다. 또 하나의 어려운 주제가 해소되었습니다. Docker 컨테이너 작업 시 직면하게 될 가장 큰 난관은 네트워킹 자체보다 오히려 방화벽 규칙 구성이라고 생각합니다. 하지만 일단 요령을 익히면 그리 어렵거나 복잡하지 않습니다.

우리는 컨테이너를 시작하고 포트를 노출하는 방법, 호스트-컨테이너, 컨테이너-호스트, 컨테이너-컨테이너 네트워킹 실습, 포워딩 설정, 그리고 삶을 훨씬 쉽고 예측 가능하게 만들어주는 링크 활용법을 살펴보았습니다. 전반적으로 바쁘지만 실용적인 가이드였기를 바랍니다. 다루었으면 하는 다른 주제가 있다면 언제든지 제안해 주세요. 이것으로 마치겠습니다.

P.S. 이 글이 마음에 드셨다면 Dedoimedo에게 사랑을 돌려주세요!

즐거운 Docker 생활 되세요.