Merge pull request #472 from acelaya-forks/feature/docker-image

Feature/docker image
This commit is contained in:
Alejandro Celaya 2019-08-16 20:50:16 +02:00 committed by GitHub
commit 234190f493
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 500 additions and 57 deletions

24
.dockerignore Normal file
View file

@ -0,0 +1,24 @@
config/autoload/*local*
data/infra
data/cache/*
data/log/*
data/locks/*
data/proxies/*
data/migrations_template.txt
data/GeoLite2-City.*
data/database.sqlite
data/shlink-tests.db
**/.gitignore
CHANGELOG.md
composer.lock
vendor
docs
indocker
docker-*
php*
infection.json
phpstan.neon
**/test*
build*
.github
hooks

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
.idea
build
!hooks/build
composer.lock
composer.phar
vendor/

View file

@ -16,6 +16,7 @@ matrix:
services:
- mysql
- postgresql
- docker
before_install:
- echo 'extension = apcu.so' >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
@ -30,9 +31,11 @@ before_script:
- mysql -e 'CREATE DATABASE shlink_test;'
- psql -c 'create database shlink_test;' -U postgres
- mkdir build
- export DOCKERFILE_CHANGED=$(git diff ${TRAVIS_COMMIT_RANGE:-origin/master} --name-only | grep Dockerfile)
script:
- composer ci
- if [[ ! -z "$DOCKERFILE_CHANGED" && "${TRAVIS_PHP_VERSION}" == "7.2" ]]; then docker build -t shlink-docker-image:temp . ; fi
after_success:
- rm -f build/clover.xml
@ -53,10 +56,4 @@ deploy:
skip_cleanup: true
on:
tags: true
php: 7.2
- provider: script
script: bash data/travis/trigger_docker_build.sh
skip_cleanup: true
on:
tags: true
php: 7.2
php: '7.2'

View file

