Giter Club home page Giter Club logo

friendly-pancake's Introduction

friendly-pancake

friendly-pancake's People

Contributors

viiviii avatar

Watchers

 avatar

friendly-pancake's Issues

활성화 된 플랫폼 조회 시 서버 위치에 따라 결과가 달라진다

기존

  • OTT 멤버십 종료일이 4월 23일이라 단순히 날짜만을 저장하도록 date type을 사용했음
@Test
void 성공하는_테스트() {
    //given
    var given = LocalDate.parse("2024-04-22");

    //when
    var now = LocalDate.now();

    //then
    assertThat(now).isEqualTo(given);
}

버그

  • 하지만 기존 구현은 서버 위치가 결과에 영향을 줌
    • 이렇게 되면 멤버십이 이미 종료되어 시청할 수 없음에도 컨텐츠로 추천되게 됨
  • 이유는 2가지 변수가 영향을 주기 때문이라고 생각
      1. 내가 저장하는 날짜는 지역에 따라 달라짐
      1. now()는 내부적으로 systemDefaultZone()를 사용하므로 시스템의 설정에 따라 달라짐
  • 내 맥북에서 실행 시 zone은 서울 기준이고, AWS EC2는 UTC임
@Test
void 실패하는_테스트() {
    //given
    var given = LocalDate.parse("2024-04-22");

    //when
    var now = LocalDate.now(Clock.system(ZoneId.of("America/Los_Angeles")));

    //then
    assertThat(now).isEqualTo(given);
}

image
image


해결

  • 시간을 함께 저장한다
  • UTC로 관리하고 클라이언트에서 로컬로 변환한다

PATCH 메서드가 허용되지 않아 읽기 처리가 되지 않는다

에러

Access to XMLHttpRequest at 'https://pancake.viiviii.xyz:8443/api/contents/1/watched'
from origin 'https://viiviii.github.io' has been blocked by CORS policy: Method PATCH is not allowed by 
Access-Control-Allow-Methods in preflight response.

프리플라이트가 성공했지만 실제 요청은 실패하므로 읽기 처리가 되지 않는다

image

이유

CORS 허용 목록에 포함된 메서드GET, HEAD POST 뿐이다

따라서 PATCH를 사용하려면 추가해줘야 함

TMDB API 토큰을 배포 환경에 추가한다

✅ 작업

git secret에 추가

tmdb.api.token=$ACCESS_TOKEN
image

운영 환경에서 배포 시 git secret을 환경 변수로 사용하도록 추가

- name: '🚀 Deploy all services'
if: steps.current.outputs.id == null
env:
spring_datasource_properties: ${{ secrets.SPRING_DATASOURCE_PROPERTIES }}
tmdb_api_properties: ${{ secrets.TMDB_API_PROPERTIES }}
run: docker compose up --wait --build --detach
- name: '🚀 Deploy newly api service'
if: steps.current.outputs.id != null
id: newly
env:
spring_datasource_properties: ${{ secrets.SPRING_DATASOURCE_PROPERTIES }}
tmdb_api_properties: ${{ secrets.TMDB_API_PROPERTIES }}
run: docker compose up --wait --build --detach --remove-orphans --no-recreate --scale api=2 api

compose에서 해당 값을 외부 속성 파일로 만들도록 추가

services:
api:
image: ghcr.io/viiviii/friendly-pancake-api:0.2421.12
secrets:
- source: spring_datasource_properties
target: /config/database/application.properties
- source: tmdb_api_properties
target: /config/tmdb/application.properties
healthcheck:
test: curl -f localhost:8080/actuator/health
# interval: 1m
interval: 10s
timeout: 10s
retries: 1
start_period: 30s
# start_interval: 5s TODO: docker compose 업데이트 아직 안됐음
secrets:
spring_datasource_properties:
environment: spring_datasource_properties
tmdb_api_properties:
environment: tmdb_api_properties


📝 메모

배포 시

기존엔 DB 값만 있어서 1개의 application.properties로 만들어서 관리했었는데,

/
   app.jar
   application.properties

현재 git secret에 각각의 값을 저장하므로 개별 폴더로 나눠 만드는게 가장 쉽다고 판단하여 이 방식으로 배포 스크립트를 변경함

config/
  postgres/
    application.properties
  tmdb/
    application.properties

이건 문서에서 아래 파트들을 보고 따라했음

This search ordering lets you specify default values in one configuration file and then selectively override those values in another. You can provide default values for your application in application.properties (or whatever other basename you choose with spring.config.name) in one of the default locations. These default values can then be overridden at runtime with a different file located in one of the custom locations.

