Start RESTful Service with Spring Boot 2.x + Kotlin

최근 안드로이드 이외에도 Kotlin을 활용해 개발할 수 있는 것을 공부하고 있었다. Android, IOS 간 Shared library 가 가능한 Kotlin Multiplatform도 도전했었고, 이전에 만들다가 문서가 하나도 없어 포기했었던 Ktor라던지…

그렇게 찾다보니 회사의 한 프로젝트가 쓰고 있는 Spring Boot 1.4 + Spring Framework 4 구조를 보고 충분히 Kotlin으로 개발하는 이점을 가지고 개발할 수 있다는 생각이 들었다.

그렇게 해서 2019년 2월 초 부터 코틀린 마이크로서비스 개발 (https://www.aladin.co.kr/shop/wproduct.aspx?ItemId=178926495) 이라는 책을 사서 공부하게 되었다.

공부하고 있던 도중 한번쯤은 토이 프로젝트로 개발해보는 것이 실제로 이점을 잘 느낄 수 있을 것 같아, 최근 몇일간 ‘파일 업로드 / 바이너리 제공‘ 에 대한 API 서비스를 개발하게 되었다.

해당 서비스는 Spring Boot 2.1 기반의 Gradle 프로젝트로 구성되고, 메인 언어는 Kotlin, 설정 파일으로는 yaml를 사용했다. 그 외에도 Swagger를 이용한 API Document 생성, Jenkins를 이용한 Continous Delivery, Dockerfile과 openjdk-8-alpine 이미지를 이용한 컨테이너 배포까지 담고 있다. (단, 작성일자 2019-02-16 기준으로 Jenkins 연동에 성공하지 못했다. 이는 아마 별도 글로 정리해야 될 것 같다.)

이 글은 그 과정에 있어서 튜토리얼 비슷한 글이 될 것이다. 이 튜토리얼에서 사용한 코드 대부분은 (지우지 않은 이상) Github에 공개되어있다. 주소는 https://github.com/WindSekirun/UploadFileBoot 이다. 참고로 이 글에서 모든 코드를 한 줄마다 설명하지는 않을 것이다.

물론, 제대로 된 백엔드 라고 말할 수 있는 서비스를 만든 것이 처음이므로 설계를 이해하지 못하고 코딩할 수 있으나… 만일 발견한다면 친절하게 알려주었으면 한다.

프로젝트 생성

프로젝트 생성은 Spring Initializer (https://start.spring.io) 를 이용했다. 맨 위에는 Gradle, Kotlin, 2.1.3 으로 설정하고 초기 Dependencies 에 Web를 입력하고서 다운로드 하면 미리 구성된 프로젝트 폴더가 열리게 된다.

이를 적당한 곳에 압축을 풀고 Intellij로 열게 되면 개발 준비가 모두 완료된다.

기본 구조

기본적인 구조는 다음과 같이 구성했다.

  • .config 패키지: 서비스 구성에 필요한 Config를 담고 있다. 후술할 Swagger에 대한 Config 파일을 담고 있다.
  • .controller 패키지: 실제 외부에 노출되는 public API단을 담고 있다. 후술할 Swagger가 이 패키지를 대상으로 검사를 시행, API 문서를 생성하게 된다.
  • .data 패키지: public API에서 호출부로 반환할 데이터 클래스를 담고 있다.
  • .exception 패키지: 요청을 처리하는 도중에 발생하는 Exception에 대한 정의 클래스를 담고 있다.
  • .service.storage 패키지: public API에 노출되면 안되는 내부 기능을 담고 있다.

여기서 public API 라는 용어를 사용했는데, 이 용어는 마이크로서비스 설계 9원칙 중 ‘구현 은닉’ 에 대한 나름대로의 용어이다.

구현 은닉 이란 ‘일반적인 마이크로서비스는 구현 세부사항을 숨기는, 명확하고 이해하기 쉬운 인터페이스를 가진다. 이는 내부의 세부 사항을 공개하면 안 되며 기술적 구현이나 이를 구현하는 비즈니스 규칙도 노출돼서는 안된다’ 라는 의미를 담고 있다.

즉, 세부 사항에 대한 변경다른 마이크로서비스에 영향을 주는 것을 줄이는 것이 마이크로서비스 설계에 있어서 중요한 점이라고 볼 수 있다. 당연하게도 한 마이크로서비스를 수정했을 때 다른 부분에 문제가 생긴다는 것은 제대로 분리가 되지 않고 이는 큰 문제를 야기시킬 수 있음을 내포하고 있다.

구현 은닉을 구현하는 제일 쉬운 방법은 Impl Design Pattern으로 외부에는 Interface를 사용하고 실제로는 Interface를 구현한 클래스를 사용하는 것이다. (혹자는 Impl 패턴이 악마같다고도 표현하지만, 세부 사항을 쉽게 은닉하는 일반적인 방법이므로 여기서는 크게 고려하지 않고 사용하는 것으로 한다.)

따라서 service.storage 패키지에는 StorageService라는 인터페이스가 있고 이를 구현하는 FileStorageService가 있다. 이 StorageService 패키지에 대해서는 다음 챕터에서 말하려 한다.

세부 사항 은닉을 위한 StorageService

전 챕터에서 구현 세부 사항을 은닉하기 위해서 Impl Design Pattern을 사용하는 것이 쉽게 은닉하는 일반적인 방법이라고 설명했다. 구조를 살펴보기 전 앞서 개발하려는 서비스를 다시 살펴보자.

파일 업로드 / 바이너리 제공

파일 업로드와 바이너리 제공 외에도 서버가 현재 보관하고 있는 바이너리의 리스트를 반환하는 기능, 모든 파일을 삭제하는 기능, 초기 구동했을 때의 initialize 작업이 필요할 것이다.

이를 Interface로 구현하면 아래 Interface로 표현할 수 있다.

interface StorageService {
   // 초기화 작업
   fun init()

   // 파일 저장
   fun store(file: MultipartFile, extension: String): String

   // 전체 파일 리스트 제공
   fun loadAll(): Stream<Path>

   // 단일 파일 경로 제공
   fun load(fileName: String): Path
   
   // 단일 파일 바이너리 제공
   fun loadAsResource(fileName: String): Resource

   // 파일 모두 삭제
   fun deleteAll()
}

이 Interface를 사용해 클래스를 구현하면 StorageService 가 필요한 곳에 실제 구현체 (예제에서는 FileStorageService)가 제공될 수 있다. (여러 구현체를 가진 경우 yaml를 통해 설정 값에 따라 제공될 객체를 변경할 수 있으나, 여기에서는 다루지 않는다.)

다만 여기서 보충하자면, deleteAll() 과 init()는 public API 에서 제공하는 기능이 아니므로 Bean을 생성할 때 호출할 것이다. FileStorageService 에 @Service 어노테이션이 있으면 자동으로 Bean이 만들어 지긴 하지만, 이를 커스텀하기 위해 별도의 Bean을 선언한다.

@SpringBootApplication
class UploadFileBootApplication {
   @Bean
   fun init(storageService: StorageService): CommandLineRunner {
       return CommandLineRunner {
           storageService.deleteAll()
           storageService.init()
      }
  }
}

위 클래스는 SpringBootApplication 이라는 어노테이션을 가진 UploadFileBootApplication 으로 실제로 이 애플리케이션이 시작되는 것이다.

fun main(args: Array<String>) {
   runApplication<UploadFileBootApplication>(*args)
}

이 부분은 Spring Initializer 를 이용하면 자동으로 잡히므로 따로 코드를 작성할 필요는 없다.

실제 구현체는 어떻게 제공될까?

위에서 StorageService가 필요한 곳에 실제 구현체가 제공될 수 있다 라고 설명하였는데, 실제로 init(storageService: StorageService) 가 실행되는 런타임에서는 StorageService의 구현체인 FileStorageService 가 값으로 들어가 있게 된다.

이는 StorageService 의 구현체인 FileStorageService가 @Service 어노테이션, 즉 Spring Framework의 기반이 되는 Dependency Injection에 대하여 FileStorageService가 제공되었고 이를 Spring Framework가 구동되는 컴포넌트 내에서는 제공된 FIleStorageService를 사용할 수 있다는 의미가 된다.

이 방식은 제어의 역전(Inversion of Control, IoC) 라는 개념에 의해 성립되는데, 제어의 역전의 반대, 즉 일반적으로 사용하는 개념은 제어의 흐름(Flow of Control, FoC) 라고 표현한다. 이는 주체인 ‘나’ 가 ‘프레임워크’ 의 메서드를 부름으로서 특정 기능을 사용하는 반면, 제어의 역전의 경우 ‘프레임워크’ 가 ‘나’를 부르는 것이다.

이를 확장한 것이 의존성 주입(Dependency Injection, DI) 이고, 이는 프레임워크가 Dependency에 대해 관리하고 주체인 ‘나’가 해당하는 Dependency를 필요로 할 때 ‘나’가 직접 Dependency를 연결하는 것이 아닌 프레임워크가 Dependency를 연결하는 것이다.

물론, 이 설명은 아주 간추려 설명한 것이므로 정확한 것과는 다소 차이가 있을 수 있다. 다만 이 문구를 기억하면 된다.

Don’t call us, we’ll call you. – Hollywood Principle

우리(프레임워크)를 호출하지 마세요, 우리가 당신(주체인 ‘나’)을 호출할 것입니다.

데이터 구조 구현

파일 업로드, 바이너리 제공에 대한 비즈니스 로직을 담고 있는 Service를 구현했으면, 이를 호출부에 적절히 반환할 데이터가 필요하다.

이 때, Http Status Code를 사용하여 표현할 수도 있지만, 호출부에 정확한 메세지를 주거나 Fallback를 할 수 있는 방법을 제공할 필요가 있다. (호출부 담당인 안드로이드의 경우 거의 모든 라이브러리에서 Http Status Code는 예외 없이 전부 Error로 간주된다.)

이를 위해 Response라는 데이터 클래스를 만드는데, 간단히 resultCode, resultMessage, data를 담고 있다.

data class Response<T>(val code: Int, val message: String, val data: T?)

Kotlin의 덕분에 toString() 나 copy() 등의 구현은 필요 없이 이 한 줄로 모든 것이 처리된다.

또한, 후술할 Exception를 익숙한 형태로 전달하기 위해 ErrorResponse라는 데이터 클래스도 만든다.

data class ErrorResponse(val code: Int = Constants.RESPONSE_FAILED_REQUEST, val message: String)

그리고, 서비스 등에서 오류가 발생했을 때 예외를 발생시킬 필요가 있는데 이를 범용적인 Exception으로 하기 보다는 커스텀 Exception을 제작해 오류를 명확히 표현하는 것이 좀 더 구성에 도움이 될 것이다.

class StorageException(message: String, exception: Exception? = null): IllegalStateException(message, exception)

Controller 구현

기본 구조, 서비스, 데이터 구조까지 만들었다면 남은 것은 public API를 만들어 외부에 제공하는 것이 남았다.

먼저 파일을 업로드하는 API를 만들면 다음과 같을 것이다.

@ResponseBody
@PostMapping("/uploadFile")
fun uploadFile(@RequestParam("extension") extension: String, @RequestParam("file") file: MultipartFile):
           ResponseEntity<Response<String>> {
       val path = storageService.store(file, extension)
       val response = Response(RESPONSE_OK, "OK", path)
       return ResponseEntity.ok()
              .header(HttpHeaders.CONTENT_TYPE, "application/json")
              .body(response)
  }

호출부에서, extension, file를 각각 Body로서 전송받으면, 내부에서 구현한 StorageService 로 전달하고 결과를 반환하는 형태이다. storageService.store 메서드는 별도로 비동기 처리가 되지 않았으므로, 호출부는 storageService.store 작업이 끝날 때 까지 대기하게 된다.

그 다음 Response 인스턴스를 생성하는데, 여기서는 업로드된 파일의 파일 이름을 반환하도록 한다. 파일 이름은 UUID로 구성되어 있어 별도의 DB를 구성하지 않아도 고유 ID로서 사용하게 했다.

마지막으로 ResponseEntity 라는 클래스의 인스턴스를 생성하는데, 이 클래스는 호출부로 반환될 Response 데이터의 메타데이터를 담을 수 있다. header에는 Response가 Json으로 직렬화될 것이므로 Content-Type로서 application/json을 반환하고, body에는 Response를 반환한다.

이 메서드에는 두 개의 어노테이션이 부착되어 있는데, @ResponseBody 와 @PostMapping 이다. PostMapping는 @RequestMapping 의 축약형으로 POST 메서드를 허용하는 API 경로로 /uploadFile가 API 경로가 된다. 즉 이 API의 전체 호출 경로는 POST http://localhost:8080/uploadFile 가 된다.

@ResponseBody 는 이 메서드가 Response를 반환하는 메서드라는 것을 표시한다.

이를 REST Client로 통해 호출하면 다음과 같은 결과가 나온다.

의도하던 대로 file, extension을 body로 넘기면 data 필드로 업로드된 파일 이름이 나오게 된다.

업로드된 파일은 로컬 환경이므로 프로젝트 폴더에 저장된다.

그 다음, 바이너리를 제공하는 API를 만들면 다음과 같을 것이다.

@ResponseBody
@GetMapping("/files/{fileName:.+}")
fun serveFile(@PathVariable fileName: String): ResponseEntity<Resource> {
       val resource = storageService.loadAsResource(fileName)
       return ResponseEntity.ok()
              .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"$fileName\"")
              .header(HttpHeaders.CONTENT_TYPE, "application/octet-stream")
              .body(resource)
  }

이 API에서는 위의 PostMapping 와 다르게 GetMapping를 사용했다. 차이는 POST 대신 GET를 사용한다는 점이다. 또한, RequestParam 대신 PathVariable 를 사용했는데 이는 파라미터를 별도로 전달하지 않고 경로 내에 표현하는 것으로 경로가 완성되는 형태를 가지고 있다. 즉 이 API의 호출 경로는 http://localhost:8080/files/706a1262-1d46-411f-80de-6488d50829a2.jpg 가 된다.

마찬가지로 StorageService에는 별도의 비동기 처리를 진행하지 않았으므로 loadAsResource() 가 끝날 때 까지 작업은 대기되고 있을 것이다.

반환할 때에는 바이너리 자체를 반환할 것이므로, 이에 대한 Content-Disposition, Content-Type를 설정해준다.

이 API도 REST API Client로 호출하면 다음과 같다.

원하는 결과대로 바이너리 결과가 나온 것을 알 수 있다.

마지막으로, 전체 파일 리스트를 반환하는 API를 만들면 다음과 같을 것이다.

@ResponseBody
@GetMapping("/listFile")
fun listFile(): ResponseEntity<Response<MutableList<String>>> {
       val list = storageService.loadAll()
              .map { it.toString() }
              .collect(Collectors.toList())

       return ResponseEntity.ok()
              .header(HttpHeaders.CONTENT_TYPE, "application/json")
              .body(Response(RESPONSE_OK, "OK", list))
  }

이 API의 호출 경로는 http://localhost:8080/listFile 가 될 것이고, 올라간 파일 이름의 리스트가 json으로서 반환될 것이다.

이로서 3개에 대한 API 구현이 모두 끝났다. 다음은 이를 배포하고 관리하기 위한 솔루션들에 대해 알아볼 차례다.

API Document 관리 – Swagger

API Document 관리를 위해 Swagger라는 라이브러리를 사용할 것이다. 의존성은 다음과 같다.

    // Swagger (API Document)
   implementation 'io.springfox:springfox-swagger2:2.9.2'
   implementation 'io.springfox:springfox-swagger-ui:2.9.2'

라이브러리를 추가했으면, config 패키지에 Swagger2Config 파일을 만든다.

@Configuration
@EnableSwagger2
class Swagger2Config {

   @Bean
   fun api(): Docket {
       return Docket(DocumentationType.SWAGGER_2)
              .select()
              .apis(RequestHandlerSelectors.basePackage("com.github.windsekirun.uploadfileboot.controller"))
              .paths(PathSelectors.regex("/.*"))
              .build()
              .apiInfo(apiInfo())
  }

   private fun apiInfo(): ApiInfo {
       return ApiInfoBuilder().apply {
           title("UploadFileBoot API")
           description("API Endpoint for serving file")
           contact(Contact("Pyxis", "https://uzuki.live", "pyxis@uzuki.live"))
           license("MIT License")
           licenseUrl("https://github.com/WindSekirun/UploadFileBoot/blob/master/LICENSE")
      }.build()
  }
}

Swagger2Config는 Swagger2를 사용하기 위한 정보를 설정할 수 있다. apiInfo() 메서드에서는 해당 API 문서 자체의 메타 데이터를 반환하고, api() 메서드에서는 Swagger2의 설정 클래스인 Docket를 반환한다.

여기서 유의할 점은 apis(RequestHandlerSelector…) 부분인데, 이 부분에 지정한 패키지가 Swagger 가 분석할 API가 담겨 있는 패키지가 된다. 여기서는 controller 패키지를 삽입한다.

이 클래스를 제작하고 실행하면 http://localhost:8080/swagger-ui.html 에 접속이 가능해진다.

Controller에 선언한 3개의 API에 대한 항목이 구성되었음을 알 수 있다. 여기서 각 api를 클릭하게 되면 각 api에 대한 설명과 Try out 메뉴가 나오게 된다.

실제로 Try it out 메뉴에서는 API에 대해 호출하고 결과를 볼 수 있다. 이로서 API Document 및 간이 Client 까지 완성되게 된다. 무엇보다, @ApiOperation 나 @ApiResponse 등의 어노테이션을 추가적으로 부착할 경우 API의 자세한 설명과 상태 코드에 따른 예상 반환 데이터까지 지정해줄 수 있다.

배포 관리 – DockerFile

배포에는 Dockerfile를 이용해서 어느 환경에서도 잘 돌아갈 수 있도록 보장할 것이다.

FROM openjdk:8-jdk-alpine

RUN apk update && apk add bash
ADD build/libs/*.jar uploadfileboot.jar
ENTRYPOINT ["java", "-jar", "uploadfileboot.jar"]

Dockerfile 내부는 최대한 간단하게 빌드된 jar를 추가하고 실행하는 코드로 마무리한다.

이를 실행시키기 위해 docker run 명령어를 직접 실행시킬 수도 있지만, 여러 컨테이너와 링크할 때 편리한 docker-compose 를 이용해 설정할 것이다.

version: '2.2'
volumes:
upload_files: {}

services:
uploadfileboot:
  build: .
  ports:
    - "8080:8080"
  volumes:
    - upload_files:/files
  container_name: uploadfileboot
  restart: always

실제 서버에서 구동할 때에는 build: . 가 아닌 이미지 자체를 빌드해서 dockerhub와 같은 registry 에 올릴 필요가 있지만, 현재는 로컬 환경이므로 build: . 로 구성한다.

새로 빌드를 할 필요가 있을 때에는 docker-compose up -d --build 를 실행하고, 빌드를 할 필요가 없을 때에는 –build 만 제외한 나머지 명령어를 입력하면 구동될 것이다.

마무리

결론적으로 매우 만족스러웠다. API 문서 작성, 배포, 자동화는 얼핏 지루한 작업이 될 수 있는데, 이를 Swagger나 Docker로 통해 자동화해서 개발자가 신경써야 할 작업을 제거했다. 또한 API를 작성하는 것 또한 비즈니스 로직을 작성하고 API로서 노출시키기만 하면 나머지 구동 설정은 전부 된다는 것도 개발자가 신경써야 할 다른 작업을 제거한 셈이다.

Spring Boot는 그 말대로 빠른 개발 + 빠른 배포에 집중화된 느낌이라, 실제로 대규모 프로덕션 규모로 가면 어떻게 작용할 지는 아직까지 모르겠지만 언젠가 공부할 쿠버네이트 등 오케스트레이션 솔루션등을 도입하면 대규모 프로덕션에서도 무리 없이 개발이 가능할 것이라고 생각한다.

무엇보다 안드로이드만 하던 입장에서는 놀랄 수 밖에 없었던게, 실제로 구현하는 느낌이 안드로이드 앱을 Dagger나 MVVM을 이용해 작성하는 느낌과 매우 비슷해서 문법이나 API가 다를 뿐 같은 코드 베이스를 구현하는 느낌이 들었다.

이 프로젝트에서는 적용하지 않았지만 Project Reactor를 이용한 리액티브 Web까지 도입하게 되면 매우 좋을 것이라고 생각되었다.

작성 일자 2019-02-16 기준 CI/CD가 연동되지 않았지만, 이는 다른 글에서 살펴봐야 될 것 같다. 생각보다 작업이 규모가 있었던 것 같다.

Nextcloud in Docker – files not showing

언젠가 갑자기 Docker로 호스팅중이었던 NextCloud에 접속했을 때, 처음 DB 설정 화면이 그대로 뜨는 일이 있었다.

바로 다시 설정하고 들어갔을 때, 파일이 보이지 않았는데 실제로 nextcloud 컨테이너 안에서는 내용을 확인할 수 있었다.

이 문제에 대해 구글링을 시도한 결과 occ란 커맨드로 실행할 수 있는 것 같았다.

OCC란?

OCC는 NextCloud의 전신이었던 OwnCloud에 Console를 더한 약자로 NextCloud를 관리하기 위한 커맨드 라인 인터페이스라고 보면 된다. 호스팅하는 컨테이너의 /var/www/html 에 위치하고 있으며, 아래와 같은 명령어로 실행한다.

sudo -u www-data php occ -V

그리고 이 문제를 해결하기 위해 사용하는 파일 스캔 명령어는 다음과 같다.

sudo -u www-data nextcloud php occ files:scan --all

문제는 도커로 호스팅중이었기 때문에, 컨테이너 내에서 작업할 수 없고 외부에서 작업해야 한다.

최종 명령어 & 실행 결과

도커를 통해 실행하려면 아래 명령어를 사용하면 된다.

docker exec -u www-data nextcloud php occ files:scan --all

쉽게 보면 sudo 대신 docker exec 를 붙이는 것으로 해결이 가능한 셈이다.

위 명령어를 사용하면, 아래와 같은 출력이 나오면서 스캔이 완료된다.

새 인스턴스로 확장 이전과 후기

이번 연휴(2018. 12. 22 ~ 2018. 12. 25) 동안 지금까지 운영해오던 UzukiLive 서버를 확장이전 하여 새롭게 환경을 구축하게 되었습니다.

기존 인스턴스와 문제점

기존 환경은 이렇게 설정되어 있습니다.

  • Vultr VC2 1core 2GB (UzukiLive 인스턴스) -> 블로그, nextcloud. 도커로 되있지 않음
  • Vultr VC2 2core 4GB (Artifactory 인스턴스) -> Artifactory, Jenkins. 도커로 되어있음

그리고 메일 서버의 경우 Zoho 플랫폼을 빌려 사용하였고, 처음에 삽질을 너무 크게 한 나머지 uzuki.live 로 접속하면 SSL 에러가 발생하곤 했습니다.
그 외 문제점이라고 하면 FTP를 쉽게 사용할 수 없거나, 너무 설정이 여기저기 있어 확장하려고 해도 쉽지 않았습니다. (오죽하면 인스턴스를 두개 생성해서 관리를 했지만요… (._. )

당연히 비용도 비용이니 좋지 않은 사양에 월 30달러나 지불해야 한다는 것은 이해가 가지도 않긴 합니다.

따라서 이번 연휴때 계획을 잡고 신규 인스턴스에 전부 이전하기로 결정했습니다.

신규 인스턴스

신규 인스턴스는 Vultr의 VC2 4core 8GB, 100GB SSD 입니다. 이전 사양보다 약 2(Artifactory 인스턴스 기준) ~ 4(UzukiLive 인스턴스 기준)배 이상 사양이 증가하였습니다.

사양을 대폭 올린 이유로는 Jenkins 때문인데, 2core 4GB 사양에서 gradle 빌드를 두 개 이상 돌리는 순간 컨테이너가 메모리 부족으로 죽어버리는 대참사가 발생하여, 빌드를 두 개 이상 동시에 돌리더라도 문제가 없도록 구성하였습니다.

신규 인스턴스는 모든 요소가 Docker에 의해 관리되는 Dockerize 환경을 사용할 것이고, 아래 범주로 서비스를 관리할 예정입니다.

  • Reverse Proxy -> nginx
  • Stack: Infra -> adminer, pure-ftpd, docker-telegram-notifier
  • Stack: Blog -> MariaDB + WordPress
  • Stack: Build-Automation -> Jenkins, Artifactory
  • Stack: Mail -> docker-mailserver(IMAP/SMTP) + rainloop (Web UI)
  • Stack: Intro -> uzukilive-intropage
  • Stack: Octobox -> Octobox + Redis + PostgreSQL

그러면, 각 범주마다 설명을 약간 추가하면서 기능을 소개하려 합니다.

Reverse Proxy + SSL

기존 UzukiLive 에서도 사용했던 Nginx를 사용하고, 여기에 LetsEncrypt를 이용하여 모든 외부용 서비스에 SSL를 제공할 계획입니다.

보통 nginx-proxy + letsencrypt-companion 조합으로 사용하지만 이 조합의 경우 docker 소켓에 붙어 일정 시간마다 스캔을 해서 추가하는 방식으로 되어있어, 그거보다는 좀 더 귀찮지만 하나의 설정 파일(conf)로 존재하는 것이 좋다고 생각되었습니다.

따라서 새 서비스를 연동할 때 마다 아래의 명령어를 통해 인증서를 발급받았습니다.

docker run -it --rm -v /data/cert:/etc/letsencrypt -v /data/cert-data:/data/letsencrypt certbot/certbot certonly --webroot --webroot-path=/data/letsencrypt -d uzuki.live

저 방법을 사용하기 위해서는 일정의 예제 conf 파일을 만들어 nginx를 가동시킬 필요가 있는데, 샘플 nginx 파일을 만들고 DONAME_NAME 만 교체해서 생성하도록 했습니다.

server {
    listen      80;
    listen [::]:80;
    server_name uzuki.live;

    location / {
        rewrite ^ https://$host$request_uri? permanent;
    }

    location ^~ /.well-known {
        allow all;
        root  /data/letsencrypt/;
    }
}

Stack: Infra

여기에는 3개의 서비스가 들어가는데, adminer 는 MySQL/MariaDB의 정보를 보거나 수정할 수 있는 프로그램으로 phpmyadmin 대체용입니다

그 다음으로 pure-ftpd는 FTP 서버로 /home/pyxis 경로에 접근하여 데이터를 주고 받을 수 있게 설정했습니다.

마지막으로 docker-telegram-notifier 는 미리 만들어둔 Telegram bot로 컨테이너의 시작/중지 등의 상태를 보내줍니다.

가령 Jenkins가 시작되었다면 아래의 메세지를 보내줍니다.

Started container jenkins
Image: windsekirun/jenkins-android-docker:1.0.2
Container ID: ae8f526e8b42d216146fb68ee49f6506631259780f3c75195718786da6245cb6
오늘도 약 400개의 메세지를 받아내는 우즈키(..)

Stack: Blog

여기에는 2개의 서비스가 들어가는데, 워드프레스와 그 DB로 MariaDB를 선택했습니다. 기존 인스턴스에는 MySQL로 되어있고, 이 데이터를 그대로 가져갈 필요가 있었습니다.
따라서 PostgreSQL 보다는 MariaDB를 선택했습니다.

주소는 기존의 blog.uzuki.live 에서 pyxispub.uzuki.live 로 변경했는데, 기존 주소로 들어가면 새로운 주소로 리다이렉션 되도록 기존 인스턴스의 nginx 단을 수정했습니다.

기존 인스턴스는 2주 뒤인 1월 11일쯤에 제거될 예정입니다.

Stack: Build-Automation

여기에는 2개의 서비스가 들어가는데, 각각 Gradle Repository용 Artifactory와 Jenkins가 들어갑니다. 이 두개 서비스에 대해서는 일전 블로그 글로 설명한 적이 있으므로 별도로 설명할 것은 없다고 생각됩니다.

다만 아주 잉여롭지만 봇을 만든 겸에 빌드 시작 / 결과를 알려주는 별도의 Jenkins 호환용 그루비 스크립트를 작성해서 적용했습니다

Stack: Mail

기존 메일(pyxis@uzuki.live) 는 Zoho Mail에서 수신/발신하도록 되어있어 내 것이 아니기도 한 존재였습니다.
따라서 이번 인스턴스에는 IMAP/SMTP 서버를 직접 구축하여 메일을 수신/발신할 수 있도록 설정하였습니다.

…다만, vultr가 SMTP 포트인 25번을 막아버리는 바람에 일단은 Gmail에 의존하여 발신하도록 설정하였으나 좀 더 Postfix를 공부하여 수정할 계획입니다.

메인 화면
글쓰기 화면

웹 메일 UI는 rainloop를 사용하였고, 특출나게 UI가 세련된 건 아니지만 나름대로 들어갈 기능은 전부 포함되어 있습니다.

Stack: Intro

물론 반응형입니다.

CSS Toolkit인 타키온(https://tachyons.io/) 을 이용하여 5~10분만에 대충 만든 인트로 페이지로, 이 서버에서 제공하는 서비스의 링크와 Github, Telegram, Contact 링크를 포함합니다.

물론, 이 인트로 페이지(라고 해도 HTML 1개 + CSS 1개) 도 docker 이미지를 만들어서 제공했으며, 도커 이미지는 https://github.com/WindSekirun/uzukilive-intropage 입니다.

Stack: Octobox

Octobox 라고 하는 Github의 알림을 관리하는 서비스가 있는데, 이 서비스가 self hosted로 하면 프라이빗 프로젝트까지 포함되어 설치하게 되었습니다.

이 서비스의 자세한 설명은 공식 홈페이지 (https://octobox.io/) 를 들어가는 것이 편리합니다.

마무리

일전에도 Jenkins나 Artifactory를 도커로 활용하고 있었지만 이번 기회를 통해 도커를 통한 관리가 얼마나 확장성과 자유성을 가져주는지 깨달았습니다.

개인 목적으로는 나름대로 규모가 있고, 따로 설치하라고 한다면 아마 멘탈 여러번 나가서 쉽게 던졌을텐데, 명령어 한 두줄 만으로 모든 것을 설정할 수 있다는 것이 큰 장점인 것 같습니다.

컨테이너가 14개가 되다보니 docker-compose로 각 스택별로 모아 필요한 스택만 켜고 끄고 할 수 있게 설정해두니, 패치할 때도 다운타임이 5~10분 내로 할 수 있다는 것도 좋았습니다.

열심히 가동되는 컨테이너(들)

그러면, 새로운 인스턴스와 새로운 환경에서 남은 6일, 앞으로 다가올 2019년에도 PyxisPub와 이 주인장(._. … )을 잘 부탁드립니다.