데이터베이스에 접근하지 않고 이미지 빌드하기

여러분 안녕하세요.

저는 꽤 작은 규모의 Discourse 인스턴스를 운영하고 있습니다(사실 수년째 운영 중이며, 거의 문제가 없었습니다): https://discuss.cubeisland.de/.
전용 VM(데이터 센터 내 자체 하드웨어)에서 표준 launcher 기반 배포 방식을 사용해 왔습니다. 수년간 변경한 유일한 사항은 외부에서 실행되는 공유 PostgreSQL 데이터베이스로 마이그레이션을 진행한 것입니다.

최근에는 자원을 절약하고 인프라의 일부가 더 "탄력적"이 되도록 하기 위해, 궁극적으로 Kubernetes 클러스터로 마이그레이션하기 위한 준비 단계로 전용 VM에서 실행 중인 애플리케이션을 Docker Swarm으로 이전하기 시작했습니다.

오늘은 남은 전용 애플리케이션 VM 중 하나인 이 작은 Discourse 인스턴스를 살펴본 날이었습니다. "이미 Docker에서 실행 중인데, Swarm으로 배포하는 것이 얼마나 어려울까?"라고 생각했습니다. 그리고 제가 읽은 내용으로 보아 실제로 그렇게 될 것 같았습니다. 현재 실행 중인 인스턴스에서 이미지를 가져와 내부 레지스트리에 푸시하고 Swarm에서 실행하면 모든 것이 정상적으로 작동할 것 같아 좋습니다.

launcher 파일, 특히 템플릿과 샘플을 살펴보니, 이러한 배포 환경에서는 Redis를 분리하는 것이 좋은 아이디어일 수 있고, 플러그인을 추가하거나 업데이트할 때 CI 작업을 설정하여 새 이미지를 빌드할 수 있을 것 같다는 생각이 들었습니다. 그래서 discourse_docker를 로컬에 체크아웃하고 기존 app.yml 컨테이너 정의를 클론에 복사한 후, 즉시 배포하지 않고 레지스트리에 푸시할 수 있는 이미지를 빌드하기 위해 ./launcher bootstrap app을 실행해 보았습니다.

놀랍게도, 스크립트는 “production” PostgreSQL 서버에 연결을 시도하며 데이터베이스 마이그레이션을 수행하려 했습니다. 다행히 제 로컬 워크스테이션에서는 해당 서버에 접근 권한이 없었습니다.

여기저기 찾아보니 apparently 이것이 정상적인 동작 방식인 것 같아, 다음과 같은 의문이 들었습니다:

  1. 아직 데이터베이스가 없는 새 인스턴스의 컨테이너를 어떻게 빌드해야 할까요? 이미지를 빌드하기 전에 production 데이터베이스를 설정해야 하는 건가요?
  2. db:migrate가 실행되는 시점이 이것뿐이라고 가정하면, 여러 유사한 인스턴스(예: prod와 test)를 가지고 있다면, 새 이미지를 빌드하기 위해 인스턴스 중 하나를 업그레이드해야 하고, 이미지가 동일함에도 불구하고 두 번째 인스턴스에 같은 이미지를 사용할 수 없게 됩니다.
  3. 이미지 빌드 시스템에서 데이터베이스 서버에 접근할 수 없는 인스턴스에 대한 이미지를 어떻게 빌드해야 할까요(이것은 그리 드문 상황이 아닐 것입니다)?

몇 가지 게시물을 읽은 후(물론 이 게시물도 포함하여), 현재 빌드 프로세스의 이유를 충분히 인지하고 있으며, 표준 풀-스펙 VM에서 Discourse를 배포하는 언급된 99%의 사용자들에게 이것이 가치를 가진다는 점을 이해합니다. 또한 “올인원” 컨테이너 모델에 익숙하며, 이에 반대하지 않습니다. 결국 Docker의 핵심 가치는 소프트웨어 벤더가 사전에 최적화된 구성을 만들어 재현 가능한 런타임 환경으로 번들링할 수 있어, 운영 측면에서 애플리케이션에 특화된 많은 지식이 필요 없게 된다는 사실에서 옵니다. 따라서 제공된 도구를 사용하는 데 완전히 동의합니다. 소프트웨어 벤더 자신보다 더 나은 컨테이너를 다른 사람이 빌드할 것이라고 기대할 이유가 무엇이며, 아무런 이득도 없이 배포를 더 “순수하게”(그것이 무엇을 의미하든…) 만들기 위해 nginx와 Ruby 애플리케이션을 분리하고 싶지 않습니다.

그러나 실행에 거의 근접하지 않은 상태에서 런타임 상태를 변경하는 컨테이너를 보는 것은 이상합니다. 저는 이미 컨테이너에서 여러 애플리케이션을 실행하고 있으며, 일부는 컨테이너 실행을 염두에 두고 있지 않았던 것들을 컨테이너화하기도 했습니다.