For example, if you have some Redis configuration and some MySQL configuration, you might want to keep those two pieces of configuration separate, while requiring that both those are present in an application.properties file. This might result in two separate application.properties files mounted at different locations such as /config/redis/application.properties and /config/mysql/application.properties. In such a case, having a wildcard location of config/*/, will result in both files being processed.

나중에 k8s등으로 관리한다면 configtree 사용 부분을 다시 읽어보십시오

로컬에서

그리고 개발 시에 나는 스프링만 실행을 거의 안해서 그간 필요성을 못느꼈는데,
이번에 tmdb token이 필수 구성 값이 되면서 어딘가에 애플리케이션이 사용하는 속성의 문서화를 할 필요가 있다 느꼈음

그래서 고민하다 config 디렉토리를 추가하여 로컬에서 값을 복사해서 쓰고 원하는 추가 설정을 하도록 해봤음

기본 값 지정에 대하여

사실 난 실사용 해볼 때 주로 도커 컴포즈로 환경을 구성하는데 환경변수를 넣어야 하는 부분이 귀찮다
그래서 application.properties 파일이든 어디에서든 기본 값을 설정할 수 있으니 종종 고민하고 있는데,
아직은 로컬에서 편하게 쓰자고 바꾸기엔 운영 환경에도 영향을 미치므로 값이 없으면 차라리 빠르게 실패하는 쪽을 선택하고 infra에 복붙 가능하도록 문서화 해놨음ㅠ

근데 뭐가 나은건진 아직도 모르겠어서 자주 고민됨. 나중에 이것 때문에 힘들어지면 꼭 댓글 달아줘,, 나 너무 궁금해,,,

k8s 구성을 제거한다

이유

실제로 쿠버네티스로 운영해 공부 해보고 싶었지만 지금 규모에선 비용 문제가 있음

k8s 구성 시 비용

k8s 구성 방법

- 관리형 쿠버네티스(EKS, AKS, GKE)
- 설치형 쿠버네티스(OpenShift, Rancher)
- 구성형 쿠버네티스(kubeadm, kops, kubespray, KRIB)

- 책 <클라우드 네이티브를 위한 쿠버네티스 실전 프로젝트>

쿠버네티스로 구성하기 위해 저기서 골라야 하는데

  • 관리형 쿠버네티스: EKS는 한달에 8만원 정도 나온다 함 + EC2 등의 추가 요금도 생각해야 함
  • 설치형 쿠버네티스: 이것도 유료임

그래서 구성형 쿠버네티스를 선택해야 하는데 이것도 비용이 없다고 볼 순 없었음

  • kOps는 클라우드 환경에 특화된 것 같고 편하게 배포할 수 있는 장점이 있지만 Route 53을 써야하는 것 같음(Route 53도 과금됨)

인스턴스 사양으로 인한 비용

  • 쿠버네티스는 최소 메모리 2GB, CPU 2 이상의 머신이 필요함
  • AWS EC2 프리티어인 t2.micro는 메모리 1GB, CPU 1임

시도

최소 사양이지 필수 사양은 아니잖아,,,하면서 트라이 해봤으나 5연패 함

  • kind가 컨테이너 기반으로 클러스터를 생성한다고 해서 설치해봄
  • kubeadm으로 설치해봄

둘 다 컨트롤 플레인 클러스터 구성까지만 해도 메모리가 70~80정도 남음

image

원래는 컨트롤 플레인 노드에서도 파드를 예약할 수 있게 해서 단일 머신으로 사용해보려 했으나

클러스터 구성까지 하고 자고 일어나면 ssh로 접속 자체가 안됨ㅠ

인스턴스 재시작 해봐도 동일해서 여쭤보니 리소스가 80~90까지 올라가면 모니터링이 끊겨서 그러는 걸수도 있다고 함
모니터링에 CPU만 있고 메모리를 보려면 cloudwatch 추가해야되는 것 같은데.. 이미 내 인스턴스는 작고 소중해서 그럴 수 없었음ㅠ

다만 중간에 인스턴스 종료 과정에서 잠시 접속이 됐었는데 이때도 이미 79%다

image

내가 문제인 것 같아 다시 설치 해봤지만 안되길래 포기했다
미니쿠베 시작할 때 메모리 4000 줬었는데 생각해보니 t2.micro보다 후하게 준 것 이다 흑흑,,, 빅쿠베,,,

나중에 진짜 운영을 한다면 마스터 노드는 예약 인스턴스로, 워커 노드는 스팟 인스턴스로 구성해서 써보고 싶다

#30 #25

LAN 환경에서는 어떻게 사용해야 할까?

목표

  • 사용자가 가족이므로 집에서 Wifi를 통한 LAN 환경에서 서비스를 제공한다
  • 내가 노트북으로 실행 중인 서비스를 다른 기기(노트북, 핸드폰)로 접속할 수 있어야 한다

어떻게

혼자 개발할 땐 localhost로 잘 접속했지만 집에서 다른 기기로 접속하려면 어떻게 해야할까?

  • wifi로 연결된 기기들은 같은 네트워크에 소속되어 있으므로 서버가 실행중인 맥북의 private IP로 통신할 수 있다
  • 안드로이드 스튜디오로 실행시키던 웹 페이지는 어떻게 제공해야 할까?
image

A) WAS가 웹서버 기능까지 맡는다

image

B) 별도의 웹 서버를 사용한다

image
  • 장점
    • docker file system 마운트 덕에 파일을 복사하지 않아도 된다
  • 단점
    • CORS 설정이 필요하다
    • 포트가 2개 공개되어야 함?

C) 리버스 프록시 서버를 사용한다(with. 포트 바인딩⭕️)

image
  • 장점
    • CORS 설정이 필요 없다
    • API Server를 늘리기 편하다
  • 단점
    • 불필요하게 노출되는 was 포트
    • NGINX에 추가 설정이 필요함?

D) 리버스 프록시 서버를 사용한다(with. 포트 바인딩❌)

image

C 방법에서 API 서버를 리버스 프록시에서만 사용하는데 굳이 외부에 공개 되어야 할까?라는 생각이 들던 차에 마침 docker 공식문서에서 비슷한 내용을 보게 되었다.

image

더 읽어보니 포트 바인딩을 사용하지 않고 도커 컨테이너끼리 네트워킹을 할 수 있다고 한다.

  • 기본 네트워크 브리지 사용(내부 IP로만 통신)
  • 사용자 정의 브리지 네트워크 사용(컨테이너 이름으로 통신 가능)

기본 네트워크 브리지를 사용하는 방법

내부 IP는 아래 명령어로 확인할 수 있다

$ docker network inspect bridge
        "Containers": {
            "a5c9bd2e0f7130baee747b4d0329cbcb07111c6969e54f7a33784d00916b1561": {
                "Name": "api2",
                "EndpointID": "be9e87bf550bbf603309e39b6ac42ea2280418a06ae013877900a96f66feadd2",
                "MacAddress": "02:42:ac:11:00:03",
                "IPv4Address": "172.17.0.3/16",
                "IPv6Address": ""
            },
            "f10f42625f4048c37e1cba4d88d8b4b048f7ddfe2db2806ffb22639c177a5810": {
                "Name": "api1",
                "EndpointID": "6de80f50c35c0e3d47c25c1f372a1b797bcd061457e420bf3af9e3ae7810a2af",
                "MacAddress": "02:42:ac:11:00:05",
                "IPv4Address": "172.17.0.5/16",
                "IPv6Address": ""
            }
        },

단점은 컨테이너를 다시 실행할 때 마다 IP주소가 변하므로 nginx 설정 변경이 필요할 수 있고,

# /etc/nginx/conf.d/default.conf

upstream docker-api {
    server 172.17.0.3:8080;
    server 172.17.0.5:8080;
}

location /api/ {
    proxy_pass   http://docker-api;
}

설정이 변경됐으니 reload도 해줘야 하는데 노가다스럽고 공식문서에도 production에선 추천하지 않는다고 한다

$ docker exec nginx1 nginx -s reload

사용자 정의 네트워크 브리지를 사용하는 방법

사용자 정의 네트워크를 만들어서 설정하면 연결된 컨테이너 이름으로 IP주소를 확인할 수 있다

... can also resolve a container name to an IP address. This capability is called automatic service discovery

$ docker network create --driver bridge api-net
$ docker network connect api-net nginx1
$ docker network connect api-net api1
$ docker run -dit --name api2 --network api-net api:1.0.0
# /etc/nginx/conf.d/default.conf

upstream docker-api {
    server api1:8080;
    server api2:8080;
}

location /api/ {
    proxy_pass   http://docker-api;
}

결론

초반에 조금 추가 설정이 들어가지만 리버스 프록시 + 사용자 정의 네트워크 브리지 조합이 가장 마음에 들었다

첫 번째로는 위에서 계속 나왔던 CORS 설정 필요 및 불필요한 포트 공개라는 단점을 해결할 수 있고,

집에서의 네트워크 환경을 구성을 목표로 했지만, 집↔️스터디카페를 옮기면서 학습 시 맥북 IP주소가 변경되므로
CORS 설정으로 인한 스프링부트 코드나 docker 설정 파일이 IP에 종속적이어서 변경 포인트가 많았는데 이 부분이 개선됐기 때문이다

본 것

컨텐츠 별로 시청 가능한 플랫폼을 화면에 보여준다

정한 것

  • 화면 호버 시 시청 가능한 플랫폼 목록 중 첫번째 아이템을 제공한다
  • 다른 시청 정보들을 첫 스텝에 바로 보이게 제공하지 않는 것은 대상 사용자의 사용 패턴 때문
    • 거의 썸네일만 보고 고름
    • 바로 재생하는 것에 관심이 있을뿐, 어느 플랫폼에서 가능한지 신경쓰거나 고르고 싶지 않아함
  • 자세히 기능은 이번에 구현하지 않을랭 (엄마가 쓰지 않을 기능임)

작업

  • api 변경된 버전을 사용하도록 엔드포인트 및 break change 수정 #92
  • 썸네일 이미지를 포스터 이미지로 변경해야 함 #94
  • 이유: 사용할 외부 API가 포스터 형식으로 이미지를 제공해서 이걸로 통일하는게 편할 듯

결정

  • 목업 디자인은 나중에 추가 기능이 생겼을 때 적용해도 무방하여 이번 작업에서 뺌
  • 오히려 필터 기능이 더 필요함

목업

image

레퍼런스

image

image

⛳️ 사용자가 1명인 서비스를 개발한다

부모님은 여러 구독 서비스의 영상들을 보다 편하게 모아보고 싶어한다
사용자가 1명(나 혼자)인 로컬 서비스를 만든다

요구사항

  • 영상 링크를 등록할 수 있어야 한다
  • 등록된 영상 링크들을 볼 수 있어야 한다
  • 영상을 시청 처리 할 수 있어야 한다
  • 이미 시청한 영상의 목록을 볼 수 있어야 한다
  • 아직 시청하지 않은 영상의 목록을 볼 수 있어야 한다

필요 작업

  • 프로젝트 세팅
  • 애플리케이션 기능 구현
  • 화면 구현
  • 배포

화면

  • 메인 화면
    image

  • 컨텐츠 등록 화면
    image

AWS EC2에 쿠버네티스를 설치한다

앞서 필요한 것

  • 2 이상의 CPU, 2 GB 이상의 램을 장착한 머신
  • 컨테이너 런타임, k8s 배포 도구, CNI plugin을 어떤 것을 사용할 지 결정

이하 Docker Engine, kubeadm, Weave Net


설치 과정 흐름

컨테이너 환경을 구성하고, k8s 다운받고, 파드끼리 네트워킹 하기 위해 CNI plugin을 설치하는 것임

공통

  • 스왑 비활성화
  • 도커 런타임 설치
  • kubeadm, kubelet 및 kubectl 설치

각각

마스터 노드

  • 클러스터 생성(컨트롤 플레인 노드 초기화)
  • CNI plugin 설치

워커 노드

  • 마스터 노드에 조인

메모

문서가 잘 되어있고 변경이 종종 있는 것 같으므로 필요할 때 마다 문서를 찾아 보는게 좋지만,
설치 중 추가 검색이 필요 했거나 포도당이 소모된 부분을 메모함

스왑 비활성화 명령어

sudo swapoff -a && sudo sed -i '/swap/s/^/#/' /etc/fstab

도커 엔진 사용 시 cri-dockerd 설치 필요

https://kubernetes.io/docs/setup/production-environment/container-runtimes/#docker

k8s v1.24에서 dockershim이 제거되어 도커 엔진을 사용하는 경우 cri-dockerd 설치가 필요해짐

문서에선 리파지토리 지침에 따라 설치하라고 되어있는데 현재는 repository를 clone해서 설치하는 방법만 있음

미리 컴파일 된 파일로 다운로드 하는 경우 파일 형식에 따라 압축을 풀고 나머지는 README 대로 비슷하게 따라하면 되는데

install packaging/systemd/* /etc/systemd/system

이 부분이 난감함
이건 소스 코드를 clone한 예시이므로 packaing/systemd/ 아래 파일들을 직접 다운받아 /etc/systemd/system로 옮겨주면 됨

wget https://raw.githubusercontent.com/Mirantis/cri-dockerd/master/packaging/systemd/cri-docker.service
wget https://raw.githubusercontent.com/Mirantis/cri-dockerd/master/packaging/systemd/cri-docker.socket

주소는 raw를 눌러서 이동한 페이지의 url을 사용하거나 git 주소에서 blob를 raw로 바꿔서 사용하면 됨

image

근데... 난 프리티어 인스턴스가 너무 작고 소중하길래 git을 설치하지 않으려고 이렇게 했으나 which git 해보니 git이 이미 있었다ㅠ

컨테이너 런타임과 kubelet cgroup driver 일치

👉 kubeadm을 설치 중 나오는 경고인데 현재 구성에선 넘어가도 된다

  • Ubuntu LTS 22.04, Docker Engine, kubeadm
image

왜 일치 시켜야하나

https://kubernetes.io/docs/setup/production-environment/container-runtimes/#cgroup-drivers

cgorup driver엔 cgroupfssystemd 두 가지가 있음

아래의 경우 시스템의 cgroup 관리자가 두 개가 되어 노드가 리소스 압박으로 인해 불안정해진다 함

  • kubelet의 기본 cgroup driver는 croupfs
  • 리눅스 배포판의 init시스템이 systemd인 경우, systemd 프로세스가 cgroup 관리자로 작동

그래서 systemd가 init 시스템인 경우, kubelet과 컨테이너 런타임이 cgroup driver로 systemd를 사용하도록 설정하는 것이 좋다고 함

왜 넘어가도 되나

지금 구성에선 내부적으로 systemd을 기본값으로 설정함

Weave Net addon 설치 링크

그리고 설치 후 좀 기다려야 노드가 준비 상태로 변경됨

image

컨텐츠 메타데이터를 외부 API를 사용해서 가져온다

목표

  • 수동으로 컨텐츠를 검색 후 등록하던 것을 TMDB API를 사용한다

선행

  • 가입 및 API 키 발급, 약관 체크

작업

외부 API로 컨텐츠 검색

컨텐츠를 내 DB에 등록

  • #114
  • 컨텐츠 메타데이터를 가져올 때 항상 TMDB API를 사용한다
  • 화면에서 컨텐츠 등록 아이콘에 이벤트 연결하고 불필요해진 화면들 정리하기

외부 API 매너있게 쓰기

  • TMDB 로고 추가하기(약관에 있음)
  • 화면에서 이벤트 디바운싱 걸기
  • 외부 api 리밋으로 인한 요청 제한 처리하기

메모

  • 에러 코드 매칭 및 에러 처리

AWS RDS를 시작한다

# EC2에 postgres client 설치
sudo apt-get install postgresql-client

# DB 접속
psql -h <엔드포인트> -U <사용자>

# 데이터베이스 생성
CREATE DATABASE pancake;

# 생성된 데이터베이스 확인
SELECT datname FROM PG_DATABASE;

# 이후 사용
# psql -h <엔드포인트> <데이터베이스> <사용자>
jdbc:<driver>://<엔드포인트>:<포트>/<데이터베이스>

블루-그린 배포 시 커넥션을 유지중이던 클라이언트 요청은 실패할 수 있다

문제

무중단 배포 스크립트 작성 후 동작을 테스트를 했을 때 nginx -s reload에서 몇 건의 실패가 발생했다

image
image

위 예외는 Locust로 테스트 했기 때문에 python request client에서 출력한 예외인데

image

주석을 보니 서버가 유효한 응답을 보내기 전에 연결을 닫았을 것이라고 한다.

Nginx reload 시 커넥션을 유지중이던 클라이언트 요청은 실패할 수 있다.

Nginx는 설정을 reload할 때 기존 worker를 graceful하게 종료하기 때문에 클라이언트의 요청이 실패하지 않을 것만 같지만

image

연결이 유지중인 상태에선 클라이언트는 old worker가 종료된 걸 모르므로 기존 커넥션으로 요청을 다시 보내므로 실패가 발생하는 것이다

해결 방법

A. Connection을 유지하지 않기

가장 간단하게는 클라이언트 ↔️ Nginx 사이에 커넥션을 유지하지 않으면 된다.

다음은 ab 명령어로 간단하게 테스트한 결과인데,

$ ab -t 30 -c 130 -k http://192.168.10.192/
  • -t: 30초 동안(timelimit, 항상 정확하게 지켜지는건 아닌듯?)
  • -c: 130건을 동시에 요청(concurrency)
  • -k: keep-alive를 사용(기본값은 사용하지 않음, 로그 보니까 ab는 http1.0으로 옴)

keep-alive로 요청을 보낼 경우, 130건이 실패하지만
image

keep-alive 없이 요청을 보낼 경우 모든 요청이 성공한다.
image

위 내용은 클라이언트에서 keep-alive를 사용해 요청을 테스트한 것 이므로, nginx에서 keepalive_timeout 값을 0으로 바꿔주면 된다.

# etc/nginx/nginx.conf

http {
    ... 생략
    keepalive_timeout  0; 👈
}

그러면 -k 옵션으로 요청을 보내도 모두 성공한 것을 볼 수 있음

image

B. 클라이언트가 처리해라

방법A가 썩 마음에 들진 않아서 방황하다가 고수님들이 계신 곳을 찾아냈는데 바로 쿠버네티스다.

ingress-nginx를 쓰던 선구자님들이 고통스러워 하다 작성한 nginx -s reload 시 커넥션 종료를 클라이언트에게 알려주게 해달라는 ticket을 보게 됐는데, Nginx 측 은 RFC 2616을 인용하며 현재 동작은 HTTP 사양과 일치하므로 해당 구현이 불필요한 것 같다고 답변했다.

  • 클라이언트와 서버는 언제든지 연결을 닫을 수 있다
  • 예시) 클라이언트가 새로운 요청 전송을 시작하는 동시에 서버는 idle한 연결을 닫기로 결정했을 수 있다
  • 이는 클라이언트와 서버는 종료 이벤트를 recover할 수 있어야 한다는 의미이다

난 이걸 서버만의 책임이 아니라 서버-클라이언트 모두 연결 종료에 대한 대비가 있어야 한다는 의미로 이해했다

문서에 왜 downtime zero라고 하는지 잘 이해가 안갔는데 여기까지 읽어보니 Nginx 의견이 맞는 것 같다ㅋㅋㅋㅋ
정말 reload 하면서 서버가 다운되는 순간은 없고 처리의 문제니까...

C. reload 안하면 됨

방법B에서 알게된 k8s nginx-ingress 고수님들은 해결책으로 엔드포인트 변경 시 nginx를 다시 로드할 필요성을 제거하는 것을 선택했다

그럼 어떻게 reload 없이 엔드 포인트 변경 사항을 적용하는 것일까?

lua 스크립트를 사용해서 동적으로 설정을 적용하는 방식이다

내부적으로 엔드포인트 변경사항을 lua로 http POST 요청을 보내 공유 데이터에 등록 후 사용하는 것 같다

추가로 초기엔 --enable-dynamic-configuration 옵션을 사용해야 했지만 이젠 default 값으로 변경돼서 엔드포인트 변경이 잦은 k8s에서도 사용자는 편히 쓸 수 있는 것 같다

궁금한 점

  • 현재 Client <- 커넥션 유지O -> Nginx <- 커넥션 유지X -> WAS 이 구성인데
    • 정적 파일이 아닌 api를 호출하면 ab로 테스트 시 실패하지 않는 이유는 뭘까? (+Locust는 그럼 왜 실패?)

image

  • 실행 중 Nginx를 업그레이드를 하기 위한 binary upgrade라는게 있고, 이건 마스터 프로세스도 새로 만드는 방식이며 기술 블로그에선 연결 끊김도 없다고 소개했는데 이건 대체 어떻게 동작하길래 연결 끊김이 없다는걸까?
  • old worker의 파일 디스크립터를 new worker가 이어 받을 순 없나?
  • 이번 문제는 그 전에 내용을 본적이 있어 편하게 접근했지만, 정말 키워드도 모를 때는 내가 어떻게 디버깅할 수 있을까?

공부해라

  • http 버전 별 커넥션 방식
  • 난 프론트가 CSR이라 NGINX에서 커넥션을 유지하고 싶다 -> 🧑🏻‍🏫 http2.0~ 멀티 플렉싱를 찾아보세요
  • 왜 루아? -> 🧑🏻‍🏫 Redis 트랜잭션 루아 스크립트를 찾아보세요

프로젝트를 생성한다

프로젝트 생성

image

IntelliJ 설정

H2 설정

  • ./h2.sh → 최초 1회 jdbc:h2:~/pancake → 접속 jdbc:h2:tcp://localhost/~/pancake
  • 프로젝트에 yml 추가

spring:
datasource:
url: jdbc:h2:tcp://localhost/~/pancake
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: create # TODO: 운영에서는 validate 또는 none 사용해야 함
properties:
hibernate:
format_sql: true # SQL 줄바꿈 등 예쁘게 출력
logging.level:
org.hibernate.SQL: debug # SQL logger 통해 출력

Git Repository 설정

  • main 브랜치 룰 생성
    • ✅ Require a pull request before merging
    • ✅ Require linear history

actuator와 쿠버네티스 probe에 대해 알아보자

actuator와 쿠버네티스

  • 스프링부트에는 일반적으로 사용되는 livenessreadiness 가용성 상태에 대한 기본 지원이 포함되어 있음
  • 쿠버네티스 환경에 배포된 경우 actuatorliveness, readiness의 정보를 수집하고 health 엔드포인트에 노출함
    • 이러한 상태 그룹은 쿠버네티스 환경에서 실행되는 경우에만 자동으로 활성화 됨
    • 스프링부트가 환경 변수로 Kubernetes 배포 환경을 자동으로 감지해주기 때문

sleep n초

그럼 스프링부트가 이렇게 관리해주니 기존 sleep n초를 걸어둔 것을 없앨 수 있을까?


스프링 블로그에서도 애플리케이션 상태는 스스로가 가장 잘 아니 이 값에 기여하는 것을 선택했다라고 소개되어있고,

A polling-only model where you need to exercise checks to know the state of the application is incomplete. Only the application knows about its lifecycle (startup, shutdown) or can provide context about runtime errors (ending in a broken state while processing tasks). The Spring Boot application context is natively publishing those events during the lifecycle of the application; your application code should also be able to contribute to this.


shutdown 될 때 변경된 readiness 상태가 publish 되는 것을 보고 희망을 품었으나

image image

변경하고 테스트 해보니 여전히 간헐적으로 실패하고 sleep을 걸어야지 안정적이길래 문서를 더 찾아봤음

image

이렇게 소개된 부분이 있길래 겸허히 받아들이기로 함


궁금

actuator용 port를 다른 것으로 지정할 수 있고 보안 상 이런 구성을 볼 수 있어서 따라하려고 했었는데

이 경우 애플리케이션이 제대로 작동하지 않더라도(예: 새 연결을 수락할 수 없음) 프로브 검사는 성공할 수 있으니 메인 서버 포트에서 readiness, livness 그룹을 사용할 수 있도록 설정하는 것이 좋다는 문서 내용을 보게 됐음

그럼 8080포트에서 livez, readyz로 호출할 수 있게 되는데 이럴거면 왜 포트를 나누는걸까?


불쌍한 프리티어 인스턴스

프리티어는 진짜 순수 학습용이지 이걸로 사이드 프로젝트를 길게 할 순 없는 것 같다.
정말 사이드 프로젝트에서 심플하게 써야할 듯

디스크랑 램이 너무 작고 귀여워

처음 세팅하고 나서 남은 램이 한 600 정도로 기억하는데

  • jar가 230~250정도
  • git self hosted 러너 스크립트도 80~90 정도라 남는게 없음

그래서

  • k8s 당연히 안됨
  • 블루/그린 배포 처음에만 되고 이후엔 연결 끊김
  • git runner에서 System.IO.IOException: No space left on device 발생함

그래도 배운 것은

CI/CD 스크립트 작성 시 runner에서

  • 사용하지 않는 파일들 정리해주는 구문 추가하기
  • dh -f로 확인 로그 표시하면 좋을 듯(그때그때 들어가서 치기 귀찮으니까)

이런 식으로

    - name: free disk space
      run: |
        sudo swapoff -a
        sudo rm -f /swapfile
        sudo apt clean
        docker rmi $(docker image ls -aq)
        df -h

https://github.com/jens-maus/RaspberryMatic/blob/d5044bef3307bc61166377c162569de1a61cf332/.github/workflows/ci.yml#L34-L40

근데 쿠버네티스였나 이전에 자동으로 안쓰는 도커 이미지 정리해줬던 것 같은데 내가 꿈을 꾼걸까?

image

나를 진짜로 킹받게 하는 테스트

목표

이제 더는 참지 않는다 테스트 노가다

왜 해야하는가

지속 가능한 나의 개발자 생활을 위해

  • 고작 필드 3개있는 초간단한 구현인데 테스트를 빌드업 하는 과정이 너무 킹받아서 개발이 처음으로 진짜 하기 싫음
  • 새로 만들어가는 과정이 지금 프로젝트까지 5번째인데 한번만 더 반복하면 개발자 접을 것 같음
  • 나를 정말정말x100 킹받게 하는 곳은 인수테스트, 컨트롤러 레이어 테스트임🔫💥

두 레이어 테스트 세팅이 킹받는 이유

하는 일도 비슷한 주제에 api가 각각 다름🔫💥🔫💥🔫💥🔫💥🔫💥🔫💥🪦☠️

  • (1) 예전 프로젝트들에서 가져올 때 기분이 좋지 않음
    • 저 두 곳은 테스트가 많아질 수록 보일러 코드가 꽤 발생해서 점점 메서드로 추출하게 됨
    • 그러다보면 dsl스럽게 만들어져서 나는 이걸 mother object로 만들어서 추출하는 패턴이 됐음
      • 예전 플젝에서 mother object를 가져와 복붙하기엔 초기 기능에 비해 클래스가 헤비하게 느껴짐
      • (내가 상속으로 사용해서 그런듯ㅠ)
      • 1개면 참아보겠는데 심지어 2개라 뭔가 거부감이 생김
  • (2) 가장 킹받는건 api가 다름
    • 왜냐하면 인수 테스트는 RestAssured를 사용하고 컨트롤러 테스트는 mockMvc를 사용하기 때문임
    • 각각의 테스트 작성 시 사용 방법이 다르니 두뇌 메모리를 많이 잡아먹음
    • 인수테스트 작성하고 컨트롤러 테스트로 넘어가는데 두뇌 스위칭이 바로바로 안됨
    • 리팩토링 할 때도 2번씩 생각하고 제각각 해줘야도밈!!!!!!!!!!

어느정도 세팅되면 괜찮은데 처음부터 시작할 때 너무 고달프다..

예상하는 탈출구

1. WebTestClient를 사용해서 api를 통일시킨다

흑ㅡㅎ흑 지푸라기 잡는 마음으로 공식 문서를 뒤지다가 WebTestClient를 알게됐음

image

그리고... 이제 다시 보니 공식문서에서 꽤 자주 WebTestClient를 어필하고 있음에도 인기가 없어보이는게,,,좀,,

2. 그리고 cucumber를 얹어서 행복한 인수테스트 작성

  • 장점: cucumber는 역사가 깊고 정보가 꽤 있음
  • 단점: cucumber도 초면임ㅠ

마우스 이벤트로 애니메이션 발생 시 시작과 끝을 테스트하자

문제

마우스 호버 시 슬라이드 애니메이션을 줄 경우, 무한루프에 주의해야 한다.

아래와 같이 시작 경계선에 마우스가 위치할 경우 의도치 않게 초당 많은 이벤트가 발생할 수 있다.

개선전

  • 호출이 너무 빨라서 안보임

티빙

  • 아날리틱스를 달아놨다면?

발생 이유

로직이 다음과 같을 때

  • 마우스 enter - 카드가 위로 올라가는 애니메이션 시작
  • 마우스 exit - 카드가 내려오는 애니메이션 시작

image

최하단에 마우스를 둘 경우 애니메이션으로 인해 자동으로 마우스 이벤트가 호출되면서 무한 루프가 발생하는 것이다

  • 카드가 올라감 ➡️ 영역이 올라가면서 exit 이벤트 발생 ➡️ 카드 내려옴 ➡️ 자동으로 enter 이벤트 발생 ➡️ 카드 다시 올라감 ➡️ 무한 반복

해결

지금 생각나는 해결 방법은 2가지 인데,

첫번째는 쓰로틀과 디바운싱으로 중복 이벤트를 처리하는 것이다.

  • 아마 티빙이 호출은 계속 되지만 시간 텀이 있는걸 보면 이 방식인 것 같다

두번째는 이번에 적용한 것인데 마우스 이벤트를 받는 컴포넌트 위치를 고정시키는 것이다

  • 현재 문제의 근본적인 원인은 이벤트를 수신하는 컴포넌트가 움직이면서 자동으로 마우스 hover가 발생하기 때문이므로 영역을 고정시켜 해결할 수 있다.

개선하기

빨간색으로 칠한 부분까지 이벤트 대상으로 하여 영역을 고정시켰다.
이 방법을 고른 이유와 아쉬운 부분을 적어보자면

  • 장점
    • 코드량에 비해 디바운싱, 쓰로틀이라는 개념이 과할 수 있다 느꼈는데 단순하게 구현 가능
    • 아예 1번만 발생하므로 디바운싱, 쓰로틀 사용 시 느리게라도 계속 동작하는 부분이 해결 됨
  • 단점
    • Inkwell이 가진 LongPress 시 카드 위를 칠하는 효과를 없애기 위해 색상을 투명으로 바꿔줘야했음
      • 영역이 카드 외부까지 잡히기 때문에 어색하다 느껴져서
    • 내 구현 코드에선 위젯 위치만 바꿔서 해결했으므로(SlideTransition ↔️ InkWell) 이 부분에 대한 기록이 남지 않아 리팩토링 도중 다시 재현될 가능성이 높다고 생각함

child: InkWell(
onTap: _handleTap,
onHover: _handleHover,
mouseCursor: SystemMouseCursors.click,
hoverColor: Colors.transparent,
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
focusColor: Colors.transparent,
child: SlideTransition(

개선

  • 위 단점을 개선하기 위해 해당 내용을 반영한 테스트 코드를 추가하여 재발 가능성을 낮춘다

이밖에 메모

  • 원래는 넷플릭스의 홈 화면을 따라하려 했었는데 마우스가 카드를 페이커 반응 속도로 지나가면 오작동하는 부분이 있었고, 해결을 해도 근본적인 해결 방식이 아니라 느껴져서 UX를 지금 방식으로 변경했다.
    • mouse enter ➡️ overlay 렌더링 시작 ➡️ mouse exit ➡️ overlay 페인팅 됨
  • 넷플릭스 홈 화면의 호버 애니메이션은 컴포넌트 사이즈가 달라지므로 오버레이 같은 레이어를 하나 더 띄워야 하는데 exit 이벤트를 받을 해당 레이어가 페인팅되기 전 마우스가 exit되면 닫히질 않는다
    • 🤔 페인팅 전까지 마우스를 가둬놓을 수도 없고? 오버레이가 그려질 때 뒤에 깔린 컴포넌트에서 mouse exit가 자동 호출되는데 그려져서 호출된건지 유저가 닫기위해 의도적으로 exit한건지 알기 어렵고? mouse out될 때 이벤트 데이터를 봤지만 유의미하지 않았고? 마우스 위치를 확인해서 구현할 수 있겠지만 복잡하다 느껴지고? 또 플러터에서 overlay가 불투명하면 해당 컴포넌트 위에서 스크롤 이벤트가 동작하지 않아 투명하게 변경해줘야 하는데 이걸 위한 테스트 코드도 또 필요해지므로 패스했음
  • 이것도 버그 발생 시 재현하기 힘들었는데 이벤트 시작으로 애니메이션이 호출되는 시점과 이벤트가 종료될 때의 순간을 주목해야 했다
  • 단위 테스트에서 경계 값 테스트가 중요하듯 화면에서도 시작과 끝을 주의깊게 테스트 해야겠다

컨텐츠 검색 화면을 구현한다

목업

image

레퍼런스

image
  • 좌: 네이버, 우: TMDB

해야될 작업

이미지 에러 처리

  • TMDB API에서 이미지 주소가 https://image.tmdb.org/t/p/w500null과 같이 반환하는 경우를 발견했음
    • 아직 개봉 전인 영화인 경우가 이에 해당
  • 메타데이터를 이제 직접 등록하는 것이 아닌 TMDB를 사용할 예정이므로 이미지 에러 처리 필요

전체 유형으로 검색

  • 영화만 있을거라고 착각해서 영화 api로 호출했는데 생각해보니 티빙, 넷플릭스에는 드라마도 있음
  • movie가 아닌 multi api 사용하도록 변경하고 결과값 수정 필요할 듯

환경에 따라 달라지는 애플리케이션 설정을 분리한다

목표

  • 현재 테스트, 개발, 운영 DB를 임의로 바꿔가며 사용하고 있는 부분을 개선한다

자료수집

실제로 현업에서 어떻게 하는지, 어떤 부분을 고려한 건지 알아보자

스프링 공식 블로그에서

https://spring.io/blog/2020/04/23/spring-tips-configuration

✅ 모든 환경에 대해 하나의 빌드가 필요하기 때문에 환경별 config가 코드 자체가 아닌 해당 환경에 있어야 한다(12factor 인용함)

환경 변수가 더 적합하다

  • 스프링 부트에는 추상화를 통해 환경 변수를 가져올 수 있는 유용한 기능이 존재
  • 환경 변수가 더 안전함 → program arguments는 운영체제 툴의 출력에 표시되므로

(이 밖에 환경 변수의 버전을 컨트롤하거나 program arguments의 보안 문제를 해결하기 위해 Spring Cloud Config Server 및 Vault 등도 함께 소개)

12factor에서

https://12factor.net/config
https://12factor.net/build-release-run

NOTE
여기서의 config란 배포 시 환경에 따라 달라지는 모든 값을 의미
- Rails의 config/routes.rb와 같은 내부 application config나 Spring에서 module이 연결되는 방식은 포함되지 않음
- 이러한 유형은 배포마다 달라지지 않으므로 코드에서 수행하는 것이 가장 좋다함


✅ 코드에서 config를 엄격하게 분리해라

  • config는 코드에 상수로도 저장되면 안된다
  • 모든 config가 코드에서 올바르게 구성되었는지 알려면 credentials을 손상시키지 않고 코드를 오픈소스로 만들 수 있는지 봐라

✅ 릴리즈 단계에서 빌드 파일과 환경에 따른 config를 합쳐서 해당 환경에서 즉시 실행할 수 있는 결과를 만들어라

image

❌ 코드에서 상수를 사용하는 것보단 버전관리를 하지않는 config 파일이 좋지만 여전히 단점이 있음

  • 실수로 해당 파일을 repository에 체크인 하기 쉬움
  • 해당 파일이 여러 위치와 다른 포맷으로 흩어지는 경향이 있어 모든 구성을 한 곳에서 보고 관리하기 어려움
  • 그리고 이런 포맷은 언어나 프레임워크 spec에 따라 달라지는 경향이 있음

❌ 또한, group으로 config를 관리하는 방법은 깔끔하게 확장되지 않는다고 함

  • 개발, 테스트, 운영과 같이 특정 배포 이름을 딴 그룹으로 config를 일괄 처리하는 것을 말함(스프링에도 있음)
  • 배포가 더 많이 생기면 스테이징, QA와 같은 새로운 환경 이름이 필요하고
  • 프로젝트가 더 커짐에 따라 config 조합이 폭발적으로 증가하여 관리 포인트가 증가한다고 함

✅ 그래서 12factor는 config를 환경변수에 저장

  • 코드를 변경하지 않고도 배포 간 쉽게 변경 가능함
  • 환경 변수 값을 실수로 repository에 체크인 할 가능성이 거의 없음
  • 사용자 지정 config 파일이나 Java System Properties와는 달리 언어 및 OS에 구애받지 않는 표준임
  • 환경 변수는 환경으로 함께 그룹화 되지 않고 독립적으로 관리되고 확장성이 좋은 모델이라 함

영한님 강의에서

인프런 영한님 강의 중 <스프링 부트 - 핵심 원리와 활용> 섹션 6. 외부설정과 프로필1 - 외부 설정이란? (미리보기)

image

❌ 각각 환경에 맞게 애플리케이션을 빌드하는 방법은 좋은 방식이 아님(개발.jar, 운영.jar)

  • 환경에 따라 빌드를 여러번 해야함
  • 개발 버전과 운영 버전의 빌드 결과물이 다름 → 개발 환경에서 검증이 끝났어도 운영용 jar는 동일한 코드 베이스의 결과물인지 확신할 수 없음
  • 빌드가 각 환경에 맞췄다 보니 다른 환경에서 사용할 수 없어 유연성 떨어짐

✅ 빌드는 한번만 하고 각 환경에서 외부 설정값을 주입해라

기존 블루-그린 배포에서 발생하던 이슈를 k8s를 사용하여 개선한다

목표

기존의 블루-그린 배포 방식에서 존재하던 두가지 이슈를 쿠버네티스를 사용하여 개선한다

Resolved #12, #17


모두 배포 시 blue ↔️ green 환경 스위칭 시 발생하는 이슈이며, 이것을 k8s nginx ingress의 동적 구성을 사용하여 개선할 수 있기 때문


어떻게

배포 중 커넥션 연결 유지로 발생하던 이슈

요약

  • 발단
    • blue ↔️ green 환경을 스위칭 시 변경을 적용하기 위해선 nginx reload를 사용해야 함
    • nginx reload 시 old worker process는 종료됨
  • 이슈
    • 연결이 유지중인던 클라이언트는 old worker가 종료된 걸 모르므로 기존 커넥션으로 요청을 보내 실패가 발생할 수 있음
  • 해결
    • reload 안하면 됨
    • 어떻게: k8s nginx ingress는 루아를 사용하여 동적으로 서비스에서 엔드포인트를 가져오는 방식임

결과

  • pod가 교체되어도 worker process가 변경되지 않은 것을 확인할 수 있음

image


배포 중 가능한 동시 사용자를 초과하여 발생하던 이슈

요약

  • 발단
    • 내장 톰캣에는 최대 스레드 갯수가 정해져있음
    • 요청이 최대 스레드를 넘길 경우 작업 큐에 저장됨
  • 이슈
    • graceful shutdown 시 작업 큐에 있던 요청은 실패함
    • 실패 시 nginx가 다른 서버로 연결 요청을 다시 시도할 수 있지만 old worker는 새로운 환경을 알지 못하므로 실패함
  • 해결
    • 이전 이슈와 마찬가지로 k8s nginx ingress는 동적 구성이므로 새로 배포된 엔드포인트를 알 수 있어 처리 가능

결과

  • 배포 시 기존 pod가 처리 중이던 요청(200) + 새로운 pod가 받은 요청(50) = 총 250이 실패 없이 처리되는 것을 확인할 수 있음

image

Java 스레드 풀과 Tomcat 스레드 풀의 동작 및 차이에 대해 알아보자

자바의 Thread Pool

https://docs.oracle.com/en/java/javase/17/docs/api/java.base/java/util/concurrent/ThreadPoolExecutor.html

자바는 ThreadPoolExecutor를 사용해서 스레드를 생성하고 관리하여 여러 작업을 동시에 실행할 수 있다.

스레드 풀 크기 조절

ThreadPoolExecutor는 스레드 풀 크기를 corePoolSizemaximumPoolSize 범위에 따라 자동으로 조절해주는데

방식은 다음과 같다.

  1. corePoolSize보다 적은 수의 스레드가 실행 중이면 새 스레드를 생성
  2. corePoolSize 이상의 스레드가 실행 중이면 작업을 큐에 추가
  3. 작업을 큐에 추가할 수 없으면 maximumPoolSize를 초과하지 않는 한 새 스레드를 생성
  4. 큐도 모두 차고 maximumPoolSize도 초과한 경우 작업이 거부됨

간단히 요약하자면 corePoolSize → queue → maximumPoolSize 순이다.

Queuing

위에서 큐가 사용된다고 하니 큐에 대해서도 보면 좋을 것 같다.

큐 처리 전략에는 일반적으로 세 가지가 있다고 한다. (큐 사이즈가 0일 때, 무제한일 때, 제한이 있을 때)

1. Direct handoffs

work queue에 대한 좋은 기본 선택은 capacity를 가지지 않는 SynchronousQueue 를 사용하는 것이라 한다.
작업을 보유하지 않고 스레드에게 넘겨주는 방식이며, 작업을 큐에 추가하는 것이 실패하므로 놀고 있는 스레드가 없으면 새 스레드가 만들어지게 된다.
보통 새로운 요청이 거부되는 것을 방지하기 위해 무제한 maximumPoolSizes가 필요하다고 한다.

2. Unbounded queues

이 경우엔 큐에 작업을 추가할 수 있으니, corePoolSize 스레드가 모두 사용 중이면 새 작업은 큐에서 대기하다 처리되지만
큐 사이즈가 무제한이라 스레드는 corePoolSize보다 더 생성되지 않는다. (maximumPoolSize 값은 아무런 영향X)

3. Bounded queues

이 경우가 위에서 본 기본 동작 방식 그대로다.
리소스 고갈을 방지하는 데 도움이 되지만 조정과 제어가 어려울 수 있다고 한다.
상황에 맞는 적절한 큐 크기와 maximumPoolSizes를 사용해야하는 것 같다.


Executors가 제공하는 팩토리 메서드를 살펴보면 각 동작에 맞는 큐를 사용하는 것을 확인할 수 있다.

image image

동작 테스트

간단한 테스트를 만들어 스레드 풀 동작 방식을 확인해보았다.

@SuppressWarnings("NonAsciiCharacters")
class ThreadPoolTest {

    private final int 코어__사이즈는_3개 = 3;
    private final int 최대__사이즈는_7개 = 7;
    private final int_사이즈는_100개 = 100;


    private final Runnable 일초_걸리는_작업 = () -> {
        try {
            SECONDS.sleep(1);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    };


    private java.util.concurrent.ThreadPoolExecutor executor;

    @BeforeEach
    void setUp() {
        executor = new java.util.concurrent.ThreadPoolExecutor(
                코어__사이즈는_3개, 최대__사이즈는_7개,
                60L, SECONDS,
                new java.util.concurrent.LinkedBlockingQueue<>(큐_사이즈는_100개)
        );
    }

    @AfterEach
    void tearDown() {
        executor.shutdown();
    }

    @Test
    void 초기화된_스레드풀의_스레드_갯수는_0개이다() {
        assertThat(executor.getPoolSize()).isZero();
    }

    @Test
    void 코어__사이즈보다_적은_수의_스레드가_실행중이면_스레드가_생성된다() {
        //given
        var_작업_갯수 = 코어__사이즈는_3개;

        //when
        for (int i = 0; i < 총_작업_갯수; i++) {
            executor.execute(일초_걸리는_작업);
        }

        //then
        assertAll(
                () -> assertThat(executor.getPoolSize()).isEqualTo(코어__사이즈는_3개),
                () -> assertThat(executor.getQueue()).isEmpty()
        );
    }

    @Test
    void 코어__사이즈_이상의_스레드가_실행중이면_작업을_큐에_한다() {
        //given
        for (int i = 0; i < 코어__사이즈는_3개; i++) {
            executor.execute(일초_걸리는_작업);
        }

        //when
        for (int i = 0; i < 큐_사이즈는_100개; i++) {
            executor.execute(일초_걸리는_작업);
        }

        //then
        assertAll(
                () -> assertThat(executor.getPoolSize()).isEqualTo(코어__사이즈는_3개),
                () -> assertThat(executor.getQueue()).hasSize(큐_사이즈는_100개)
        );
    }

    @Test
    void 요청을_큐에___없으면_최대__사이즈까지_스레드를_생성한다() {
        //given
        for (int i = 0; i < 코어__사이즈는_3개 + 큐_사이즈는_100개; i++) {
            executor.execute(일초_걸리는_작업);
        }

        //when
        var 현재__사이즈 = executor.getPoolSize();
        for (int i = 현재__사이즈; i < 최대__사이즈는_7개; i++) {
            executor.execute(일초_걸리는_작업);
        }

        //then
        assertAll(
                () -> assertThat(executor.getPoolSize()).isEqualTo(최대__사이즈는_7개),
                () -> assertThat(executor.getQueue()).hasSize(큐_사이즈는_100개)
        );
    }

    @Test
    void 최대_스레드와_가_모두_차면_이후_작업은_거부된다() {
        //given
        for (int i = 0; i < 최대__사이즈는_7개 + 큐_사이즈는_100개; i++) {
            executor.execute(일초_걸리는_작업);
        }

        //when
        ThrowingCallable rejected = () -> executor.execute(일초_걸리는_작업);

        //then
        assertThatThrownBy(rejected).isInstanceOf(RejectedExecutionException.class);
    }
}

테스트는 모두 성공한다.

image

톰캣의 Thread Pool

사실 이것 때문에 테스트를 작성했다.

난 톰캣 스레드 풀은 큐를 채우기 전에 최대 스레드 풀 갯수까지 스레드를 생성하는 것 같았기 때문이다.

톰캣 내부 구현을 보면 스레드풀을 생성할 때,
concurrent 패키지가 아닌 org.apache.tomcat.util.threads.ThreadPoolExecutor를 사용하고
큐는 executor와 함께 사용하기 위해 특별히 설계한 TaskQueue를 사용하며 Unbounded queues 방식이다.

public class TaskQueue extends LinkedBlockingQueue<Runnable> {
    public TaskQueue() {
        super();
    }
...
    public LinkedBlockingQueue() {
        this(Integer.MAX_VALUE);
    }

이상하지 않나?
위에서 Unbounded queues 처리 방식에서 maximumPoolSize 값은 아무런 영향이 없다고 했는데

스프링에선 해당 값을 바꿀 수 있는 옵션을 제공하고, 실제로 그만큼 스레드 갯수가 늘어난다.

image

일단 확인을 위해 위에서 작성한 테스트를 톰캣 스레드풀 생성 방식처럼 변경하고

private org.apache.tomcat.util.threads.ThreadPoolExecutor executor;

@BeforeEach
void setUp() {
    var queue = new org.apache.tomcat.util.threads.TaskQueue(큐_사이즈는_100개);
    executor = new org.apache.tomcat.util.threads.ThreadPoolExecutor(
            코어__사이즈는_3개, 최대__사이즈는_7개,
            60L, SECONDS,
            queue
    );
    queue.setParent(executor);
}

테스트를 다시 돌려보면 일부 케이스가 실패한 것을 확인할 수 있다.

image

다른 점

톰캣 스레드 풀은 생성될 때 코어 풀 사이즈만큼 스레드를 시작해둔다

image

java ThradPoolExecutor는 초기화 후 새 작업이 요청될 때 새로운 코어 스레드를 시작했지만,
톰캣 ThradPoolExecutor생성자에서 prestartAllCoreThreads()를 호출해서 모든 코어 스레드를 시작한 후 idle 상태로 기다리게 한다.

/**
 * Starts all core threads, causing them to idly wait for work. This
 * overrides the default policy of starting core threads only when
 * new tasks are executed.
 *
 * @return the number of threads started
 */
