Compare commits

..

No commits in common. "master" and "1.11.0" have entirely different histories.

12 changed files with 87 additions and 263 deletions

View file

@ -1,33 +0,0 @@
name: build docker image
on:
push:
tags:
- "**"
jobs:
build:
runs-on: ubuntu-22.04
steps:
- name: Checkout the code
uses: actions/checkout@v4
# https://github.com/docker/setup-qemu-action
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# https://github.com/docker/setup-buildx-action
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Get latest release version number
id: docker-tag
uses: yuya-takeyama/docker-tag-from-github-ref-action@v1
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: fradelg
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build multiarch image
run: |
docker buildx build --push \
--tag fradelg/mysql-cron-backup:${{ steps.docker-tag.outputs.tag }} \
--platform linux/amd64,linux/arm/v7,linux/arm64 .

40
.github/workflows/image.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: build docker image
on:
workflow_dispatch:
push:
branches:
- "**"
tags:
- "**"
jobs:
test:
runs-on: ubuntu-20.04
steps:
- name: Checkout the code
uses: actions/checkout@v3
- name: Test Bash scripts
run: sudo apt-get -qq update && sudo apt-get install -y devscripts shellcheck && make test
build:
runs-on: ubuntu-20.04
needs: test
steps:
- name: Checkout the code
uses: actions/checkout@v3
# https://github.com/docker/setup-qemu-action
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
# https://github.com/docker/setup-buildx-action
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Get latest release version number
id: docker-tag
uses: yuya-takeyama/docker-tag-from-github-ref-action@v1
- name: Login to Docker Hub
run: echo "${{ secrets.DOCKER_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- name: Build multiarch image
run: |
docker buildx build --push \
--tag fradelg/mysql-cron-backup:${{ steps.docker-tag.outputs.tag }} \
--platform linux/amd64,linux/arm/v7,linux/arm64 .

View file

@ -1,26 +0,0 @@
name: build docker image
on:
workflow_dispatch:
push:
branches:
- "**"
jobs:
test:
runs-on: ubuntu-22.04
steps:
- name: Checkout the code
uses: actions/checkout@v4
- name: Test Bash scripts
run: sudo apt-get -qq update && sudo apt-get install -y devscripts shellcheck && make test
- name: Test image
env:
VOLUME_PATH: /tmp/mariadb
DATABASE_NAME: foo
MARIADB_ROOT_PASSWORD: abcd
run: |
docker compose up -d mariadb
docker compose run backup /backup.sh
docker compose run backup /restore.sh /backup/latest.foo.sql.gz
docker compose stop

1
.gitignore vendored
View file

@ -1 +0,0 @@
data

View file