@ -15,6 +15,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
* [#450](https://github.com/shlinkio/shlink/issues/450) Added PHP 7.4 to the build matrix, as an allowed-to-fail env.
* [#441](https://github.com/shlinkio/shlink/issues/441) and [#443](https://github.com/shlinkio/shlink/issues/443) Split some logic into independent modules.
* [#451](https://github.com/shlinkio/shlink/issues/451) Updated to infection 0.13.
* [#467](https://github.com/shlinkio/shlink/issues/467) Moved docker image config to this repo.
#### Deprecated
@ -22,7 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com), and this
#### Removed
* *Nothing*
* [#468](https://github.com/shlinkio/shlink/issues/468) Removed APCu extension from docker image.
#### Fixed

56
Dockerfile Normal file
View file

@ -0,0 +1,56 @@
FROM php:7.3.8-cli-alpine3.10
LABEL maintainer="Alejandro Celaya <alejandro@alejandrocelaya.com>"
ARG SHLINK_VERSION=1.18.1
ENV SHLINK_VERSION ${SHLINK_VERSION}
ENV SWOOLE_VERSION 4.3.3
ENV COMPOSER_VERSION 1.9.0
WORKDIR /etc/shlink
RUN \
# Install mysl and calendar
docker-php-ext-install -j"$(nproc)" pdo_mysql calendar && \
# Install sqlite
apk add --no-cache sqlite-libs sqlite-dev && \
docker-php-ext-install -j"$(nproc)" pdo_sqlite && \
# Install postgres
apk add --no-cache postgresql-dev && \
docker-php-ext-install -j"$(nproc)" pdo_pgsql && \
# [Deprecated] Install intl
apk add --no-cache icu-dev && \
docker-php-ext-install -j"$(nproc)" intl && \
# Install zip and gd
apk add --no-cache libzip-dev zlib-dev libpng-dev && \
docker-php-ext-install -j"$(nproc)" zip gd
# Install swoole
# First line fixes an error when installing pecl extensions. Found in https://github.com/docker-library/php/issues/233
RUN apk add --no-cache --virtual .phpize-deps ${PHPIZE_DEPS} && \
pecl install swoole-${SWOOLE_VERSION} && \
docker-php-ext-enable swoole && \
apk del .phpize-deps
# Install shlink
COPY . .
RUN rm -rf ./docker && \
wget https://getcomposer.org/download/${COMPOSER_VERSION}/composer.phar && \
php composer.phar install --no-dev --optimize-autoloader --no-progress --no-interaction && \
php composer.phar clear-cache && \
rm composer.*
# Add shlink to the path to ease running it after container is created
RUN ln -s /etc/shlink/bin/cli /usr/local/bin/shlink
RUN sed -i "s/%SHLINK_VERSION%/${SHLINK_VERSION}/g" config/autoload/app_options.global.php
# Expose swoole port
EXPOSE 8080
# Expose params config dir, since the user is expected to provide custom config from there
VOLUME /etc/shlink/config/params
# Copy config specific for the image
COPY docker/docker-entrypoint.sh docker-entrypoint.sh
COPY docker/config/shlink_in_docker.local.php config/autoload/shlink_in_docker.local.php
ENTRYPOINT ["/bin/sh", "./docker-entrypoint.sh"]

View file

@ -225,7 +225,7 @@ Right now, it does not import cached info (like website previews), but it will.
## Using a docker image
Starting with version 1.15.0, an official docker image is provided. You can find the docs on how to use it [here](https://hub.docker.com/r/shlinkio/shlink/).
Starting with version 1.15.0, an official docker image is provided. You can learn how to use it by reading [the docs](docker/README.md).
The idea is that you can just generate a container using the image and provide custom config via env vars.

View file

@ -17,38 +17,15 @@ echo 'Copying project files...'
rm -rf "${builtcontent}"
mkdir -p "${builtcontent}"
rsync -av * "${builtcontent}" \
--exclude=bin/test \
--exclude=data/infra \
--exclude=data/travis \
--exclude=data/cache/* \
--exclude=data/log/* \
--exclude=data/locks/* \
--exclude=data/proxies/* \
--exclude=data/migrations_template.txt \
--exclude=data/GeoLite2-City.* \
--exclude=data/database.sqlite \
--exclude=data/shlink-tests.db \
--exclude=**/.gitignore \
--exclude=CHANGELOG.md \
--exclude=composer.lock \
--exclude=vendor \
--exclude=docs \
--exclude=indocker \
--exclude=docker* \
--exclude=php* \
--exclude=infection.json \
--exclude=phpstan.neon \
--exclude=config/autoload/*local* \
--exclude=config/test \
--exclude=**/test* \
--exclude=build* \
--exclude=.github
--exclude=*docker* \
--exclude=Dockerfile \
--exclude-from=./.dockerignore
cd "${builtcontent}"
# Install dependencies
echo "Installing dependencies with $composerBin..."
${composerBin} self-update
${composerBin} install --no-dev --optimize-autoloader --apcu-autoloader --no-progress --no-interaction
${composerBin} install --no-dev --optimize-autoloader --no-progress --no-interaction
# Delete development files
echo 'Deleting dev files...'

View file

@ -102,7 +102,7 @@
"cs": "phpcs",
"cs:fix": "phpcbf",
"stan": "phpstan analyse module/*/src/ module/*/config config --level=5 -c phpstan.neon",
"stan": "phpstan analyse module/*/src/ module/*/config config docker/config --level=5 -c phpstan.neon",
"test": [
"@test:unit",

View file

@ -1,20 +0,0 @@
#!/bin/bash
set -e
# Get latest commit in master, in plain text
LATEST_MASTER_COMMIT=$(curl -H "Accept: application/vnd.github.sha" -X GET https://api.github.com/repos/shlinkio/shlink-docker-image/commits/master)
# Create new tag and a ref to the tag, which will trigger image build on it
curl -u acelaya:${GITHUB_OAUTH_KEY} \
-H "Content-Type: application/json" \
--data "{ \"tag\": \"${TRAVIS_TAG}\", \"message\": \"${TRAVIS_TAG}\", \"object\": \"${LATEST_MASTER_COMMIT}\", \"type\": \"commit\" }" \
-X POST https://api.github.com/repos/shlinkio/shlink-docker-image/git/tags
curl -u acelaya:${GITHUB_OAUTH_KEY} \
-H "Content-Type: application/json" \
--data "{ \"ref\": \"refs/tags/${TRAVIS_TAG}\", \"sha\": \"${LATEST_MASTER_COMMIT}\" }" \
-X POST https://api.github.com/repos/shlinkio/shlink-docker-image/git/refs
# Trigger image build for "latest
curl -H "Content-Type: application/json" \
--data '{ "docker_tag": "latest" }' \
-X POST https://registry.hub.docker.com/u/shlinkio/shlink/trigger/${DOCKER_TRIGGER_TOKEN}/

204
docker/README.md Normal file
View file

@ -0,0 +1,204 @@
# Shlink Docker image
[![Docker build status](https://img.shields.io/docker/cloud/build/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
[![Docker pulls](https://img.shields.io/docker/pulls/shlinkio/shlink.svg?style=flat-square)](https://hub.docker.com/r/shlinkio/shlink/)
This image provides an easy way to set up [shlink](https://shlink.io) on a container-based runtime.
It exposes a shlink instance served with [swoole](https://www.swoole.co.uk/), which persists data in a local [sqlite](https://www.sqlite.org/index.html) database.
## Usage
Shlink docker image exposes port `8080` in order to interact with its HTTP interface.
It also expects these two env vars to be provided, in order to properly generate short URLs at runtime.
* `SHORT_DOMAIN_HOST`: The custom short domain used for this shlink instance. For example **doma.in**.
* `SHORT_DOMAIN_SCHEMA`: Either **http** or **https**.
So based on this, to run shlink on a local docker service, you should run a command like this:
```bash
docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https shlinkio/shlink
```
### Interact with shlink's CLI on a running container.
Once the shlink container is running, you can interact with the CLI tool by running `shlink` with any of the supported commands.
For example, if the container is called `shlink_container`, you can generate a new API key with:
```bash
docker exec -it shlink_container shlink api-key:generate
```
Or you can list all tags with:
```bash
docker exec -it shlink_container shlink tag:list
```
Or process remaining visits with:
```bash
docker exec -it shlink_container shlink visit:process
```
All shlink commands will work the same way.
You can also list all available commands just by running this:
```bash
docker exec -it shlink_container shlink
```
## Use an external DB
The image comes with a working sqlite database, but in production you will probably want to usa a distributed database.
It is possible to use a set of env vars to make this shlink instance interact with an external MySQL or PostgreSQL database.
* `DB_DRIVER`: **[Mandatory]**. Use the value **mysql** or **postgres** to prevent the sqlite database to be used.
* `DB_NAME`: [Optional]. The database name to be used. Defaults to **shlink**.
* `DB_USER`: **[Mandatory]**. The username credential for the database server.
* `DB_PASSWORD`: **[Mandatory]**. The password credential for the database server.
* `DB_HOST`: **[Mandatory]**. The host name of the server running the database engine.
* `DB_PORT`: [Optional]. The port in which the database service is running.
* Default value is based on the driver:
* **mysql** -> `3306`
* **postgres** -> `5432`
> PostgreSQL is supported since v1.16.1 of this image. Do not try to use it with previous versions.
Taking this into account, you could run shlink on a local docker service like this:
```bash
docker run --name shlink -p 8080:8080 -e SHORT_DOMAIN_HOST=doma.in -e SHORT_DOMAIN_SCHEMA=https -e DB_DRIVER=mysql -e DB_USER=root -e DB_PASSWORD=123abc -e DB_HOST=something.rds.amazonaws.com shlinkio/shlink
```
You could even link to a local database running on a different container:
```bash
docker run --name shlink -p 8080:8080 [...] -e DB_HOST=some_mysql_container --link some_mysql_container shlinkio/shlink
```
> If you have considered using SQLite but sharing the database file with a volume, read [this issue](https://github.com/shlinkio/shlink-docker-image/issues/40) first.
## Supported env vars
A few env vars have been already used in previous examples, but this image supports others that can be used to customize its behavior.
This is the complete list of supported env vars:
* `SHORT_DOMAIN_HOST`: The custom short domain used for this shlink instance. For example **doma.in**.
* `SHORT_DOMAIN_SCHEMA`: Either **http** or **https**.
* `SHORTCODE_CHARS`: A charset to use when building short codes. Only needed when using more than one shlink instance ([Multi instance considerations](#multi-instance-considerations)).
* `DB_DRIVER`: **sqlite** (which is the default value), **mysql** or **postgres**.
* `DB_NAME`: The database name to be used when using an external database driver. Defaults to **shlink**.
* `DB_USER`: The username credential to be used when using an external database driver.
* `DB_PASSWORD`: The password credential to be used when using an external database driver.
* `DB_HOST`: The host name of the database server when using an external database driver.
* `DB_PORT`: The port in which the database service is running when using an external database driver. Defaults to **3306**.
* `DISABLE_TRACK_PARAM`: The name of a query param that can be used to visit short URLs avoiding the visit to be tracked. This feature won't be available if not value is provided.
* `DELETE_SHORT_URL_THRESHOLD`: The amount of visits on short URLs which will not allow them to be deleted. Defaults to `15`.
* `LOCALE`: Defines the default language for error pages when a user accesses a short URL which does not exist. Supported values are **es** and **en**. Defaults to **en**.
* `VALIDATE_URLS`: Boolean which tells if shlink should validate a status 20x (after following redirects) is returned when trying to shorten a URL. Defaults to `true`.
* `NOT_FOUND_REDIRECT_TO`: If a URL is provided here, when a user tries to access an invalid short URL, he/she will be redirected to this value. If this env var is not provided, the user will see a generic `404 - not found` page.
* `REDIS_SERVERS`: A comma-separated list of redis servers where Shlink locks are stored (locks are used to prevent some operations to be run more than once in parallel).
This is important when running more than one Shlink instance ([Multi instance considerations](#multi-instance-considerations)). If not provided, Shlink stores locks on every instance separately.
If more than one server is provided, Shlink will expect them to be configured as a [redis cluster](https://redis.io/topics/cluster-tutorial).
In the future, these redis servers could be used for other caching operations performed by shlink.
An example using all env vars could look like this:
```bash
docker run \
--name shlink \
-p 8080:8080 \
-e SHORT_DOMAIN_HOST=doma.in \
-e SHORT_DOMAIN_SCHEMA=https \
-e DB_DRIVER=mysql \
-e DB_NAME=shlink \
-e DB_USER=root \
-e DB_PASSWORD=123abc \
-e DB_HOST=something.rds.amazonaws.com \
-e DB_PORT=3306 \
-e DISABLE_TRACK_PARAM="no-track" \
-e DELETE_SHORT_URL_THRESHOLD=30 \
-e LOCALE=es \
-e VALIDATE_URLS=false \
-e "NOT_FOUND_REDIRECT_TO=https://www.google.com" \
-e "REDIS_SERVERS=tcp://172.20.0.1:6379,tcp://172.20.0.2:6379" \
shlinkio/shlink
```
## Provide config via volumes
Rather than providing custom configuration via env vars, it is also possible ot provide config files in json format.
Mounting a volume at `config/params` you will make shlink load all the files on it with the `.config.json` suffix.
The whole configuration should have this format, but it can be split into multiple files that will be merged:
```json
{
"disable_track_param": "my_param",
"delete_short_url_threshold": 30,
"locale": "es",
"short_domain_schema": "https",
"short_domain_host": "doma.in",
"validate_url": false,
"not_found_redirect_to": "https://my-landing-page.com",
"redis_servers": [
"tcp://172.20.0.1:6379",
"tcp://172.20.0.2:6379"
],
"db_config": {
"driver": "pdo_mysql",
"dbname": "shlink",
"user": "root",
"password": "123abc",
"host": "something.rds.amazonaws.com",
"port": "3306"
}
}
```
> This is internally parsed to how shlink expects the config. If you are using a version previous to 1.17.0, this parser is not present and you need to provide a config structure like the one [documented previously](https://github.com/shlinkio/shlink-docker-image/tree/v1.16.3#provide-config-via-volumes).
Once created just run shlink with the volume:
```bash
docker run --name shlink -p 8080:8080 -v ${PWD}/my/config/dir:/etc/shlink/config/params shlinkio/shlink
```
## Multi instance considerations
These are some considerations to take into account when running multiple instances of shlink.
* The first time shlink is run, it generates a charset used to generate short codes, which is a shuffled base62 charset.
If you are using several shlink instances, you will probably want all of them to use the same charset.
You can get a shuffled base62 charset by going to [https://shlink.io/short-code-chars](https://shlink.io/short-code-chars), and then you just need to pass it to all shlink instances using the `SHORTCODE_CHARS` env var.
If you don't do this, each shlink instance will use a different charset. However this shouldn't be a problem in practice, since the chances to get a collision will be very low.
* Some operations performed by Shlink should never be run more than once at the same time (like creating the database for the first time, or downloading the GeoLite2 database). For this reason, Shlink uses a locking system.
However, these locks are locally scoped to each Shlink instance by default.
You can (and should) make the locks to be shared by all Shlink instances by using a redis server/cluster. Just define the `REDIS_SERVERS` env var with the list of servers.
## Versions
Versions of this image match the shlink version it contains.
For example, installing `shlinkio/shlink:1.15.0`, you will get an image containing shlink v1.15.0.
The `latest` docker tag always holds the latest contents in master, and it's considered unestable and not suitable for production.
> There are no official shlink images previous to v1.15.0.

View file

@ -0,0 +1,175 @@
<?php
declare(strict_types=1);
namespace Shlinkio\Shlink;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use function explode;
use function file_exists;
use function file_get_contents;
use function file_put_contents;
use function implode;
use function Shlinkio\Shlink\Common\env;
use function sprintf;
use function str_shuffle;
use function substr;
use function sys_get_temp_dir;
$helper = new class {
private const BASE62 = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
private const DB_DRIVERS_MAP = [
'mysql' => 'pdo_mysql',
'postgres' => 'pdo_pgsql',
];
private const DB_PORTS_MAP = [
'mysql' => '3306',
'postgres' => '5432',
];
/** @var string */
private $charset;
/** @var string */
private $secretKey;
public function __construct()
{
[$this->charset, $this->secretKey] = $this->initShlinkKeys();
}
private function initShlinkKeys(): array
{
$keysFile = sprintf('%s/shlink.keys', sys_get_temp_dir());
if (file_exists($keysFile)) {
return explode(',', file_get_contents($keysFile));
}
$keys = [
env('SHORTCODE_CHARS', $this->generateShortcodeChars()),
env('SECRET_KEY', $this->generateSecretKey()),
];
file_put_contents($keysFile, implode(',', $keys));
return $keys;
}
private function generateShortcodeChars(): string
{
return str_shuffle(self::BASE62);
}
private function generateSecretKey(): string
{
return substr(str_shuffle(self::BASE62), 0, 32);
}
public function getShortcodeChars(): string
{
return $this->charset;
}
public function getSecretKey(): string
{
return $this->secretKey;
}
public function getDbConfig(): array
{
$driver = env('DB_DRIVER');
if ($driver === null || $driver === 'sqlite') {
return [
'driver' => 'pdo_sqlite',
'path' => 'data/database.sqlite',
];
}
$driverOptions = $driver !== 'mysql' ? [] : [
// PDO::MYSQL_ATTR_INIT_COMMAND => 'SET NAMES utf8',
1002 => 'SET NAMES utf8',
];
return [
'driver' => self::DB_DRIVERS_MAP[$driver],
'dbname' => env('DB_NAME', 'shlink'),
'user' => env('DB_USER'),
'password' => env('DB_PASSWORD'),
'host' => env('DB_HOST'),
'port' => env('DB_PORT', self::DB_PORTS_MAP[$driver]),
'driverOptions' => $driverOptions,
];
}
public function getNotFoundConfig(): array
{
$notFoundRedirectTo = env('NOT_FOUND_REDIRECT_TO');
return [
'enable_redirection' => $notFoundRedirectTo !== null,
'redirect_to' => $notFoundRedirectTo,
];
}
};
return [
'config_cache_enabled' => false,
'app_options' => [
'secret_key' => $helper->getSecretKey(),
'disable_track_param' => env('DISABLE_TRACK_PARAM'),
],
'delete_short_urls' => [
'check_visits_threshold' => true,
'visits_threshold' => (int) env('DELETE_SHORT_URL_THRESHOLD', 15),
],
'translator' => [
'locale' => env('LOCALE', 'en'),
],
'entity_manager' => [
'connection' => $helper->getDbConfig(),
],
'url_shortener' => [
'domain' => [
'schema' => env('SHORT_DOMAIN_SCHEMA', 'http'),
'hostname' => env('SHORT_DOMAIN_HOST', ''),
],
'shortcode_chars' => $helper->getShortcodeChars(),
'validate_url' => (bool) env('VALIDATE_URLS', true),
'not_found_short_url' => $helper->getNotFoundConfig(),
],
'logger' => [
'handlers' => [
'shlink_rotating_handler' => [
'level' => Logger::EMERGENCY, // This basically disables regular file logs
],
'shlink_stdout_handler' => [
'class' => StreamHandler::class,
'level' => Logger::INFO,
'stream' => 'php://stdout',
'formatter' => 'dashed',
],
],
'loggers' => [
'Shlink' => [
'handlers' => ['shlink_stdout_handler'],
],
],
],
'dependencies' => [
'aliases' => env('REDIS_SERVERS') === null ? [] : [
'lock_store' => 'redis_lock_store',
],
],
'redis' => [
'servers' => env('REDIS_SERVERS'),
],
];

View file

@ -0,0 +1,17 @@
#!/usr/bin/env sh
set -e
cd /etc/shlink
echo "Creating fresh database if needed..."
php bin/cli db:create -n -q
echo "Updating database..."
php bin/cli db:migrate -n -q
echo "Generating proxies..."
php vendor/doctrine/orm/bin/doctrine.php orm:generate-proxies -n -q
# When restarting the container, swoole might think it is already in execution
# This forces the app to be started every second until the exit code is 0
until php vendor/zendframework/zend-expressive-swoole/bin/zend-expressive-swoole start; do sleep 1 ; done

10
hooks/build Executable file
View file

@ -0,0 +1,10 @@
#!/bin/bash
set -ex
if [[ ${SOURCE_BRANCH} == 'master' ]]; then
SHLINK_RELEASE='latest'
else
SHLINK_RELEASE=${SOURCE_BRANCH}
fi
docker build --build-arg SHLINK_VERSION=${SHLINK_RELEASE#?} -t ${IMAGE_NAME} .

View file

@ -14,6 +14,7 @@
<file>module</file>
<file>data/migrations</file>
<file>config</file>
<file>docker/config</file>
<file>public/index.php</file>
<!-- Paths to exclude -->