public int prestartAllCoreThreads() {
    int n = 0;
    while (addWorker(null, true)) {
        ++n;
    }
    return n;
}

톰캣 스레드 풀은 가능한 최대 풀 사이즈까지 스레드 생성을 우선 하고 큐를 채운다

image

위 결과를 보면 스레드는 maximumPoolSize만큼 생성되어 있고, 큐는 maximumPoolSize - corePoolSize 만큼 부족하게 채워져있다.

더 확실한 확인을 위해 아래와 같이 테스트 코드를 작성하여 실행해보면

@Test
void 톰캣_스레드_풀은_최대__사이즈까지_스레드_생성을__대기보다_우선한다() {
    //given
    for (int i = 0; i < 코어__사이즈는_3개; i++) {
        executor.execute(일초_걸리는_작업);
    }

    //when
    var 현재__사이즈 = executor.getPoolSize();
    for (int i = 현재__사이즈; i < 최대__사이즈는_7개; i++) {
        executor.execute(일초_걸리는_작업);
    }

    //then
    assertAll(
            () -> assertThat(executor.getPoolSize()).isEqualTo(최대__사이즈는_7개),
            () -> assertThat(executor.getQueue()).isEmpty()
    );
}

스레드 풀 사이즈는 최대 풀 사이즈까지 늘어났고 작업 큐는 비어있음을 확인할 수 있다.

