제가 하나의 VM에 여러 컨테이너를 두면서 생기는 문제 중 하나로는 외부에서 접속할 때 "IP는 한 개인데 포트를 80이나 443으로 유지하면서, 여러 컨테이너에 접속 시켜주냐" 였습니다.
찾아본 결과 Apache나 Nginx 등의 웹 서버(Web Server)를 통해 어떤 도메인으로 접속했냐에 따라서 각각 다른 서버에 리다이렉트 해줄 수 있더라구요. 또한 SSL을 적용할 수도 있었습니다.
이번 글에서는 제가 AWS Lightsail로 이전하면서, 사용했던 Conf 파일과 docker-compose.yaml 파일을 공유해드림과 동시에 어떻게 구축했는지 설명드리고자 합니다.
어떤 도메인을 요청하던지 일단, Nginx가 받은 다음에 요청을 분류하여 컨테이너에 전달해주거나 정적인 데이터를 바로 전달합니다. 특히, 정적인 데이터의 경우에는 WAS가 아닌 웹 서버가 바로 전달해주는 것이 빠르기 때문에 서브 디렉터리로도 분류하기로 합니다.
이전 글을 보면, 모든 서버는 Docker 컨테이너로 올라가 있는 상태입니다. 즉, Nginx가 올라간 컨테이너는 다른 컨테이너에 접속할 수 있게 구성해줘야합니다.
저도 처음에 구성할 때에는 "그러면 각 컨테이너의 IP를 고정 시키고 기억해야하는건가?"라는 질문을 저에게 던졌습니다. 찾아보니, Dokcer는 Service Discovery라하여 Docker 네트워크 상에 내부 DNS 서버를 갖고 있다고 합니다. 같은 Docker 네트워크 상에 있다면 각 컨테이너에서는 서비스 이름만으로도 접속할 수 있다는 것이죠.
즉, 각 컨테이너와 Nginx 컨테이너를 한 네트워크에 넣으면 Service 명으로도 리다이렉트 시켜줄 수 있게 됩니다.
그림으로 그리면 위와 같습니다. Nginx에서는 DB에 직접 접속을 할 수는 없지만, 각각의 서버들은 DB에 접속할 수 있습니다. 이렇게 구성하면 보안 상에도 좋겠지요.
이렇게 큰 그림을 그려보았으니 이제 구현해보겠습니다.
먼저, 여러가지 컨테이너의 정보를 하나의 파일로 정의할 수 있는 docker-compose.yaml 파일을 만들어보겠습니다.
version: '3.1'
networks:
front-network:
driver: bridge
back-network:
driver: bridge
services:
nginx:
container_name: nginx
image: nginx:1.15-alpine
restart: always
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/etc/letsencrypt:/etc/letsencrypt
- ./nginx/var/lib/letsencrypt:/var/lib/letsencrypt
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
- ./content:/var/www
networks:
- front-network
- back-network
environment:
- TZ=Asia/Seoul
depends_on:
- bot-api
- broadcast
- phpmyadmin
mariadb:
container_name: mariadb
image: mariadb:10.1.48-bionic
restart: always
expose:
- "3306"
volumes:
- ./db/data:/var/lib/mysql
- ./db/config:/etc/mysql/conf.d
environment:
- "MYSQL_ROOT_PASSWORD=test1234"
- "TZ=Asia/Seoul"
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
networks:
- back-network
phpmyadmin:
container_name: phpmyadmin
image: phpmyadmin:latest
restart: always
expose:
- "80"
environment:
- "PMA_HOST=mariadb"
- "PMA_PORT=3306"
- "PMA_ABSOLUTE_URI=/dbadmin"
- "UPLOAD_LIMIT=50M"
- "TZ=Asia/Seoul"
networks:
- front-network
- back-network
depends_on:
- mariadb
bot-api:
container_name: bot-api
image: ghcr.io/shin6949/ox-bot-api:latest
restart: always
expose:
- "8080"
environment:
- "DB_URL=jdbc:mariadb://mariadb:3306/bot"
- "DB_USER=root"
- "DB_PASSWORD=test1234"
- "TZ=Asia/Seoul"
networks:
- back-network
- front-network
depends_on:
- mariadb
broadcast:
container_name: broadcast
image: ghcr.io/shin6949/broadcast:latest
restart: always
expose:
- "8080"
environment:
- "DB_URL=jdbc:mariadb://mariadb:3306/broadcast"
- "DB_USER=root"
- "DB_PASSWORD=test1234"
- "TZ=Asia/Seoul"
networks:
- front-network
- back-network
depends_on:
- mariadb
여기서 주목하실 내용은 networks와 expose 입니다. networks의 경우, "컨테이너 네트워크 구성"에서 설명을 드렸었는데요. Compose 파일에서의 구문을 좀 더 설명드리겠습니다.
networks:
front-network:
driver: bridge
back-network:
driver: bridge
"front-network", "back-network"라는 이름을 가진 Bridge 형태의 Docker Network를 만든다는 의미입니다. 이렇게 만든 네트워크를 밑의 서비스들에 할당해줍니다. 할당 받은 컨테이너들은 동일 네트워크 내에 있는 서비스를 찾을 수 있게 되죠.
저 같은 경우에는 DB나 챗봇은 굳이 외부에서 요청을 받을 것이 아니기 때문에 Nginx가 알고 있을 컨테이너가 아니라고 판단하여, back-network에 위치하게 하여 Nginx가 알지 못하게 하였습니다.
Compose의 내용을 보면, ports 구문과 expose 구문이 나누어져 있는 것을 확인하실 수 있습니다. 그런데 두 구문 모두 값만 보면 포트를 뜻한다는 것을 알 수 있는데요.
ports의 경우 Host의 포트와 Container 포트를 연결해줍니다. 예를 들어 "80:8080"이라고 하면, Host의 80번 포트를 컨테이너의 8080번 포트와 연결시켜줍니다. 즉, Host의 포트의 경우에는 중복될 수 없겠지요.
Expose의 경우에는 컨테이너가 속해있는 네트워크에 지정한 포트를 여는겁니다. 위에 있는 broadcast
라는 서비스는 8080 포트를 back-network, front-network에 열어주는거죠. 하지만, 호스트의 모든 포트로 접속을 시도해도 접속이 되지 않습니다. 컨테이너 사이에서만 통신이 되는거죠.
즉, DB의 경우에는 3306 포트를 호스트에는 열지 않았기 때문에 직접적으로 공격할 수 없게 됩니다. 그리고, 호스트에서 방화벽을 따로 설정할 필요도 없어지고요.
다음에는 Nginx를 구성해보겠습니다. Nginx의 구성 파일을 작성할 때에는 이게 컨테이너 환경에서 작동할 것이라고 생각하고 파일을 작성해야합니다.
이제 위에서 구상한대로 파일을 작성해보겠습니다.
# broadcast.cocoblue.me.conf
server {
listen 80;
listen [::]:80;
server_name broadcast.cocoblue.me;
location / {
return 301 https://$host$request_uri;
}
location /robots.txt {
return 200 "User-agent: *\nDisallow: /";
}
}
server {
listen 443 ssl;
server_name broadcast.cocoblue.me;
ssl_certificate /etc/letsencrypt/live/cocoblue.me/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/cocoblue.me/privkey.pem;
location / {
proxy_pass http://broadcast:8080;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /images/ {
rewrite ^/images(/.*)$ $1 break;
root /var/www/broadcast/images;
}
location /robots.txt {
return 200 "User-agent: *\nDisallow: /";
}
}
예시로 한 개의 파일을 갖고 왔는데요. 아실 분들은 다 아실 겁니다. 이제 간단하게 설명드리겠습니다.
broadcast.cocoblue.me:80을 보시면, 일부 경로는 제외하고 return 302로 설정했는데요. 이는 HTTP 연결이 아닌 HTTPS로 연결하도록 강제하는 역할을 합니다.
이는 이전의 사이트가 http라서 http가 기반으로 되어 있는 링크가 다른 웹에 뿌려져 있는 경우 용이합니다.
즉, 실제 Reverse Proxy 구문은 443 포트를 LISTEN 하는 부분을 보시면 됩니다.
ssl_certificate와 ssl_certificate_key 구문을 보시면, SSL 인증서의 위치를 지정해줬는데요. 이는 Let's Encrypt 등을 이용하여 발급 받은 인증서의 컨테이너 기준 경로를 지정해줬습니다. 호스트에는 (compose 파일 위치)/nginx/etc/letsencrypt
에 있습니다.
저 같은 경우에는 WAS를 띄우면서, 정적인 데이터(이미지 등)는 바로 파일을 직접 참조하도록 설정하였는데요. 토이 프로젝트에는 이미지 밖에 없어서, 서브 디렉터리 'images'를 참조하고자 하면, WAS 컨테이너가 아닌 파일로 안내하게 하였습니다.
이 위치도 당연히 컨테이너 기준 경로입니다. 실제 파일은 호스트의 (compose 파일 위치)/content
에 존재합니다.
만일, AWS S3, Azure Blob Storage 등 클라우드 비정형 데이터 저장소와 연동하고자 하면, Reverse Proxy를 통해 연동할 수 있습니다. Reverse Proxy를 통해 설정한다면 이후에 다른 클라우드 업체로 변경하거나 아예 서버에서 제공하는 식으로 바꿔도 서비스에는 영향이 가지 않겠지요.
이 구문의 경우, 결과 값으로 "User-agent: *\nDisallow: /"
을 return 하도록 설정되어 있는데요. 이는 구글, 네이버 등 포털 업체의 봇이 방문 했을 때 "어디서 어디까지 참조해라"는 의미를 담긴 파일입니다. 하지만, Disallow를 return 하기 때문에 검색 업체의 봇들은 이 사이트를 검색에 반영하지 않습니다.
이 설정은 실제 서비스를 할 때는 일부는 허용하고 일부는 금지해야하는데요. 메인 페이지와 각 기능의 페이지는 열어두되, 사용자들이 업로드한 데이터들이 검색 엔진에 의해 직접적으로 보이지 않게 설정할 수 있게 됩니다.
웹 서버를 응용하면, Load Balancing도 가능합니다. 동일한 컨테이너를 여러 개 띄워두고 upstream 구문을 통해 컨테이너의 서비스 명을 모두 정의한다면, 실제 Host는 하나인데 여러 개가 동시에 돌아가는 것처럼 서비스 할 수 있겠죠? 하지만, 이럴려면 그냥 K8S가 편할 것 같긴합니다,,
간단(?)하게 제가 설정했던 웹 서버의 설정을 공유해드렸습니다. 제가 Azure의 여러 서비스를 이용하다가 AWS Lightsail의 Instance 서비스만 단일로 이용하기 위해서는 웹 서버에 대한 이해와 서브 도메인을 어떻게 연결시킬지 등을 고민했는데요.
이번 구축으로 덕분에 많은 것을 알아가고 실제 구동이 되는 모습을 보니 뿌듯합니다. 혹시 개선 아이디어나 질문 사항이 있으시면 편하게 댓글 달아주시면 감사하겠습니다.
nginx컨테이너에서 백엔드 컨테이너로 프록시 패스 처리를 어떻게 해야하나 고민 많았는데 글 잘보고 갑니다~!