Lightsail 서버가 자꾸 죽어요

Jetpack이 요즘처럼 많이 말을 건 적이 있었던가?

최근에 Bitnami에서 Amazon Linux 2023으로 이사하면서 Lightsail instance를 한 단계 비싼 것으로 올리고 나서부터 웹사이트가 다운되었다는 Jetpack의 노티가 계속 뜬다.

더 비싸면 더 잘돌아야 되는 것 아닌가?

이런 노티를 받고나서 확인해 보면 블로그가 접속되지 않을 뿐만 아니라 SSH 조차도 뜨지 않는 상태가 되어 있어서 서버를 강제 재시작 하는 방법 밖에 없다. Lightsail의 Metrics 메뉴에서 확인해 보면 이때마다 CPU 사용률이 치솟고 있는 것도 보인다.

적습! – XML RPC

따로 cron task를 걸어 놓은 것도 없는데 이렇게 많은 CPU 자원이 소모되는 이유는 뭘까?

Nginx의 access log를 들여다 봤더니 짧은시간 동안에 한 IP에서 아주 많은 xmlrpc.php에 대한 접속시도가 있었다. XML RPC는 REST API로 대체되어 요즘에는 실제로 사용되는 경우가 거의 없는데, 공격자들은 system.multicall 기능을 활용해 다수의 비밀번호 무차별 대입 공격을 수행하는데 자주 쓴다고 한다.

xmlrpc를 막는 여러가지 방법이 있으나, WordPress에서 막지 않고 아예 Nginx에서 xmlrpc접속을 막아버리도록 다음과 같이 설정해 주었다.

# xmlrpc.php 차단
location = /xmlrpc.php {
    deny all;          # xmlrpc.php 접속을 차단
    access_log off;    # 접속 로그를 남기지 않아서 IO 자원을 아낌
    log_not_found off; # Not found(404) 로그도 남기지 않는다
    return 444;        # Nginx 비표준, 404 응답을 보내지 않고 연결을 끊어버림
}

두번째 적습! – Search Flood

이렇게 막고 나서 하루 정도는 잠잠했었는데, 바로 다음날 저녁에 다시 서버에 접속할 수 없다는 Jetpack의 알람이 왔다.

이번에도 CPU 사용량이 치솟으며 SSH접속도 안될 정도로 무언가를 엄청나게 하고 있었다. 분명히 xmlrpc는 막아 두었는데 이번엔 또 뭘까?

로그를 보니 이번에도 하나의 IP에서 짧은 시간동안에 태그, 검색어, 저자, 문서번호 등으로 엄청난 조회(GET) 요청을 받고 있었다. 이번 것은 XML RPC 공격때와 같이 로그인 비밀번호를 알아 내기 위한 것 보다는 서버에 많은 부하를 주어서 서비스를 방해하려는 목적인 것 같다.

외부에서 들어오는 조회가 진짜인지 가짜인지 확인하는 뾰족한 방법은 없고, 다만 너무 잦은 것이 문제가 되는 상황이니 Rate limiting을 걸어서 이런 경우를 걸러내기로 했다.

Nginx 설정파일을 변경해서 먼저 http 영역에서 비정상적인 검색을 시도하는 IP들을 추적하도록 설정한다.

http {
    ...

    # 검색을 요청하는 경우 IP를 저장해 둔다.
    map $arg_s $search_traffic {
        default "";              # 일반적인 접속, track안함.
        ~.+ $binary_remote_addr; # 검색요청하는 IP는 track.
    }

    # 분당 10회 이상의 검색 시도가 있으면 $search_traffic zone에 추가.
    limit_req_zone $search_traffic zone=search_block:10m rate=10r/m;
    ...
}

그리고 나서 server 영역에는 이러한 시도가 5번을 넘기면 차단하도록 다음과 같이 설정한다. 해당 IP는 Service Unavailable(503) 응답을 받게 될 것이다.