image

사실 톰캣ThradPoolExecutor와 concurrent ThradPoolExecutor의 execute 로직 자체는 같다

image

심지어 해당 메서드의 스레드 풀 기본 동작 3단계을 기술한 주석 내용도 같은데 어떻게 큐보다 maximumPoolSize가 우선인걸까?

그 이유는, 위 execute 코드를 보면 아래와 같이 동작하는데

  1. corePoolSize보다 적으면 워커 스레드 생성
  2. 실행 중이면 큐에 작업을 추가
  3. (2)가 false이면 워커 스레드 생성

TaskQueue작업을 추가할 때 최대 풀 사이즈까지 스레드를 생성할 수 있도록 false를 반환하기 때문이다.

그래서 위 로직에서 (2)가 실패하고 (3)이 실행되어 워커 스레드가 생성되는 것

🚨 진짜 엄밀히 말하면 무조건 항상 스레드를 Max까지 만들고서야 큐가 채워지는 것은 아니다. offer() 다른 로직을 보면 놀고있는 스레드가 있는 경우 작업을 큐에 추가해서 스레드가 일하게끔 하므로 queue에도 작업이 들어갈 때가 있음


public class TaskQueue extends LinkedBlockingQueue<Runnable> {

    @Override
    public boolean offer(Runnable o) {
        ...
        //if we have less threads than maximum force creation of a new thread
        if (parent.getPoolSizeNoLock() < parent.getMaximumPoolSize()) {
            return false;
        }
image

스프링 내장 톰캣으로 테스트

확실한게 좋으니까 직접 애플리케이션을 실행해서 실제 내장 톰캣의 동작을 확인해보자

3초 걸리는 api를 호출하는 통합 테스트 코드를 작성했다.

참고: @SpringBootTest properties나 @TestPropertySource로 해당 테스트에서만 사용할 속성 값을 정할 수도 있다

@SuppressWarnings("NonAsciiCharacters")
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Import(TomcatTest.SlowApiController.class)
@TestPropertySource(properties = {"server.tomcat.threads.min-spare=7", "server.tomcat.threads.max=30"})
@TestMethodOrder(OrderAnnotation.class)
class TomcatTest {

    @Value("${server.tomcat.threads.min-spare}")
    int threadsMinSpare;

    @Value("${server.tomcat.threads.max}")
    int threadsMax;

    @LocalServerPort
    int port;


    @Order(1)
    @Test
    void 초기값_테스트(WebServerApplicationContext context) {
        //given
        var executor = getTomcatExecutorFrom(context);

        //then
        assertAll(
                () -> assertThat(executor.getActiveCount()).isZero(),
                () -> assertThat(executor.getPoolSize()).isEqualTo(threadsMinSpare),
                () -> assertThat(executor.getMaximumPoolSize()).isEqualTo(threadsMax),
                () -> assertThat(executor.getQueue()).isEmpty()
        );
    }

    @Order(2)
    @Test
    void 톰캣은_최대_갯수까지_새로운_스레드_생성을_강제한다(WebServerApplicationContext context) throws InterruptedException {
        //given
        var executor = getTomcatExecutorFrom(context);

        //when
        for (int i = 0; i < threadsMax; i++) {
            비동기로_3_걸리는_api_호출한다();
        }

        SECONDS.sleep(1); // 🚧 비동기 요청이라 실제로 요청이 가기까지 약간 기다린다

        //then
        assertAll(
                () -> assertThat(executor.getActiveCount()).isEqualTo(threadsMax),
                () -> assertThat(executor.getPoolSize()).isEqualTo(threadsMax),
                () -> assertThat(executor.getQueue()).isEmpty()
        );
    }

    @Order(3)
    @Test
    void 최대_갯수까지_스레드가_생성되면_가_요청은_큐에서_대기한다(WebServerApplicationContext context) throws InterruptedException {
        //given
        var executor = getTomcatExecutorFrom(context);
        var가_요청_5개 = 5;

        //when
        for (int i = 0; i < 추가_요청_5개; i++) {
            비동기로_3_걸리는_api_호출한다();
        }

        SECONDS.sleep(1); // 🚧 비동기 요청이라 실제로 요청이 가기까지 약간 기다린다

        //then
        assertAll(
                () -> assertThat(executor.getActiveCount()).isEqualTo(threadsMax),
                () -> assertThat(executor.getPoolSize()).isEqualTo(threadsMax),
                () -> assertThat(executor.getQueue()).hasSize(추가_요청_5개)
        );
    }

    private ThreadPoolExecutor getTomcatExecutorFrom(WebServerApplicationContext ctx) {
        var tomcatWebServer = (TomcatWebServer) ctx.getWebServer();
        return (ThreadPoolExecutor) tomcatWebServer.getTomcat().getConnector().getProtocolHandler().getExecutor();
    }

    private void 비동기로_3_걸리는_api_호출한다() {
        WebClient.create()
                .get()
                .uri("http://localhost:" + port + "/slow")
                .retrieve()
                .bodyToMono(Void.class)
                .onErrorResume(e -> Mono.empty())
                .subscribe();
    }

    @RestController
    static class SlowApiController {

        @GetMapping("/slow")
        ResponseEntity<Void> sleep() throws InterruptedException {
            SECONDS.sleep(3);

            return status(OK).build();
        }
    }
}

테스트를 돌려보면 예상대로 모두 성공한다

image

블루-그린 무중단 배포를 실험 해보다 이슈가 있어 max thread 값을 기본 200에서 300으로 변경해봤는데 RPS가 그대로 반영되는 것을 보고
톰캣 스레드 풀은 스레드를 먼저 최대까지 생성하고 그 다음 요청부턴 큐에 대기 시키는 것 같다고 느꼈는데
인터넷과 톰캣 ThreadPoolExecutor 문서만 읽어보면 헷갈려서 확실히 하고자 테스트를 두 가지로 작성하며 확인해봤다.

Flutter version을 3.22으로 업그레이드 한다

이유

자꾸 deprecated warning이 뜨고 dart switch 새로운 버전도 쓸겸

Warning

Warning: In index.html:37: Local variable for “serviceWorkerVersion” is deprecated. Use “{{flutter_service_worker_version}}” template token instead.
Warning: In index.html:46: “FlutterLoader.loadEntrypoint” is deprecated. Use “FlutterLoader.load” instead.
The platformViewRegistry getter is deprecated and will be removed in a future release. Please import it from `dart:ui_web` instead.

블루-그린 배포 시 동시 요청 사용자 200명을 초과하면 에러가 발생한다

문제

  • 블루/그린 무중단 배포 시 동시 사용자 200명을 초과하는 경우 커넥션이 종료됐다는 에러가 발생한다
[error] 1861#1861: *113062 upstream prematurely closed connection while reading response header from upstream
image

상황

  • graceful shutdown 설정O
  • Connection: Close로 요청
  • 10초를 sleep 하는 API 호출
  • 그 외 서버는 기본 설정

몇 번의 실패 끝에 이제 위 설정대로 하면 배포 시 에러 없이 요청이 잘 처리될 것 이다라는 생각으로 테스트를 돌려봤다.

하지만 이번에도 몇 건의 요청이 실패하길래 재차 테스트를 하여 동시 사용자수 200을 초과하는 +@만큼 예외가 발생한 것을 유추할 수 있었는데 (204 → 4건 실패, 277 → 77건 실패)

문제는 난 이 200이란 값을 어디에도 설정한 적이 없고, 대체 어디서 나온 값인지 가늠할 수 없었다는 것이다🫠


200의 정체

  • 톰캣 Nio max thread 기본 값이다

이건 정말 답을 못찾겠어서 고수님께 여쭤봤는데 바로 주신 글이 Java, max user processes, open files이다.

server:
  tomcat:
    threads.max: 300 # 👈 기본 값은 200

해당 글에서 Tomcat NIO는 max thread 기본 값이 200이라고 나와있어 설정을 바꿔봤더니 바로 RPS가 30으로 오르길래 이놈이구나 싶었는데, 실제로 유저수 250으로 테스트해도 더이상 50건의 요청 실패가 발생하지 않았다

image

(왜 유저수 200명부터는 RPS가 20으로 고정되는걸 희한하다 생각만 하고 지나갔던걸까?)


값을 늘리는게 지금 나의 해결책일까?

그냥 값을 더 늘리고 넘어갈까 했는데 그건 내 상황에서 근본적인 해결 방법이 아니었다.

일반적인 요청 상황에선 최대 스레드 수를 조절하는 것이 좋아보이지만,
난 배포 중 서버를 교체하면서 기존 서버가 갖고있던 초과된 요청이 종료되는 문제라 300으로 늘리더라도 300+@는 여전히 실패할 것이다.

그럼 어떻게 해야할까? Nginx에서? 아니면 톰캣이 뭔가 해줄 수 있나? 누가?

마침 고수님이 톰캣 max threads가 200이라면 기본 값은 뭔지? 어떻게 늘어나는지?라는 문제를 출제해주셨는데,
아 이거.. 내가 Nginx랑 톰캣을 대충 보고 지나갔기 때문에 사막에서 바늘 찾기를 하고 있었구나란 반성을 했다.


어떻게?

  • 내가 생각한 해결 방법은 Nginx가 처리해주는 것이다.

잊고있었지만 Nginx는 서버에 장애가 일어나면 다른 서버와의 연결을 재시도한다(Failure Detection).

Nginx health-check가 Plus(이하 유료)에서만 가능한 줄 알았는데, health check에는 2가지가 있으며 그 중 active가 유료 모델에서만 지원하고 passive는 무료, 유료 모두 지원한다.

  • passive: 일단 요청 보내고 실패하면 해당 서버를 다운으로 체크
  • active: 먼저 주기적으로 서버들을 헬스체크해서 서버 다운을 체크

추가로 실패 횟수나 timeout 등의 옵션이 있는데 max_fails 기본 값은 1이고, 그룹에 단일 서버만 있는 경우 fail_timeoutmax_fails 옵션은 무시되고 사용 불가능으로 표시하지 않는다고 한다.

그래서 Nginx의 Passive health check와 Failure Detection을 사용하여,

a. 구 버전을 셧다운 하지 않고 backup 서버로 전환

image

b. 예전에 봤던 엔드포인트를 동적으로 변경하는 방법 -> 못해봤음

으로 해결하면 될 것 같다.

중간에 '엇 그럼 config 파일에 구버전을 잠시만 backup으로 뒀다가 제거하면 되지 않을까?'란 생각을 했었는데
old worker process는 예전 설정만을 알고 있고 이걸 참고해서 실패 시 다른 서버로 재시도 하므로 이후 설정에서 추가 서버를 정적으로 구성해주는 것은 의미가 없었음


메모

현재는 아무 설정도 안했다.

궁금해서 n초간 sleep하는 API로 테스트해본 것이라 max-thread 200을 충족하기 쉬웠지만
일반적인 api를 호출해보면 컴퓨터 연산이 워낙 빨라서인지 톰캣 스레드 풀의 active count가 그리 쉽게 올라가지 않았다.

진짜 active count 200을 초과할 정도의 트래픽을 맞이한다고 가정할 때 쯤엔 다른 좋은 도구들을 공부하고 있지 않을까

action 실행 시 System.IO.IOException이 발생한다

이유

  • 디스크가 부족해서
  • 내가 사용하지 않는 도커 이미지를 정리해주지 않았음
System.IO.IOException: No space left on device : ‘/home/ubuntu/actions-runner/_diag/Worker_20240324-072138-utc.log’
   at [System.IO](http://system.io/).RandomAccess.WriteAtOffset(SafeFileHandle handle, ReadOnlySpan`1 buffer, Int64 fileOffset)

작업

  • docker image prune이었나 그거 추가해
  • df -h 확인 명령어도 추가

ssl 인증서를 2달에 한 번 자동으로 갱신한다

목표

  • 인증서를 2달에 한 번 자동 갱신한다

정보

🔗 git action schedule
https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule

🔗 인증서 갱신
https://eff-certbot.readthedocs.io/en/latest/using.html#renewing-certificates

  • git actions의 schedule 이벤트를 사용하면 주기적으로 이벤트를 실행할 수 있다
  • Let's Encrypt CA의 인증서 기간은 90일이다
  • certbot renew 명령어를 사용하면 30일 이내에 만료되는 인증서를 갱신할 수 있다
    • 다른 옵션을 지정하지 않는 한 인증서가 처음 발급될 때 사용된 것과 동일한 구성이 갱신에 사용된다

할 때 고민해라

A) certbot renew + --force-renewal

  • 대신 이걸 사용하려면 기존 설정인 --keep-until-expiring--keep으로 바꾸고 쓰는게 좋을 듯?
  • 장점: 이걸로 정상적으로 갱신됨을 이번에 확인했음
  • 단점: certbot은 명령어 실행 후 실행 종료되므로 docker run으로 renew 명령을 실행했는데 포트 지정이 필요함. 포트 구성이 바뀌면 여기도 함께 바뀌어야 하는데 기억 안날듯?
    • docker compose run --rm -p 80:80 -p 443:443 ssl renew

B) 기존 그대로 사용