제 생각에 Discourse와 유사한 요구사항/문제를 유사한 방식으로 처리하는 애플리케이션의 대표적인 예는 GitLab입니다. GitLab은 이제 완전히 분해된 "있어야 할 방식"의 Kubernetes 배포를 위한 멋진 Helm 차트를 제공하지만, (숫자를 확인하지 않았지만) 추측컨대 소규모~중규모 배포의 유사한 99%는 GitLab의 omnibus Docker 이미지(또는 실질적으로 동일한 OS 패키지)를 사용한다고 봅니다. GitLab에도 유사한 부트스트래핑 프로세스가 있지만, 이는 컨테이너 내부의 chef를 기반으로 하며, 모든 시작 시마다 실행되어 데이터베이스 마이그레이션 및 자산 컴파일과 같은 일반적인 작업을 수행합니다.

네, GitLab의 시작은 이로 인해 몇 분 걸릴 수 있지만, 제가 본 배포(일부는 대기업)에서 이것이 문제가 된 적은 결코 없습니다. 특히 Docker-Swarm과 Kubernetes 및 기타 최신 오케스트레이션 시스템과 같이 롤링 업그레이드를 수행할 수 있는 시스템에서는, 새 인스턴스가 실행 중이고 성공적으로 헬스 체크 및 리디 체크를 통과한 경우에만 구 인스턴스가 종료되므로, 긴 배포 프로세스가 실제로는 문제가 되지 않을 수 있습니다. 하지만 작동할 수도 있고 아닐 수도 있는 고급 롤링 업그레이드 없이도, 많은 상황에서 상당한 다운타임도 감당할 수 있습니다.

그러므로: launcher를 구성하여 이미지 빌드 시 데이터베이스에 의존하는 작업을 건너뛰고, 대신 컨테이너 시작 시 이러한 작업을 수행하도록 하는 것이 가능할까요?

저는 확실히 여기에 시간을 투자할 의향이 있지만, 저녁 시간은 제한적이므로 어떤 조언이라도 매우 환영합니다.

이것이 멍청하거나 심지어 불가능하다고 생각하신다면, 완전히 다른 프로세스에 대해서도 개방적입니다.

피드백 주셔서 감사합니다!

저도 여러분과 같은 방식으로 하고 싶었습니다. 저희는 Amazon ECS에서 Discourse를 운영하므로, 웹 이미지만 빌드하여 레지스트리에 푸시할 수 있어야 했습니다. 지원되는 설치 방식에 최대한 가깝게 유지하고 싶었기 때문에 Discourse 빌드 프로세스를 직접 수정하는 것은 원치 않았습니다.

대신, 로컬 머신에서 표준 launcher 스크립트를 사용하여 두 컨테이너 구성을 빌드하되, 데이터 컨테이너는 무시하고 웹 컨테이너만 레지스트리에 푸시합니다. 런타임에는 환경 변수를 통해 Postgres와 Redis 연결 정보를 오버라이드합니다.

새로운 이미지를 배포하는 과정은 3단계입니다:

  1. 안전한 사전 마이그레이션 실행. ECS가 이 명령을 실행하도록 합니다(새로운 이미지 사용):

     SKIP_POST_DEPLOYMENT_MIGRATIONS=1 rake db:migrate
    
  2. 새로운 이미지 배포. ECS 서비스를 업데이트합니다.

  3. 사후 마이그레이션 실행. ECS가 이 명령을 실행하도록 합니다:

     SKIP_POST_DEPLOYMENT_MIGRATIONS=0 rake db:migrate
    

이미지를 빌드하는 동안 로컬 데이터 컨테이너를 실행하는 것은 낭비일 수 있지만, 이를 통해 데이터베이스나 Redis와 통신하려는 부분이 무엇인지 걱정하지 않고 표준 web.template.yml을 사용할 수 있습니다.

고맙습니다! 이미지 빌드 중에 postgres를 실행했다가 실제 빌드가 끝나면 버리면 되겠다는 생각도 들었습니다.

드디어 시간을 내서 이 작업을 구현했습니다!

빌드 중에 postgres와 redis를 서비스로 실행하고, 빌드 후에는 삭제하는 gitlab-ci 파이프라인을 사용하여 이미지 빌드를 구현했습니다:

이제 DB 마이그레이션과 함께 배포를 자동화하기만 하면 됩니다.

이 녀석은 2.8 릴리스를 위해 한 번도 건드리지 않은 채 1년 넘게 실행되고 있었습니다.

이미지 빌드를 GitHub로 이동했습니다: GitHub - pschichtel/discourse-docker: A reusable Discourse container built using the launcher tool · GitHub.

이미지는 pschichtel/discourse:stable-web_only에 공개됩니다.

이제야 이 문제가 마침내 터진 것 같습니다. 3.0.6에서 3.1.0으로 업그레이드할 때 DB 마이그레이션이 수행되지 않았습니다. 하지만 실행 중인 컨테이너 안에서 최종 bundle exec rake db:migrate를 실행하니 동작했고, 다만 컨테이너를 한 번 더 재시작한 후에야 가능했습니다.

새 이미지가 해당 환경 변수가 설정되지 않은 상태로 시작되면 다시 마이그레이션을 수행해야 합니다. 이를 수행하는 rake 작업이 있지만, 제 휴대폰에서 그 이름을 기억하거나 찾을 수 없습니다. ensure_post_migrations 같은 이름이었을 겁니다.

