Post

홈서버 구축 5 - Docker로 서비스 실행하기

들어가며

도커는 격리된 환경을 제공하여 서비스 간 간섭을 줄일 수 있고, 이식성도 매우 좋다.
어디서든 동일한 환경으로 쉽게 실행시킬 수 있으므로, 만약 홈서버가 다운되더라도 클라우드 서버에서 복구하는 시간이 적어진다.
이번 포스팅에서는 도커로 서비스를 실행하고, github actions의 셀프 호스팅으로 CI/CD 파이프라인을 구축하는 과정을 소개한다.


도커 설치

먼저 도커와 도커 컴포즈를 설치해야 한다.
꽤 많은 과정이 필요하기 때문에 한번에 해결 가능한 스크립트를 제공한다.
스크립트 내용을 복사하고 ./install_docker.sh로 실행해서 도커와 도커 컴포즈를 설치하자

https://github.com/ajroot5685/Script/blob/main/docker/install_docker.sh


도커 컴포즈 설정

단일 도커 파일을 사용하면 이미지 빌드, 컨테이너 생성 및 실행, 볼륨/환경변수/네트워크 등을 모두 수동으로 설정해야 하므로 귀찮다.
도커 컴포즈를 이용해 위 작업들을 자동화하자


Dockerfile

도커 컨테이너를 실행하기 위해선 이미지가 필요하다.
jar 파일로 빌드된 애플리케이션 파일을 실행하는 도커 파일을 루트에 만들어준다

1
2
3
4
5
6
7
FROM openjdk:17