server {
    ...
    location / {
        # 5번까지의 burst시도까지는 허가.
        limit_req zone=search_block burst=5 nodelay;
    
        try_files $uri $uri/ /index.php$is_args$args;
    }
    ...
}

Swap 설정 추가

그리고 조금 놀라웠던 사실인데 AL2023 instance에는 기본적으로 swap 영역이 설정되어 있지 않았다. 서비스가 조금 느려지더라도 응답 못하는 일은 없도록 swap영역을 설정해 주었다. swap 설정하는 방법은 인터넷에 많으니 패스.

결론

Bitnami에서 설정해 준 것으로 안락하게 지내다가, 직접 서버를 설정하겠다고 나선지 며칠만에 무작위 공격들로 가득찬 인터넷을 겪고 보니 새삼 살벌한 세상이 실감된다.

하지만 그 덕에 다양한 공격 방법들과 설정 방법들에 대해 더 알게되니 재미있기도 하다. 상용서비스가 아니라서 문제가 생기면 재부팅이라도 할 수 있으니 그나마 다행이랄까.

Udemy가 내 Apple Pay 결제를 먹었다

Udemy에서 in-app purchase로 강의를 구매했는데 아무리 새로고침을 해도 Udemy에 뜨질 않는다.

Alex와 대화

Udemy의 고객지원 부서에 직접 연락하는 방법은 찾지 못했고, Help center에서 “Contact us” 버튼을 눌러서 AI 상담사인 Alex에게 상황을 설명하고, support ticket을 만들어 달라고 하면 몇가지 질문 후에 메일이 날아온다. 이 과정에서 구매한 내역에 대한 사진을 첨부해 달라고 하는데, 구매 내역에 대한 스크린샷을 찍어서 인증해 주었다.

AppStore -> 계정 아이콘 -> Purchase History에서 확인 할 수 있다.

이 과정이 끝나고 나면 Udemy support team에서 이메일이 하나 날아오는데 여기에 추가 할 사항이 있다면 추가해서 회신해 준다.

사람과 대화

이메일 회신 이후에는 사람 담당자가 관련한 처리를 하게 되는데, 이 때 구매 후 받은 invoice email의 캡쳐를 보내 달라고 했다. 그런데 찾아보니 내 메일함에는 수신된게 없었다. Apple의 문제점 보고(Report a problem) 페이지에서도 확인해 보니 구매한 기록은 있는데, 해당 구매에 대한 invoice 내역은 비어 있었다.

macOS에서 구매내역을 봤더니 이것 또한 PENDING으로 표시되어 있다.

카드는 정상 승인되었는데 컨텐츠 제공사에서 이 내역을 받지 못했다면 중간에 Apple에서 제대로 넘겨지지 않았을 것이라 생각해서 Apple care 한국(080-333-4000)에 전화를 했더니 이와 관련한 환불을 진행해 주겠다고 한다. 어차피 다시 구매할 것이니 환불하지 않고 그냥 구매를 진행 할 수는 없겠느냐고 물었더니 그런건 불가능 하다고…

성질급한 한국사람

한 밤 자고 나니, 어제 못 받은 줄 알았던 invoice가 밤사이 email로 전송되어 있었다. 그리고 또 조금 지나서 Apple에서 환불 승인이 되었다는 메일 또한 받았다.

몰려드는 invoice 처리 요청을 사람이 일일이 수작업으로 처리한다는 것은 여간 귀찮은 일이 아닐테니 사람이 하지는 않을테고 그렇다고 시스템이 자동으로 처리한다고 보기엔 너무 느린 이 애매한 처리 시간은 도대체 뭘까?

환불 메일을 받은 후에도 여전히 카드사에서는 구매한 기록만 나와 있고 환불 내용은 없었다. 내가 좀 급했던 것 같으니 조금 기다려 보기로 했다.

환불완료