참고로, 나는 깨진 부분이 있다는 것을 전혀 느끼지 못했습니다. 저는 주로 베타 릴리스 브랜치를 따르고 있는데, 제가 아는 한 3.1.0.beta… 시리즈의 모든 단계에서 마이그레이션이 올바르게 실행되었습니다.

rake -AT를 통해 db:ensure_post_migrations를 찾았습니다.

SKIP_POST_DEPLOYMENT_MIGRATIONS=0 옵션이 붙은 db:migrate와 db:ensure_post_migrations의 차이점은 무엇인가요?

코드를 살펴본 후 db:ensure_post_migrations가 무엇을 하는지 이해했습니다. 이는 db:migrate 실행 전에 같은 rake 실행 내에서 SKIP_POST_DEPLOYMENT_MIGRATIONS가 0으로 설정되도록 보장하기 위해 사용되어야 합니다. 제 스크립트에는 이미 이 기능이 포함되어 있습니다:

.gitlab-ci.yml:

./migrate.sh pre || echo "Redis not running during pre migrations, skipping..."
docker stack deploy --prune --resolve-image always -c "$STACK.yml" "$STACK"
./docker-stack-wait.sh -t 180 "$STACK"
./migrate.sh post

migrate.sh:

#!/usr/bin/env sh

if [ "$(docker ps -q --filter "label=com.docker.stack.namespace=${STACK}" --filter "label=com.docker.swarm.service.name=${STACK}_${DISCOURSE_REDIS_HOST}" | wc -l)" = "0" ]
then
    echo "No redis container found, unable to run migrations!"
    exit 1
fi

if [ "$1" = "pre" ]
then
    skip_post=1
else
    skip_post=0
fi


docker run \
    --rm \
    --name "discourse-migration-${DISCOURSE_DB_HOST}-${DISCOURSE_DB_NAME}" \
    --network "${STACK}_discourse" \
    --workdir /var/www/discourse \
    -u discourse \
    -e SKIP_POST_DEPLOYMENT_MIGRATIONS="$skip_post" \
    -e LANG="${LANG}" \
    -e DISCOURSE_DEFAULT_LOCALE="${DISCOURSE_DEFAULT_LOCALE}" \
    -e DISCOURSE_HOSTNAME="${DISCOURSE_HOSTNAME}" \
    -e DISCOURSE_DEVELOPER_EMAILS="${DISCOURSE_DEVELOPER_EMAILS}" \
    -e DISCOURSE_SMTP_ADDRESS="${DISCOURSE_SMTP_ADDRESS}" \
    -e DISCOURSE_SMTP_PORT="${DISCOURSE_SMTP_PORT}" \
    -e DISCOURSE_DB_USERNAME="${DISCOURSE_DB_USERNAME}" \
    -e DISCOURSE_DB_PASSWORD="${DISCOURSE_DB_PASSWORD}" \
    -e DISCOURSE_DB_HOST="${DISCOURSE_DB_HOST}" \
    -e DISCOURSE_DB_NAME="${DISCOURSE_DB_NAME}" \
    -e DISCOURSE_REDIS_HOST="${DISCOURSE_REDIS_HOST}" \
    "$DISCOURSE_IMAGE" \
    bundle exec rake db:migrate

이 스크립트는 discourse가 여전히 구버전을 실행 중인 동안 docker swarm에서 새로운 이미지를 사용하여 SKIP_POST_DEPLOYMENT_MIGRATIONS=1과 함께 db:migrate를 실행합니다. 그런 다음 새 이미지를 swarm에 배포하고 컨버지스가 완료될 때까지 기다립니다. 마지막으로 SKIP_POST_DEPLOYMENT_MIGRATIONS=0과 함께 db:migrate를 다시 실행합니다.

이 방식은 2년 넘게 모든 버전에서 안정적으로 작동해 왔습니다. @simonk 님에게는 잘 작동했다고 하셨는데, 제 스크립트와 비교했을 때 근본적으로 다른 무언가를 하신 건가요?

아니요, 저는 여전히 여기에서 설명한 동일한 프로세스를 따르고 있으며, 제가 보기에는 당신의 것과 거의 같습니다. 저는 bundle exec rake db:migrate 대신 rake db:migrate를 사용하지만, 이것이 큰 차이를 만들 거라고는 생각할 수 없습니다.

저는 docker stack이나 swarm을 사용한 적이 없습니다. 당신의 스크립트 어딘가에 버그가 있어 migrate.sh 스크립트가 새 이미지 대신 오래된 이미지를 사용하게 만드는 가능성이 있을까요?

그 부분을 명시적으로 확인해 보지는 않았지만, 살펴보겠습니다. 스웜은 최신 이미지를 definitely 사용할 것입니다. 하지만 CI 스크립트가 어떤 이유로 최신 이미지를 사용하지는 않았을 수도 있습니다.

3.1.1 업데이트로 이 문제를 확인해 보았습니다. 실제로 CI 스크립트가 컨테이너의 이전 버전을 사용하고 있었습니다.