  • 장점: 포트 몰라도 됨
  • 단점: 안해봤음

AWS EC2를 생성한다

https://docs.aws.amazon.com/ko_kr/AWSEC2/latest/UserGuide/EC2_GetStarted.html

이하 AWS 시작하기 자습서 흐름을 그대로 따라함


Amazon EC2 사용 설정

https://docs.aws.amazon.com/ko_kr/AWSEC2/latest/UserGuide/get-set-up-for-amazon-ec2.html

  1. AWS 계정에 가입
  2. 관리 사용자 생성
  3. 키 페어 생성
  4. 보안 그룹 생성

위의 순서대로 진행되며 각 단계를 요약하자면

  • 관리 사용자 생성: 가입한 계정(루트 사용자)을 MFA로 보호하고 루트 사용자 대신 사용할 계정과 권한을 추가하는 파트
  • 키 페어 생성: 인스턴스를 안전하게 사용하기 위한 키 페어를 생성하는 파트
  • 보안 그룹 생성: 보안을 위해 인스턴스에 접근할 수 있는 인바운드 트래픽을 제어하도록 설정하는 파트

위 흐름에서 (2)는 당장 안해도 되지만 (3), (4)는 일반적으로 필요한 단계임

  • 인스턴스를 시작할 때 키 페어보안 그룹을 지정하여 인스턴스를 보호하기 때문

Amazon EC2 인스턴스 시작

https://docs.aws.amazon.com/ko_kr/AWSEC2/latest/UserGuide/EC2_GetStarted.html

1단계: 인스턴스 시작

인스턴스의 이름, OS, 유형, 키 페어, 보안 그룹을 설정하여 인스턴스를 시작함