ARG JAR_FILE=build/libs/*.jar

COPY ${JAR_FILE} app.jar

ENTRYPOINT ["java", "-jar", "app.jar"]


docker-compose.yml

1
2
3
4
5
6
7
8
services:
  <앱 이름>:
    image: <도커 유저이름>/<이미지 이름>:latest
    container_name: app
    env_file:
      - .env
    ports:
      - "11000:8080"

image : 사용할 이미지를 지정한다.
container_name : 명시적으로 컨테이너 명을 지정한다.
env_file : 환경변수가 필요시 지정한다.
ports : 외부에서 요청을 전달하기 위해 호스트의 포트(11000)와 컨테이너의 포트(8080)를 연결한다.

사실 build로 도커파일을 지정해야 이미지 빌드까지 자동화되지만, 빌드 단계에서 오류가 나는지를 확인하기 위해 분리한다.


테스트

CI/CD 환경에서 테스트하기 전에 로컬에서 파일들이 잘 작동되는지 먼저 검사하자

  • 이미지 빌드 : docker build -t <도커 유저이름>/<이미지 이름>:latest .
  • docker-compose 실행 : docker-compose up -d
  • 이후 매핑한 호스트 포트(11000)로 요청이 잘 가는지 확인한다.

만약 도커 컴포즈 이름을 다르게 설정했다면 -f 옵션을 사용해 지정할 수 있다.

  • docker-compose -f docker-compose-app.yml up -d


Nginx 서버 블록 추가

이전 포스팅을 참고해서 서버 블록을 추가하자.


github actions 셀프 호스팅

깃허브 액션은 레포지토리를 public으로 설정하면 무료로 깃허브 서버로 파이프라인을 구축할 수 있다.
이 방식으로 홈서버에 배포하려면 깃허브 서버에서 구동되는 runners가 접근할 수 있도록 SSH 접속을 설정해야 한다.
하지만 나는 홈서버 SSH 보안을 위해 SSH 접근을 최소화하고 싶기 때문에 홈서버에서 파이프라인이 실행되는 셀프 호스팅 방식을 사용한다.


셀프 호스팅 준비

여기에 잘 설명이 되어있지만, Linux 기준으로 다시 설명한다.

self_hosted

깃허브 액션을 사용하고자 하는 레포지토리에서 Settings - Actions - Runners로 이동한다.
New self-hosted runner를 누르고 Linux를 선택하면 설치 가이드가 있다.
원하는 위치에 가이드를 따라 설치하고 ./run.sh로 실행이 잘되는지 확인한다.
백그라운드로 실행하기 위해서는 제공된 ./svc.sh를 사용하면 된다.

1
2
sudo ./svc.sh install
sudo ./svc.sh start

가이드의 configure 단계를 따라 저장소에 잘 연결되었다면 깃허브의 runners에 보여질 것이다.

runners


액션즈 환경변수 설정

레포지토리가 public이면 워크플로 파일에서 보안이 위험할 수 있다.
중요한 파일이나 변수들은 레포지토리의 비밀변수로 설정하여 노출되지 않도록 하자.

secrets

레포지토리의 Settings - Secrets and variables - Actions 에서 비밀 변수를 추가할 수 있다.
추가/수정/삭제만 되고 그 누구도 읽을 수 없으므로 노출될 염려가 없다.
이렇게 설정한 변수는 워크플로우 스크립트에서 ${{ secrets.DOCKER_IMAGE_NAME }} 와 같이 설정 가능하다.
yaml 같이 띄어쓰기 형식이 중요할 때에는 base64로 인코딩하여 설정하고, 사용할 때 디코딩하여 사용해야 한다.

base64 인코딩은 변환시켜주는 아무 웹사이트를 찾아서 사용하면 된다.


워크플로우 스크립트 설정

레포지토리에 github/workflows/github-actions.yml 파일을 만든다.
위에서 말했듯 이미지 빌드 오류가 나는지를 확인하기 위해 이미지 빌드 단계를 분리한다.
아래 파일을 추가하고 main 브랜치에 push했을 때 파이프라인이 정상 작동되는지 확인한다.

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
name: Manitto CI/CD 파이프라인

# main 브랜치에 push될 때 실행
on:
  push:
    branches:
      - main

# runners는 시스템 자원을 꽤나 많이 사용한다.
# 동시에 하나의 워크플로만 실행해야 시스템의 부담을 덜 수 있다.
concurrency:
  group: jg-runner-group # 같은 그룹의 워크플로는 동시에 실행되지 않음
  cancel-in-progress: false # 이미 실행중인 이전 워크플로를 취소하지 않고 기다림

jobs:
  build-and-deploy: # 작업 이름
    runs-on: self-hosted # runners의 태그, 여러 태그를 조합해 실행할 러너를 상세 지정 할 수 있다.
    steps:
      - name: Checkout Repository # 현재 레포지토리의 코드를 가져옴
        uses: actions/checkout@v3

      - name: Set up JDK 17 # 애플리케이션을 빌드하기 위해 JDK 17 설치
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '17'

      - name: Build Spring Boot Application # 테스트를 제외하고 빌드를 수행
        run: |
          chmod +x gradlew
          ./gradlew clean build -x test

      - name: Build Docker Image # 이미지 빌드
        run: |
          docker build --no-cache -t ${{ secrets.DOCKER_IMAGE_NAME }}:latest .

      - name: Add Private Files # 비밀 변수를 디코딩하여 파일에 저장
        run: |
          echo "${{ secrets.APPLICATION_ENV }}" | base64 --decode > .env
          echo "${{ secrets.DOCKER_COMPOSE }}" | base64 --decode > docker-compose.yml

      - name: Run New Container # 컨테이너 실행
        run: |
          docker-compose up -d --build

  clean:
    runs-on: self-hosted
    needs: build-and-deploy
    steps:
      - name: Cleanup Previous Workspace # 기록이 남지 않도록 전체 파일 삭제
        run: rm -rf ./*

      - name: Cleanup Docker and Cache # 도커의 찌꺼기 데이터들 제거
        run: |
          docker system prune -af --volumes
          rm -rf ~/.gradle/caches


주의 - ufw는 도커 방화벽을 제어할 수 없다

리눅스는 방화벽 시스템으로 iptables를 사용한다.
그리고 ufw는 iptables 설정의 복잡성을 간편하게 사용할 수 있게 해주는 도구이다.

이때 ufw는 도커 컨테이너의 방화벽을 제어할 수 없다.
그 이유는 도커는 iptables를 직접 조작하기 때문이다.
ufw도 iptables를 조작하지만 도커 방화벽 정책의 우선순위가 더 높다.

도커 컨테이너로 전달되는 요청은 내부 도커 네트워크를 통해 홈서버를 거쳐가는 것이기 때문에 FORWARD 체인을 사용한다.
여기서 설정되는 도커의 우선순위가 ufw 우선순위보다 높기 때문에 ufw로 도커 컨테이너 방화벽을 제어할 수 없는 것이다.

iptables


그렇다면 어떻게 방화벽을 제어하는가?

도커 컨테이너는 기본적으로 컨테이너의 네트워크를 개방하기 때문에 보안상 안전하지 않은 상태가 된다.
이에 대한 2가지 대응 방법이 있다.

  1. 컨테이너는 개방된 상태로 두고, 포트포워딩과 리버스 프록시를 이용해 외부 노출을 제한한다.
    • 홈서버는 공유기를 통해 통신하므로 포트포워딩 설정이 없다면, 외부 접근은 불가능하다.
    • 외부 요청은 Nginx와 같은 리버스 프록시를 통해 받고, Nginx가 받은 요청을 내부 컨테이너로 전달시킨다.
    • 이때 Nginx가 사용하는 포트만 ufw로 관리하면, 컨테이너에 대한 외부 접근은 막으면서도 방화벽 제어가 가능하다.
  2. iptables를 직접 조작한다.
    • 이미지에 나와있듯이 커스텀 체인을 만들어서 컨테이너로 전달되기 전 트래픽을 제어할 수 있다.
    • 꽤 난이도가 있는 작업이므로, 특수한 목적이 있을 때에만 권장한다.
This post is licensed under CC BY 4.0 by the author.