@ -1,28 +1,27 @@
FROM golang:1.20.4-alpine3.18 AS binary
FROM golang:1.15.8-alpine3.12 AS binary
RUN apk -U add openssl git
ARG DOCKERIZE_VERSION=v0.7.0
ARG DOCKERIZE_VERSION=v0.6.1
WORKDIR /go/src/github.com/jwilder
RUN git clone https://github.com/jwilder/dockerize.git && \
cd dockerize && \
git checkout ${DOCKERIZE_VERSION}
WORKDIR /go/src/github.com/jwilder/dockerize
ENV GO111MODULE=on
RUN go mod tidy
RUN CGO_ENABLED=0 GOOS=linux GO111MODULE=on go build -a -o /go/bin/dockerize .
RUN go get github.com/robfig/glock
RUN glock sync -n < GLOCKFILE
RUN go install
FROM alpine:3.20.3
FROM alpine:3.16.0
LABEL maintainer "Fco. Javier Delgado del Hoyo <frandelhoyo@gmail.com>"
RUN apk add --update \
tzdata \
bash \
gzip \
openssl \
mysql-client=~10.11 \
mariadb-connector-c \
fdupes && \
tzdata \
bash \
mysql-client \
gzip \
openssl \
mariadb-connector-c && \
rm -rf /var/cache/apk/*
COPY --from=binary /go/bin/dockerize /usr/local/bin
@ -33,16 +32,13 @@ ENV CRON_TIME="0 3 * * sun" \
TIMEOUT="10s" \
MYSQLDUMP_OPTS="--quick"
COPY ["run.sh", "backup.sh", "restore.sh", "/delete.sh", "/"]
COPY ["run.sh", "backup.sh", "restore.sh", "/"]
RUN mkdir /backup && \
chmod 777 /backup && \
chmod 755 /run.sh /backup.sh /restore.sh /delete.sh && \
chmod 755 /run.sh /backup.sh /restore.sh && \
touch /mysql_backup.log && \
chmod 666 /mysql_backup.log
VOLUME ["/backup"]
HEALTHCHECK --interval=2s --retries=1800 \
CMD stat /HEALTHY.status || exit 1
CMD dockerize -wait tcp://${MYSQL_HOST}:${MYSQL_PORT} -timeout ${TIMEOUT} /run.sh

View file

@ -6,7 +6,7 @@ test:
# Checking for syntax errors
set -e; for SCRIPT in *.sh; \
do \
bash -n $$SCRIPT; \
sh -n $$SCRIPT; \
done
# Checking for bashisms (currently not failing, but only listing)

107
README.md
View file

@ -13,36 +13,24 @@ docker container run -d \
fradelg/mysql-cron-backup
```
### Healthcheck
Healthcheck is provided as a basic init control.
Container is **Healthy** after the database init phase, that is after `INIT_BACKUP` or `INIT_RESTORE_LATEST` happends without check if there is an error, **Starting** otherwise. Not other checks are actually provided.
## Variables
- `MYSQL_HOST`: The host/ip of your mysql database.
- `MYSQL_HOST_FILE`: The file in container where to find the host of your mysql database (cf. docker secrets). You should use either MYSQL_HOST_FILE or MYSQL_HOST (see examples below).
- `MYSQL_PORT`: The port number of your mysql database.
- `MYSQL_USER`: The username of your mysql database.
- `MYSQL_USER_FILE`: The file in container where to find the user of your mysql database (cf. docker secrets). You should use either MYSQL_USER_FILE or MYSQL_USER (see examples below).
- `MYSQL_PASS`: The password of your mysql database.
- `MYSQL_PASS_FILE`: The file in container where to find the password of your mysql database (cf. docker secrets). You should use either MYSQL_PASS_FILE or MYSQL_PASS (see examples below).
- `MYSQL_DATABASE`: The database name to dump. Default: `--all-databases`.
- `MYSQL_DATABASE_FILE`: The file in container where to find the database name(s) in your mysql database (cf. docker secrets). In that file, there can be several database names: one per line. You should use either MYSQL_DATABASE or MYSQL_DATABASE_FILE (see examples below).
- `MYSQLDUMP_OPTS`: Command line arguments to pass to mysqldump (see [mysqldump documentation](https://dev.mysql.com/doc/refman/8.0/en/mysqldump.html)).
- `MYSQL_SSL_OPTS`: Command line arguments to use [SSL](https://dev.mysql.com/doc/refman/5.6/en/using-encrypted-connections.html).
- `CRON_TIME`: The interval of cron job to run mysqldump. `0 3 * * sun` by default, which is every Sunday at 03:00. It uses UTC timezone.
- `MAX_BACKUPS`: The number of backups to keep. When reaching the limit, the old backup will be discarded. No limit by default.
- `INIT_BACKUP`: If set, create a backup when the container starts.
- `INIT_RESTORE_LATEST`: If set, restores latest backup.
- `EXIT_BACKUP`: If set, create a backup when the container stops.
- `TIMEOUT`: Wait a given number of seconds for the database to be ready and make the first backup, `10s` by default. After that time, the initial attempt for backup gives up and only the Cron job will try to make a backup.
- `GZIP_LEVEL`: Specify the level of gzip compression from 1 (quickest, least compressed) to 9 (slowest, most compressed), default is 6.
- `USE_PLAIN_SQL`: If set, back up and restore plain SQL files without gzip.
- `TZ`: Specify TIMEZONE in Container. E.g. "Europe/Berlin". Default is UTC.
- `REMOVE_DUPLICATES`: Use [fdupes](https://github.com/adrianlopezroche/fdupes) to remove duplicate database dumps
If you want to make this image the perfect companion of your MySQL container, use [docker-compose](https://docs.docker.com/compose/). You can add more services that will be able to connect to the MySQL image using the name `my_mariadb`, note that you only expose the port `3306` internally to the servers and not to the host:
@ -81,8 +69,6 @@ services:
- CRON_TIME=0 3 * * *
# Make it small
- GZIP_LEVEL=9
# As of MySQL 8.0.21 this is needed
- MYSQLDUMP_OPTS=--no-tablespaces
restart: unless-stopped
volumes:
@ -93,23 +79,17 @@ volumes:
The database root password passed to docker container by using [docker secrets](https://docs.docker.com/engine/swarm/).
In example below, docker is in classic 'docker engine mode' (iow. not swarm mode) and secret sources are local files on host filesystem.
In example below, docker is in classic 'docker engine mode' (iow. not swarm mode) and secret source is a local file on host filesystem.
Alternatively, secrets can be stored in docker secrets engine (iow. not in host filesystem).
Alternatively, secret can be stored in docker secrets engine (iow. not in host filesystem).
```yaml
version: "3.7"
secrets:
# Place your secret file somewhere on your host filesystem, with your password inside
mysql_root_password:
# Place your secret file somewhere on your host filesystem, with your password inside
file: ./secrets/mysql_root_password
mysql_user:
file: ./secrets/mysql_user
mysql_password:
file: ./secrets/mysql_password
mysql_database:
file: ./secrets/mysql_database
services:
mariadb:
@ -121,15 +101,10 @@ services:
- data:/var/lib/mysql
- ${VOLUME_PATH}/backup:/backup
environment:
- MYSQL_DATABASE=${DATABASE_NAME}
- MYSQL_ROOT_PASSWORD_FILE=/run/secrets/mysql_root_password
- MYSQL_USER_FILE=/run/secrets/mysql_user
- MYSQL_PASSWORD_FILE=/run/secrets/mysql_password
- MYSQL_DATABASE_FILE=/run/secrets/mysql_database
secrets:
- mysql_root_password
- mysql_user
- mysql_password
- mysql_database
restart: unless-stopped
backup:
@ -141,18 +116,13 @@ services:
- ${VOLUME_PATH}/backup:/backup
environment:
- MYSQL_HOST=my_mariadb
# Alternatively to MYSQL_USER_FILE, we can use MYSQL_USER=root to use root user instead
- MYSQL_USER_FILE=/run/secrets/mysql_user
# Alternatively, we can use /run/secrets/mysql_root_password when using root user
- MYSQL_PASS_FILE=/run/secrets/mysql_password
- MYSQL_DATABASE_FILE=/run/secrets/mysql_database
- MYSQL_USER=root
- MYSQL_PASS_FILE=/run/secrets/mysql_root_password
- MAX_BACKUPS=10
- INIT_BACKUP=1
- CRON_TIME=0 0 * * *
secrets:
- mysql_user
- mysql_password
- mysql_database
- mysql_root_password
restart: unless-stopped
volumes:
@ -194,65 +164,4 @@ mysql-cron-backup:
docker container exec <your_mysql_backup_container_name> /restore.sh /backup/<your_sql_backup_gz_file>
```
if no database name is specified, `restore.sh` will try to find the database name from the backup file.
### Automatic backup and restore on container starts and stops
Set `INIT_RESTORE_LATEST` to automatic restore the last backup on startup.
Set `EXIT_BACKUP` to automatic create a last backup on shutdown.
```yaml
mysql-cron-backup:
image: fradelg/mysql-cron-backup
depends_on:
- mariadb
volumes:
- ${VOLUME_PATH}/backup:/backup
environment:
- MYSQL_HOST=my_mariadb
- MYSQL_USER=${MYSQL_USER}
- MYSQL_PASS=${MYSQL_PASSWORD}
- MAX_BACKUPS=15
- INIT_RESTORE_LATEST=1
- EXIT_BACKUP=1
# Every day at 03:00
- CRON_TIME=0 3 * * *
# Make it small
- GZIP_LEVEL=9
restart: unless-stopped
volumes:
data:
```
Docker database image could expose a directory you could add files as init sql script.
```yaml
mysql:
image: mysql
expose:
- 3306
volumes:
- data:/var/lib/mysql
# If there is not scheme, restore using the init script (if exists)
- ./init-script.sql:/docker-entrypoint-initdb.d/database.sql.gz
environment:
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
- MYSQL_DATABASE=${DATABASE_NAME}
restart: unless-stopped
```
```yaml
mariadb:
image: mariadb
expose:
- 3306
volumes:
- data:/var/lib/mysql
# If there is not scheme, restore using the init script (if exists)
- ./init-script.sql:/docker-entrypoint-initdb.d/database.sql.gz
environment:
- MYSQL_ROOT_PASSWORD=${MARIADB_ROOT_PASSWORD}
- MYSQL_DATABASE=${DATABASE_NAME}
restart: unless-stopped
```
if no database name is specified, `restore.sh` will try to find the database name from the backup file.

View file

@ -1,18 +1,10 @@
#!/bin/bash
# Get hostname: try read from file, else get from env
[ -z "${MYSQL_HOST_FILE}" ] || { MYSQL_HOST=$(head -1 "${MYSQL_HOST_FILE}"); }
[ -z "${MYSQL_HOST}" ] && { echo "=> MYSQL_HOST cannot be empty" && exit 1; }
# Get username: try read from file, else get from env
[ -z "${MYSQL_USER_FILE}" ] || { MYSQL_USER=$(head -1 "${MYSQL_USER_FILE}"); }
[ -z "${MYSQL_USER}" ] && { echo "=> MYSQL_USER cannot be empty" && exit 1; }
# Get password: try read from file, else get from env, else get from MYSQL_PASSWORD env
# If provided, take password from file
[ -z "${MYSQL_PASS_FILE}" ] || { MYSQL_PASS=$(head -1 "${MYSQL_PASS_FILE}"); }
# Alternatively, take it from env var
[ -z "${MYSQL_PASS:=$MYSQL_PASSWORD}" ] && { echo "=> MYSQL_PASS cannot be empty" && exit 1; }
# Get database name(s): try read from file, else get from env
# Note: when from file, there can be one database name per line in that file
[ -z "${MYSQL_DATABASE_FILE}" ] || { MYSQL_DATABASE=$(cat "${MYSQL_DATABASE_FILE}"); }
# Get level from env, else use 6
[ -z "${GZIP_LEVEL}" ] && { GZIP_LEVEL=6; }
DATE=$(date +%Y%m%d%H%M)
@ -29,18 +21,13 @@ do
echo "==> Dumping database: $db"
FILENAME=/backup/$DATE.$db.sql
LATEST=/backup/latest.$db.sql
BASIC_OPTS="--single-transaction"
if [ -n "$REMOVE_DUPLICATES" ]
then
BASIC_OPTS="$BASIC_OPTS --skip-dump-date"
fi
if mysqldump $BASIC_OPTS $MYSQLDUMP_OPTS -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASS" $MYSQL_SSL_OPTS "$db" > "$FILENAME"
if mysqldump --single-transaction $MYSQLDUMP_OPTS -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASS" $MYSQL_SSL_OPTS "$db" > "$FILENAME"
then
EXT=
if [ -z "${USE_PLAIN_SQL}" ]
then
echo "==> Compressing $db with LEVEL $GZIP_LEVEL"
gzip "-$GZIP_LEVEL" -n -f "$FILENAME"
gzip "-$GZIP_LEVEL" -f "$FILENAME"
EXT=.gz
FILENAME=$FILENAME$EXT
LATEST=$LATEST$EXT
@ -49,15 +36,15 @@ do
echo "==> Creating symlink to latest backup: $BASENAME"
rm "$LATEST" 2> /dev/null
cd /backup || exit && ln -s "$BASENAME" "$(basename "$LATEST")"
if [ -n "$REMOVE_DUPLICATES" ]
then
echo "==> Removing duplicate database dumps"
fdupes -idN /backup/
fi
if [ -n "$MAX_BACKUPS" ]
then
# Execute the delete script, delete older backup or other custom delete script
/delete.sh "$db" $EXT
while [ "$(find /backup -maxdepth 1 -name "*.$db.sql$EXT" -type f | wc -l)" -gt "$MAX_BACKUPS" ]
do
TARGET=$(find /backup -maxdepth 1 -name "*.$db.sql$EXT" -type f | sort | head -n 1)
echo "==> Max number of ($MAX_BACKUPS) backups reached. Deleting ${TARGET} ..."
rm -rf "${TARGET}"
echo "==> Backup ${TARGET} deleted"
done
fi
else
rm -rf "$FILENAME"

View file

@ -1,14 +0,0 @@
#!/bin/bash
db=$1
EXT=$2
# This file could be customized to create custom delete strategy
while [ "$(find /backup -maxdepth 1 -name "*.$db.sql$EXT" -type f | wc -l)" -gt "$MAX_BACKUPS" ]
do
TARGET=$(find /backup -maxdepth 1 -name "*.$db.sql$EXT" -type f | sort | head -n 1)
echo "==> Max number of ($MAX_BACKUPS) backups reached. Deleting ${TARGET} ..."
rm -rf "${TARGET}"
echo "==> Backup ${TARGET} deleted"
done

View file

@ -1,9 +1,8 @@
version: "2"
services:
mariadb:
image: mariadb:10.11
image: mariadb:10
container_name: my_mariadb
security_opt:
- seccomp:unconfined
expose:
- 3306
volumes:
@ -12,19 +11,13 @@ services:
environment:
- MYSQL_DATABASE=${DATABASE_NAME}
- MYSQL_ROOT_PASSWORD=${MARIADB_ROOT_PASSWORD}
- MYSQL_ALLOW_EMPTY_ROOT_PASSWORD=yes
restart: unless-stopped
healthcheck:
test: [ "CMD", "healthcheck.sh", "--su-mysql", "--connect" ]
timeout: 5s
retries: 10
backup:
build: .
image: fradelg/mysql-cron-backup
depends_on:
mariadb:
condition: service_healthy
- mariadb
volumes:
- ${VOLUME_PATH}/backup:/backup
environment:
@ -35,6 +28,6 @@ services:
- INIT_BACKUP=1
- CRON_TIME=0 0 * * *
restart: unless-stopped
volumes:
data:
volumes:
data:

View file

@ -1,25 +1,20 @@
#!/bin/bash
# Get hostname: try read from file, else get from env
[ -z "${MYSQL_HOST_FILE}" ] || { MYSQL_HOST=$(head -1 "${MYSQL_HOST_FILE}"); }
[ -z "${MYSQL_HOST}" ] && { echo "=> MYSQL_HOST cannot be empty" && exit 1; }
# Get username: try read from file, else get from env
[ -z "${MYSQL_USER_FILE}" ] || { MYSQL_USER=$(head -1 "${MYSQL_USER_FILE}"); }
[ -z "${MYSQL_USER}" ] && { echo "=> MYSQL_USER cannot be empty" && exit 1; }
# Get password: try read from file, else get from env, else get from MYSQL_PASSWORD env
# If provided, take password from file
[ -z "${MYSQL_PASS_FILE}" ] || { MYSQL_PASS=$(head -1 "${MYSQL_PASS_FILE}"); }
[ -z "${MYSQL_PASS:=$MYSQL_PASSWORD}" ] && { echo "=> MYSQL_PASS cannot be empty" && exit 1; }
# Alternatively, take it from env var
[ -z "${MYSQL_PASS}" ] && { echo "=> MYSQL_PASS cannot be empty" && exit 1; }
if [ "$#" -ne 1 ]
then
echo "You must pass the path of the backup file to restore"
exit 1
fi
set -o pipefail
if [ -z "${USE_PLAIN_SQL}" ]
then
then
SQL=$(gunzip -c "$1")
else
SQL=$(cat "$1")

28
run.sh
View file

@ -1,7 +1,7 @@
#!/bin/bash
tail -F /mysql_backup.log &
if [ "${INIT_BACKUP:-0}" -gt "0" ]; then
if [ "${INIT_BACKUP}" -gt "0" ]; then
echo "=> Create a backup on the startup"
/backup.sh
elif [ -n "${INIT_RESTORE_LATEST}" ]; then
@ -11,32 +11,10 @@ elif [ -n "${INIT_RESTORE_LATEST}" ]; then
echo "waiting database container..."
sleep 1
done
# Needed to exclude the 'latest.<database>.sql.gz' file, consider only filenames starting with number
# Only data-tagged backups, eg. '202212250457.database.sql.gz', must be trapped by the regex
find /backup -maxdepth 1 -name '[0-9]*.*.sql.gz' | sort | tail -1 | xargs /restore.sh
find /backup -maxdepth 1 -name '*.sql.gz' | tail -1 | xargs /restore.sh
fi
function final_backup {
echo "=> Captured trap for final backup"
echo "=> Requested last backup at $(date "+%Y-%m-%d %H:%M:%S")"
exec /backup.sh
exit 0
}
if [ -n "${EXIT_BACKUP}" ]; then
echo "=> Listening on container shutdown gracefully to make last backup before close"
trap final_backup SIGHUP SIGINT SIGTERM
fi
touch /HEALTHY.status
echo "${CRON_TIME} /backup.sh >> /mysql_backup.log 2>&1" > /tmp/crontab.conf
crontab /tmp/crontab.conf
echo "=> Running cron task manager in foreground"
crond -f -l 8 -L /mysql_backup.log &
echo "Listening on crond, and wait..."
tail -f /dev/null & wait $!
echo "Script is shutted down."
exec crond -f -l 8 -L /mysql_backup.log