  • 순서대로 진행하면서 이전 단계에서 생성해둔 키 페어보안 그룹을 사용하면 됨
  • 문서에선 Amazon Linux를 사용하지만 난 Ubuntu를 사용함
image

2단계: 인스턴스에 연결

https://docs.aws.amazon.com/ko_kr/AWSEC2/latest/UserGuide/AccessingInstancesLinux.html#AccessingInstancesLinuxSSHClient

그냥 잘 됐는지 확인만 하기 위해 제일 간단한 방법을 사용

  1. [EC2 대시보드] → [인스턴스] → [연결] → [SSH 클라이언트]로 이동
  2. 예: 부분의 복사 아이콘을 눌러 명령어 복사 후 pem 파일이 위치한 곳에서 실행
image

결과

위 단계가 모두 끝나면 얻는 결과

  • AWS 회원 가입 완료되어 프리티어 1년간 시작됨
  • (선택) 루트 계정 MFA로 다단계 인증 설정됨
  • (선택) AdministratorAccess, PowerUserAccess 권한을 가진 다른 계정으로 로그인 가능해짐
  • (선택) <사용자지정>.awsapps.com/start와 같은 url을 통해 로그인 가능해짐
  • 키 페어 - pem 파일 생성 및 다운로드 완료됨
  • 보안 그룹 - 내 컴퓨터 IP로 SSH 접근이 가능하도록 생성됨
  • 키 페어, 보안 그룹을 사용하는 EC2 인스턴스 시작 및 연결 완료됨

image

Node.js 16 actions are deprecated

Node.js 16 actions are deprecated. 
Please update the following actions to use Node.js 20: 
actions/checkout@v3, actions/setup-java@v3, gradle/[email protected], gradle/[email protected], actions/upload-artifact@v3.

For more information see: 
https://github.blog/changelog/2023-09-22-github-actions-transitioning-from-node-16-to-node-20/.
image

ssl 인증 만료 에러가 발생한다

문제

사이트에 접속하면 api 호출이 제대로 되지 않고 아래와 같은 로그가 뜸

GET https://pancake.viiviii.xyz:8443/api/contents net::ERR_CERT_DATE_INVALID

발생 이유