그리고 나서 하루가 더 지난 후에 카드사로 부터 구매 취소가 완료되었다는 알람이 왔고 청구 취소가 이루어졌다. 월요일에 문제를 제기하고 환불완료는 수요일에 되었으니 모든 과정에 약 3일 정도가 걸린 셈이다.

Lightsail 스냅샷으로 하위 인스턴스를 생성할 수 없다

Lightsail의 WordPress 인스턴스를 AL2023으로 전환 할 때, All-in-One Migration utility로 import를 시도하는데 이전에 본 적이 없던 internal error가 발생했다. 분명 업로드 가능한 파일 크기도 import하는 압축 파일 보다 크게 설정해 주었고 다른 설정들도 모두 마쳤음에도 계속해서 실패하는 것이다.

YYYY/MM/DD HH:MM:SS [error] 32777#32777: *4 FastCGI sent in stderr: "PHP message: PHP Fatal error:  Uncaught ValueError: Path cannot be empty in /var/www/html/wp-content/plugins/all-in-one-wp-migration/functions.php:1881 
Stack trace: 
#0 /var/www/html/wp-content/plugins/all-in-one-wp-migration/functions.php(1881): fopen() 
#1 /var/www/html/wp-content/plugins/all-in-one-wp-migration/lib/model/import/class-ai1wm-import-upload.php(86): ai1wm_is_filedata_supported() 
#2 /var/www/html/wp-content/plugins/all-in-one-wp-migration/lib/controller/class-ai1wm-import-controller.php(77): Ai1wm_Import_Upload::execute() 
#3 /var/www/html/wp-includes/class-wp-hook.php(341): Ai1wm_Import_Controller::import() 
#4 /var/www/html/wp-includes/class-wp-hook.php(365): WP_Hook->apply_filters() 
#5 /var/www/html/wp-includes/plugin.php(522): WP_Hook->do_action() 
#6 /var/www/html/wp-admin/admin-ajax.php(192): do_action() 
#7 {main} 
  thrown in /var/www/html/wp-content/plugins/all-in-one-wp-migration/functions.php on line 1881" while reading response header from upstream, client: [CLIENT_IP], server: _, request: "POST /wp-admin/admin-ajax.php?action=ai1wm_import&ai1wm_import=1 HTTP/1.1", upstream: "fastcgi://unix:/run/php-fpm/www.sock:", host: "[SERVER_IP]", referrer: "http://[SERVER_IP]/wp-admin/admin.php?page=ai1wm_import"

메모리 문젠가?

Nginx의 로그를 보니 Path cannot be empty ... 라는 PHP Fatal error가 찍혀 있었다. 이 에러메세지 자체로는 문제점이 잘 드러나지 않지만, 검색해보니 업로드가 완료된 후 압축 해제와 import를 처리 하는 과정중에 메모리가 부족해서 이런 문제가 발생할 수 있다는 내용이 있었는데, 업로드가 100%까지 진행되고 나서 그 후에 문제가 발생하는 것으로 볼 때 이 말이 사실일 가능성이 있어 보였다.

혹시나 해서 지금 사용하고 있는 것보다 높은 사양의 인스턴스를 하나 새로 파서 테스트해 보니 오류없이 잘 동작했다. 여기까지 확인해서 메모리 부족 때문에 발생하는 문제라는 확증이 들자 한가지 꼼수가 떠올랐다.

  1. 높은 사양의 인스턴스로 All-in-One으로 마이그레이션을 하고
  2. 스냅샷을 하나 만든 다음
  3. 스냅샷으로 부터 원래의 낮은 사양으로 인스턴스를 생성한다.

“그래, 이런 기발한 방법이!” 라며 먼저 $12짜리 인스턴스에 마이그레이션을 완료하고 스냅샷을 만들었다. 그리고 나서 스냅샷으로 부터 인스턴스를 생성하려고 하는데 이런화면이 나왔다.

스냅샷을 생성할 때의 인스턴스보다 낮은 티어는 생성할 수가 없다는 것이다.

결론