  • 사용 중인 Let's Encrypt 인증서는 기간이 90일이며 내가 갱신하지 않아서 인증서가 만료됐음

해결 방법

  • certbot renew 명령어로 갱신 후 nginx reload

인증서 갱신
https://eff-certbot.readthedocs.io/en/latest/using.html#renewing-certificates

어플리케이션을 graceful하게 종료한다

문제

  • SIGHUB으로 애플리케이션이 종료되어도 처리 중이던 작업이 완료되지 않아 에러가 발생한다

재현

다음과 같이 10초 후에 응답하는 api를 만든 후

@GetMapping("/api/slow")
public ResponseEntity<String> slowApi() throws InterruptedException {
    Thread.sleep(10_000);

    return status(OK).body("success");
}

해당 api를 호출하고 프로세스를 종료(SIGTERM)하면 요청이 제대로 처리되지 않는 것을 볼 수 있다.

image

해결

https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#web.graceful-shutdown

스프링에 graceful 설정을 추가하면 된다

image

주의

timeout-per-shutdown-phase 값 보다 처리 작업이 더 오래 걸리는 경우 당연히 실패한다

Commencing graceful shutdown. Waiting for active requests to complete
Failed to shut down 1 bean with phase value 2147482623 within timeout of 5000ms: [webServerGracefulShutdown]
Graceful shutdown aborted with one or more requests still active

Github Pages에서 CORS 에러가 발생한다

에러

Access to XMLHttpRequest at 'http://.../api/contents' from origin 'https://viiviii.github.io' 
has been blocked by CORS policy: Response to preflight request doesn't pass access control check: 
No 'Access-Control-Allow-Origin' header is present on the requested resource.

Nginx 기본 동작을 알아보자

Nginx는 왜 빠른가?

https://www.nginx.com/blog/inside-nginx-how-we-designed-for-performance-scale/

기술 블로그를 인용하여 체스 게임으로 이해해보자

1:1 체스(Prefork)

전통적인 웹서버에 사용했던 prefork 방식은 process‑per‑connection, thread‑per‑connection모델이다.

이 방식은 체스 게임을 고수(웹서버)와 초보(클라이언트)가 1:1로 하는 것인데
결정이 빠른 고수는 결정이 느린 초보를 매번 기다려야 하므로 게임 한 판이 오래 걸린다.

그럼 체스를 어차피 둬야되듯이 요청도 누군가 처리하는 것엔 변함이 없는데 어떻게 개선한걸까?

1:N 체스(Nginx)

👉 결정이 빠른 고수가 초보를 기다리는 병목을 없애는 것이다.

결정이 빠른 고수가 동시에 수십 명의 초보들을 상대로 체스를 두는 것인데, 이것이 Nginx Worker Process의 방식이라고 한다.

image

체스를 둔 초보들이 완료를 알리면 고수는 해당 플레이어들을 순회하며 빠르게 체스를 둘 것이다.

그럼 이걸 실제로 어떻게 처리하는걸까? 여기에서 이벤트와 멀티플렉싱이라는 개념이 나오는데 찾아봤더니 너무 어렵다 흑흑
지금은 체스를 둔 초보자가 완료를 알리듯, 커널이 소켓의 이벤트를 알려주면 Worker가 처리하는 것으로 이해하고 넘어가야겠다.

image

Thread Pool

https://www.nginx.com/blog/thread-pools-boost-performance-9x/

여전히 존재하는 병목

위와 같이 이벤트 기반으로 동작 하더라도 병목은 존재한다.

예를 들어, 체스 게임 도중 특정 상황에서 고수가 초보에게 경품을 줘야된다 생각해보자.
고수가 경품을 가지러 다녀오는 사이 차례를 기다리던 플레이어들은 긴 시간 함께 대기하게 된다.

이런 상황을 blocking operation이라고 하는데 Nginx가 CPU를 많이 사용하는 긴 처리 작업으로 바쁘거나 리소스에 액세스하기 위해 기다리는 것이다.

배달 서비스

그래서 이 병목을 해결하기 위해 긴 작업을 대신 처리해주는 배달원을 고용하는데 이 개념이 WorkerThread pool이다.

image

위와 같이 고수(Worker)는 계속 체스를 두고, 배달원(Thread)들이 긴 작업을 처리 후 갖다준다.

여기까지 공부하면서 기존의 1:1 연결-프로세스 방식의 병목을 이벤트+비동기+스레드풀로 개선해가는 것을 알 수 있었다.

하지만 그럼에도 Worker 내부가 명확히 상상이 안가 답답했는데 어떤 고수님이 잘 그려주셔서 이 투어의 마침표를 찍을 수 있었다.

https://ssup2.github.io/theory_analysis/Nginx_Thread_Pool/

image

연결 거부로 SSL 인증서 발급이 실패한다

검증 실패

Fetching http://pancake.viiviii.xyz/.well-known/acme-challenge/FiEvtUZOp: Connection refused
pancake-ssl-1  |
pancake-ssl-1  |
pancake-ssl-1  | Hint: The Certificate Authority failed to download the challenge files 
from the temporary standalone webserver started by Certbot on port 80. 
Ensure that the listed domains point to this machine and that it can accept inbound connections from the internet.

발생:

  • webroot 방식으로 인증했다가 standalone 방식으로 변경
    • 이 때 ec2에서 --dry-run으로 테스트 시에는 성공이었으나 실제론 검증에 실패함

이유:

  • certbot은 도커 컨테이너로 동작하고 있음
  • 각 방식의 동작 차이
    • webroot는 nginx가 직접 임시 파일을 호스팅했지만
    • standalone는 certbot이 임의의 서버를 띄워 검증하는 방식임
  • 때문에 certbot 컨테이너의 검증에 필요한 80, 443 포트를 publish 해줘야됨

해결:

ssl:
image: certbot/certbot:latest
volumes:
- ssl_certificates:/etc/letsencrypt
ports:
- 80:80
- 443:443



nginx 파일 찾을 수 없음

image

발생:

  • 위 에러를 수정하여 인증서 성공적으로 받았음에도 nginx에서 파일을 찾을 수 없어 에러가 발생함

디버깅:

  • ✅ 인증서 성공 로그 확인

image

  • ✅ docker volume에 파일이 존재하는지 확인

image

  • ✅ nginx 컨테이너에 접속해서 파일이 존재하는지 확인

image

이유:

  • 해당 파일은 심링크인데 실제 원본인 archive 디렉토리에 접근할 수 없고 볼륨에도 존재하지 않기 때문
    • /etc/letsencrypt/live 사용하는 심링크
    • /etc/letsencrypt/archive 실제론 여기가 본체임
image

해결: ca17c2c

검색 결과로 컨텐츠를 저장한다

컨텐츠 메타데이터를 내가 관리하지 않고 외부 API를 통해 매번 가져오려고 하는데
이때 컨텐츠 검색 결과를 사용해 바로 등록하게 되면 사용자에게 저장 요청으로 온 데이터와 내가 이전에 내려준 검색 결과가 일치하는지(사용자에 의해 변경되지 않았는지) 검증이 필요하다

가장 간단한 방법은 외부 API를 사용해 같은 값인지 검증하는 것인데 그럼 호출이 2번 발생하며 해당 API는 호출 제한이 있따

그 다음으로 생각한 방법은 서버가 검색 결과를 내려줄 때 어딘가에 잠시 저장해두는 방법 -> 어디에? 그리고 언제 어떻게 임시 데이터를 제거할건지?

그리고 "스파이더맨"으로 검색하면 결과는 "[스파이더맨1, 스파이더맨2, 스파이더맨그어떤시리즈, ...]" 이런 식으로 올텐데 거기서 선택한 값과 메타데이터를 어떻게 매칭?

String.fromEnvironment can only be used as a const constructor

image

이게 web에선 환경 변수에 접근할 수 없으니 const여야 한다고 하는 건데 (관련 이슈)

처음 개발할 땐 로컬에서 웹으로 한 번 실행해봐서 const 생성자를 추가했는데
이번에 개발에선 테스트만 돌려보고 웹으로 실행은 하질 않아서 깜빡 잊었음

이거 테스트 추가 안하면 미래에 또 까먹을 것 같음ㅠ

  • 🤔 이거 테스트 추가가 쉽지 않음. 빌드도 되고 페이지도 띄워지기 때문에ㅠ

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.